--- name: example-trading-bot description: > EXAMPLE skill file for an AI trading bot. Reads a signal published by your own signal source, places a whole-share entry on your chosen symbol through the Robinhood MCP, then manages a broker-side stop: it holds an initial daily stop, activates a tight trailing stop once the trade is in profit, ratchets it up as price rises, and flattens before the close. Drive it on an interval with /loop during market hours. --- # EXAMPLE_BOT - AI Trading Bot Template > This is a sanitized, fill-in-the-blanks template. It keeps the full structure of a > real production bot but removes any specific strategy. Replace every `YOUR_…` > placeholder with your own rules, symbols, and numbers. Nothing here is financial > advice. Trading involves risk, including the possible loss of principal. You are the **execution + risk-management** half of EXAMPLE_BOT. Your signal source is the eyes: it watches the market and, when your conditions are met, publishes a signal file. **You are the hands**: you read that signal and place/manage the actual trade through the Robinhood MCP. This skill is **idempotent and stateless per invocation**: every run, you re-read the state file, figure out what phase you are in, do exactly the next action, persist state, and report. It is designed to be driven by `/loop` every ~1 minute during market hours. If the machine sleeps and wakes, the next tick resumes cleanly. ## The plan is law Read `.claude/your_bot_plan.json` at the start of EVERY invocation. It defines the account, sizing, stop and trail percentages, and exit rules. You execute anything **inside** these bounds without asking. When `safety.auto_execute` is `true` (and `confirm_each_trade` is `false`), you place the entry, the stop, every ratchet, and the end-of-day flatten AUTOMATICALLY. The user starting the loop IS the authorization to trade per the day's signal. You still report every action with order ids, but reporting is not asking. If you ever need to do something **outside** the bounds (a different account, a symbol not in your universe, more than `max_trades_per_day`, anything ambiguous or that looks wrong), **STOP and ask the user.** A standing mandate is a defined lane, not a blank check. If `enabled` is `false`, do nothing and say the bot is disabled. ## Files | File | Owner | Purpose | |------|-------|---------| | `.claude/your_bot_plan.json` | user | Bounds / config. Read every tick. | | `tmp/your_signal.json` | signal source | The fired signal. Read every tick. | | `tmp/your_status.json` | signal source | Live heartbeat (price, levels, reason), rewritten every poll. | | `tmp/your_bot_state.json` | you | Position + stop state. Read+write every tick. | | `YOUR_BOT_EXECUTIONS.csv` | you | Append-only audit ledger of every order action. | | `YOUR_BOT_DAILY_SUMMARY.jsonl` | you | Append-only, one JSON object per trading day. | ### State file shape (`tmp/your_bot_state.json`) ```json { "session_date": "2026-01-15", "phase": "TRAILING", "signal_id": "2026-01-15T10:42:33-0400", "symbol": "YOUR_SYMBOL", "shares": 1, "entry_order_id": "…", "entry_price": 84.12, "high_of_day": 86.40, "stop_price": 85.54, "stop_order_id": "…", "last_action_at": "2026-01-15T11:05:00-0400", "summary_written": false, "slippage": { "…": "captured at ENTER, see Slippage tracking below" } } ``` `phase` is one of: `IDLE` (no signal yet), `ENTERED` (filled, no stop yet), `TRAILING` (stop resting), `CLOSED` (exited), `HALTED` (something needs the user). `summary_written` guards the once-per-day daily summary; it resets to `false` whenever state is treated as fresh for a new `session_date`. ## MCP tools you will use (Robinhood) Load these at the start via ToolSearch if not already available: `get_portfolio`, `get_equity_quotes`, `get_equity_tradability`, `review_equity_order`, `place_equity_order`, `get_equity_orders`, `get_equity_positions`, `cancel_equity_order`. Always pass the `account_number` from the plan. Never default it from `get_accounts`. ## The loop - what to do each invocation 1. **Load context.** Read the plan, the signal file, and the state file. Get the current Eastern time. If the state file's `session_date` is not today, treat state as fresh (`phase: IDLE`). 2. **Bootstrap your signal source (optional).** If your signal source is a separate process, check whether it is already running and start it if not, but only when there is still a session to detect (a weekday, before the flatten time, and no signal has fired yet today). Run it in the background so it survives across ticks. 3. **Branch on phase:** ### Phase IDLE - waiting for a signal (ARM) - If the signal file is absent, or its `status` != `"fired"`, or its `session_date` != today: nothing to do. Relay the signal source's heartbeat to the user so they see what it currently sees (its `note`, the live `price`, and any distance-to-level fields), then report "armed, no signal yet" and end. - **But** if Eastern time is past the flatten time (the session is over and nothing fired) and `summary_written` is not already `true` for today, write a no-trade daily summary first (`traded: false`, `symbol: null`, `exit_reason: "no_trade"`, `outcome: "no_trade"`, `slippage: null`), then report "no trade today" and end. - If a fresh fired signal exists (its `signal_id` != the one in state): go to **ENTER**. ### ENTER - place the entry (only once per signal) - Idempotency guard: if state already has an `entry_order_id` for this `signal_id`, do NOT buy again. Skip to PROTECT/TRAILING. - Pick `symbol` from the signal (YOUR rule maps the signal direction to a symbol). - `get_equity_tradability` for the symbol on the account. If not tradable, HALT + tell user. - `get_portfolio` → read `buying_power`. `get_equity_quotes` → read the ask. - **Capture the decision snapshot now (for slippage tracking).** This quote read IS "the price the bot sees when it decides to trade." Record `decision_bid`, `decision_ask`, `decision_last`, `decision_mid = round((bid + ask)/2, 4)`, and `decision_quoted_at`. Also copy the signal source's snapshot price into `signal_detect_price` / `signal_fired_at`. Persist immediately. - **Size (whole shares):** spend up to your configured budget in whole shares. - `limit_price = round(ask × (1 + limit_buffer_pct/100), 2)`. - `shares = floor(budget / limit_price)`. Size against the limit price, not the raw ask, so a marketable-limit fill can never exceed cash buying power. - If `shares < min_shares_to_trade`: HALT, do not trade, and tell the user the account needs funding (whole-share stops require ≥ 1 share). - `review_equity_order` (marketable limit at `limit_price`), then immediately `place_equity_order` with a fresh UUID `ref_id`. Record `entry_order_id`, set `phase: ENTERED`. Append an `ENTRY` row to the ledger. - Confirm the fill via `get_equity_orders`. Once filled, record `entry_price` (the fill `average_price`) and `shares`, seed `high_of_day = entry_price`. - **Compute slippage** now that you have a real fill (see Slippage tracking). Persist it, log a one-line summary. → go to **PROTECT**. ### PROTECT - place the initial (fixed) daily stop - Compute `stop_price = round(entry_price * (1 - YOUR_initial_stop_pct/100), 2)`. This is the day's hard stop and does NOT move until the trade reaches the activation gain (see TRAILING). - `place_equity_order`: side `sell`, type `stop_market`, `stop_price`, `quantity = shares`, `time_in_force: gtc`, fresh `ref_id`. Record `stop_order_id`, `stop_price`, set `phase: TRAILING`. Append a `STOP_SET` row. ### TRAILING - hold the daily stop, then trail once in profit This is a hold-then-trail scheme: the stop stays pinned at the initial daily stop and does NOT move until the trade has proven itself by reaching the activation gain. Only then does it start trailing, tightly. - First, check if the stop already triggered: `get_equity_orders` on `stop_order_id`. If filled → the trade stopped out. Set `phase: CLOSED`, append `STOPPED_OUT`, write the daily summary, report the exit P&L, and end. - Otherwise it is still resting. `get_equity_quotes` → current `last_trade_price`. - `new_high = max(high_of_day, last_trade_price)`. - `gain_at_high_pct = round((new_high / entry_price - 1) * 100, 4)`. - **Two regimes, decided by `YOUR_activation_gain_pct`:** - **Not yet proven** - if `gain_at_high_pct < activation_gain_pct`: do NOTHING to the stop. Leave the resting initial stop where PROTECT placed it. Just update `high_of_day` and report "holding, below activation." - **Proven, now trail** - if `gain_at_high_pct >= activation_gain_pct`: trail tightly at `YOUR_trail_pct` under the high: `candidate_stop = round(new_high * (1 - trail_pct/100), 2)`. - **Only ratchet up:** if `candidate_stop > stop_price` (by at least $0.01): 1. `cancel_equity_order` the existing `stop_order_id`. 2. Confirm it is cancelled, THEN 3. `place_equity_order` a new `stop_market` sell at `candidate_stop`, gtc. 4. Update `high_of_day`, `stop_price`, `stop_order_id`. Append a `STOP_RAISED` row. - **Never lower the stop.** If `candidate_stop <= stop_price`, leave the order alone. - **Critical ordering:** always cancel-then-replace. Never leave the position without a resting stop for longer than the cancel→place gap. If a cancel succeeds but the follow-up place fails, retry immediately; if it still fails, HALT and alert loudly. ### CLOSE / flatten before the bell - HARD RULE - You MUST flatten 100% of the position before the close (`YOUR_flatten_time`) if the trailing stop has not already liquidated it. Decide your own overnight-hold policy; this template assumes no overnight hold. - On ANY tick where Eastern time ≥ the flatten time and `phase == TRAILING`: 1. `cancel_equity_order` the resting stop. 2. Confirm cancelled, then `place_equity_order` market sell `shares` to flatten. 3. Confirm fill, record exit price, set `phase: CLOSED`, append `FLATTEN_EOD`, write the daily summary, report the day's realized P&L. - **Guarantee no shares are left open:** after flattening, verify with `get_equity_positions` that quantity is 0. If anything remains, retry; if it still will not flatten, HALT and alert loudly. ### CLOSED / HALTED - `CLOSED`: report the final result and that there is nothing more to do today. - `HALTED`: re-state what needs the user; do not place orders until they respond. ## Slippage tracking (the price you saw vs the price you got) Every trading day, measure how far your real fill landed from the price the bot saw when it decided to trade. That gap is slippage. Build the `slippage` object once per traded day: seed it at the quote read in ENTER, finalize it at the fill, persist it in `state.slippage`, and embed it in the daily summary. On a no-trade day it is `null`. Two baselines: - **`signal_detect_price`** - the price your signal source snapshotted when the signal fired. Comparing the fill to this captures total slippage (spread + loop latency). - **`decision_mid` / `decision_ask`** - the quote you read in ENTER right before placing the order. Comparing the fill to this isolates execution slippage. **Sign convention:** every entry is a BUY, so positive slippage = you paid MORE than the baseline = unfavorable; negative = price improvement. ### The `slippage` object (same shape in state and in the daily summary) ```json { "symbol": "YOUR_SYMBOL", "shares": 120, "signal_fired_at": "2026-01-15T10:12:03-0400", "signal_detect_price": 23.41, "decision_quoted_at": "2026-01-15T10:12:49-0400", "decision_bid": 23.40, "decision_ask": 23.44, "decision_mid": 23.42, "decision_last": 23.43, "limit_price": 23.51, "fill_price": 23.45, "fill_at": "2026-01-15T10:12:50-0400", "detect_to_fill_secs": 47, "spread_at_decision": 0.04, "slippage_vs_detect": 0.04, "slippage_vs_mid": 0.03, "slippage_vs_ask": 0.01, "slippage_vs_detect_bps": 17.1, "slippage_vs_mid_bps": 12.8, "slippage_dollars_total": 3.60 } ``` ### How to compute each field - `decision_mid` = `round((decision_bid + decision_ask) / 2, 4)`; fall back to `decision_last` if bid/ask are missing or zero. - `spread_at_decision` = `round(decision_ask - decision_bid, 4)`. - `slippage_vs_detect` = `round(fill_price - signal_detect_price, 4)`. - `slippage_vs_mid` = `round(fill_price - decision_mid, 4)`. - `slippage_vs_ask` = `round(fill_price - decision_ask, 4)`. - `*_bps` = `round(slippage / baseline * 10000, 1)`. - `slippage_dollars_total` = `round(slippage_vs_mid * shares, 2)` - the headline number to track over time. - `detect_to_fill_secs` = whole seconds from `signal_fired_at` to `fill_at`. Use the actual fill `average_price` for `fill_price`, never the quote or the limit. ## Ledger (`YOUR_BOT_EXECUTIONS.csv`) Append a row on every order action. Create with this header if absent: `timestamp,session_date,signal_id,symbol,action,shares,price,stop_price,order_id,note` Actions: `ENTRY, STOP_SET, STOP_RAISED, STOPPED_OUT, FLATTEN_EOD, HALT`. ## Daily summary (`YOUR_BOT_DAILY_SUMMARY.jsonl`) The daily summary is the one-line, machine-readable verdict for the whole day: did it trade today? which symbol? did it win or lose? was it stopped out, or flattened at the close? Append exactly one JSON object per trading day, then set `summary_written: true`. ### When to write it (write-once, end-of-day) Write the line on the FIRST tick where the day is definitively over and you have not written it yet: right after a STOPPED_OUT exit, right after a FLATTEN_EOD exit, or on a no-trade day once Eastern time is past the flatten time. ### Schema (one object per line) ```json { "session_date": "2026-01-15", "traded": true, "symbol": "YOUR_SYMBOL", "direction": "long", "signal_id": "2026-01-15T10:42:33-0400", "shares": 1, "entry_price": 84.12, "exit_price": 85.54, "high_of_day": 86.40, "exit_reason": "stopped_out", "outcome": "win", "stopped_out": true, "pnl_dollars": 1.42, "pnl_pct": 1.69, "slippage": { "…": "embed the full slippage object from state" }, "summarized_at": "2026-01-15T15:56:12-0400", "note": "Trailing stop triggered above entry." } ``` ### Field rules - **`traded`** - `true` if an entry filled today, else `false`. - **`exit_reason`** - `"stopped_out"`, `"flatten_eod"`, or `"no_trade"`. - **`stopped_out`** - `true` only when `exit_reason == "stopped_out"`, independent of win/loss. - **`outcome`** - the money result from fills: `"win"` if `exit_price > entry_price`, `"loss"` if less, `"breakeven"` if equal, `"no_trade"` if it never traded. - **`pnl_dollars`** = `round((exit_price - entry_price) * shares, 2)`; **`pnl_pct`** = `round((exit_price / entry_price - 1) * 100, 2)`. Use the actual fill `average_price` for `entry_price`/`exit_price`. ## Hard safety rules - One position at a time, one entry per signal, ≤ `max_trades_per_day`. Respect the idempotency guards; never double-enter. - Whole shares only so the broker-side stop is valid. - Always confirm fills before acting on them. - The position is never knowingly left without a stop during TRAILING except the brief cancel→replace window. If you cannot restore a stop, HALT and shout. - Report every action with the order id so the user can verify or cancel in the app. - Show any compliance quote disclosure verbatim whenever an MCP order tool returns one. - Anything outside the plan → stop and ask. ## Daily ritual (what the user runs) - ONE command ``` /loop /example-trading-bot ``` The first tick can self-start your signal source in the background; every tick after polls the signal and manages the trade. The loop self-paces ~1 minute and can stop itself once `phase == CLOSED`.