Skill: clawperp/clawswap (version 0.2.1)

# ClawSwap Skill Workspace This directory contains the publishable OpenClaw skill package at `skill/clawswap/`. Use `skill/clawswap/SKILL.md` as the canonical skill definition.

path: clawswap/runtime_client.py
content: #!/usr/bin/env python3
"""
ClawSwap Self-Hosted Runtime Client
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Speaks the /runtime/v1/* protocol to connect a self-hosted agent to ClawSwap.

Lifecycle:
  A. Auto-register (if API key provided and no saved state):
     1. Create self-hosted paper agent via POST /api/v1/agents
     2. Issue bootstrap token via POST /api/v1/agents/:id/bootstrap
  B. Bootstrap exchange: swap clsw_bt_* token for clsw_rt_* runtime token
  C. Heartbeat loop (every 30s)
  D. Telemetry reports (every 60s)
  E. Strategy loop → trade intents via POST /runtime/v1/trade

Usage:
    # Auto-register with API key (creates agent + bootstraps automatically):
    python3 runtime_client.py --api-key clsw_xxxxx

    # Or via env var:
    CLAWSWAP_API_KEY=clsw_xxxxx python3 runtime_client.py

    # Manual bootstrap (from dashboard):
    python3 runtime_client.py --agent-id agt_xxx --bootstrap-token clsw_bt_xxx

    # Reconnect with saved state (auto-detected from .runtime_token):
    python3 runtime_client.py

Environment variables:
    CLAWSWAP_API_KEY           API key from Dashboard Settings (for auto-register)
    CLAWSWAP_GATEWAY_URL       Gateway base URL (default: https://api.clawswap.trade)
    CLAWSWAP_BOOTSTRAP_TOKEN   Bootstrap token for manual bootstrap
    CLAWSWAP_RUNTIME_TOKEN     Runtime token for reconnection
    CLAWSWAP_AGENT_ID          Agent ID
"""

import argparse
import json
import logging
import os
import platform
import random
import signal
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger("clawswap.runtime")

RUNTIME_VERSION = "0.2.0"
SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
TOKEN_FILE = os.path.join(SKILL_DIR, ".runtime_token")
API_KEY_FILE = os.path.join(SKILL_DIR, ".clawswap_api_key")


# ── Strategy Adapter ────────────────────────────────────────────────────────

class DemoStrategy:
    """Simple demo strategy: alternates buy/sell on a single ticker.

    Produces one trade intent per tick. Cycles through:
      1. Buy ticker with size_pct of cash
      2. Wait (hold)
      3. Sell (close position)
      4. Wait (hold)
    """

    def __init__(self, ticker="BTC", size_pct=0.1, interval=30):
        self.ticker = ticker.upper()
        self.size_pct = size_pct
        self.interval = interval  # seconds between strategy ticks
        self.tick_count = 0
        self.in_position = False
        self._trade_counter = 0

    def tick(self):
        """Called every `interval` seconds. Returns list of trade intents."""
        self.tick_count += 1
        intents = []

        # Simple cycle: buy on tick 1, sell on tick 3, repeat every 4 ticks
        phase = self.tick_count % 4

        if phase == 1 and not self.in_position:
            self._trade_counter += 1
            intents.append({
                "ticker": self.ticker,
                "side": "buy",
                "size_pct": self.size_pct,
                "client_order_id": f"demo_{self._trade_counter:04d}",
            })
            self.in_position = True
        elif phase == 3 and self.in_position:
            self._trade_counter += 1
            intents.append({
                "ticker": self.ticker,
                "side": "sell",
                "client_order_id": f"demo_{self._trade_counter:04d}",
            })
            self.in_position = False

        return intents


class LiveStrategyAdapter:
    """Adapts a strategies/ class (on_candle/get_signal/get_exit_signal) to the
    tick() interface used by RuntimeClient.

    Fetches real-time mid-price from Hyperliquid on each tick, feeds it as a
    candle, and converts signals to trade intents.
    """

    def __init__(self, strategy, cfg, ticker="BTC", interval=30):
        self.strategy = strategy
        self.cfg = cfg
        self.ticker = ticker.upper()
        self.interval = interval
        self.in_position = False
        self.position_side = None
        self._trade_counter = 0

    def _get_price(self):
        """Fetch live mid-price from Hyperliquid."""
        try:
            data = json.dumps({"type": "allMids"}).encode()
            req = Request(
                "https://api.hyperliquid.xyz/info",
                data=data,
                headers={
                    "Content-Type": "application/json",
                    "User-Agent": "ClawSwap-RuntimeClient/1.0",
                },
                method="POST",
            )
            with urlopen(req, timeout=10) as resp:
                mids = json.load(resp)
            price_str = mids.get(self.ticker)
            if price_str:
                return float(price_str)
        except Exception as e:
            log.debug(f"Price fetch failed: {e}")
        return 0.0

    def tick(self):
        """Fetch price, feed candle, check signals, return trade intents."""
        price = self._get_price()
        if price <= 0:
            return []

        candle = {
            "open": price, "high": price, "low": price,
            "close": price, "volume": 0,
        }
        if hasattr(self.strategy, "on_candle"):
            self.strategy.on_candle(candle)

        intents = []
        self._trade_counter += 1
        coid = f"live_{self._trade_counter:04d}"

        if not self.in_position:
            sig = self.strategy.get_signal() if hasattr(self.strategy, "get_signal") else None
            if sig == "sell" and getattr(self.cfg, "direction", None) == "short":
                sig = "short"
            if sig in ("buy", "short"):
                size_pct = getattr(self.cfg, "position_size_pct", 0.2)
                intents.append({
                    "ticker": self.ticker,
                    "side": sig,
                    "size_pct": size_pct,
                    "client_order_id": coid,
                })
                self.in_position = True
                self.position_side = "long" if sig == "buy" else "short"
                if hasattr(self.strategy, "on_fill"):
                    self.strategy.on_fill(sig, price, coid)
        else:
            exit_sig = self.strategy.get_exit_signal(price) if hasattr(self.strategy, "get_exit_signal") else None
            if exit_sig:
                close_side = "cover" if self.position_side == "short" else "sell"
                intents.append({
                    "ticker": self.ticker,
                    "side": close_side,
                    "client_order_id": coid,
                })
                self.in_position = False
                self.position_side = None
                if hasattr(self.strategy, "on_fill"):
                    self.strategy.on_fill(close_side, price, coid)

        return intents


class RandomStrategy:
    """Random walk strategy for testing: randomly opens/closes positions on multiple tickers."""

    def __init__(self, tickers=None, size_pct=0.05, interval=20):
        self.tickers = [t.upper() for t in (tickers or ["BTC", "ETH"])]
        self.size_pct = size_pct
        self.interval = interval
        self.positions = set()  # tickers currently held
        self._trade_counter = 0

    def tick(self):
        """Called every `interval` seconds. Returns list of trade intents."""
        intents = []
        ticker = random.choice(self.tickers)
        self._trade_counter += 1
        coid = f"rand_{self._trade_counter:04d}"

        if ticker in self.positions:
            # 60% chance to close
            if random.random() < 0.6:
                intents.append({"ticker": ticker, "side": "sell", "client_order_id": coid})
                self.positions.discard(ticker)
        else:
            # 40% chance to open
            if random.random() < 0.4:
                side = random.choice(["buy", "short"])
                intents.append({
                    "ticker": ticker,
                    "side": side,
                    "size_pct": self.size_pct,
                    "client_order_id": coid,
                })
                self.positions.add(ticker)

        return intents


def http_json(method, url, data=None, token=None, timeout=15):
    """Send an HTTP request and return (status_code, response_body_dict | None)."""
    headers = {
        "Content-Type": "application/json",
        "User-Agent": "ClawSwap-RuntimeClient/1.0",
    }
    if [REDACTED_SECRET]"Authorization"] = f"Bearer {token}"
    body = json.dumps(data).encode() if data else None
    req = Request(url, data=body, headers=headers, method=method)
    try:
        with urlopen(req, timeout=timeout) as resp:
            return resp.status, json.load(resp)
    except HTTPError as e:
        try:
            err_body = json.loads(e.read().decode())
        except Exception:
            err_body = None
        return e.code, err_body
    except (URLError, Exception) as e:
        log.warning(f"Request failed: {e}")
        return 0, None


def get_host_fingerprint():
    """Generate a simple host fingerprint for the bootstrap exchange."""
    return f"{platform.node()}/{platform.system()}/{platform.machine()}"


class ControlPlaneError(RuntimeError):
    """Error from a control-plane API call, carrying the HTTP status code."""

    def __init__(self, message, status=0):
        super().__init__(message)
        self.status = status


# ── Control Plane Client ──────────────────────────────────────────────────

class ControlPlaneClient:
    """Uses an API key to call control-plane endpoints (create agent, issue bootstrap)."""

    def __init__(self, api_key, gateway_url):
        self.[REDACTED_SECRET]
        self.gateway_url = gateway_url.rstrip("/")

    def create_agent(self, name="OpenClaw Agent", strategy_kind="DemoStrategy"):
        """Create a self-hosted paper agent via POST /api/v1/agents.

        Returns (agent_id, response_dict) on success, raises on failure.
        """
        log.info(f"Creating self-hosted paper agent: {name} ({strategy_kind})")

        status, resp = http_json(
            "POST",
            f"{self.gateway_url}/api/v1/agents",
            data={
                "name": name,
                "strategy_kind": strategy_kind,
                "hosting_mode": "self_hosted",
                "runtime_mode": "paper",
                "strategy_source": "self_hosted",
            },
            [REDACTED_SECRET],
        )

        if status == 200 and resp:
            agent_id = resp["agent"]["id"]
            log.info(f"Agent created: {agent_id}")
            return agent_id, resp
        elif status == 401:
            raise ControlPlaneError("API key rejected (401). Key may be invalid or revoked.", status=401)
        elif status == 0:
            raise ControlPlaneError("Cannot reach gateway. Check CLAWSWAP_GATEWAY_URL.", status=0)
        else:
            msg = resp.get("error", {}).get("message", str(resp)) if resp else f"HTTP {status}"
            raise ControlPlaneError(f"Agent creation failed: {msg}", status=status)

    def issue_bootstrap(self, agent_id):
        """Issue a bootstrap token via POST /api/v1/agents/:id/bootstrap.

        Returns bootstrap_token on success.
        Raises ControlPlaneError with .status on failure (401, 0, 5xx, etc.).
        """
        log.info(f"Issuing bootstrap token for agent {agent_id}")

        status, resp = http_json(
            "POST",
            f"{self.gateway_url}/api/v1/agents/{agent_id}/bootstrap",
            [REDACTED_SECRET],
        )

        if status == 200 and resp:
            bootstrap_token = resp["bootstrap_token"]
            log.info(f"Bootstrap token issued: {bootstrap_token[:16]}...")
            return bootstrap_token
        elif status == 401:
            raise ControlPlaneError("API key rejected (401). Key may be invalid or revoked.", status=401)
        elif status == 0:
            raise ControlPlaneError("Cannot reach gateway. Check CLAWSWAP_GATEWAY_URL.", status=0)
        else:
            msg = resp.get("error", {}).get("message", str(resp)) if resp else f"HTTP {status}"
            raise ControlPlaneError(f"Bootstrap token issuance failed: {msg}", status=status)


class RuntimeClient:
    """Minimal runtime client that speaks the /runtime/v1/* protocol."""

    def __init__(self, agent_id, gateway_url, bootstrap_token=None, runtime_token=None,
                 strategy=None, api_key=None):
        self.agent_id = agent_id
        self.gateway_url = gateway_url.rstrip("/")
        self.bootstrap_token = bootstrap_token
        self.runtime_token = runtime_token
        self.running = False
        self.strategy = strategy
        self.[REDACTED_SECRET]

        # Intervals (may be overridden by server response)
        self.heartbeat_interval = 30
        self.telemetry_interval = 60

        # Simulated telemetry values
        self.equity = 10000.0
        self.pnl_pct = 0.0

        # Trade failure tracking — consecutive failures trigger degraded mode
        self.consecutive_trade_failures = 0
        self.max_consecutive_failures = 5  # enter degraded mode after this many
        self.degraded = False

    def bootstrap_exchange(self):
        """Exchange bootstrap token for a runtime token via POST /runtime/v1/bootstrap."""
        if not self.bootstrap_token:
            log.error("No bootstrap token available")
            return False

        log.info(f"Exchanging bootstrap token ({self.bootstrap_token[:16]}...)")

        status, resp = http_json(
            "POST",
            f"{self.gateway_url}/runtime/v1/bootstrap",
            data={
                "agent_id": self.agent_id,
                "runtime_version": RUNTIME_VERSION,
                "host_fingerprint": get_host_fingerprint(),
            },
            [REDACTED_SECRET],
        )

        if status == 200 and resp:
            self.runtime_token = resp["runtime_token"]
            self.bootstrap_token = None  # consumed

            # Server may override intervals
            self.heartbeat_interval = resp.get("heartbeat_interval_sec", 30)
            self.telemetry_interval = resp.get("telemetry_interval_sec", 60)

            config = resp.get("config", {})
            log.info(f"Bootstrap exchange OK")
            log.info(f"  Runtime [REDACTED_SECRET]")
            log.info(f"  Strategy: {config.get('strategy_kind', '?')}")
            log.info(f"  Heartbeat: {self.heartbeat_interval}s, Telemetry: {self.telemetry_interval}s")

            self._save_token()
            return True
        else:
            msg = resp.get("error", {}).get("message", str(resp)) if resp else f"HTTP {status}"
            log.error(f"Bootstrap exchange failed: {msg}")
            return False

    def auto_register(self, name="OpenClaw Agent", strategy_kind="DemoStrategy"):
        """Auto-register: create agent + issue bootstrap + exchange.

        Uses the API key to call control-plane, then bootstraps into runtime mode.
        Persists agent_id immediately after creation so that a failed bootstrap
        exchange does not cause orphan agents on retry.
        Returns True on success.
        """
        if not self.[REDACTED_SECRET]"No API key available for auto-registration")
            return False

        cp = ControlPlaneClient(self.api_key, self.gateway_url)

        # Step 1: Create agent
        agent_id, _ = cp.create_agent(name=name, strategy_kind=strategy_kind)
        self.agent_id = agent_id

        # Persist agent_id immediately so retries reuse it instead of creating again
        self._save_partial_state()

        # Step 2: Issue bootstrap token
        bootstrap_token = cp.issue_bootstrap(agent_id)
        self.bootstrap_token = bootstrap_token

        # Step 3: Exchange bootstrap for runtime token
        return self.bootstrap_exchange()

    def send_heartbeat(self):
        """Send heartbeat via POST /runtime/v1/heartbeat."""
        status, resp = http_json(
            "POST",
            f"{self.gateway_url}/runtime/v1/heartbeat",
            data={"agent_id": self.agent_id},
            [REDACTED_SECRET],
        )

        if status == 200:
            log.debug("Heartbeat OK")
            return True
        elif status == 401:
            log.warning("Heartbeat rejected (401) — token may be revoked or rotated")
            return False
        else:
            log.warning(f"Heartbeat failed: HTTP {status}")
            return True  # transient error, keep trying

    def send_telemetry(self):
        """Send telemetry via POST /runtime/v1/telemetry."""
        status, resp = http_json(
            "POST",
            f"{self.gateway_url}/runtime/v1/telemetry",
            data={
                "agent_id": self.agent_id,
                "current_equity": round(self.equity, 2),
                "current_pnl_pct": round(self.pnl_pct, 4),
            },
            [REDACTED_SECRET],
        )

        if status == 200:
            log.debug("Telemetry OK")
            return True
        elif status == 401:
            log.warning("Telemetry rejected (401) — token may be revoked")
            return False
        else:
            log.warning(f"Telemetry failed: HTTP {status}")
            return True

    def send_event(self, event_type, payload=None):
        """Send a discrete event via POST /runtime/v1/events."""
        status, resp = http_json(
            "POST",
            f"{self.gateway_url}/runtime/v1/events",
            data={
                "agent_id": self.agent_id,
                "event_type": event_type,
                "payload": payload or {},
            },
            [REDACTED_SECRET],
        )

        if status == 200:
            log.info(f"Event sent: {event_type}")
            return True
        elif status == 401:
            log.warning("Event rejected (401)")
            return False
        else:
            log.warning(f"Event failed: HTTP {status}")
            return True

    def submit_trade(self, ticker, side, size_pct=None, leverage=None, client_order_id=None):
        """Submit a trade intent via POST /runtime/v1/trade.

        Args:
            ticker: e.g. "BTC", "ETH"
            side: "buy" (open long), "short" (open short), "sell" (close long), "cover" (close short)
            size_pct: fraction of cash to use (0.01–1.0), only for opens
            leverage: optional leverage override
            client_order_id: optional client-supplied ID for tracing

        Returns:
            (ok: bool, fill_result: dict | None)
            ok=False means token revoked — caller should stop.
            ok=True means continue (even on trade rejection).
        """
        if self.degraded:
            log.warning("Skipping trade — in degraded mode due to consecutive failures")
            return True, None

        data = {
            "agent_id": self.agent_id,
            "ticker": ticker,
            "side": side,
        }
        if size_pct is not None:
            data["size_pct"] = size_pct
        if leverage is not None:
            data["leverage"] = leverage
        if client_order_id is not None:
            data["client_order_id"] = client_order_id

        for attempt in range(3):
            status, resp = http_json(
                "POST",
                f"{self.gateway_url}/runtime/v1/trade",
                data=data,
                [REDACTED_SECRET],
            )

            if status == 200 and resp:
                fill = resp.get("fill", {})
                log.info(
                    f"Trade filled: {side} {ticker} | "
                    f"price={fill.get('price', '?')} size={fill.get('size', '?')} "
                    f"pnl={fill.get('pnl', '-')} | "
                    f"equity={resp.get('equity_after', '?')}"
                )
                self.equity = resp.get("equity_after", self.equity)
                self.consecutive_trade_failures = 0
                return True, resp
            elif status == 401:
                log.warning("Trade rejected (401) — token revoked")
                return False, resp
            elif status == 400:
                # Business logic rejection (duplicate position, no cash, etc.)
                # Not a failure — reset counter, don't retry
                msg = resp.get("error", {}).get("message", str(resp)) if resp else f"HTTP {status}"
                log.warning(f"Trade rejected (400): {msg}")
                self.consecutive_trade_failures = 0
                return True, resp
            elif status == 0 and attempt < 2:
                log.warning(f"Trade request failed, retrying ({attempt + 1}/3)...")
                time.sleep(2 ** attempt)
                continue
            else:
                # 5xx or final network failure
                self.consecutive_trade_failures += 1
                log.error(
                    f"Trade failed: HTTP {status} "
                    f"(consecutive failures: {self.consecutive_trade_failures}/{self.max_consecutive_failures})"
                )
                if self.consecutive_trade_failures >= self.max_consecutive_failures:
                    self.degraded = True
                    log.error("Entering DEGRADED mode — strategy trading suspended")
                    self.send_event("trade_submission_degraded", {
                        "consecutive_failures": self.consecutive_trade_failures,
                        "last_status": status,
                        "ticker": ticker,
                        "side": side,
                    })
                return True, resp

        # All retries exhausted
        self.consecutive_trade_failures += 1
        log.error(
            f"Trade failed after 3 retries "
            f"(consecutive failures: {self.consecutive_trade_failures}/{self.max_consecutive_failures})"
        )
        if self.consecutive_trade_failures >= self.max_consecutive_failures:
            self.degraded = True
            log.error("Entering DEGRADED mode — strategy trading suspended")
            self.send_event("trade_submission_degraded", {
                "consecutive_failures": self.consecutive_trade_failures,
                "ticker": ticker,
                "side": side,
            })
        return True, None

    def _attempt_reconnect(self):
        """Attempt to reconnect after a 401 (token rotated or revoked).

        If an API key is available, tries to issue a new bootstrap token and
        exchange it.

        Returns:
            True  — reconnect succeeded, caller should continue running.
            False — reconnect failed permanently (401); caller should clear state and exit.
            None  — reconnect failed transiently; caller should exit but preserve state.
        """
        if not self.[REDACTED_SECRET] False

        log.info("Attempting reconnect with API key...")
        self.runtime_token = None
        cp = ControlPlaneClient(self.api_key, self.gateway_url)

        try:
            bootstrap_token = cp.issue_bootstrap(self.agent_id)
            self.bootstrap_token = bootstrap_token
            if self.bootstrap_exchange():
                log.info("Reconnect successful — runtime token refreshed")
                self.degraded = False
                self.consecutive_trade_failures = 0
                self.send_event("runtime_reconnected", {"version": RUNTIME_VERSION})
                return True
        except ControlPlaneError as e:
            if e.status == 401:
                log.error(f"Reconnect failed (permanent): {e}")
                return False
            log.error(f"Reconnect failed (transient): {e}")
            return None
        except RuntimeError as e:
            log.error(f"Reconnect failed: {e}")
            return None

        return None

    def run(self):
        """Main loop: heartbeat + telemetry + strategy."""
        # Step 1: get a runtime token (bootstrap or existing)
        if not self.runtime_token:
            if not self.bootstrap_exchange():
                log.error("Cannot start without a runtime token. Exiting.")
                sys.exit(1)
        else:
            log.info(f"Using existing runtime token ({self.runtime_token[:16]}...)")

        self.running = True
        log.info("=" * 50)
        log.info(f"Runtime client ONLINE — agent {self.agent_id}")
        log.info(f"  Gateway: {self.gateway_url}")
        log.info(f"  Strategy: {self.strategy.__class__.__name__ if self.strategy else 'none'}")
        log.info(f"  Heartbeat: {self.heartbeat_interval}s, Telemetry: {self.telemetry_interval}s")
        log.info("=" * 50)

        # Send startup event
        self.send_event("runtime_started", {"version": RUNTIME_VERSION})

        last_heartbeat = 0.0
        last_telemetry = 0.0
        last_strategy = 0.0

        while self.running:
            now = time.time()

            # Heartbeat
            if now - last_heartbeat >= self.heartbeat_interval:
                if not self.send_heartbeat():
                    # Token rejected — try to reconnect if we have an API key
                    reconnect = self._attempt_reconnect()
                    if reconnect is True:
                        last_heartbeat = now
                        continue
                    if reconnect is False:
                        # 401 — agent permanently revoked; clear state for fresh start
                        log.error("Agent permanently rejected. Clearing state.")
                        _clear_saved_state()
                    else:
                        # Transient failure — preserve state for retry
                        log.error("Reconnect failed (transient). Retry later.")
                    sys.exit(1)
                last_heartbeat = now

            # Strategy tick
            if self.strategy and now - last_strategy >= self.strategy.interval:
                intents = self.strategy.tick()
                for intent in intents:
                    ok, resp = self.submit_trade(
                        ticker=intent["ticker"],
                        side=intent["side"],
                        size_pct=intent.get("size_pct"),
                        client_order_id=intent.get("client_order_id"),
                    )
                    if not ok:
                        # Token rejected — try to reconnect
                        reconnect = self._attempt_reconnect()
                        if reconnect is True:
                            break  # skip remaining intents, resume on next tick
                        if reconnect is False:
                            log.error("Agent permanently rejected. Clearing state.")
                            _clear_saved_state()
                        else:
                            log.error("Reconnect failed (transient). Retry later.")
                        sys.exit(1)
                last_strategy = now

            # Telemetry
            if now - last_telemetry >= self.telemetry_interval:
                self.send_telemetry()
                last_telemetry = now

            time.sleep(min(self.heartbeat_interval, self.strategy.interval if self.strategy else 10, 10))

        log.info("Runtime client stopped")

    def stop(self):
        """Signal the main loop to stop."""
        self.running = False

    def _save_partial_state(self):
        """Persist agent_id + gateway_url without runtime_token.

        Called immediately after create_agent() so that if bootstrap_exchange()
        fails, the next startup will reuse this agent_id instead of creating
        another one (avoiding orphan agents).
        """
        try:
            data = {
                "agent_id": self.agent_id,
                "gateway_url": self.gateway_url,
            }
            # Preserve existing runtime_token if present
            existing = load_saved_token() or {}
            if existing.get("runtime_token"):
                data["runtime_token"] = existing["runtime_token"]
            with open(TOKEN_FILE, "w") as f:
                json.dump(data, f, indent=2)
            os.chmod(TOKEN_FILE, 0o600)
            log.info(f"Agent state saved (agent_id={self.agent_id})")
        except Exception as e:
            log.warning(f"Could not save partial state: {e}")

    def _save_token(self):
        """Persist runtime token to disk for reconnection."""
        try:
            data = {
                "agent_id": self.agent_id,
                "runtime_token": self.runtime_token,
                "gateway_url": self.gateway_url,
            }
            with open(TOKEN_FILE, "w") as f:
                json.dump(data, f, indent=2)
            os.chmod(TOKEN_FILE, 0o600)
            log.info(f"Runtime token saved to {TOKEN_FILE}")
        except Exception as e:
            log.warning(f"Could not save token: {e}")

    def _remove_token(self):
        """Clear runtime_token from state file (after revocation).

        Preserves agent_id so the next startup can re-bootstrap the same agent
        instead of creating a new one.
        """
        try:
            if os.path.exists(TOKEN_FILE):
                existing = load_saved_token() or {}
                agent_id = existing.get("agent_id")
                gateway_url = existing.get("gateway_url")
                if agent_id:
                    # Keep agent_id, clear runtime_token
                    with open(TOKEN_FILE, "w") as f:
                        json.dump({"agent_id": agent_id, "gateway_url": gateway_url}, f, indent=2)
                    log.info(f"Runtime token cleared, agent_id preserved ({agent_id})")
                else:
                    os.remove(TOKEN_FILE)
        except Exception:
            pass


def load_saved_token():
    """Load previously saved runtime token from disk."""
    try:
        if os.path.exists(TOKEN_FILE):
            with open(TOKEN_FILE) as f:
                return json.load(f)
    except Exception:
        pass
    return None


def _clear_saved_state():
    """Remove the saved state file entirely (agent_id + runtime_token).

    Used when a saved agent is permanently unreachable (revoked/deleted) and
    the client needs to fall back to creating a fresh agent on next startup.
    """
    try:
        if os.path.exists(TOKEN_FILE):
            os.remove(TOKEN_FILE)
            log.info(f"Saved state cleared ({TOKEN_FILE})")
    except Exception:
        pass


def load_api_key():
    """Load API key from file (~/.clawswap_api_key or local .clawswap_api_key)."""
    try:
        if os.path.exists(API_KEY_FILE):
            with open(API_KEY_FILE) as f:
                key = f.read().strip()
                if key:
                    return key
    except Exception:
        pass
    return None


def save_api_key(api_key):
    """Persist API key to file for future use."""
    try:
        with open(API_KEY_FILE, "w") as f:
            f.write(api_key)
        os.chmod(API_KEY_FILE, 0o600)
        log.info(f"API key saved to {API_KEY_FILE}")
    except Exception as e:
        log.warning(f"Could not save API key: {e}")


def main():
    parser = argparse.ArgumentParser(
        description="ClawSwap Self-Hosted Runtime Client",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument("--api-key", help="API key from Dashboard Settings (or CLAWSWAP_API_KEY env)")
    parser.add_argument("--agent-id", help="Agent ID (or CLAWSWAP_AGENT_ID env)")
    parser.add_argument("--agent-name", default="OpenClaw Agent", help="Agent name for auto-registration")
    parser.add_argument("--gateway", help="Gateway URL (or CLAWSWAP_GATEWAY_URL env)")
    parser.add_argument("--bootstrap-token", help="Bootstrap token (or CLAWSWAP_BOOTSTRAP_TOKEN env)")
    parser.add_argument("--runtime-token", help="Runtime token (or CLAWSWAP_RUNTIME_TOKEN env)")
    parser.add_argument("--strategy", default="demo",
                        help="Strategy: demo | random | none | mean_reversion | momentum | grid | breakout | short_momentum | dual_ma | range_scalper | adaptive (default: demo)")
    parser.add_argument("--ticker", default="BTC", help="Ticker for demo strategy (default: BTC)")
    parser.add_argument("--strategy-interval", type=int, default=30,
                        help="Seconds between strategy ticks (default: 30)")
    args = parser.parse_args()

    # Load .env files: local .env (skill root) then gateway/tests/.env.e2e
    # Later files do NOT override earlier ones or real env vars.
    env_files = [
        os.path.join(SKILL_DIR, ".env"),
        os.path.join(SKILL_DIR, "..", "..", "gateway", "tests", ".env.e2e"),
    ]
    for env_file in env_files:
        if os.path.exists(env_file):
            with open(env_file) as f:
                for line in f:
                    line = line.strip()
                    if line and not line.startswith("#") and "=" in line:
                        k, _, v = line.partition("=")
                        if v and k.strip() not in os.environ:
                            os.environ[k.strip()] = v.strip()

    # Resolve from args → env → saved file
    saved = load_saved_token() or {}

    [REDACTED_SECRET] or os.environ.get("CLAWSWAP_API_KEY") or load_api_key()
    agent_id = args.agent_id or os.environ.get("CLAWSWAP_AGENT_ID") or saved.get("agent_id")
    gateway_url = args.gateway or os.environ.get("CLAWSWAP_GATEWAY_URL") or os.environ.get("GATEWAY_URL") or saved.get("gateway_url", "https://api.clawswap.trade")
    bootstrap_token = args.bootstrap_token or os.environ.get("CLAWSWAP_BOOTSTRAP_TOKEN")
    runtime_token = args.runtime_token or os.environ.get("CLAWSWAP_RUNTIME_TOKEN") or saved.get("runtime_token")

    # Persist API key if provided via CLI/env for future runs
    if api_key and not load_api_key():
        save_api_key(api_key)

    # Build strategy
    strategy = None
    strategy_kind = args.strategy
    if args.strategy == "demo":
        strategy = DemoStrategy(ticker=args.ticker, interval=args.strategy_interval)
        strategy_kind = "DemoStrategy"
    elif args.strategy == "random":
        strategy = RandomStrategy(interval=args.strategy_interval)
        strategy_kind = "RandomStrategy"
    elif args.strategy == "none":
        strategy = None
        strategy_kind = "None"
    else:
        # Load a real strategy from strategies/
        try:
            sys.path.insert(0, SKILL_DIR)
            from strategies import STRATEGY_MAP, _ALIAS_DEFAULTS
            if args.strategy not in STRATEGY_MAP:
                log.error(f"Unknown strategy: {args.strategy}. Available: {list(STRATEGY_MAP.keys())}")
                sys.exit(1)
            StratClass, CfgClass = STRATEGY_MAP[args.strategy]
            cfg_kwargs = {"ticker": args.ticker}
            if args.strategy in _ALIAS_DEFAULTS:
                cfg_kwargs.update(_ALIAS_DEFAULTS[args.strategy])
            cfg = CfgClass(**cfg_kwargs)
            strat_instance = StratClass(cfg)
            strategy = LiveStrategyAdapter(
                strat_instance, cfg, ticker=args.ticker, interval=args.strategy_interval,
            )
            strategy_kind = args.strategy
            log.info(f"Loaded strategy: {args.strategy} ({StratClass.__name__})")
        except ImportError as e:
            log.error(f"Cannot load strategy '{args.strategy}': {e}")
            sys.exit(1)

    # ── Path A: auto-register with API key ──────────────────────────────
    # If saved state has agent_id but no runtime_token, skip creation and
    # go to Path B (re-bootstrap) to avoid orphan agent duplication.
    if api_key and not agent_id and not runtime_token and not bootstrap_token:
        log.info("Auto-registration mode: creating agent with API key")
        client = RuntimeClient(
            agent_id="pending",
            gateway_url=gateway_url,
            [REDACTED_SECRET],
            strategy=strategy,
        )

        try:
            if not client.auto_register(name=args.agent_name, strategy_kind=strategy_kind):
                log.error("Auto-registration failed. Exiting.")
                sys.exit(1)
        except RuntimeError as e:
            log.error(f"Auto-registration failed: {e}")
            sys.exit(1)

        def _shutdown(sig, frame):
            log.info("Shutdown signal received")
            client.send_event("runtime_stopped", {"reason": "signal"})
            client.stop()

        signal.signal(signal.SIGINT, _shutdown)
        signal.signal(signal.SIGTERM, _shutdown)

        client.run()
        return

    # ── Path B: re-bootstrap existing agent with API key ────────────────
    if api_key and agent_id and not runtime_token and not bootstrap_token:
        log.info(f"Re-bootstrapping agent {agent_id} with API key")
        cp = ControlPlaneClient(api_key, gateway_url)
        try:
            bootstrap_token = cp.issue_bootstrap(agent_id)
        except ControlPlaneError as e:
            if e.status == 401:
                # 401 = API key invalid OR agent revoked/deleted — permanent.
                # Clear saved state and fall back to Path A (create new agent).
                log.warning(f"Agent {agent_id} rejected (401): {e}")
                log.info("Clearing saved agent state and creating a new agent...")
                _clear_saved_state()
                client = RuntimeClient(
                    agent_id="pending",
                    gateway_url=gateway_url,
                    [REDACTED_SECRET],
                    strategy=strategy,
                )

                try:
                    if not client.auto_register(name=args.agent_name, strategy_kind=strategy_kind):
                        log.error("Auto-registration failed. Exiting.")
                        sys.exit(1)
                except RuntimeError as e2:
                    log.error(f"Auto-registration failed: {e2}")
                    sys.exit(1)

                def _shutdown(sig, frame):
                    log.info("Shutdown signal received")
                    client.send_event("runtime_stopped", {"reason": "signal"})
                    client.stop()

                signal.signal(signal.SIGINT, _shutdown)
                signal.signal(signal.SIGTERM, _shutdown)

                client.run()
                return
            else:
                # Transient failure (network=0, 5xx, etc.) — keep saved
                # agent_id intact so the next startup retries the same agent.
                log.error(f"Re-bootstrap failed (transient): {e}")
                log.error("Saved agent state preserved. Please retry later.")
                sys.exit(1)

    # ── Path C: manual / reconnect ──────────────────────────────────────
    if not agent_id:
        print("Error: --agent-id or CLAWSWAP_AGENT_ID required (or use --api-key for auto-registration)")
        sys.exit(1)

    if not bootstrap_token and not runtime_token:
        print("Error: need --api-key, --bootstrap-token, or --runtime-token")
        print("  Option 1: Use --api-key (from Dashboard Settings) for auto-registration")
        print("  Option 2: Use --bootstrap-token (from Dashboard Agent Detail)")
        print("  Option 3: Use --runtime-token (from saved state)")
        sys.exit(1)

    client = RuntimeClient(
        agent_id=agent_id,
        gateway_url=gateway_url,
        bootstrap_token=bootstrap_token,
        runtime_token=runtime_token,
        strategy=strategy,
        [REDACTED_SECRET],
    )

    def _shutdown(sig, frame):
        log.info("Shutdown signal received")
        client.send_event("runtime_stopped", {"reason": "signal"})
        client.stop()

    signal.signal(signal.SIGINT, _shutdown)
    signal.signal(signal.SIGTERM, _shutdown)

    client.run()


if __name__ == "__main__":
    main()


path: clawswap/skill.json
content: {
  "name": "clawswap",
  "version": "0.2.1",
  "description": "Run and iterate a self-hosted ClawSwap AI trading agent with Python. Supports runtime trading, local backtests, and market data download.",
  "author": "clawswap",
  "homepage": "https://clawswap.trade",
  "repository": "https://github.com/clawswap/agent-skill",
  "tags": [
    "trading",
    "defi",
    "hyperliquid",
    "ai-agent",
    "perpetuals"
  ],
  "commands": [
    {
      "name": "runtime",
      "description": "Start runtime client with strategy loop, heartbeat, telemetry, and reconnect flow",
      "entry": "runtime_client.py"
    },
    {
      "name": "backtest",
      "description": "Run local backtest over downloaded historical candles",
      "entry": "tools/backtest.py"
    }
  ],
  "config": {
    "api_key": {
      "description": "ClawSwap API key (clsw_...)",
      "required": true,
      "env": "CLAWSWAP_API_KEY",
      "secret": true
    },
    "gateway_url": {
      "description": "ClawSwap API base URL",
      "required": false,
      "default": "https://api.clawswap.trade",
      "env": "CLAWSWAP_GATEWAY_URL"
    },
    "strategy": {
      "description": "Trading strategy: demo | momentum | mean_reversion | grid | breakout | dual_ma | short_momentum | range_scalper | adaptive | random | none",
      "required": false,
      "default": "demo",
      "env": "CLAWSWAP_STRATEGY"
    },
    "ticker": {
      "description": "Trading pair: BTC | ETH | SOL",
      "required": false,
      "default": "BTC",
      "env": "CLAWSWAP_TICKER"
    }
  },
  "runtime": "python",
  "entrypoint": "runtime_client.py",
  "dependencies": {
    "python": ">=3.10"
  },
  "changelog": {
    "0.3.1": "Align metadata semantics with SKILL.md, tighten command definitions, and add release packaging workflow.",
    "0.3.0": "Runtime v1 protocol: API key auth, auto-registration, paper trading, heartbeat/telemetry, rotate/revoke recovery",
    "0.2.0": "Self-hosted agent runner, local backtest engine, strategy templates, Binance data downloader",
    "0.1.0": "Initial release: trade/balance/competitions/points commands"
  }
}


path: clawswap/examples/rsi_macd_strategy.py
content: """
Example Custom Strategy: Mean Reversion + RSI + MACD Filters
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Combines the proven mean_reversion logic (BTC +4.78% baseline) with
RSI oversold/overbought confirmation and MACD trend alignment.

LONG:  Price drops 4%+ from 480-bar high AND RSI < 40 AND MACD histogram rising
SHORT: Price rises 4%+ from 480-bar low AND RSI > 60 AND MACD histogram falling
Exit:  3% recovery OR 5% stop loss OR 8% take profit

Tuned for 1-minute candle data with 3.5bps fees per trade.
"""


def strategy(df):
    signals = []
    in_position = False
    is_long = True
    entry_price = 0.0
    bars_since_exit = 999
    cooldown = 120        # 2h cooldown between trades
    lookback = 480        # 8h lookback window
    entry_threshold = 0.04  # 4% move to trigger entry
    exit_threshold = 0.03   # 3% recovery to take profit
    stop_loss = 0.05        # 5% stop loss
    take_profit = 0.08      # 8% take profit

    # Indicators for filtering
    rsi_vals = rsi(df["close"], period=14)
    _, _, macd_hist = macd(df["close"], fast=12, slow=26, signal=9)

    for i in range(lookback, len(df)):
        price = df.iloc[i]["close"]
        window = df.iloc[i - lookback:i]
        window_high = window["high"].max()
        window_low = window["low"].min()

        if not in_position:
            bars_since_exit += 1
            if bars_since_exit < cooldown:
                continue

            # LONG: price dropped from high + RSI confirms oversold area + MACD not deeply bearish
            drop_from_high = (window_high - price) / window_high
            if drop_from_high >= entry_threshold:
                rsi_ok = rsi_vals.iloc[i] < 40  # More lenient than pure RSI strategy
                macd_ok = macd_hist.iloc[i] > macd_hist.iloc[i - 1]  # MACD improving
                if rsi_ok and macd_ok:
                    signals.append({
                        "bar": i,
                        "action": "long",
                        "size": 0.2,
                        "reason": f"Drop {drop_from_high*100:.1f}% + RSI={rsi_vals.iloc[i]:.0f} + MACD↑",
                    })
                    in_position = True
                    is_long = True
                    entry_price = price
                    continue

            # SHORT: price rose from low + RSI confirms overbought area + MACD weakening
            rise_from_low = (price - window_low) / window_low
            if rise_from_low >= entry_threshold:
                rsi_ok = rsi_vals.iloc[i] > 60
                macd_ok = macd_hist.iloc[i] < macd_hist.iloc[i - 1]  # MACD declining
                if rsi_ok and macd_ok:
                    signals.append({
                        "bar": i,
                        "action": "short",
                        "size": 0.2,
                        "reason": f"Rise {rise_from_low*100:.1f}% + RSI={rsi_vals.iloc[i]:.0f} + MACD↓",
                    })
                    in_position = True
                    is_long = False
                    entry_price = price

        else:
            # PnL calculation
            if is_long:
                pnl_pct = (price - entry_price) / entry_price
            else:
                pnl_pct = (entry_price - price) / entry_price

            exit_reason = None
            if pnl_pct <= -stop_loss:
                exit_reason = f"Stop loss {pnl_pct*100:.1f}%"
            elif pnl_pct >= take_profit:
                exit_reason = f"Take profit {pnl_pct*100:.1f}%"
            elif pnl_pct >= exit_threshold:
                exit_reason = f"Target exit {pnl_pct*100:.1f}%"

            if exit_reason:
                signals.append({
                    "bar": i, "action": "close", "size": 1.0,
                    "reason": exit_reason,
                })
                in_position = False
                bars_since_exit = 0

    return signals


path: clawswap/tests/test_runtime_client.py
content: #!/usr/bin/env python3
"""
Unit tests for runtime_client.py

Run from skill root (skill/clawswap/):
    python3 tests/test_runtime_client.py
    python3 -m pytest tests/test_runtime_client.py
"""

import json
import os
import random
import sys
import unittest
from unittest.mock import patch, call

# Ensure runtime_client is importable (it lives in the skill root)
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))

from runtime_client import (
    RuntimeClient, DemoStrategy, RandomStrategy, LiveStrategyAdapter,
    ControlPlaneClient, ControlPlaneError,
    load_api_key, save_api_key, API_KEY_FILE,
    _clear_saved_state,
)
from strategies import MomentumStrategy, MomentumConfig


class TokenFileCleanupMixin:
    """Mixin that backs up and restores TOKEN_FILE around each test."""

    def setUp(self):
        from runtime_client import TOKEN_FILE
        self._token_file = TOKEN_FILE
        self._token_backup = None
        if os.path.exists(TOKEN_FILE):
            with open(TOKEN_FILE) as f:
                self._token_backup = f.read()
            os.remove(TOKEN_FILE)

    def tearDown(self):
        if os.path.exists(self._token_file):
            os.remove(self._token_file)
        if self._token_backup is not None:
            with open(self._token_file, "w") as f:
                f.write(self._token_backup)


class TestBootstrap(TokenFileCleanupMixin, unittest.TestCase):
    """Tests for RuntimeClient.bootstrap_exchange()."""

    @patch("runtime_client.http_json")
    def test_bootstrap_success(self, mock_http):
        """On 200, runtime_token is set and bootstrap_token is cleared."""
        mock_http.return_value = (200, {
            "runtime_token": "clsw_rt_xxx_test_token_value",
            "heartbeat_interval_sec": 30,
            "telemetry_interval_sec": 60,
            "config": {"strategy_kind": "demo"},
        })

        client = RuntimeClient(
            agent_id="agt_test",
            gateway_url="http://localhost:3033",
            bootstrap_token="clsw_bt_bootstrap_token_value",
        )

        result = client.bootstrap_exchange()

        self.assertTrue(result)
        self.assertEqual(client.runtime_token, "clsw_rt_xxx_test_token_value")
        self.assertIsNone(client.bootstrap_token)

    @patch("runtime_client.http_json")
    def test_bootstrap_failure(self, mock_http):
        """On 401, returns False and runtime_token stays None."""
        mock_http.return_value = (401, None)

        client = RuntimeClient(
            agent_id="agt_test",
            gateway_url="http://localhost:3033",
            bootstrap_token="clsw_bt_bootstrap_token_value",
        )

        result = client.bootstrap_exchange()

        self.assertFalse(result)
        self.assertIsNone(client.runtime_token)


class TestSubmitTrade(unittest.TestCase):
    """Tests for RuntimeClient.submit_trade()."""

    def _make_client(self, **kwargs):
        defaults = {
            "agent_id": "agt_test",
            "gateway_url": "http://localhost:3033",
            "runtime_token": "clsw_rt_test",
        }
        defaults.update(kwargs)
        return RuntimeClient(**defaults)

    @patch("runtime_client.http_json")
    def test_trade_submit_success(self, mock_http):
        """On 200, returns (True, resp), equity is updated, failures reset."""
        resp_body = {
            "ok": True,
            "fill": {"price": 100, "size": 0.5},
            "equity_after": 9900,
        }
        mock_http.return_value = (200, resp_body)

        client = self._make_client()
        client.consecutive_trade_failures = 2  # pre-existing failures

        ok, resp = client.submit_trade("BTC", "buy", size_pct=0.1)

        self.assertTrue(ok)
        self.assertEqual(resp, resp_body)
        self.assertEqual(client.equity, 9900)
        self.assertEqual(client.consecutive_trade_failures, 0)

    @patch("runtime_client.http_json")
    def test_trade_submit_400_no_degraded(self, mock_http):
        """On 400, returns (True, resp), degraded stays False, failures reset."""
        resp_body = {"error": {"message": "Already in position"}}
        mock_http.return_value = (400, resp_body)

        client = self._make_client()
        client.consecutive_trade_failures = 3

        ok, resp = client.submit_trade("BTC", "buy", size_pct=0.1)

        self.assertTrue(ok)
        self.assertEqual(resp, resp_body)
        self.assertFalse(client.degraded)
        self.assertEqual(client.consecutive_trade_failures, 0)

    @patch("runtime_client.http_json")
    def test_trade_submit_consecutive_5xx_enters_degraded(self, mock_http):
        """After 5 consecutive 5xx failures, degraded mode is activated."""
        mock_http.return_value = (500, None)

        client = self._make_client()

        # Also mock send_event to verify it is called with the right event type
        with patch.object(client, "send_event") as mock_event:
            for i in range(5):
                ok, resp = client.submit_trade("BTC", "buy", size_pct=0.1)
                self.assertTrue(ok)  # ok=True even on 5xx (continue running)

            self.assertTrue(client.degraded)
            self.assertEqual(client.consecutive_trade_failures, 5)

            # Verify send_event was called with "trade_submission_degraded"
            event_calls = [c for c in mock_event.call_args_list
                           if c[0][0] == "trade_submission_degraded"]
            self.assertGreaterEqual(len(event_calls), 1,
                                    "send_event('trade_submission_degraded', ...) should have been called")

    @patch("runtime_client.http_json")
    def test_trade_submit_skipped_when_degraded(self, mock_http):
        """When degraded=True, submit_trade skips without calling http_json."""
        client = self._make_client()
        client.degraded = True

        ok, resp = client.submit_trade("BTC", "buy", size_pct=0.1)

        self.assertTrue(ok)
        self.assertIsNone(resp)
        mock_http.assert_not_called()


class TestStrategyNone(unittest.TestCase):
    """Tests for RuntimeClient with strategy=None."""

    def test_strategy_none_no_crash(self):
        """Creating a RuntimeClient with strategy=None should not crash.

        The run() method accesses self.strategy conditionally, so a None
        strategy should be safe at construction time and in the log line.
        """
        client = RuntimeClient(
            agent_id="agt_test",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_test",
            strategy=None,
        )

        self.assertIsNone(client.strategy)

        # Verify the conditional access pattern used in the log line:
        #   self.strategy.__class__.__name__ if self.strategy else 'none'
        label = client.strategy.__class__.__name__ if client.strategy else "none"
        self.assertEqual(label, "none")


class TestDemoStrategy(unittest.TestCase):
    """Tests for DemoStrategy tick cycle."""

    def test_demo_strategy_cycle(self):
        """DemoStrategy follows the pattern: buy, hold, sell, hold, repeat."""
        strategy = DemoStrategy(ticker="BTC", size_pct=0.1)

        # Expected over 8 ticks:
        #   tick 1 (phase 1): buy   — opens position
        #   tick 2 (phase 2): hold  — no intents
        #   tick 3 (phase 3): sell  — closes position
        #   tick 4 (phase 0): hold  — no intents
        #   tick 5 (phase 1): buy   — opens position
        #   tick 6 (phase 2): hold  — no intents
        #   tick 7 (phase 3): sell  — closes position
        #   tick 8 (phase 0): hold  — no intents
        expected = [
            ("buy",),    # tick 1
            (),          # tick 2
            ("sell",),   # tick 3
            (),          # tick 4
            ("buy",),    # tick 5
            (),          # tick 6
            ("sell",),   # tick 7
            (),          # tick 8
        ]

        for i, expected_sides in enumerate(expected, start=1):
            intents = strategy.tick()
            actual_sides = tuple(intent["side"] for intent in intents)
            self.assertEqual(
                actual_sides, expected_sides,
                f"Tick {i}: expected sides {expected_sides}, got {actual_sides}"
            )

            # Verify ticker is always BTC when an intent is produced
            for intent in intents:
                self.assertEqual(intent["ticker"], "BTC")


class TestRandomStrategy(unittest.TestCase):
    """Tests for RandomStrategy."""

    def test_random_strategy_produces_intents(self):
        """With a fixed seed, RandomStrategy produces at least 1 intent in 20 ticks."""
        # Use a fixed seed for reproducibility
        random.seed(42)
        strategy = RandomStrategy(tickers=["BTC", "ETH"], size_pct=0.05)

        all_intents = []
        for _ in range(20):
            intents = strategy.tick()
            all_intents.extend(intents)

        self.assertGreaterEqual(
            len(all_intents), 1,
            "RandomStrategy should produce at least 1 intent in 20 ticks with seed 42"
        )

        # Verify all intents have required fields
        for intent in all_intents:
            self.assertIn("ticker", intent)
            self.assertIn("side", intent)
            self.assertIn("client_order_id", intent)
            self.assertIn(intent["ticker"], ["BTC", "ETH"])
            self.assertIn(intent["side"], ["buy", "short", "sell"])


class TestLiveStrategyAdapter(unittest.TestCase):
    """Regression tests for strategy side mapping in the live adapter."""

    def test_short_momentum_entry_uses_short_side(self):
        cfg = MomentumConfig(
            ticker="SOL",
            direction="short",
            breakout_lookback=3,
            entry_threshold_pct=4.0,
            cooldown_candles=0,
        )
        strategy = MomentumStrategy(cfg)
        adapter = LiveStrategyAdapter(strategy, cfg, ticker="SOL", interval=30)

        with patch.object(adapter, "_get_price", side_effect=[100.0, 102.0, 104.0, 98.0]):
            intents = []
            for _ in range(4):
                intents.extend(adapter.tick())

        self.assertEqual(len(intents), 1)
        self.assertEqual(intents[0]["ticker"], "SOL")
        self.assertEqual(intents[0]["side"], "short")
        self.assertTrue(adapter.in_position)
        self.assertEqual(adapter.position_side, "short")
        self.assertTrue(strategy.state.in_position)
        self.assertFalse(strategy.state.is_long)

    def test_short_momentum_exit_uses_cover_side(self):
        cfg = MomentumConfig(
            ticker="SOL",
            direction="short",
            breakout_lookback=3,
            cooldown_candles=0,
        )
        strategy = MomentumStrategy(cfg)
        strategy.on_fill("short", 100.0, "pos_short")

        adapter = LiveStrategyAdapter(strategy, cfg, ticker="SOL", interval=30)
        adapter.in_position = True
        adapter.position_side = "short"

        with patch.object(adapter, "_get_price", return_value=90.0):
            intents = adapter.tick()

        self.assertEqual(len(intents), 1)
        self.assertEqual(intents[0]["side"], "cover")
        self.assertFalse(adapter.in_position)
        self.assertIsNone(adapter.position_side)
        self.assertFalse(strategy.state.in_position)
        self.assertEqual(strategy.state.bars_since_exit, 0)


class TestControlPlaneClient(unittest.TestCase):
    """Tests for ControlPlaneClient (API key based operations)."""

    @patch("runtime_client.http_json")
    def test_create_agent_success(self, mock_http):
        """On 200, returns agent_id from nested response."""
        mock_http.return_value = (200, {
            "agent": {"id": "agt_new_123", "name": "Test Agent"},
        })

        cp = ControlPlaneClient([REDACTED_SECRET]", gateway_url="http://localhost:3033")
        agent_id, resp = cp.create_agent(name="Test Agent", strategy_kind="Demo")

        self.assertEqual(agent_id, "agt_new_123")
        # Verify the API key was used as the Bearer token
        call_args = mock_http.call_args
        self.assertEqual(call_args[1].get("token") or call_args[0][3], "clsw_testkey")

    @patch("runtime_client.http_json")
    def test_create_agent_invalid_key(self, mock_http):
        """On 401, raises RuntimeError with clear message."""
        mock_http.return_value = (401, None)

        cp = ControlPlaneClient([REDACTED_SECRET]", gateway_url="http://localhost:3033")

        with self.assertRaises(RuntimeError) as ctx:
            cp.create_agent()
        self.assertIn("401", str(ctx.exception))

    @patch("runtime_client.http_json")
    def test_create_agent_network_error(self, mock_http):
        """On status 0 (network failure), raises RuntimeError."""
        mock_http.return_value = (0, None)

        cp = ControlPlaneClient([REDACTED_SECRET]", gateway_url="http://unreachable:3033")

        with self.assertRaises(RuntimeError) as ctx:
            cp.create_agent()
        self.assertIn("gateway", str(ctx.exception).lower())

    @patch("runtime_client.http_json")
    def test_issue_bootstrap_success(self, mock_http):
        """On 200, returns bootstrap_token."""
        mock_http.return_value = (200, {
            "bootstrap_token": "clsw_bt_new_bootstrap",
            "credential_id": "cred_123",
        })

        cp = ControlPlaneClient([REDACTED_SECRET]", gateway_url="http://localhost:3033")
        [REDACTED_SECRET]"agt_123")

        self.assertEqual(token, "clsw_bt_new_bootstrap")

    @patch("runtime_client.http_json")
    def test_issue_bootstrap_invalid_key(self, mock_http):
        """On 401, raises RuntimeError."""
        mock_http.return_value = (401, None)

        cp = ControlPlaneClient([REDACTED_SECRET]", gateway_url="http://localhost:3033")

        with self.assertRaises(RuntimeError) as ctx:
            cp.issue_bootstrap("agt_123")
        self.assertIn("401", str(ctx.exception))


class TestAutoRegister(TokenFileCleanupMixin, unittest.TestCase):
    """Tests for RuntimeClient.auto_register()."""

    @patch("runtime_client.http_json")
    def test_auto_register_full_flow(self, mock_http):
        """auto_register creates agent, issues bootstrap, exchanges for runtime token."""
        # Mock: create agent → bootstrap issue → bootstrap exchange
        mock_http.side_effect = [
            # 1. POST /api/v1/agents (create agent)
            (200, {"agent": {"id": "agt_auto_123"}}),
            # 2. POST /api/v1/agents/:id/bootstrap (issue bootstrap)
            (200, {"bootstrap_token": "clsw_bt_auto_bootstrap"}),
            # 3. POST /runtime/v1/bootstrap (exchange)
            (200, {
                "runtime_token": "clsw_rt_auto_runtime",
                "heartbeat_interval_sec": 30,
                "telemetry_interval_sec": 60,
                "config": {"strategy_kind": "DemoStrategy"},
            }),
        ]

        client = RuntimeClient(
            agent_id="pending",
            gateway_url="http://localhost:3033",
            [REDACTED_SECRET]",
        )

        result = client.auto_register(name="Test Agent")

        self.assertTrue(result)
        self.assertEqual(client.agent_id, "agt_auto_123")
        self.assertEqual(client.runtime_token, "clsw_rt_auto_runtime")
        self.assertIsNone(client.bootstrap_token)

    @patch("runtime_client.http_json")
    def test_auto_register_no_api_key(self, mock_http):
        """auto_register fails if no API key is set."""
        client = RuntimeClient(
            agent_id="pending",
            gateway_url="http://localhost:3033",
        )

        result = client.auto_register()

        self.assertFalse(result)
        mock_http.assert_not_called()

    @patch("runtime_client.http_json")
    def test_auto_register_create_fails(self, mock_http):
        """auto_register raises if agent creation returns 401."""
        mock_http.return_value = (401, None)

        client = RuntimeClient(
            agent_id="pending",
            gateway_url="http://localhost:3033",
            [REDACTED_SECRET]",
        )

        with self.assertRaises(RuntimeError):
            client.auto_register()


class TestApiKeyPersistence(unittest.TestCase):
    """Tests for API key file persistence."""

    def setUp(self):
        """Save and remove any existing API key file."""
        self._backup = None
        if os.path.exists(API_KEY_FILE):
            with open(API_KEY_FILE) as f:
                self._backup = f.read()
            os.remove(API_KEY_FILE)

    def tearDown(self):
        """Restore original API key file if it existed."""
        if os.path.exists(API_KEY_FILE):
            os.remove(API_KEY_FILE)
        if self._backup is not None:
            with open(API_KEY_FILE, "w") as f:
                f.write(self._backup)

    def test_save_and_load_api_key(self):
        """save_api_key persists key that load_api_key can read."""
        save_api_key("clsw_test_persist_key")
        loaded = load_api_key()
        self.assertEqual(loaded, "clsw_test_persist_key")

    def test_load_api_key_missing_file(self):
        """load_api_key returns None when file doesn't exist."""
        self.assertIsNone(load_api_key())


class TestRuntimeClientApiKeyField(unittest.TestCase):
    """Tests for RuntimeClient accepting api_key parameter."""

    def test_client_stores_api_key(self):
        """RuntimeClient stores api_key for use by auto_register."""
        client = RuntimeClient(
            agent_id="agt_test",
            gateway_url="http://localhost:3033",
            [REDACTED_SECRET]",
        )
        self.assertEqual(client.api_key, "clsw_test_key")

    def test_client_without_api_key(self):
        """RuntimeClient works without api_key (backward compatible)."""
        client = RuntimeClient(
            agent_id="agt_test",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_test",
        )
        self.assertIsNone(client.api_key)


class TestAutoRegisterRecovery(TokenFileCleanupMixin, unittest.TestCase):
    """Tests for auto_register orphan-agent recovery."""

    @patch("runtime_client.http_json")
    def test_auto_register_saves_agent_id_before_exchange(self, mock_http):
        """After create_agent succeeds, agent_id is saved even if exchange fails."""
        from runtime_client import TOKEN_FILE, load_saved_token
        mock_http.side_effect = [
            # 1. POST /api/v1/agents (create agent) — success
            (200, {"agent": {"id": "agt_recovery_123"}}),
            # 2. POST /api/v1/agents/:id/bootstrap — success
            (200, {"bootstrap_token": "clsw_bt_recovery"}),
            # 3. POST /runtime/v1/bootstrap (exchange) — FAIL
            (500, None),
        ]

        client = RuntimeClient(
            agent_id="pending",
            gateway_url="http://localhost:3033",
            [REDACTED_SECRET]",
        )

        result = client.auto_register()

        # Exchange failed, so auto_register returns False
        self.assertFalse(result)
        # But agent_id was persisted to disk
        saved = load_saved_token()
        self.assertIsNotNone(saved)
        self.assertEqual(saved["agent_id"], "agt_recovery_123")
        # No runtime_token in saved state
        self.assertNotIn("runtime_token", saved)

    @patch("runtime_client.http_json")
    def test_saved_agent_id_prevents_duplicate_create(self, mock_http):
        """If agent_id is saved (no runtime_token), Path B re-bootstraps instead of Path A."""
        from runtime_client import TOKEN_FILE
        # Simulate saved state from a previous failed auto_register
        with open(TOKEN_FILE, "w") as f:
            json.dump({"agent_id": "agt_saved_456", "gateway_url": "http://localhost:3033"}, f)

        saved = {"agent_id": "agt_saved_456", "gateway_url": "http://localhost:3033"}

        # Resolve like main() does
        [REDACTED_SECRET]"
        agent_id = saved.get("agent_id")  # "agt_saved_456"
        runtime_token = saved.get("runtime_token")  # None

        # Path A condition: api_key and NOT agent_id
        path_a = api_key and not agent_id and not runtime_token
        # Path B condition: api_key and agent_id and NOT runtime_token
        path_b = api_key and agent_id and not runtime_token

        self.assertFalse(path_a, "Should NOT enter Path A when agent_id is saved")
        self.assertTrue(path_b, "Should enter Path B to re-bootstrap existing agent")

    @patch("runtime_client.http_json")
    def test_remove_token_preserves_agent_id(self, mock_http):
        """_remove_token clears runtime_token but keeps agent_id for recovery."""
        from runtime_client import TOKEN_FILE, load_saved_token
        client = RuntimeClient(
            agent_id="agt_persist_789",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_will_be_removed",
        )
        # Save full state first
        client._save_token()
        saved_before = load_saved_token()
        self.assertEqual(saved_before["runtime_token"], "clsw_rt_will_be_removed")

        # Remove token (simulates rotate — keep agent_id)
        client._remove_token()
        saved_after = load_saved_token()
        self.assertIsNotNone(saved_after)
        self.assertEqual(saved_after["agent_id"], "agt_persist_789")
        self.assertNotIn("runtime_token", saved_after)

    def test_clear_saved_state_removes_file(self):
        """_clear_saved_state removes the state file entirely."""
        from runtime_client import TOKEN_FILE, load_saved_token
        # Write a state file
        with open(TOKEN_FILE, "w") as f:
            json.dump({"agent_id": "agt_will_be_cleared", "gateway_url": "http://localhost:3033"}, f)

        _clear_saved_state()

        self.assertFalse(os.path.exists(TOKEN_FILE))
        self.assertIsNone(load_saved_token())

    def test_revoked_agent_clears_state_for_fresh_start(self):
        """After revoke, saved state is fully cleared so next startup creates new agent.

        Simulates: saved agent_id exists → Path B fails (revoke) → state cleared →
        next startup would enter Path A (no agent_id, no runtime_token).
        """
        from runtime_client import TOKEN_FILE, load_saved_token
        # Simulate saved state from revoked agent
        with open(TOKEN_FILE, "w") as f:
            json.dump({"agent_id": "agt_revoked_999", "gateway_url": "http://localhost:3033"}, f)

        # _clear_saved_state is what Path B calls on failure
        _clear_saved_state()

        # Simulate next startup: resolve from saved file
        saved = load_saved_token() or {}
        [REDACTED_SECRET]"
        agent_id = saved.get("agent_id")  # None — file removed
        runtime_token = saved.get("runtime_token")  # None

        # Should enter Path A (auto-register)
        path_a = api_key and not agent_id and not runtime_token
        self.assertTrue(path_a, "After clearing state, should enter Path A to create new agent")


class TestReconnect(TokenFileCleanupMixin, unittest.TestCase):
    """Tests for _attempt_reconnect (rotate/revoke recovery)."""

    @patch("runtime_client.http_json")
    def test_reconnect_succeeds_with_api_key(self, mock_http):
        """With API key, _attempt_reconnect issues new bootstrap and exchanges."""
        mock_http.side_effect = [
            # 1. POST /api/v1/agents/:id/bootstrap (issue bootstrap)
            (200, {"bootstrap_token": "clsw_bt_reconnect"}),
            # 2. POST /runtime/v1/bootstrap (exchange)
            (200, {
                "runtime_token": "clsw_rt_new_after_rotate",
                "heartbeat_interval_sec": 30,
                "telemetry_interval_sec": 60,
                "config": {},
            }),
            # 3. POST /runtime/v1/events (runtime_reconnected event)
            (200, {"ok": True}),
        ]

        client = RuntimeClient(
            agent_id="agt_reconnect",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_old_revoked",
            [REDACTED_SECRET]",
        )
        client.degraded = True
        client.consecutive_trade_failures = 5

        result = client._attempt_reconnect()

        self.assertTrue(result)
        self.assertEqual(client.runtime_token, "clsw_rt_new_after_rotate")
        self.assertFalse(client.degraded, "Degraded should be cleared after reconnect")
        self.assertEqual(client.consecutive_trade_failures, 0)

    def test_reconnect_fails_without_api_key(self):
        """Without API key, _attempt_reconnect returns False immediately."""
        client = RuntimeClient(
            agent_id="agt_no_key",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_old",
        )

        result = client._attempt_reconnect()

        self.assertFalse(result)

    @patch("runtime_client.http_json")
    def test_reconnect_fails_on_revoked_agent(self, mock_http):
        """If the agent is revoked (401), reconnect returns False (permanent)."""
        mock_http.return_value = (401, None)

        client = RuntimeClient(
            agent_id="agt_revoked",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_old",
            [REDACTED_SECRET]",
        )

        result = client._attempt_reconnect()

        self.assertIs(result, False)

    @patch("runtime_client.http_json")
    def test_reconnect_returns_none_on_transient_failure(self, mock_http):
        """On transient failure (network/5xx), reconnect returns None (not False)."""
        mock_http.return_value = (0, None)  # network failure

        client = RuntimeClient(
            agent_id="agt_transient",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_old",
            [REDACTED_SECRET]",
        )

        result = client._attempt_reconnect()

        self.assertIsNone(result, "Transient failure should return None, not False")

    @patch("runtime_client.http_json")
    def test_reconnect_returns_none_on_5xx(self, mock_http):
        """On 5xx, reconnect returns None (transient, preserve state)."""
        mock_http.return_value = (500, {"error": {"message": "Internal error"}})

        client = RuntimeClient(
            agent_id="agt_5xx",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_old",
            [REDACTED_SECRET]",
        )

        result = client._attempt_reconnect()

        self.assertIsNone(result)


class TestRotateRevokeHandling(unittest.TestCase):
    """Tests for rotate/revoke state handling in heartbeat/trade."""

    @patch("runtime_client.http_json")
    def test_heartbeat_401_returns_false(self, mock_http):
        """Heartbeat returns False on 401, signaling token rejection."""
        mock_http.return_value = (401, None)

        client = RuntimeClient(
            agent_id="agt_test",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_rotated_out",
        )

        result = client.send_heartbeat()
        self.assertFalse(result)

    @patch("runtime_client.http_json")
    def test_trade_401_returns_not_ok(self, mock_http):
        """submit_trade returns (False, resp) on 401, signaling stop."""
        mock_http.return_value = (401, {"error": {"message": "Token revoked"}})

        client = RuntimeClient(
            agent_id="agt_test",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_revoked",
        )

        ok, resp = client.submit_trade("BTC", "buy", size_pct=0.1)
        self.assertFalse(ok)

    @patch("runtime_client.http_json")
    def test_telemetry_401_returns_false(self, mock_http):
        """Telemetry returns False on 401."""
        mock_http.return_value = (401, None)

        client = RuntimeClient(
            agent_id="agt_test",
            gateway_url="http://localhost:3033",
            runtime_token="clsw_rt_revoked",
        )

        result = client.send_telemetry()
        self.assertFalse(result)


if __name__ == "__main__":
    unittest.main()


path: clawswap/strategies/grid.py
content: """
Grid Trading Strategy — ClawSwap Agent Template
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Place buy/sell orders at fixed price intervals above/below current price.
Low-risk, profits from sideways oscillation.
"""

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class GridConfig:
    ticker: str = "BTC"
    leverage: float = 1.0
    grid_count: int = 3                 # Grids above + below (Rust grid_levels=3)
    grid_spacing_pct: float = 2.0       # 2% spacing (Rust grid_spacing=0.02)
    size_per_grid: float = 100.0        # USD per grid order
    stop_loss_pct: float = 5.0          # Rust stop_loss=0.05
    grid_reset_bars: int = 480          # Reset grid every 8h (Rust grid_reset_bars=480)
    reinvest_profit: bool = True        # Add profits back to grid


@dataclass
class GridLevel:
    price: float
    side: str                           # 'buy' or 'sell'
    filled: bool = False
    order_id: Optional[str] = None


@dataclass
class GridState:
    initialized: bool = False
    center_price: float = 0.0
    levels: list = field(default_factory=list)
    total_profit: float = 0.0


class GridStrategy:
    def __init__(self, cfg=None):
        self.cfg = cfg or GridConfig()
        self.state = GridState()

    def initialize(self, current_price: float) -> list[GridLevel]:
        """Create grid levels centered at current_price. Returns list of levels to place."""
        self.state.center_price = current_price
        self.state.initialized = True
        levels = []
        for i in range(1, self.cfg.grid_count + 1):
            buy_price = current_price * (1 - i * self.cfg.grid_spacing_pct / 100)
            sell_price = current_price * (1 + i * self.cfg.grid_spacing_pct / 100)
            levels.append(GridLevel(price=buy_price, side="buy"))
            levels.append(GridLevel(price=sell_price, side="sell"))
        self.state.levels = sorted(levels, key=lambda l: l.price)
        return self.state.levels

    def on_fill(self, filled_level: GridLevel, current_price: float) -> Optional[GridLevel]:
        """
        When a grid level fills:
        - Buy filled → place sell one level up
        - Sell filled → place buy one level down
        Returns new counter-level to place, or None.
        """
        filled_level.filled = True
        spacing = self.state.center_price * self.cfg.grid_spacing_pct / 100

        if filled_level.side == "buy":
            new_sell_price = filled_level.price + spacing
            profit = spacing  # simplified
            if self.cfg.reinvest_profit:
                self.state.total_profit += profit
            return GridLevel(price=new_sell_price, side="sell")
        else:  # sell
            new_buy_price = filled_level.price - spacing
            return GridLevel(price=new_buy_price, side="buy")

    def get_unfilled_levels(self) -> list[GridLevel]:
        return [l for l in self.state.levels if not l.filled]


path: clawswap/strategies/adaptive_trend.py
content: """
Adaptive Trend Following — Multi-Timeframe with ATR Stops
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Uses EMA crossover on aggregated (15m) bars for trend direction.
Enters on 1m pullbacks within the trend.
ATR-based trailing stop adapts to volatility.
"""

from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np


@dataclass
class AdaptiveTrendConfig:
    ticker: str = "BTC"
    leverage: float = 1.0           # Matches Rust engine default
    position_size_pct: float = 0.2
    fast_ema: int = 50              # Fast period (Rust fast_period=50 for dual_ma)
    slow_ema: int = 150             # Slow period (Rust slow_period=150)
    atr_period: int = 14            # ATR/ADX period
    adx_threshold: float = 25.0     # Rust adx_threshold=25
    stop_loss_pct: float = 5.0      # Rust stop_loss=0.05
    take_profit_pct: float = 8.0    # Rust take_profit=0.08
    entry_threshold_pct: float = 4.0  # Rust entry_threshold=0.04
    agg_period: int = 1             # 1-min bars (no aggregation to match Rust)
    max_hold_candles: int = 720     # Max 12 hours
    cooldown_candles: int = 240     # Rust adaptive cooldown=240


class AdaptiveTrendStrategy:
    def __init__(self, cfg=None):
        self.cfg = cfg or AdaptiveTrendConfig()
        self.candle_buffer = []  # 1m candles
        self.agg_highs = []
        self.agg_lows = []
        self.agg_closes = []
        self.fast_ema_val = None
        self.slow_ema_val = None
        self.trend_bars = 0  # consecutive bars of trend
        self.trend_dir = 0   # 1 = up, -1 = down, 0 = none
        self.in_position = False
        self.entry_price = 0.0
        self.trail_stop = 0.0
        self.candles_held = 0
        self.cooldown = 0
        self.atr_val = 0.0
        self.current_close = 0.0
        self.recent_high = 0.0

    def _ema_update(self, prev, val, period):
        if prev is None:
            return val
        alpha = 2 / (period + 1)
        return alpha * val + (1 - alpha) * prev

    def on_candle(self, candle: dict):
        self.current_close = candle["close"]
        self.candle_buffer.append(candle)

        if self.in_position:
            self.candles_held += 1
            self.recent_high = max(self.recent_high, candle["high"])
        if self.cooldown > 0:
            self.cooldown -= 1

        # Aggregate to higher timeframe
        if len(self.candle_buffer) >= self.cfg.agg_period:
            bars = self.candle_buffer[-self.cfg.agg_period:]
            agg_h = max(b["high"] for b in bars)
            agg_l = min(b["low"] for b in bars)
            agg_c = bars[-1]["close"]

            self.agg_highs.append(agg_h)
            self.agg_lows.append(agg_l)
            self.agg_closes.append(agg_c)

            # Keep bounded
            if len(self.agg_closes) > 200:
                self.agg_highs = self.agg_highs[-100:]
                self.agg_lows = self.agg_lows[-100:]
                self.agg_closes = self.agg_closes[-100:]

            # Update EMAs
            self.fast_ema_val = self._ema_update(self.fast_ema_val, agg_c, self.cfg.fast_ema)
            self.slow_ema_val = self._ema_update(self.slow_ema_val, agg_c, self.cfg.slow_ema)

            # ATR
            if len(self.agg_highs) >= 2:
                tr = max(
                    agg_h - agg_l,
                    abs(agg_h - self.agg_closes[-2]),
                    abs(agg_l - self.agg_closes[-2])
                )
                if self.atr_val == 0:
                    self.atr_val = tr
                else:
                    self.atr_val = self._ema_update(self.atr_val, tr, self.cfg.atr_period)

            # Update trend
            if self.fast_ema_val and self.slow_ema_val:
                if self.fast_ema_val > self.slow_ema_val:
                    if self.trend_dir == 1:
                        self.trend_bars += 1
                    else:
                        self.trend_dir = 1
                        self.trend_bars = 1
                elif self.fast_ema_val < self.slow_ema_val:
                    if self.trend_dir == -1:
                        self.trend_bars += 1
                    else:
                        self.trend_dir = -1
                        self.trend_bars = 1
                else:
                    self.trend_dir = 0
                    self.trend_bars = 0

            self.candle_buffer = []

    def get_signal(self) -> Optional[str]:
        if self.in_position or self.cooldown > 0:
            return None
        if self.trend_dir != 1 or self.trend_bars < self.cfg.min_trend_bars:
            return None  # Only long in uptrend
        if self.fast_ema_val is None:
            return None

        # Enter on pullback toward fast EMA
        price = self.current_close
        if price <= self.fast_ema_val * (1 + self.cfg.pullback_pct / 100):
            if price > self.slow_ema_val:  # Still above slow EMA
                return "buy"
        return None

    def get_exit_signal(self, current_price: float) -> Optional[str]:
        if not self.in_position:
            return None

        # ATR trailing stop
        if self.atr_val > 0:
            stop = self.recent_high - self.cfg.atr_stop_mult * self.atr_val
            self.trail_stop = max(self.trail_stop, stop)
            if current_price <= self.trail_stop:
                return "trailing_stop"

        # Trend reversal
        if self.trend_dir == -1:
            return "trend_reversal"

        # Timeout
        if self.candles_held >= self.cfg.max_hold_candles:
            return "timeout"

        return None

    def on_fill(self, side, price, position_id):
        if side == "buy":
            self.in_position = True
            self.entry_price = price
            self.recent_high = price
            self.trail_stop = price - self.cfg.atr_stop_mult * self.atr_val if self.atr_val > 0 else price * 0.98
            self.candles_held = 0
        elif side == "sell":
            self.in_position = False
            self.cooldown = self.cfg.cooldown_candles


path: clawswap/strategies/__init__.py
content: """ClawSwap strategy templates."""
import json
from pathlib import Path
from typing import Tuple, Any, Optional

from .mean_reversion import MeanReversionStrategy, MeanReversionConfig
from .momentum import MomentumStrategy, MomentumConfig
from .grid import GridStrategy, GridConfig
from .bollinger_rsi import BollingerRsiStrategy, BollingerRsiConfig
from .vwap_scalper import VwapScalperStrategy, VwapScalperConfig
from .adaptive_trend import AdaptiveTrendStrategy, AdaptiveTrendConfig
from .breakout_volume import BreakoutVolumeStrategy, BreakoutVolumeConfig

STRATEGY_MAP = {
    "mean_reversion": (MeanReversionStrategy, MeanReversionConfig),
    "momentum": (MomentumStrategy, MomentumConfig),
    "grid": (GridStrategy, GridConfig),
    "bollinger_rsi": (BollingerRsiStrategy, BollingerRsiConfig),
    "vwap_scalper": (VwapScalperStrategy, VwapScalperConfig),
    "adaptive_trend": (AdaptiveTrendStrategy, AdaptiveTrendConfig),
    "breakout_volume": (BreakoutVolumeStrategy, BreakoutVolumeConfig),

    # Aliases — AI/Rust engine may generate these names
    "breakout": (BreakoutVolumeStrategy, BreakoutVolumeConfig),
    "short_momentum": (MomentumStrategy, MomentumConfig),
    "dual_ma": (AdaptiveTrendStrategy, AdaptiveTrendConfig),
    "range_scalper": (BollingerRsiStrategy, BollingerRsiConfig),
    "adaptive": (AdaptiveTrendStrategy, AdaptiveTrendConfig),
}


# Strategy-specific default overrides for aliases
_ALIAS_DEFAULTS = {
    "short_momentum": {"direction": "short"},
}


def load_workshop_export(path_or_dict) -> Tuple[Any, Any]:
    """Load a Workshop-exported strategy JSON into a (Strategy, Config) pair.

    Accepts a file path (str/Path) or a dict from the Workshop export endpoint
    (GET /api/v1/workshop/sessions/:id/export).

    Returns (strategy_instance, config_instance) ready for use.
    """
    if isinstance(path_or_dict, (str, Path)):
        with open(path_or_dict) as f:
            data = json.load(f)
    else:
        data = path_or_dict

    strategy_type = data.get("strategy_type", "short_momentum")
    config_params = data.get("config", {})

    if strategy_type not in STRATEGY_MAP:
        raise ValueError(
            f"Unknown strategy_type '{strategy_type}'. "
            f"Available: {list(STRATEGY_MAP.keys())}"
        )

    strategy_cls, config_cls = STRATEGY_MAP[strategy_type]

    # Apply alias-specific defaults first, then user overrides
    import dataclasses
    valid_fields = {f.name for f in dataclasses.fields(config_cls)}
    merged = {}
    if strategy_type in _ALIAS_DEFAULTS:
        merged.update({k: v for k, v in _ALIAS_DEFAULTS[strategy_type].items() if k in valid_fields})
    merged.update({k: v for k, v in config_params.items() if k in valid_fields})
    cfg = config_cls(**merged)

    return strategy_cls(cfg), cfg


__all__ = [
    "MeanReversionStrategy", "MeanReversionConfig",
    "MomentumStrategy", "MomentumConfig",
    "GridStrategy", "GridConfig",
    "BollingerRsiStrategy", "BollingerRsiConfig",
    "VwapScalperStrategy", "VwapScalperConfig",
    "AdaptiveTrendStrategy", "AdaptiveTrendConfig",
    "BreakoutVolumeStrategy", "BreakoutVolumeConfig",
    "STRATEGY_MAP",
    "load_workshop_export",
    "_ALIAS_DEFAULTS",
]


path: clawswap/strategies/indicators.py
content: """
Built-in indicator functions for custom strategies.

Usage in strategy(df):
    from strategies.indicators import rsi, macd, ema, bollinger, atr, adx, sma

All functions accept pandas Series or numpy arrays and return pandas Series.
"""

from __future__ import annotations
import numpy as np
import pandas as pd
from typing import Tuple


def sma(prices: pd.Series, period: int = 20) -> pd.Series:
    """Simple Moving Average."""
    return prices.rolling(window=period, min_periods=period).mean()


def ema(prices: pd.Series, period: int = 20) -> pd.Series:
    """Exponential Moving Average."""
    return prices.ewm(span=period, adjust=False).mean()


def rsi(prices: pd.Series, period: int = 14) -> pd.Series:
    """Relative Strength Index (0-100)."""
    delta = prices.diff()
    gain = delta.where(delta > 0, 0.0)
    loss = (-delta).where(delta < 0, 0.0)
    avg_gain = gain.ewm(alpha=1.0 / period, min_periods=period, adjust=False).mean()
    avg_loss = loss.ewm(alpha=1.0 / period, min_periods=period, adjust=False).mean()
    rs = avg_gain / avg_loss.replace(0, np.nan)
    return 100.0 - 100.0 / (1.0 + rs)


def macd(
    prices: pd.Series,
    fast: int = 12,
    slow: int = 26,
    signal: int = 9,
) -> Tuple[pd.Series, pd.Series, pd.Series]:
    """MACD (line, signal, histogram)."""
    fast_ema = prices.ewm(span=fast, adjust=False).mean()
    slow_ema = prices.ewm(span=slow, adjust=False).mean()
    macd_line = fast_ema - slow_ema
    signal_line = macd_line.ewm(span=signal, adjust=False).mean()
    histogram = macd_line - signal_line
    return macd_line, signal_line, histogram


def bollinger(
    prices: pd.Series,
    period: int = 20,
    std_dev: float = 2.0,
) -> Tuple[pd.Series, pd.Series, pd.Series]:
    """Bollinger Bands (upper, middle, lower)."""
    middle = prices.rolling(window=period, min_periods=period).mean()
    std = prices.rolling(window=period, min_periods=period).std()
    upper = middle + std_dev * std
    lower = middle - std_dev * std
    return upper, middle, lower


def atr(
    high: pd.Series,
    low: pd.Series,
    close: pd.Series,
    period: int = 14,
) -> pd.Series:
    """Average True Range."""
    prev_close = close.shift(1)
    tr = pd.concat([
        high - low,
        (high - prev_close).abs(),
        (low - prev_close).abs(),
    ], axis=1).max(axis=1)
    return tr.ewm(alpha=1.0 / period, min_periods=period, adjust=False).mean()


def adx(
    high: pd.Series,
    low: pd.Series,
    close: pd.Series,
    period: int = 14,
) -> Tuple[pd.Series, pd.Series, pd.Series]:
    """Average Directional Index (ADX, +DI, -DI)."""
    prev_high = high.shift(1)
    prev_low = low.shift(1)

    plus_dm = (high - prev_high).where((high - prev_high) > (prev_low - low), 0.0)
    plus_dm = plus_dm.where(plus_dm > 0, 0.0)
    minus_dm = (prev_low - low).where((prev_low - low) > (high - prev_high), 0.0)
    minus_dm = minus_dm.where(minus_dm > 0, 0.0)

    tr = atr(high, low, close, period=1)  # True Range (not averaged)
    atr_val = tr.ewm(alpha=1.0 / period, min_periods=period, adjust=False).mean()

    plus_di = 100 * (plus_dm.ewm(alpha=1.0 / period, min_periods=period, adjust=False).mean() / atr_val)
    minus_di = 100 * (minus_dm.ewm(alpha=1.0 / period, min_periods=period, adjust=False).mean() / atr_val)

    dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
    adx_val = dx.ewm(alpha=1.0 / period, min_periods=period, adjust=False).mean()

    return adx_val, plus_di, minus_di


def vwap(close: pd.Series, volume: pd.Series, period: int = 0) -> pd.Series:
    """Volume-Weighted Average Price. period=0 means cumulative (session VWAP)."""
    if period > 0:
        cum_vol = volume.rolling(period).sum()
        cum_pv = (close * volume).rolling(period).sum()
        return cum_pv / cum_vol.replace(0, np.nan)
    else:
        cum_vol = volume.cumsum()
        cum_pv = (close * volume).cumsum()
        return cum_pv / cum_vol.replace(0, np.nan)


def stochastic(
    high: pd.Series,
    low: pd.Series,
    close: pd.Series,
    k_period: int = 14,
    d_period: int = 3,
) -> Tuple[pd.Series, pd.Series]:
    """Stochastic Oscillator (%K, %D)."""
    lowest_low = low.rolling(k_period).min()
    highest_high = high.rolling(k_period).max()
    k = 100 * (close - lowest_low) / (highest_high - lowest_low).replace(0, np.nan)
    d = k.rolling(d_period).mean()
    return k, d


def supertrend(
    high: pd.Series,
    low: pd.Series,
    close: pd.Series,
    period: int = 10,
    multiplier: float = 3.0,
) -> Tuple[pd.Series, pd.Series]:
    """Supertrend (line, direction). direction: 1=uptrend, -1=downtrend."""
    atr_val = atr(high, low, close, period)
    hl2 = (high + low) / 2

    upper = hl2 + multiplier * atr_val
    lower = hl2 - multiplier * atr_val

    st = pd.Series(index=close.index, dtype=float)
    direction = pd.Series(index=close.index, dtype=float)
    st.iloc[0] = upper.iloc[0]
    direction.iloc[0] = 1

    for i in range(1, len(close)):
        if close.iloc[i] > upper.iloc[i - 1] if not np.isnan(upper.iloc[i - 1]) else False:
            st.iloc[i] = lower.iloc[i]
            direction.iloc[i] = 1
        elif close.iloc[i] < lower.iloc[i - 1] if not np.isnan(lower.iloc[i - 1]) else False:
            st.iloc[i] = upper.iloc[i]
            direction.iloc[i] = -1
        else:
            if direction.iloc[i - 1] == 1:
                st.iloc[i] = max(lower.iloc[i], st.iloc[i - 1]) if not np.isnan(st.iloc[i - 1]) else lower.iloc[i]
                direction.iloc[i] = 1
            else:
                st.iloc[i] = min(upper.iloc[i], st.iloc[i - 1]) if not np.isnan(st.iloc[i - 1]) else upper.iloc[i]
                direction.iloc[i] = -1

    return st, direction


path: clawswap/strategies/bollinger_rsi.py
content: """
Bollinger Band + RSI Mean Reversion
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Buy when price touches lower Bollinger Band AND RSI < 30 (oversold).
Sell when price reaches middle band OR RSI > 70.
Trailing stop for risk management.
"""

from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np


@dataclass
class BollingerRsiConfig:
    ticker: str = "BTC"
    leverage: float = 1.0           # Matches Rust engine default
    position_size_pct: float = 0.2
    bb_period: int = 240            # 4-hour BB (Rust bb_period=240 for range_scalper)
    bb_std: float = 3.0             # Wider bands (Rust bb_std=3.0)
    rsi_period: int = 84            # ~1.4h RSI
    rsi_oversold: float = 30.0
    rsi_overbought: float = 70.0
    stop_loss_pct: float = 5.0      # Rust stop_loss=0.05
    take_profit_pct: float = 8.0    # Rust take_profit=0.08
    max_hold_candles: int = 360     # Max hold 6 hours
    cooldown_candles: int = 120     # Rust cooldown_bars=120


class BollingerRsiStrategy:
    def __init__(self, cfg=None):
        self.cfg = cfg or BollingerRsiConfig()
        self.closes = []
        self.in_position = False
        self.entry_price = 0.0
        self.highest_since_entry = 0.0
        self.candles_in_position = 0
        self.cooldown = 0

    def on_candle(self, candle: dict):
        self.closes.append(candle["close"])
        if len(self.closes) > self.cfg.bb_period * 3:
            self.closes = self.closes[-self.cfg.bb_period * 3:]
        if self.in_position:
            self.candles_in_position += 1
            self.highest_since_entry = max(self.highest_since_entry, candle["close"])
        if self.cooldown > 0:
            self.cooldown -= 1

    def _rsi(self) -> float:
        if len(self.closes) < self.cfg.rsi_period + 1:
            return 50.0
        prices = np.array(self.closes[-(self.cfg.rsi_period + 1):])
        deltas = np.diff(prices)
        gains = np.where(deltas > 0, deltas, 0)
        losses = np.where(deltas < 0, -deltas, 0)
        avg_gain = np.mean(gains)
        avg_loss = np.mean(losses)
        if avg_loss == 0:
            return 100.0
        rs = avg_gain / avg_loss
        return 100 - (100 / (1 + rs))

    def _bollinger(self):
        if len(self.closes) < self.cfg.bb_period:
            return None, None, None
        window = np.array(self.closes[-self.cfg.bb_period:])
        mid = np.mean(window)
        std = np.std(window)
        return mid - self.cfg.bb_std * std, mid, mid + self.cfg.bb_std * std

    def get_signal(self) -> Optional[str]:
        if self.in_position or self.cooldown > 0:
            return None
        lower, mid, upper = self._bollinger()
        if lower is None:
            return None
        rsi = self._rsi()
        price = self.closes[-1]
        if price <= lower and rsi < self.cfg.rsi_oversold:
            return "buy"
        return None

    def get_exit_signal(self, current_price: float) -> Optional[str]:
        if not self.in_position:
            return None
        _, mid, _ = self._bollinger()
        rsi = self._rsi()

        # Trailing stop
        drawdown = (self.highest_since_entry - current_price) / self.highest_since_entry * 100
        if drawdown >= self.cfg.trailing_stop_pct:
            return "trailing_stop"

        # Take profit at middle band or RSI overbought
        if mid and current_price >= mid:
            return "take_profit"
        if rsi > self.cfg.rsi_overbought:
            return "take_profit"

        # Max hold time
        if self.candles_in_position >= self.cfg.max_hold_candles:
            return "timeout"

        return None

    def on_fill(self, side, price, position_id):
        if side == "buy":
            self.in_position = True
            self.entry_price = price
            self.highest_since_entry = price
            self.candles_in_position = 0
        elif side == "sell":
            self.in_position = False
            self.entry_price = 0.0
            self.cooldown = self.cfg.cooldown_candles


path: clawswap/strategies/mean_reversion.py
content: """
Mean Reversion Strategy — ClawSwap Agent Template
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Buy when price drops X% from recent high.
Take profit at Y%, stop loss at Z%.
"""

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import time


@dataclass
class MeanReversionConfig:
    ticker: str = "BTC"
    leverage: float = 1.0               # Matches Rust engine default
    position_size_pct: float = 0.2      # 20% of equity per trade
    lookback_candles: int = 480         # 8h lookback (1min candles), matches Rust lookback_bars
    entry_drop_pct: float = 4.0         # Buy when price drops 4% from high (Rust entry_threshold=0.04)
    exit_threshold_pct: float = 3.0     # Exit at 3% recovery (Rust exit_threshold=0.03)
    take_profit_pct: float = 8.0        # Take profit at 8% (Rust take_profit=0.08)
    stop_loss_pct: float = 5.0          # Stop loss at 5% (Rust stop_loss=0.05)
    cooldown_candles: int = 120         # 2h cooldown between trades (Rust cooldown_bars=120)
    max_open_positions: int = 1         # Max concurrent positions


@dataclass
class StrategyState:
    in_position: bool = False
    entry_price: float = 0.0
    position_id: Optional[str] = None
    recent_high: float = 0.0
    candle_buffer: list = field(default_factory=list)


def compute_signal(prices: list[float], cfg: MeanReversionConfig) -> Optional[str]:
    """
    Returns 'buy', 'sell', or None.
    prices: list of recent close prices (newest last)
    """
    if len(prices) < cfg.lookback_candles:
        return None

    recent = prices[-cfg.lookback_candles:]
    high = max(recent)
    current = prices[-1]
    drop_pct = (high - current) / high * 100

    if drop_pct >= cfg.entry_drop_pct:
        return "buy"
    return None


def should_exit(entry_price: float, current_price: float, cfg: MeanReversionConfig) -> Optional[str]:
    """Returns 'take_profit', 'stop_loss', or None."""
    pnl_pct = (current_price - entry_price) / entry_price * 100
    if pnl_pct >= cfg.take_profit_pct:
        return "take_profit"
    if pnl_pct <= -cfg.stop_loss_pct:
        return "stop_loss"
    return None


class MeanReversionStrategy:
    """
    Stateful wrapper for use with the ClawSwap agent runner.
    Implement run_cycle() — called every 30s by the agent.
    """

    def __init__(self, cfg=None):
        self.cfg = cfg or MeanReversionConfig()
        self.state = StrategyState()

    def on_candle(self, candle: dict) -> None:
        """Feed a new candle. candle = {open, high, low, close, volume, timestamp}"""
        self.state.candle_buffer.append(candle["close"])
        if len(self.state.candle_buffer) > self.cfg.lookback_candles * 2:
            self.state.candle_buffer.pop(0)

    def get_signal(self) -> Optional[str]:
        """Return entry signal."""
        if self.state.in_position:
            return None
        return compute_signal(self.state.candle_buffer, self.cfg)

    def get_exit_signal(self, current_price: float) -> Optional[str]:
        """Return exit reason if we should close."""
        if not self.state.in_position:
            return None
        return should_exit(self.state.entry_price, current_price, self.cfg)

    def on_fill(self, side: str, price: float, position_id: str) -> None:
        if side == "buy":
            self.state.in_position = True
            self.state.entry_price = price
            self.state.position_id = position_id
        elif side == "sell":
            self.state.in_position = False
            self.state.entry_price = 0.0
            self.state.position_id = None


path: clawswap/strategies/breakout_volume.py
content: """
Volume Breakout — Momentum with Confirmation
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Enter on price breakout above N-period high WITH volume spike.
Volume must be 2x+ average — confirms institutional participation.
Trailing stop locks in gains. Time stop prevents holding losers.
"""

from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np


@dataclass
class BreakoutVolumeConfig:
    ticker: str = "BTC"
    leverage: float = 1.0           # Matches Rust engine default
    position_size_pct: float = 0.2
    breakout_period: int = 480      # Rust lookback_bars=480
    volume_lookback: int = 120      # 2h volume average
    volume_mult: float = 2.0        # Volume must be 2x average
    stop_loss_pct: float = 5.0      # Rust stop_loss=0.05
    take_profit_pct: float = 8.0    # Rust take_profit=0.08
    max_hold_candles: int = 480     # Max 8 hours
    cooldown_candles: int = 120     # Rust cooldown_bars=120
    min_breakout_pct: float = 0.2   # Rust entry_threshold ~0.04 but ATR filtered


class BreakoutVolumeStrategy:
    def __init__(self, cfg=None):
        self.cfg = cfg or BreakoutVolumeConfig()
        self.highs = []
        self.closes = []
        self.volumes = []
        self.in_position = False
        self.entry_price = 0.0
        self.highest_since_entry = 0.0
        self.candles_held = 0
        self.cooldown = 0

    def on_candle(self, candle: dict):
        self.highs.append(candle["high"])
        self.closes.append(candle["close"])
        self.volumes.append(candle.get("volume", 0))

        max_len = max(self.cfg.breakout_period, self.cfg.volume_lookback) * 2
        if len(self.closes) > max_len:
            self.highs = self.highs[-max_len:]
            self.closes = self.closes[-max_len:]
            self.volumes = self.volumes[-max_len:]

        if self.in_position:
            self.candles_held += 1
            self.highest_since_entry = max(self.highest_since_entry, candle["high"])
        if self.cooldown > 0:
            self.cooldown -= 1

    def get_signal(self) -> Optional[str]:
        if self.in_position or self.cooldown > 0:
            return None
        if len(self.highs) < self.cfg.breakout_period + 1:
            return None
        if len(self.volumes) < self.cfg.volume_lookback + 1:
            return None

        price = self.closes[-1]
        prev_high = max(self.highs[-(self.cfg.breakout_period + 1):-1])
        min_break = prev_high * (1 + self.cfg.min_breakout_pct / 100)

        if price <= min_break:
            return None

        # Volume confirmation
        current_vol = self.volumes[-1]
        avg_vol = np.mean(self.volumes[-(self.cfg.volume_lookback + 1):-1])
        if avg_vol > 0 and current_vol >= avg_vol * self.cfg.volume_mult:
            return "buy"

        return None

    def get_exit_signal(self, current_price: float) -> Optional[str]:
        if not self.in_position:
            return None

        # Trailing stop
        drawdown = (self.highest_since_entry - current_price) / self.highest_since_entry * 100
        if drawdown >= self.cfg.trailing_stop_pct:
            return "trailing_stop"

        # Time stop
        if self.candles_held >= self.cfg.max_hold_candles:
            return "timeout"

        return None

    def on_fill(self, side, price, position_id):
        if side == "buy":
            self.in_position = True
            self.entry_price = price
            self.highest_since_entry = price
            self.candles_held = 0
        elif side == "sell":
            self.in_position = False
            self.cooldown = self.cfg.cooldown_candles


path: clawswap/strategies/momentum.py
content: """
Momentum Strategy — ClawSwap Agent Template
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Bidirectional trend-following:
- LONG when price breaks above recent high
- SHORT when price breaks below recent low
Trailing stop loss to lock in profits.

Also used as `short_momentum` (short-only variant via alias).
"""

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class MomentumConfig:
    ticker: str = "BTC"
    leverage: float = 1.0               # Matches Rust engine default
    position_size_pct: float = 0.2
    breakout_lookback: int = 480        # Lookback bars (Rust lookback_bars=480)
    entry_threshold_pct: float = 4.0    # Price must move 4% (Rust entry_threshold=0.04)
    take_profit_pct: float = 8.0        # Rust take_profit=0.08
    stop_loss_pct: float = 5.0          # Rust stop_loss=0.05
    trailing_stop_pct: float = 3.0      # Trailing stop (Rust exit_threshold=0.03)
    max_open_positions: int = 1
    direction: str = "both"             # "both", "long", "short"
    cooldown_candles: int = 120         # Rust cooldown_bars=120


@dataclass
class MomentumState:
    in_position: bool = False
    entry_price: float = 0.0
    peak_price: float = 0.0
    trough_price: float = float('inf')
    is_long: bool = True
    position_id: Optional[str] = None
    candle_buffer: list = field(default_factory=list)
    bars_since_exit: int = 999


class MomentumStrategy:
    def __init__(self, cfg=None):
        self.cfg = cfg or MomentumConfig()
        self.state = MomentumState()

    def _open_position(self, is_long: bool, price: float, position_id: str) -> None:
        self.state.in_position = True
        self.state.entry_price = price
        self.state.is_long = is_long
        self.state.peak_price = price
        self.state.trough_price = price
        self.state.position_id = position_id

    def _close_position(self) -> None:
        self.state.in_position = False
        self.state.entry_price = 0.0
        self.state.peak_price = 0.0
        self.state.trough_price = float('inf')
        self.state.position_id = None
        self.state.bars_since_exit = 0

    def on_candle(self, candle: dict) -> None:
        self.state.candle_buffer.append(candle["close"])
        if len(self.state.candle_buffer) > self.cfg.breakout_lookback * 3:
            self.state.candle_buffer.pop(0)
        if not self.state.in_position:
            self.state.bars_since_exit += 1

    def get_signal(self) -> Optional[str]:
        if self.state.in_position:
            return None
        if self.state.bars_since_exit < self.cfg.cooldown_candles:
            return None

        buf = self.state.candle_buffer
        if len(buf) < self.cfg.breakout_lookback + 1:
            return None

        window = buf[-(self.cfg.breakout_lookback + 1):-1]
        window_high = max(window)
        window_low = min(window)
        current = buf[-1]
        threshold = self.cfg.entry_threshold_pct / 100

        # Long signal: price rises from window low
        if self.cfg.direction in ("both", "long"):
            if window_low > 0 and (current - window_low) / window_low >= threshold:
                return "buy"

        # Short signal: price drops from window high
        if self.cfg.direction in ("both", "short"):
            if window_high > 0 and (window_high - current) / window_high >= threshold:
                return "short"

        return None

    def get_exit_signal(self, current_price: float) -> Optional[str]:
        if not self.state.in_position:
            return None

        if self.state.is_long:
            # Update peak for trailing stop
            if current_price > self.state.peak_price:
                self.state.peak_price = current_price
            # Trailing stop
            trail_stop = self.state.peak_price * (1 - self.cfg.trailing_stop_pct / 100)
            if current_price <= trail_stop:
                return "trailing_stop"
            # PnL-based exits
            pnl_pct = (current_price - self.state.entry_price) / self.state.entry_price * 100
        else:
            # Update trough for trailing stop (short)
            if current_price < self.state.trough_price:
                self.state.trough_price = current_price
            trail_stop = self.state.trough_price * (1 + self.cfg.trailing_stop_pct / 100)
            if current_price >= trail_stop:
                return "trailing_stop"
            pnl_pct = (self.state.entry_price - current_price) / self.state.entry_price * 100

        leveraged_pnl = pnl_pct * self.cfg.leverage / 100
        if leveraged_pnl <= -self.cfg.stop_loss_pct / 100:
            return "stop_loss"
        if leveraged_pnl >= self.cfg.take_profit_pct / 100:
            return "take_profit"

        return None

    def on_fill(self, side: str, price: float, position_id: str) -> None:
        if side == "buy":
            self._open_position(is_long=True, price=price, position_id=position_id)
        elif side == "short":
            self._open_position(is_long=False, price=price, position_id=position_id)
        elif side == "sell":
            if self.state.in_position and self.state.is_long:
                self._close_position()
            elif not self.state.in_position and self.cfg.direction == "short":
                self._open_position(is_long=False, price=price, position_id=position_id)
        elif side == "cover":
            if self.state.in_position and not self.state.is_long:
                self._close_position()
        elif side in ("close_long", "close_short", "close"):
            self._close_position()


path: clawswap/strategies/vwap_scalper.py
content: """
VWAP Scalper — Intraday Mean Reversion
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Trades around Volume-Weighted Average Price.
Buy below VWAP - 1σ, sell at VWAP.
Resets VWAP calculation every session (1440 candles = 1 day).
Tight stops, high win rate, small gains.
"""

from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np


@dataclass
class VwapScalperConfig:
    ticker: str = "BTC"
    leverage: float = 3.0
    position_size_pct: float = 0.10
    session_length: int = 1440       # Reset VWAP every 24h
    entry_sigma: float = 1.2         # Buy below VWAP - N*sigma
    take_profit_sigma: float = 0.0   # Sell at VWAP (0 sigma)
    stop_loss_pct: float = 0.8       # Tight stop
    min_volume_ratio: float = 0.8    # Volume must be > 80% of avg
    max_hold_candles: int = 120      # Max 2 hours
    cooldown_candles: int = 15       # 15min cooldown


class VwapScalperStrategy:
    def __init__(self, cfg=None):
        self.cfg = cfg or VwapScalperConfig()
        self.session_prices = []
        self.session_volumes = []
        self.all_volumes = []
        self.candle_count = 0
        self.in_position = False
        self.entry_price = 0.0
        self.candles_held = 0
        self.cooldown = 0
        self.current_volume = 0.0

    def on_candle(self, candle: dict):
        self.candle_count += 1
        vol = candle.get("volume", 0)
        self.current_volume = vol
        self.all_volumes.append(vol)
        if len(self.all_volumes) > 10000:
            self.all_volumes = self.all_volumes[-5000:]

        # Reset session every N candles
        if len(self.session_prices) >= self.cfg.session_length:
            self.session_prices = []
            self.session_volumes = []

        self.session_prices.append(candle["close"])
        self.session_volumes.append(vol)

        if self.in_position:
            self.candles_held += 1
        if self.cooldown > 0:
            self.cooldown -= 1

    def _vwap_and_std(self):
        if len(self.session_prices) < 60:  # Need at least 1h
            return None, None
        prices = np.array(self.session_prices)
        volumes = np.array(self.session_volumes)
        total_vol = np.sum(volumes)
        if total_vol == 0:
            return np.mean(prices), np.std(prices)
        vwap = np.sum(prices * volumes) / total_vol
        # Volume-weighted standard deviation
        variance = np.sum(volumes * (prices - vwap) ** 2) / total_vol
        std = np.sqrt(variance)
        return vwap, std

    def _volume_ok(self) -> bool:
        if len(self.all_volumes) < 100:
            return True
        avg_vol = np.mean(self.all_volumes[-1000:]) if len(self.all_volumes) >= 1000 else np.mean(self.all_volumes)
        return self.current_volume >= avg_vol * self.cfg.min_volume_ratio

    def get_signal(self) -> Optional[str]:
        if self.in_position or self.cooldown > 0:
            return None
        vwap, std = self._vwap_and_std()
        if vwap is None or std <= 0:
            return None
        price = self.session_prices[-1]
        entry_level = vwap - self.cfg.entry_sigma * std

        if price <= entry_level and self._volume_ok():
            return "buy"
        return None

    def get_exit_signal(self, current_price: float) -> Optional[str]:
        if not self.in_position:
            return None

        # Stop loss
        pnl_pct = (current_price - self.entry_price) / self.entry_price * 100
        if pnl_pct <= -self.cfg.stop_loss_pct:
            return "stop_loss"

        # Take profit at VWAP
        vwap, std = self._vwap_and_std()
        if vwap is not None:
            target = vwap + self.cfg.take_profit_sigma * std
            if current_price >= target:
                return "take_profit"

        # Timeout
        if self.candles_held >= self.cfg.max_hold_candles:
            return "timeout"

        return None

    def on_fill(self, side, price, position_id):
        if side == "buy":
            self.in_position = True
            self.entry_price = price
            self.candles_held = 0
        elif side == "sell":
            self.in_position = False
            self.cooldown = self.cfg.cooldown_candles


path: clawswap/tools/prepublish_check.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

ok() { echo "✅ $1"; }
warn() { echo "⚠️  $1"; }
fail() { echo "❌ $1"; exit 1; }

echo "== ClawSwap Skill Prepublish Checklist =="
echo "Root: $ROOT_DIR"

# 1) Required files
for f in SKILL.md skill.json runtime_client.py .env.example; do
  [[ -f "$ROOT_DIR/$f" ]] || fail "missing required file: $f"
done
ok "required files present"

# 2) SKILL.md frontmatter sanity
head -n 20 "$ROOT_DIR/SKILL.md" | grep -q "^name: clawswap$" || fail "SKILL.md frontmatter missing name: clawswap"
head -n 20 "$ROOT_DIR/SKILL.md" | grep -q "^description:" || fail "SKILL.md frontmatter missing description"
ok "SKILL.md frontmatter valid"

# 3) Secrets/local state must not be tracked
for f in .runtime_token .clawswap_api_key .env; do
  if git -C "$ROOT_DIR/.." ls-files --error-unmatch "clawswap/$f" >/dev/null 2>&1; then
    fail "sensitive/local file is tracked in git: clawswap/$f"
  fi
done
ok "sensitive/local files are not tracked"

# 4) No compiled cache in release source
if find "$ROOT_DIR" -type d -name '__pycache__' | grep -q .; then
  warn "__pycache__ directories found (packaging script will exclude)"
else
  ok "no __pycache__ directories"
fi

if find "$ROOT_DIR" -type f -name '*.pyc' | grep -q .; then
  warn "*.pyc files found (packaging script will exclude)"
else
  ok "no *.pyc files"
fi

# 5) Python unit tests
(
  cd "$ROOT_DIR"
  python3 tests/test_runtime_client.py >/tmp/clawswap_skill_test.log
)
ok "unit tests passed (tests/test_runtime_client.py)"

echo "== Checklist complete =="


path: clawswap/tools/custom_backtest.py
content: #!/usr/bin/env python3
"""
Custom Strategy Backtest Runner — Track B

Runs arbitrary Python strategy code against historical candle data.
The user implements strategy(df) → list of signals.
This runner executes the signals with proper PnL, fees, and risk management.

Usage:
    python3 custom_backtest.py strategy.py --ticker BTC --days 90

Or from OpenClaw Skill:
    Programmatic: run_custom_backtest(code_str, candles_df, config)
"""

from __future__ import annotations
import sys
import json
import importlib.util
import traceback
import tempfile
from pathlib import Path

# Ensure skill root is on sys.path so `from strategies import ...` works
sys.path.insert(0, str(Path(__file__).parent.parent))
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

import numpy as np
import pandas as pd


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

@dataclass
class BacktestConfig:
    initial_capital: float = 10000.0
    fee_rate: float = 0.00035          # 3.5 bps
    max_leverage: float = 10.0
    max_position_size: float = 1.0     # Max 100% of equity per trade
    hard_stop_loss: float = 0.20       # -20% forced liquidation (can't be bypassed)
    max_positions: int = 5             # Max concurrent positions


# ── Signal Validation ───────────────────────────────────────────────────────

VALID_ACTIONS = {"long", "short", "close", "close_long", "close_short"}


def validate_signal(sig: dict, bar_count: int) -> Optional[dict]:
    """Validate and normalize a trade signal."""
    if not isinstance(sig, dict):
        return None
    action = sig.get("action", "").lower()
    if action not in VALID_ACTIONS:
        return None
    bar = sig.get("bar", -1)
    if not isinstance(bar, (int, float)) or bar < 0 or bar >= bar_count:
        return None
    size = sig.get("size", 0.2)
    size = max(0.01, min(1.0, float(size)))
    return {
        "bar": int(bar),
        "action": action,
        "size": size,
        "reason": str(sig.get("reason", "")),
    }


# ── Execution Engine ────────────────────────────────────────────────────────

@dataclass
class Position:
    side: str           # "long" or "short"
    entry_price: float
    size: float         # fraction of equity
    entry_bar: int


@dataclass
class Trade:
    bar: int
    side: str
    entry_price: float
    exit_price: float
    pnl: float
    reason: str


def execute_signals(
    df: pd.DataFrame,
    signals: list[dict],
    config: BacktestConfig,
) -> dict:
    """Execute validated signals against candle data, compute PnL."""
    equity = config.initial_capital
    peak_equity = equity
    max_drawdown = 0.0
    positions: list[Position] = []
    trades: list[Trade] = []
    total_fees = 0.0
    pnl_curve = []
    curve_sample = max(1, len(df) // 500)

    # Sort signals by bar index
    signals.sort(key=lambda s: s["bar"])
    signal_idx = 0

    for i in range(len(df)):
        price = df.iloc[i]["close"]

        # ── Process signals at this bar ─────────────────────────────
        while signal_idx < len(signals) and signals[signal_idx]["bar"] == i:
            sig = signals[signal_idx]
            signal_idx += 1

            if sig["action"] in ("close", "close_long", "close_short"):
                # Close matching positions
                to_close = []
                for j, pos in enumerate(positions):
                    if sig["action"] == "close" or \
                       (sig["action"] == "close_long" and pos.side == "long") or \
                       (sig["action"] == "close_short" and pos.side == "short"):
                        to_close.append(j)
                for j in reversed(to_close):
                    pos = positions.pop(j)
                    raw_pnl = (price - pos.entry_price) / pos.entry_price if pos.side == "long" \
                              else (pos.entry_price - price) / pos.entry_price
                    trade_pnl = equity * pos.size * raw_pnl
                    fee = equity * pos.size * config.fee_rate
                    equity += trade_pnl - fee
                    total_fees += fee
                    trades.append(Trade(
                        bar=i, side=pos.side,
                        entry_price=pos.entry_price, exit_price=price,
                        pnl=trade_pnl - fee, reason=sig["reason"],
                    ))

            elif sig["action"] in ("long", "short"):
                if len(positions) >= config.max_positions:
                    continue  # Skip, max positions reached
                size = min(sig["size"], config.max_position_size)
                fee = equity * size * config.fee_rate
                equity -= fee
                total_fees += fee
                positions.append(Position(
                    side=sig["action"],
                    entry_price=price,
                    size=size,
                    entry_bar=i,
                ))

        # ── Hard stop loss check (server-side, can't bypass) ────────
        to_liquidate = []
        for j, pos in enumerate(positions):
            raw_pnl = (price - pos.entry_price) / pos.entry_price if pos.side == "long" \
                      else (pos.entry_price - price) / pos.entry_price
            if raw_pnl <= -config.hard_stop_loss:
                to_liquidate.append(j)
        for j in reversed(to_liquidate):
            pos = positions.pop(j)
            raw_pnl = (price - pos.entry_price) / pos.entry_price if pos.side == "long" \
                      else (pos.entry_price - price) / pos.entry_price
            trade_pnl = equity * pos.size * raw_pnl
            fee = equity * pos.size * config.fee_rate
            equity += trade_pnl - fee
            total_fees += fee
            trades.append(Trade(
                bar=i, side=pos.side,
                entry_price=pos.entry_price, exit_price=price,
                pnl=trade_pnl - fee, reason="HARD_STOP_LOSS",
            ))

        # ── Drawdown tracking ───────────────────────────────────────
        # Include unrealized PnL in equity for DD calculation
        unrealized = 0.0
        for pos in positions:
            raw_pnl = (price - pos.entry_price) / pos.entry_price if pos.side == "long" \
                      else (pos.entry_price - price) / pos.entry_price
            unrealized += equity * pos.size * raw_pnl
        mark_equity = equity + unrealized

        peak_equity = max(peak_equity, mark_equity)
        dd = (peak_equity - mark_equity) / peak_equity if peak_equity > 0 else 0
        max_drawdown = max(max_drawdown, dd)

        if i % curve_sample == 0:
            pnl_curve.append(round((mark_equity - config.initial_capital) / config.initial_capital * 100, 2))

        # Liquidation check
        if equity <= config.initial_capital * 0.05:
            break

    # ── Close remaining positions at last price ─────────────────────
    last_price = df.iloc[-1]["close"]
    for pos in positions:
        raw_pnl = (last_price - pos.entry_price) / pos.entry_price if pos.side == "long" \
                  else (pos.entry_price - last_price) / pos.entry_price
        trade_pnl = equity * pos.size * raw_pnl
        fee = equity * pos.size * config.fee_rate
        equity += trade_pnl - fee
        total_fees += fee
        trades.append(Trade(
            bar=len(df) - 1, side=pos.side,
            entry_price=pos.entry_price, exit_price=last_price,
            pnl=trade_pnl - fee, reason="end_of_backtest",
        ))

    pnl_curve.append(round((equity - config.initial_capital) / config.initial_capital * 100, 2))

    # ── Compute stats ───────────────────────────────────────────────
    total_return = (equity - config.initial_capital) / config.initial_capital * 100
    win_trades = [t for t in trades if t.pnl > 0]
    lose_trades = [t for t in trades if t.pnl <= 0]
    win_rate = len(win_trades) / len(trades) * 100 if trades else 0
    gross_profit = sum(t.pnl for t in win_trades)
    gross_loss = abs(sum(t.pnl for t in lose_trades))
    profit_factor = gross_profit / gross_loss if gross_loss > 0 else (99.9 if gross_profit > 0 else 0)

    # Sharpe from PnL curve
    returns = [pnl_curve[i] - pnl_curve[i - 1] for i in range(1, len(pnl_curve))]
    mean_ret = np.mean(returns) if returns else 0
    std_ret = np.std(returns, ddof=1) if len(returns) > 1 else 1
    sharpe = mean_ret / max(std_ret, 0.0001) * np.sqrt(252)

    return {
        "total_return_pct": round(total_return, 2),
        "max_drawdown_pct": round(max_drawdown * 100, 2),
        "sharpe_ratio": round(sharpe, 2),
        "win_rate": round(win_rate, 2),
        "total_trades": len(trades),
        "avg_trade_pnl": round(np.mean([t.pnl for t in trades]), 2) if trades else 0,
        "max_win": round(max((t.pnl for t in trades), default=0), 2),
        "max_loss": round(min((t.pnl for t in trades), default=0), 2),
        "profit_factor": round(profit_factor, 2),
        "total_fees": round(total_fees, 2),
        "pnl_curve": pnl_curve,
        "trades": [
            {"bar": t.bar, "side": t.side, "entry": t.entry_price,
             "exit": t.exit_price, "pnl": round(t.pnl, 2), "reason": t.reason}
            for t in trades
        ],
    }


# ── Strategy Loading ────────────────────────────────────────────────────────

def load_strategy_from_code(code: str):
    """Load a strategy function from code string. Returns the strategy callable."""
    # Write to temp file and import
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
        f.write(code)
        f.flush()
        spec = importlib.util.spec_from_file_location("user_strategy", f.name)
        mod = importlib.util.module_from_spec(spec)

        # Inject built-in indicators into module namespace
        from strategies import indicators
        for name in dir(indicators):
            if not name.startswith("_"):
                setattr(mod, name, getattr(indicators, name))

        spec.loader.exec_module(mod)

    if not hasattr(mod, "strategy"):
        raise ValueError("Strategy code must define a `strategy(df)` function")

    return mod.strategy


def load_strategy_from_file(path: str):
    """Load strategy function from a .py file."""
    with open(path) as f:
        return load_strategy_from_code(f.read())


# ── Public API ──────────────────────────────────────────────────────────────

def run_custom_backtest(
    code: str,
    df: pd.DataFrame,
    config: Optional[BacktestConfig] = None,
    max_retries: int = 0,
    fix_callback=None,
) -> dict:
    """
    Run a custom strategy backtest.

    Args:
        code: Python code string with strategy(df) function
        df: DataFrame with columns: timestamp, open, high, low, close, volume
        config: Backtest configuration
        max_retries: Number of auto-fix attempts (0 = no retries)
        fix_callback: async fn(code, error) -> fixed_code (AI-powered fix)

    Returns:
        Standard backtest result dict (same format as Rust engine)
    """
    if config is None:
        config = BacktestConfig()

    attempt = 0
    current_code = code

    # Normalize column names (backtest data uses ts/o/h/l/c/v)
    col_map = {"ts": "timestamp", "o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"}
    df = df.rename(columns={k: v for k, v in col_map.items() if k in df.columns})

    while True:
        try:
            strategy_fn = load_strategy_from_code(current_code)
            t0 = time.time()
            raw_signals = strategy_fn(df.copy())
            elapsed = (time.time() - t0) * 1000

            if not isinstance(raw_signals, list):
                raise ValueError(f"strategy() must return a list, got {type(raw_signals).__name__}")

            # Validate signals
            signals = []
            warnings = []
            for i, sig in enumerate(raw_signals):
                validated = validate_signal(sig, len(df))
                if validated:
                    signals.append(validated)
                else:
                    warnings.append(f"Signal #{i} invalid: {sig}")

            result = execute_signals(df, signals, config)
            result["elapsed_ms"] = round(elapsed)
            result["signals_total"] = len(raw_signals)
            result["signals_valid"] = len(signals)
            if warnings:
                result["warnings"] = warnings[:10]  # Cap at 10
            return result

        except Exception as e:
            attempt += 1
            error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"

            if attempt > max_retries or fix_callback is None:
                return {
                    "error": error_msg,
                    "attempt": attempt,
                    "code": current_code,
                }

            # Try AI auto-fix
            try:
                current_code = fix_callback(current_code, error_msg)
            except Exception:
                return {
                    "error": error_msg,
                    "fix_error": "Auto-fix failed",
                    "attempt": attempt,
                }


# ── CLI ─────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="Custom strategy backtest")
    parser.add_argument("strategy_file", help="Path to strategy.py")
    parser.add_argument("--ticker", default="BTC", help="Ticker (BTC/ETH/SOL)")
    parser.add_argument("--days", type=int, default=90, help="Backtest period")
    parser.add_argument("--data-dir", default="./data/candles", help="Candle data directory")
    parser.add_argument("--capital", type=float, default=10000, help="Initial capital")
    args = parser.parse_args()

    # Load candle data
    data_file = Path(args.data_dir) / f"{args.ticker}.json"
    if not data_file.exists():
        print(f"Error: {data_file} not found. Run download_data.py first.", file=sys.stderr)
        sys.exit(1)

    with open(data_file) as f:
        raw = json.load(f)

    # Convert to DataFrame
    df = pd.DataFrame(raw)
    if "ts" in df.columns:
        df = df.rename(columns={"ts": "timestamp", "o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"})

    # Slice to period
    if args.days > 0:
        bars_needed = args.days * 1440  # 1-min bars
        df = df.tail(bars_needed).reset_index(drop=True)

    # Load and run strategy
    with open(args.strategy_file) as f:
        code = f.read()

    config = BacktestConfig(initial_capital=args.capital)
    result = run_custom_backtest(code, df, config)

    if "error" in result:
        print(f"Error: {result['error']}", file=sys.stderr)
        sys.exit(1)

    print(json.dumps(result, indent=2))


path: clawswap/tools/download_data.py
content: #!/usr/bin/env python3
"""
Download historical candle data from data.binance.vision.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Data source: https://data.binance.vision/data/futures/um/daily/klines/
- Free, no API key needed
- Daily ZIP files containing 1-minute CSV candle data
- USDT-M Futures (most liquid markets)

Usage:
    python download_data.py --ticker BTC --interval 1m --days 180
    python download_data.py --ticker BTC ETH SOL --interval 1m --days 180
"""

import argparse
import csv
import io
import sys
import time
import zipfile
from datetime import datetime, timezone, timedelta
from pathlib import Path
from urllib.request import Request, urlopen, ProxyHandler, build_opener, install_opener
from urllib.error import HTTPError, URLError

# Bypass system proxy (macOS Shimo VPN etc.) — connect directly to Binance
install_opener(build_opener(ProxyHandler({})))

# https://data.binance.vision/data/futures/um/daily/klines/{PAIR}/{interval}/{PAIR}-{interval}-{date}.zip
BASE_URL = "https://data.binance.vision/data/futures/um/daily/klines"
DATA_DIR = Path(__file__).parent.parent / "data" / "candles"


def download_day(symbol: str, interval: str, date_str: str) -> list:
    """Download one day's kline ZIP from data.binance.vision."""
    pair = f"{symbol}USDT"
    url = f"{BASE_URL}/{pair}/{interval}/{pair}-{interval}-{date_str}.zip"
    req = Request(url, headers={"User-Agent": "ClawSwap/1.0"})

    for attempt in range(3):
        try:
            with urlopen(req, timeout=30) as resp:
                zip_bytes = resp.read()
            break
        except HTTPError as e:
            if e.code == 404:
                return []  # No data for this day (not yet published)
            if attempt < 2:
                time.sleep(1)
                continue
            return []
        except (URLError, Exception) as e:
            if attempt < 2:
                time.sleep(1)
                continue
            print(f"  [!] {date_str}: {e}", file=sys.stderr)
            return []

    candles = []
    try:
        with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
            for name in zf.namelist():
                if name.endswith(".csv"):
                    with zf.open(name) as f:
                        reader = csv.reader(io.TextIOWrapper(f))
                        for row in reader:
                            if len(row) < 6:
                                continue
                            try:
                                candles.append([
                                    int(row[0]),    # open_time ms
                                    float(row[1]),  # open
                                    float(row[2]),  # high
                                    float(row[3]),  # low
                                    float(row[4]),  # close
                                    float(row[5]),  # volume
                                ])
                            except (ValueError, IndexError):
                                continue
    except zipfile.BadZipFile:
        return []

    return candles


def download_ticker(ticker: str, interval: str, days: int):
    """Download candle data for a single ticker."""
    symbol = ticker.upper()
    today = datetime.now(timezone.utc).date()
    start_date = today - timedelta(days=days)

    all_candles = []
    total_days = (today - start_date).days
    good = 0
    skipped = 0

    print(f"  [{symbol}] Downloading {days}d of {interval} from data.binance.vision...")

    current = start_date
    while current < today:  # today's data isn't published yet
        date_str = current.strftime("%Y-%m-%d")
        day_num = (current - start_date).days + 1

        candles = download_day(symbol, interval, date_str)

        if candles:
            all_candles.extend(candles)
            good += 1
            sys.stdout.write(f"\r  [{symbol}] {date_str} | {len(all_candles):,} candles | {day_num}/{total_days} days")
            sys.stdout.flush()
        else:
            skipped += 1

        current += timedelta(days=1)

    print()  # newline after progress

    if not all_candles:
        print(f"  [{symbol}] No data downloaded!")
        return

    # Dedup + sort
    seen = set()
    deduped = []
    for c in all_candles:
        if c[0] not in seen:
            seen.add(c[0])
            deduped.append(c)
    deduped.sort(key=lambda x: x[0])

    # Save
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    filename = f"{symbol}-{interval}-{days}d.csv"
    filepath = DATA_DIR / filename

    with open(filepath, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["timestamp", "open", "high", "low", "close", "volume"])
        writer.writerows(deduped)

    size_mb = filepath.stat().st_size / (1024 * 1024)
    start_dt = datetime.fromtimestamp(deduped[0][0] / 1000, tz=timezone.utc)
    end_dt = datetime.fromtimestamp(deduped[-1][0] / 1000, tz=timezone.utc)
    print(f"  [{symbol}] ✓ {len(deduped):,} candles → {filepath.name} ({size_mb:.1f} MB)")
    print(f"  [{symbol}]   {start_dt:%Y-%m-%d} → {end_dt:%Y-%m-%d} | {good} days downloaded, {skipped} skipped")


def main():
    parser = argparse.ArgumentParser(description="Download candle data from data.binance.vision")
    parser.add_argument("--ticker", nargs="+", default=["BTC", "ETH", "SOL"],
                        help="Tickers to download (default: BTC ETH SOL)")
    parser.add_argument("--interval", default="1m",
                        help="Interval: 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d")
    parser.add_argument("--days", type=int, default=180,
                        help="Days of history (default: 180)")
    args = parser.parse_args()

    print(f"━━━ ClawSwap Data Downloader ━━━")
    print(f"Source:   data.binance.vision (USDT-M Futures daily ZIPs)")
    print(f"Tickers:  {', '.join(args.ticker)}")
    print(f"Interval: {args.interval} | Days: {args.days}")
    print()

    for ticker in args.ticker:
        download_ticker(ticker, args.interval, args.days)
        print()

    print("Done!")


if __name__ == "__main__":
    main()


path: clawswap/tools/package_skill.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_DIR="${1:-$ROOT_DIR/dist}"
VERSION="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["version"])' "$ROOT_DIR/skill.json")"
PACKAGE_BASENAME="clawswap-v${VERSION}.skill"
PACKAGE_PATH="$OUT_DIR/$PACKAGE_BASENAME"
STAGE_DIR="$(mktemp -d)"

cleanup() {
  rm -rf "$STAGE_DIR"
}
trap cleanup EXIT

mkdir -p "$OUT_DIR"

# Run prepublish checks first
"$ROOT_DIR/tools/prepublish_check.sh"

# Whitelist copy: include only release-relevant files
mkdir -p "$STAGE_DIR/clawswap"
cp "$ROOT_DIR/SKILL.md" "$STAGE_DIR/clawswap/"
cp "$ROOT_DIR/skill.json" "$STAGE_DIR/clawswap/"
cp "$ROOT_DIR/runtime_client.py" "$STAGE_DIR/clawswap/"
cp "$ROOT_DIR/.env.example" "$STAGE_DIR/clawswap/"

cp -R "$ROOT_DIR/strategies" "$STAGE_DIR/clawswap/"
cp -R "$ROOT_DIR/tools" "$STAGE_DIR/clawswap/"
cp -R "$ROOT_DIR/examples" "$STAGE_DIR/clawswap/"

# Remove non-release files from staged tree
find "$STAGE_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$STAGE_DIR" -type f -name '*.pyc' -delete
find "$STAGE_DIR" -type f \( -name '.runtime_token' -o -name '.clawswap_api_key' -o -name '.env' \) -delete
rm -rf "$STAGE_DIR/clawswap/tests"

# Avoid nesting package scripts inside shipped tools
rm -f "$STAGE_DIR/clawswap/tools/package_skill.sh" "$STAGE_DIR/clawswap/tools/prepublish_check.sh"

(
  cd "$STAGE_DIR"
  zip -q -r "$PACKAGE_PATH" clawswap
)

echo "✅ Packaged: $PACKAGE_PATH"
echo "📦 Contents:"
unzip -l "$PACKAGE_PATH"


path: clawswap/tools/backtest.py
content: #!/usr/bin/env python3
"""
ClawSwap — Local Backtest Engine (Vectorized)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
High-performance strategy backtests on locally cached candle data.
Numpy-vectorized core — supports 200+ concurrent backtests.

Usage:
    python backtest.py --strategy mean_reversion --ticker BTC --days 180
    python backtest.py --strategy grid --ticker SOL --days 180 --json
    python backtest.py --strategy momentum --ticker ETH --json
"""

import argparse
import json
import sys
import time
from dataclasses import dataclass, field, asdict
from pathlib import Path

try:
    import numpy as np
    import pandas as pd
except ImportError:
    print("Missing: pip install numpy pandas")
    sys.exit(1)

sys.path.insert(0, str(Path(__file__).parent.parent))
from strategies import STRATEGY_MAP


@dataclass
class BacktestResult:
    strategy: str
    ticker: str
    days: int
    interval: str
    initial_equity: float
    final_equity: float
    total_return_pct: float
    sharpe_ratio: float
    max_drawdown_pct: float
    win_rate: float
    total_trades: int
    profit_factor: float
    avg_trade_pnl: float
    equity_curve: list = field(default_factory=list)
    elapsed_ms: int = 0


def load_candles(ticker, days, interval, data_dir):
    """Find and load candle CSV."""
    coin = ticker.upper()
    exact = data_dir / f"{coin}-{interval}-{days}d.csv"
    if exact.exists():
        return pd.read_csv(exact)
    candidates = sorted(data_dir.glob(f"{coin}-{interval}-*.csv"))
    if candidates:
        df = pd.read_csv(candidates[-1])
        cpd = {"1m": 1440, "5m": 288, "15m": 96, "1h": 24, "4h": 6, "1d": 1}
        return df.tail(days * cpd.get(interval, 1440))
    legacy = sorted(data_dir.glob(f"{coin}USDT-*-merged-*.csv"))
    if legacy:
        return pd.read_csv(legacy[-1])
    raise FileNotFoundError(
        f"No data for {coin}. Run: python scripts/download_data.py --tickers {coin} --days {days} --interval {interval}"
    )


# ── Vectorized strategy engines ────────────────────────────────

def _bt_mean_reversion(close, timestamps, cfg, initial_equity):
    """Vectorized mean reversion: buy on dip from rolling high, TP/SL exit."""
    lookback = getattr(cfg, 'lookback_candles', 96)
    entry_drop = getattr(cfg, 'entry_drop_pct', 2.0) / 100
    tp_pct = getattr(cfg, 'take_profit_pct', 1.5) / 100
    sl_pct = getattr(cfg, 'stop_loss_pct', 3.0) / 100
    leverage = getattr(cfg, 'leverage', 2.0)
    size_pct = getattr(cfg, 'position_size_pct', 0.2)
    fee_rate = 0.0005  # 5 bps

    n = len(close)
    # Rolling max over lookback window
    rolling_high = pd.Series(close).rolling(lookback, min_periods=1).max().values

    # Simulate trades
    equity = initial_equity
    trades = []
    equity_arr = np.empty(n + 1)
    equity_arr[0] = equity
    in_pos = False
    entry_px = 0.0

    for i in range(n):
        px = close[i]
        if not in_pos:
            if i >= lookback:
                drop = (rolling_high[i] - px) / rolling_high[i]
                if drop >= entry_drop:
                    entry_px = px
                    equity -= equity * size_pct * leverage * fee_rate
                    in_pos = True
        else:
            pnl_r = (px - entry_px) / entry_px
            if pnl_r >= tp_pct or pnl_r <= -sl_pct:
                pnl_usd = equity * size_pct * leverage * pnl_r
                fee = equity * size_pct * leverage * fee_rate
                equity += pnl_usd - fee
                trades.append(pnl_usd - fee)
                in_pos = False

        unreal = 0.0
        if in_pos and entry_px > 0:
            unreal = equity * size_pct * leverage * (px - entry_px) / entry_px
        equity_arr[i + 1] = equity + unreal

    # Close open
    if in_pos and entry_px > 0:
        pnl_r = (close[-1] - entry_px) / entry_px
        equity += equity * size_pct * leverage * pnl_r
        equity_arr[-1] = equity

    return equity, trades, equity_arr


def _bt_momentum(close, timestamps, cfg, initial_equity):
    """Vectorized momentum: buy on fast>slow MA crossover."""
    fast = getattr(cfg, 'fast_period', 12)
    slow = getattr(cfg, 'slow_period', 26)
    tp_pct = getattr(cfg, 'take_profit_pct', 3.0) / 100
    sl_pct = getattr(cfg, 'stop_loss_pct', 2.0) / 100
    leverage = getattr(cfg, 'leverage', 3.0)
    size_pct = getattr(cfg, 'position_size_pct', 0.15)
    fee_rate = 0.0005

    n = len(close)
    s = pd.Series(close)
    fast_ma = s.rolling(fast, min_periods=1).mean().values
    slow_ma = s.rolling(slow, min_periods=1).mean().values

    equity = initial_equity
    trades = []
    equity_arr = np.empty(n + 1)
    equity_arr[0] = equity
    in_pos = False
    entry_px = 0.0

    for i in range(slow, n):
        px = close[i]
        if not in_pos:
            if fast_ma[i] > slow_ma[i] and fast_ma[i - 1] <= slow_ma[i - 1]:
                entry_px = px
                equity -= equity * size_pct * leverage * fee_rate
                in_pos = True
        else:
            pnl_r = (px - entry_px) / entry_px
            if pnl_r >= tp_pct or pnl_r <= -sl_pct or (fast_ma[i] < slow_ma[i]):
                pnl_usd = equity * size_pct * leverage * pnl_r
                fee = equity * size_pct * leverage * fee_rate
                equity += pnl_usd - fee
                trades.append(pnl_usd - fee)
                in_pos = False

        unreal = 0.0
        if in_pos and entry_px > 0:
            unreal = equity * size_pct * leverage * (px - entry_px) / entry_px
        equity_arr[i + 1] = equity + unreal

    # Fill early indices
    equity_arr[:slow + 1] = initial_equity

    if in_pos and entry_px > 0:
        pnl_r = (close[-1] - entry_px) / entry_px
        equity += equity * size_pct * leverage * pnl_r
        equity_arr[-1] = equity

    return equity, trades, equity_arr


def _bt_grid(close, timestamps, cfg, initial_equity):
    """Vectorized grid: buy/sell at grid levels around a center price."""
    grid_pct = getattr(cfg, 'grid_spacing_pct', 1.0) / 100
    num_grids = getattr(cfg, 'num_grids', 10)
    leverage = getattr(cfg, 'leverage', 2.0)
    fee_rate = 0.0005

    n = len(close)
    # Center = median of first 1000 candles
    center = float(np.median(close[:min(1000, n)]))
    grid_size = equity_per_grid = initial_equity / num_grids

    # Grid levels
    buy_levels = [center * (1 - grid_pct * (i + 1)) for i in range(num_grids // 2)]
    sell_levels = [center * (1 + grid_pct * (i + 1)) for i in range(num_grids // 2)]

    equity = initial_equity
    trades = []
    equity_arr = np.empty(n + 1)
    equity_arr[0] = equity
    positions = {}  # level_idx -> entry_price

    for i in range(n):
        px = close[i]
        # Check buy levels
        for li, level in enumerate(buy_levels):
            if px <= level and li not in positions:
                positions[li] = px
                equity -= equity_per_grid * leverage * fee_rate
                break
        # Check sell levels / take profit on positions
        closed = []
        for li, ep in positions.items():
            pnl_r = (px - ep) / ep
            # Close if price returned to center or hit grid above
            if pnl_r >= grid_pct or pnl_r <= -grid_pct * 3:
                pnl_usd = equity_per_grid * leverage * pnl_r
                fee = equity_per_grid * leverage * fee_rate
                equity += pnl_usd - fee
                trades.append(pnl_usd - fee)
                closed.append(li)
        for li in closed:
            del positions[li]

        unreal = sum(
            equity_per_grid * leverage * (px - ep) / ep
            for ep in positions.values()
        )
        equity_arr[i + 1] = equity + unreal

    # Close all open
    for ep in positions.values():
        pnl_r = (close[-1] - ep) / ep
        equity += equity_per_grid * leverage * pnl_r
    equity_arr[-1] = equity

    return equity, trades, equity_arr


def _bt_class_fallback(df, StratClass, cfg, initial_equity, close, timestamps):
    """Generic fallback using strategy class interface (slower but universal)."""
    strategy = StratClass(cfg)
    leverage = getattr(cfg, 'leverage', 2.0)
    size_pct = getattr(cfg, 'position_size_pct', 0.15)
    fee_rate = 0.0005
    n = len(close)

    equity = initial_equity
    trades = []
    equity_arr = np.empty(n + 1)
    equity_arr[0] = equity
    in_pos = False
    entry_px = 0.0

    for i in range(n):
        candle = {
            "open": float(df.iloc[i]["open"]) if "open" in df.columns else close[i],
            "high": float(df.iloc[i]["high"]) if "high" in df.columns else close[i],
            "low": float(df.iloc[i]["low"]) if "low" in df.columns else close[i],
            "close": close[i],
            "volume": float(df.iloc[i]["volume"]) if "volume" in df.columns else 0,
        }
        px = close[i]
        if hasattr(strategy, 'on_candle'):
            strategy.on_candle(candle)

        if not in_pos:
            sig = strategy.get_signal() if hasattr(strategy, 'get_signal') else None
            if sig == "buy":
                entry_px = px
                equity -= equity * size_pct * leverage * fee_rate
                in_pos = True
                if hasattr(strategy, 'on_fill'):
                    strategy.on_fill("buy", px, f"t_{len(trades)}")
        else:
            exit_sig = strategy.get_exit_signal(px) if hasattr(strategy, 'get_exit_signal') else None
            if exit_sig:
                pnl_r = (px - entry_px) / entry_px
                pnl_usd = equity * size_pct * leverage * pnl_r
                fee = equity * size_pct * leverage * fee_rate
                equity += pnl_usd - fee
                trades.append(pnl_usd - fee)
                in_pos = False
                if hasattr(strategy, 'on_fill'):
                    strategy.on_fill("sell", px, f"t_{len(trades)}")

        unreal = equity * size_pct * leverage * (px - entry_px) / entry_px if in_pos and entry_px > 0 else 0
        equity_arr[i + 1] = equity + unreal

    if in_pos and entry_px > 0:
        equity += equity * size_pct * leverage * (close[-1] - entry_px) / entry_px
        equity_arr[-1] = equity

    return equity, trades, equity_arr


VECTORIZED = {
    "mean_reversion": _bt_mean_reversion,
    "momentum": _bt_momentum,
    "grid": _bt_grid,
}


def run_backtest(strategy_name, ticker, days, interval="1m",
                 initial_equity=10_000.0, config_overrides=None, data_dir=None):
    if data_dir is None:
        data_dir = Path(__file__).parent.parent / "data" / "candles"

    t0 = time.time()
    df = load_candles(ticker, days, interval, data_dir)

    if len(df) < 50:
        raise ValueError(f"Not enough data: {len(df)} candles (need 50+)")

    # Get config from strategy map
    if strategy_name not in STRATEGY_MAP:
        raise ValueError(f"Unknown strategy: {strategy_name}. Available: {list(STRATEGY_MAP.keys())}")

    StratClass, CfgClass = STRATEGY_MAP[strategy_name]
    cfg_kwargs = {"ticker": ticker}
    if config_overrides:
        cfg_kwargs.update(config_overrides)
    cfg = CfgClass(**cfg_kwargs)

    # Extract numpy arrays
    close = df["close"].values.astype(np.float64)
    timestamps = df["timestamp"].values.astype(np.int64) if "timestamp" in df.columns else np.arange(len(df))

    # Run vectorized backtest (or fallback to class-based)
    bt_fn = VECTORIZED.get(strategy_name)
    if bt_fn is not None:
        equity, trades, equity_arr = bt_fn(close, timestamps, cfg, initial_equity)
    else:
        equity, trades, equity_arr = _bt_class_fallback(
            df, StratClass, cfg, initial_equity, close, timestamps
        )

    # Metrics
    n = len(close)
    ret = (equity - initial_equity) / initial_equity * 100

    # Max drawdown from equity array
    peak = np.maximum.accumulate(equity_arr)
    dd = (peak - equity_arr) / np.where(peak > 0, peak, 1) * 100
    max_dd = float(np.max(dd))

    # Sharpe from returns
    if len(equity_arr) > 2:
        rets = np.diff(equity_arr) / np.where(equity_arr[:-1] != 0, equity_arr[:-1], 1)
        rets = rets[np.isfinite(rets)]
        if len(rets) > 1 and np.std(rets) > 0:
            sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(252))
        else:
            sharpe = 0.0
    else:
        sharpe = 0.0

    wins = [t for t in trades if t > 0]
    losses = [t for t in trades if t <= 0]
    win_rate = len(wins) / len(trades) * 100 if trades else 0.0
    gp = sum(wins)
    gl = abs(sum(losses))
    pf = gp / gl if gl > 0 else (9.99 if gp > 0 else 0.0)
    avg = sum(trades) / len(trades) if trades else 0.0

    # Downsample equity curve to ~300 points with timestamps
    step = max(1, (n + 1) // 300)
    eq_with_ts = []
    for i in range(0, n + 1, step):
        ts = int(timestamps[min(i, n - 1)]) if i < n else int(timestamps[-1])
        eq_with_ts.append([ts, round(float(equity_arr[i]), 2)])

    return BacktestResult(
        strategy=strategy_name, ticker=ticker, days=days, interval=interval,
        initial_equity=initial_equity, final_equity=round(equity, 2),
        total_return_pct=round(ret, 2), sharpe_ratio=round(sharpe, 3),
        max_drawdown_pct=round(max_dd, 2), win_rate=round(win_rate, 1),
        total_trades=len(trades), profit_factor=round(pf, 2),
        avg_trade_pnl=round(avg, 2), equity_curve=eq_with_ts,
        elapsed_ms=int((time.time() - t0) * 1000),
    )


def print_ascii_chart(curve, width=50, height=12):
    if len(curve) < 2:
        return
    vals = [p[1] if isinstance(p, list) else p for p in curve]
    mn, mx = min(vals), max(vals)
    rng = mx - mn or 1
    step = max(1, len(vals) // width)
    sampled = [vals[i] for i in range(0, len(vals), step)][:width]

    chart = []
    for row in range(height, -1, -1):
        threshold = mn + (rng * row / height)
        line = ""
        for v in sampled:
            line += "█" if v >= threshold else " "
        label = f"${threshold:>10,.0f}" if row in (0, height // 2, height) else " " * 11
        chart.append(f"{label} │{line}")
    chart.append(" " * 11 + " └" + "─" * len(sampled))
    print("\n".join(chart))


def main():
    parser = argparse.ArgumentParser(description="ClawSwap local backtest")
    parser.add_argument("--strategy", default="mean_reversion", choices=list(STRATEGY_MAP.keys()))
    parser.add_argument("--ticker", default="BTC")
    parser.add_argument("--days", type=int, default=180)
    parser.add_argument("--interval", default="1m", choices=["1m", "5m", "15m", "1h"])
    parser.add_argument("--equity", type=float, default=10_000.0)
    parser.add_argument("--config", default="{}", help="JSON config overrides")
    parser.add_argument("--data-dir", default=None)
    parser.add_argument("--json", action="store_true", help="Output JSON")
    parser.add_argument("--chart", action="store_true", help="Show ASCII equity chart")
    args = parser.parse_args()

    overrides = json.loads(args.config) if args.config else {}
    data_dir = Path(args.data_dir) if args.data_dir else None

    try:
        result = run_backtest(
            strategy_name=args.strategy, ticker=args.ticker, days=args.days,
            interval=args.interval, initial_equity=args.equity,
            config_overrides=overrides, data_dir=data_dir,
        )
    except FileNotFoundError as e:
        print(f"Error: {e}")
        sys.exit(1)

    if args.json:
        print(json.dumps(asdict(result)))
        return

    sign = "+" if result.total_return_pct >= 0 else ""
    print(f"""
╔══ BACKTEST RESULTS ═══════════════════════════╗
  Strategy    : {result.strategy}
  Ticker      : {result.ticker}
  Period      : {result.days} days ({result.total_trades} trades)
  Interval    : {result.interval}

  Return      : {sign}{result.total_return_pct}%
  Sharpe      : {result.sharpe_ratio}
  Max DD      : -{result.max_drawdown_pct}%
  Win Rate    : {result.win_rate}%
  Profit Fac. : {result.profit_factor}
  Avg Trade   : ${result.avg_trade_pnl:,.2f}

  Initial Eq  : ${result.initial_equity:,.2f}
  Final Eq    : ${result.final_equity:,.2f}
  Elapsed     : {result.elapsed_ms}ms
╚═══════════════════════════════════════════════╝""")

    if args.chart or not args.json:
        print("\n  Equity Curve:")
        print_ascii_chart(result.equity_curve)
        print()


if __name__ == "__main__":
    main()


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