[object Object]

← back to Zero Code Trading Bot

zero-code-trading-bot skill: plain-English → paper-first prediction-market bot (Sharbel-inspired)

899abc38943ea9409108b25c68f007bed711403f · 2026-07-16 20:54:56 -0700 · Steve

Files touched

Diff

commit 899abc38943ea9409108b25c68f007bed711403f
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 16 20:54:56 2026 -0700

    zero-code-trading-bot skill: plain-English → paper-first prediction-market bot (Sharbel-inspired)
---
 .gitignore                           |  14 +++
 LICENSE                              |  21 +++++
 README.md                            |  50 +++++++++++
 SKILL.md                             | 132 +++++++++++++++++++++++++++
 assets/bot-template/README.md        |  33 +++++++
 assets/bot-template/bot.py           | 168 +++++++++++++++++++++++++++++++++++
 assets/bot-template/dashboard.py     |  94 ++++++++++++++++++++
 assets/bot-template/executor.py      |  87 ++++++++++++++++++
 assets/bot-template/ledger.py        | 115 ++++++++++++++++++++++++
 assets/bot-template/requirements.txt |   6 ++
 assets/bot-template/safe_eval.py     |  98 ++++++++++++++++++++
 assets/bot-template/strategy.json    |  42 +++++++++
 assets/bot-template/venue_adapter.py | 151 +++++++++++++++++++++++++++++++
 references/safety-model.md           |  52 +++++++++++
 references/strategy-dsl.md           |  78 ++++++++++++++++
 references/venues.md                 |  45 ++++++++++
 scripts/new_bot.py                   |  71 +++++++++++++++
 scripts/validate_strategy.py         |  82 +++++++++++++++++
 18 files changed, 1339 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0ff3fa3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+__pycache__/
+*.pyc
+# never commit a bot's live-trade confirmation token, kill file, or paper ledger
+assets/bot-template/data/
+data/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7181d72
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Steve Abrams
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..56a4ee3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,50 @@
+# zero-code-trading-bot
+
+A Claude Code **skill** (own repo, symlinked into `~/.claude/skills/`) that turns a
+plain-English trading strategy into a **paper-first** prediction-market bot.
+
+Inspired by **Sharbel (@sharbel)** on X — *"how I built a trading bot with zero
+code… just talking to an AI agent and iterating."* Adapted to the house rule that
+**anything touching money is gated**: bots ship in paper mode, and going live is a
+deliberate, multi-gate, human-approved step.
+
+## What it does
+
+1. **Compile** — you describe a strategy in words; Claude writes a validated
+   declarative `strategy.json` (no code you have to write).
+2. **Validate** — `scripts/validate_strategy.py` checks the schema and runs every
+   rule through a sandboxed evaluator (strategy files are data, never `eval`).
+3. **Scaffold** — `scripts/new_bot.py <name>` stamps out a self-contained,
+   zero-dependency Python bot project (paper executor + append-only P&L ledger +
+   read-only market data + dashboard), git-init'd.
+4. **Paper-run** — the bot trades **live Polymarket markets for $0** (public API,
+   no key), records simulated fills, and shows P&L.
+5. **Go live?** — gated. Requires `LIVE=1` + `SAFE_MODE=0` + a confirmation token +
+   a real broker key + a wired order call + a go-live approval memo. The skill
+   never does this for you.
+
+## Composes with
+
+- **`polymtrx`** — read-only Polymarket edge scanner (discovery layer).
+- **Ken / Kalshi dashboard** (`:7810`) — Kalshi paper portfolios + safe_mode
+  autoTrader (Kalshi execution layer).
+
+## Layout
+
+```
+SKILL.md                     orchestrator + when-to-use
+scripts/new_bot.py           scaffold a bot project
+scripts/validate_strategy.py schema + sandbox check
+references/strategy-dsl.md    plain-English → strategy.json
+references/safety-model.md    the paper-first / gated-live design
+references/venues.md          Polymarket + Kalshi wiring, composition
+assets/bot-template/          the runnable bot copied per strategy
+```
+
+## Safety in one line
+
+Paper by default · sandboxed rules · kill switch · daily-loss breaker · exposure
+cap · live trading blocked behind five gates + a human. See
+`references/safety-model.md`.
+
+MIT.
diff --git a/SKILL.md b/SKILL.md
new file mode 100644
index 0000000..520afbd
--- /dev/null
+++ b/SKILL.md
@@ -0,0 +1,132 @@
+---
+name: zero-code-trading-bot
+description: 'Turn a plain-English trading strategy into a runnable, PAPER-FIRST prediction-market bot — scaffolded, validated, and safe by construction. The user describes a strategy in words ("buy Polymarket longshots under 8c with real volume, take profit at 15c, stop at 3c, $25 a bet, $250 max exposure"); this skill compiles it to a declarative strategy.json, validates every rule through a sandboxed evaluator, and scaffolds a self-contained bot project (zero-dependency Python: read-only market data + paper executor + append-only P&L ledger + dashboard) that runs against Polymarket''s public API with no key and $0. Use when Steve says "zero code trading bot", "build me a trading bot", "make a bot that trades this strategy", "paper-trade this idea", "backtest/paper this on Polymarket", "/zero-code-trading-bot", or describes a market strategy he wants automated. Composes with the polymtrx skill (signal discovery) and the Ken/Kalshi dashboard (Kalshi execution) instead of duplicating them. HARD RAIL: paper by default; going LIVE is Steve-gated (requires LIVE=1 + SAFE_MODE=0 + a confirmation token + a real broker key + a wired order call + a go-live approval memo) — the skill never places a real order, never sets those switches, never deploys a live loop. Always shows cost ($0 for paper).'
+argument-hint: 'zero-code-trading-bot "buy polymarket longshots under 8c, tp 15c, stop 3c, $25/bet, $250 cap" | validate strategy.json | scaffold <name>'
+allowed-tools: Bash, Read, Write, Edit, AskUserQuestion
+license: MIT
+user-invocable: true
+metadata:
+  type: reference
+  inspired-by: Sharbel (@sharbel) on X — "how I built a trading bot with zero code" / no-code AI-agent trading playbooks
+  engine: assets/bot-template (scaffolded per bot) + scripts/new_bot.py + scripts/validate_strategy.py
+  composes-with: [polymtrx, Ken/Kalshi dashboard]
+  tags:
+    - trading-bot
+    - prediction-markets
+    - polymarket
+    - kalshi
+    - no-code
+    - paper-trading
+    - strategy-dsl
+    - safe-by-default
+    - gated-live
+    - zero-cost
+---
+
+# zero-code-trading-bot
+
+## Overview
+
+Reproduce the **Sharbel (@sharbel)** "trading bot with zero code" playbook —
+*describe a strategy in plain English, iterate with an AI agent, end up with a
+running bot* — as a deterministic, **paper-first** scaffold. The user never
+writes code: they say what they want, Claude compiles it to a validated
+`strategy.json`, and this skill stamps out a self-contained bot project that
+paper-trades live prediction markets for **$0, no keys**.
+
+> **Provenance note.** The seed is the tweet at
+> `x.com/sharbel/status/2055680438417412359`. That exact post is unreachable (X
+> returns 404/paywall to automated reads), so the specific wording could not be
+> captured verbatim. This skill is built from Sharbel's verified, consistent body
+> of work — *"how I built a trading bot with zero code: 345 wins, 26 losses, no
+> Python… just talking to an AI agent and iterating"*, plus his Polymarket
+> copy-trading-bot and "run your business on AI agents" playbooks — and adapted to
+> Steve's hard rule that anything touching money is gated.
+
+This is a **build-and-paper-test contract**, not a license to trade. The skill
+authors, validates, scaffolds, and paper-runs. It stops at the money line.
+
+## When to use
+
+- "build me a trading bot" / "zero code trading bot" / "make a bot for this strategy"
+- "paper-trade this idea on Polymarket" / "automate this market strategy"
+- Steve describes any entry/exit/sizing strategy for prediction markets
+- After a `/polymtrx` scan surfaces an edge worth encoding as a repeatable bot
+
+Not for: placing live orders, wallet operations, non-prediction-market venues, or
+"just tell me the odds" (that's `/polymtrx`).
+
+## How to use it
+
+### 1. Compile the plain-English strategy → `strategy.json`
+
+Read `references/strategy-dsl.md` for the schema and the plain-English → JSON
+mapping. Translate the user's words into a `strategy.json` with `universe`,
+`entry`, `exit`, `sizing`, and `risk`. Rule expressions may reference **only**
+`price`, `volume_usd`, `liquidity_usd`, `spread`, `days_to_resolve`. Always set
+`mode: "paper"`.
+
+If the strategy is underspecified (no stop, no exposure cap, no bet size), ask
+with `AskUserQuestion` — a strategy without a risk cap is not shippable.
+
+### 2. Validate before anything runs
+
+```bash
+scripts/validate_strategy.py <path>/strategy.json
+```
+
+This confirms required keys, sane sizing/risk, and — critically — that every rule
+parses under the sandboxed evaluator with only known variables. Fix any error
+before scaffolding. A rule that fails here would silently misfire at runtime.
+
+### 3. Scaffold the bot project (paper, git-init'd)
+
+```bash
+scripts/new_bot.py <bot-name> --strategy <path>/strategy.json
+# → ~/Projects/<bot-name>/  (copied from assets/bot-template, mode forced to paper)
+```
+
+### 4. Paper-run and iterate
+
+```bash
+cd ~/Projects/<bot-name>
+python venue_adapter.py          # confirm live market data ($0, no key)
+python bot.py --once --dry-run   # decide, place NO fills — safest first look
+python bot.py --once             # first paper fills → data/ledger.jsonl
+python bot.py --loop             # continuous, every strategy.poll_seconds
+python dashboard.py --serve      # live paper P&L on a free localhost port
+```
+
+Iterate on the rules with the user, re-validate, re-dry-run. Every cycle prints a
+`cost:` line — `$0 (paper)` until someone deliberately goes live.
+
+### 5. Going live is Steve's call — never yours
+
+Read `references/safety-model.md`. To place real orders a human must clear **all**
+of: `LIVE=1`, `SAFE_MODE=0`, a `data/LIVE_CONFIRMED` token, a real broker key, a
+*wired* order call (the template leaves it unimplemented on purpose), and a
+go-live approval memo drafted to `~/.claude/yolo-queue/pending-approval/`. This
+skill does none of those automatically. `safe_mode` is a hard precondition, not a
+suggestion — the Ken/Kalshi autoTrader once traded with safe_mode on; here it
+cannot.
+
+## Composition (don't reinvent what exists)
+
+See `references/venues.md`.
+
+- **`polymtrx`** finds the edge (read-only Polymarket scan). Use `/polymtrx scan
+  <topic>` to *discover* a strategy, then encode it here.
+- **Ken / Kalshi dashboard** (`~/Projects/Ken/kalshi-dash`, pm2 `ken` :7810) owns
+  Kalshi paper portfolios + a `safe_mode`-honoring autoTrader. For Kalshi
+  execution, hand off to Ken rather than wiring a second money path — the
+  `KalshiDemoAdapter` here stays DEMO until Steve's prod Kalshi creds exist.
+
+## Files
+
+- `assets/bot-template/` — the scaffold copied per bot: `bot.py` (runner),
+  `strategy.json` (rules), `venue_adapter.py` (read-only data), `executor.py`
+  (paper vs gated-live), `ledger.py` (append-only P&L), `safe_eval.py` (sandboxed
+  rules), `dashboard.py` (P&L viewer).
+- `scripts/new_bot.py` — scaffold a bot project (paper, git-init'd).
+- `scripts/validate_strategy.py` — validate a strategy.json (schema + sandbox check).
+- `references/strategy-dsl.md` · `references/safety-model.md` · `references/venues.md`.
diff --git a/assets/bot-template/README.md b/assets/bot-template/README.md
new file mode 100644
index 0000000..65c7bb4
--- /dev/null
+++ b/assets/bot-template/README.md
@@ -0,0 +1,33 @@
+# <bot name> — a paper-first prediction-market bot
+
+Scaffolded by the `zero-code-trading-bot` skill. **Paper mode by default — no keys, $0.**
+
+## Run it
+
+```bash
+python venue_adapter.py       # sanity check: pull ~50 live Polymarket markets
+python bot.py --once --dry-run # decide, place NO fills (safest first run)
+python bot.py --once           # one cycle, records paper fills to data/ledger.jsonl
+python bot.py --loop           # run continuously every strategy.poll_seconds
+python dashboard.py            # print paper P&L
+python dashboard.py --serve    # live-refreshing P&L page on a free localhost port
+```
+
+## The strategy lives in `strategy.json`
+
+Plain-English rules compiled to declarative filters + entry/exit expressions.
+Edit it directly, or ask Claude ("change the take-profit to 20c"). Variables a
+rule may use: `price`, `volume_usd`, `liquidity_usd`, `spread`, `days_to_resolve`.
+
+## Safety
+
+- **Paper is the default.** `mode: "paper"` writes simulated fills only.
+- **Kill switch:** `touch data/KILL` halts all new entries immediately (exits still run).
+- **Circuit breaker:** trips at `risk.max_daily_loss_usd`.
+- **Live is gated.** See `../references/safety-model.md`. Going live is a Steve-only
+  step — it requires `LIVE=1`, `SAFE_MODE=0`, a `data/LIVE_CONFIRMED` token, a real
+  broker key, a wired order call, and a go-live approval memo.
+
+Files: `bot.py` (runner) · `strategy.json` (rules) · `venue_adapter.py` (read-only
+market data) · `executor.py` (paper vs gated-live) · `ledger.py` (append-only P&L)
+· `safe_eval.py` (sandboxed rule evaluator) · `dashboard.py` (P&L viewer).
diff --git a/assets/bot-template/bot.py b/assets/bot-template/bot.py
new file mode 100755
index 0000000..4dde663
--- /dev/null
+++ b/assets/bot-template/bot.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+"""
+bot.py — the runner. One cycle:
+
+  1. fetch the market universe (venue_adapter, read-only)
+  2. mark + evaluate EXIT rules on every open position → close matches
+  3. filter the universe, evaluate ENTRY rules on candidates
+  4. apply risk caps (max positions, exposure, daily loss, kill switch)
+  5. route surviving decisions to the executor (paper by default)
+  6. print a per-cycle summary + an explicit cost line
+
+Usage:
+    python bot.py --once              # one cycle, then exit
+    python bot.py --loop              # loop forever every strategy.poll_seconds
+    python bot.py --once --dry-run    # decide but place NO fills (even paper)
+    python bot.py --strategy other.json
+
+Paper is the default and requires no keys. Live mode is gated (see executor.py).
+"""
+
+import argparse
+import json
+import os
+import time
+
+import ledger
+from executor import get_executor
+from safe_eval import MARKET_VARS, eval_rule
+from venue_adapter import get_adapter
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+
+def load_strategy(path):
+    with open(path) as f:
+        s = json.load(f)
+    s.setdefault("mode", "paper")
+    s.setdefault("poll_seconds", 300)
+    s.setdefault("universe", {})
+    s.setdefault("sizing", {})
+    s.setdefault("risk", {})
+    return s
+
+
+def market_vars(m):
+    return {k: m.get(k, 0) for k in MARKET_VARS}
+
+
+def passes_universe(m, u):
+    if m["volume_usd"] < u.get("min_volume_usd", 0):
+        return False
+    if m["spread"] > u.get("max_spread", 1.0):
+        return False
+    if m["days_to_resolve"] > u.get("resolves_within_days", 1e9):
+        return False
+    q = u.get("query", "")
+    return not q or q.lower() in m["title"].lower()
+
+
+def run_cycle(strategy, adapter, executor, dry_run=False):
+    mode = strategy["mode"]
+    sizing, risk = strategy["sizing"], strategy["risk"]
+    actions = []
+
+    # kill switch: a data/KILL file halts all new entries immediately.
+    kill = os.path.exists(os.path.join(HERE, "data", "KILL"))
+
+    universe = adapter.fetch_universe(
+        query=strategy["universe"].get("query", ""),
+        limit=strategy["universe"].get("limit", 200),
+    )
+    by_id = {m["id"]: m for m in universe}
+
+    # --- exits first (always allowed, even when the kill switch is on) ---
+    for pos in ledger.open_positions().values():
+        mark = by_id.get(pos["market_id"], {}).get("price")
+        if mark is None:
+            mark = adapter.mark_price(pos["market_id"])
+        if mark is None:
+            continue
+        mvars = {"price": mark, "volume_usd": 0, "liquidity_usd": 0, "spread": 0,
+                 "days_to_resolve": by_id.get(pos["market_id"], {}).get("days_to_resolve", 9999)}
+        for rule in strategy.get("exit", []):
+            if eval_rule(rule["when"], mvars):
+                actions.append(("close", pos["title"], rule["reason"], mark))
+                if not dry_run:
+                    executor.close_position(pos, mark, rule["reason"])
+                break
+
+    # --- daily-loss circuit breaker ---
+    max_daily_loss = risk.get("max_daily_loss_usd")
+    if max_daily_loss is not None and ledger.pnl_today_usd() <= -abs(max_daily_loss):
+        return {"actions": actions, "halted": "max_daily_loss hit — no new entries",
+                "summary": ledger.summary(mark_fn=adapter.mark_price)}
+
+    if kill or risk.get("kill_switch_engaged"):
+        return {"actions": actions, "halted": "kill switch engaged",
+                "summary": ledger.summary(mark_fn=adapter.mark_price)}
+
+    # --- entries ---
+    open_now = ledger.open_positions()
+    for m in universe:
+        if m["id"] in open_now:
+            continue
+        if len(open_now) >= sizing.get("max_open_positions", 9999):
+            break
+        if not passes_universe(m, strategy["universe"]):
+            continue
+        size = min(sizing.get("amount", 0), sizing.get("max_position_usd", 1e9))
+        if ledger.total_exposure_usd() + size > risk.get("max_total_exposure_usd", 1e12):
+            continue
+        for rule in strategy.get("entry", []):
+            if eval_rule(rule["when"], market_vars(m)):
+                actions.append(("open", m["title"], rule["reason"], m["price"]))
+                if not dry_run:
+                    executor.open_position(m, rule.get("side", "YES"), size, rule["reason"])
+                    open_now[m["id"]] = True
+                break
+
+    return {"actions": actions, "halted": None,
+            "summary": ledger.summary(mark_fn=adapter.mark_price)}
+
+
+def print_cycle(strategy, result, dry_run):
+    tag = "DRY-RUN" if dry_run else strategy["mode"].upper()
+    print(f"\n── cycle [{tag}] {strategy['name']} @ {strategy['venue']} ──")
+    if result["halted"]:
+        print(f"  ⛔ HALTED: {result['halted']}")
+    if not result["actions"]:
+        print("  · no entries/exits triggered this cycle")
+    for act, title, reason, price in result["actions"]:
+        arrow = "▲ OPEN " if act == "open" else "▼ CLOSE"
+        print(f"  {arrow} {price:.3f}  {title[:60]}  ({reason})")
+    s = result["summary"]
+    print(f"  positions={s['open_count']} exposure=${s['exposure_usd']:.0f} "
+          f"realized=${s['realized_pnl_usd']:.2f} unrealized=${s['unrealized_pnl_usd']:.2f} "
+          f"today=${s['pnl_today_usd']:.2f}")
+    cost = "$0 (paper — public API only)" if strategy["mode"] != "live" else "REAL MONEY (live)"
+    print(f"  cost: {cost}")
+
+
+def main():
+    ap = argparse.ArgumentParser(description="zero-code prediction-market paper bot")
+    ap.add_argument("--strategy", default=os.path.join(HERE, "strategy.json"))
+    ap.add_argument("--once", action="store_true", help="run a single cycle")
+    ap.add_argument("--loop", action="store_true", help="loop every poll_seconds")
+    ap.add_argument("--dry-run", action="store_true", help="decide but place no fills")
+    args = ap.parse_args()
+
+    strategy = load_strategy(args.strategy)
+    adapter = get_adapter(strategy["venue"])
+    executor = get_executor(strategy["mode"], strategy["venue"])
+
+    if args.loop:
+        print(f"looping every {strategy['poll_seconds']}s — Ctrl-C to stop "
+              f"(touch data/KILL to halt entries)")
+        while True:
+            try:
+                print_cycle(strategy, run_cycle(strategy, adapter, executor, args.dry_run), args.dry_run)
+            except Exception as e:  # a bad cycle should never kill the loop
+                print(f"  ! cycle error: {e}")
+            time.sleep(strategy["poll_seconds"])
+    else:
+        print_cycle(strategy, run_cycle(strategy, adapter, executor, args.dry_run), args.dry_run)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/assets/bot-template/dashboard.py b/assets/bot-template/dashboard.py
new file mode 100755
index 0000000..520eb02
--- /dev/null
+++ b/assets/bot-template/dashboard.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""
+dashboard.py — a zero-dependency P&L viewer for the paper ledger.
+
+    python dashboard.py            # print a P&L snapshot to the terminal
+    python dashboard.py --serve    # serve an auto-refreshing HTML table on a free port
+
+Marks open positions against live prices (venue_adapter) so unrealized P&L is
+current. Read-only — it never trades.
+"""
+
+import argparse
+import json
+import socket
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+import ledger
+from venue_adapter import get_adapter
+
+_adapter = None
+
+
+def _mark(mid):
+    global _adapter
+    if _adapter is None:
+        _adapter = get_adapter("polymarket")
+    return _adapter.mark_price(mid)
+
+
+def snapshot():
+    return ledger.summary(mark_fn=_mark)
+
+
+def render_html(s):
+    rows = "".join(
+        f"<tr><td>{p['title'][:70]}</td><td>{p['side']}</td>"
+        f"<td>{p['entry_price']:.3f}</td><td>{p.get('mark','—')}</td>"
+        f"<td>${p['size_usd']:.0f}</td><td>${p.get('unrealized_usd',0):.2f}</td></tr>"
+        for p in s["open_positions"]
+    ) or "<tr><td colspan=6>no open positions</td></tr>"
+    return f"""<!doctype html><meta charset=utf-8><meta http-equiv=refresh content=15>
+<title>paper P&amp;L</title>
+<style>body{{font:14px/1.5 system-ui;margin:2rem;color:#111}}
+table{{border-collapse:collapse;width:100%}}td,th{{border:1px solid #ddd;padding:.4rem .6rem;text-align:left}}
+.big{{font-size:1.4rem;font-weight:600}}.k{{color:#666}}</style>
+<h2>Paper trading — P&amp;L <span class=k>(read-only · $0)</span></h2>
+<p class=big>realized ${s['realized_pnl_usd']:.2f} · unrealized ${s['unrealized_pnl_usd']:.2f}
+· today ${s['pnl_today_usd']:.2f}</p>
+<p class=k>open {s['open_count']} · exposure ${s['exposure_usd']:.0f}</p>
+<table><tr><th>market</th><th>side</th><th>entry</th><th>mark</th><th>size</th><th>unreal</th></tr>
+{rows}</table>"""
+
+
+def _free_port():
+    s = socket.socket()
+    s.bind(("127.0.0.1", 0))
+    port = s.getsockname()[1]
+    s.close()
+    return port
+
+
+def serve():
+    port = _free_port()
+
+    class H(BaseHTTPRequestHandler):
+        def do_GET(self):
+            body = (json.dumps(snapshot()) if self.path.startswith("/api")
+                    else render_html(snapshot())).encode()
+            self.send_response(200)
+            self.send_header("Content-Type",
+                             "application/json" if self.path.startswith("/api") else "text/html")
+            self.end_headers()
+            self.wfile.write(body)
+
+        def log_message(self, *a):
+            pass
+
+    print(f"paper P&L dashboard → http://127.0.0.1:{port}   (Ctrl-C to stop)")
+    HTTPServer(("127.0.0.1", port), H).serve_forever()
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--serve", action="store_true")
+    args = ap.parse_args()
+    if args.serve:
+        serve()
+    else:
+        s = snapshot()
+        print(json.dumps(s, indent=2))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/assets/bot-template/executor.py b/assets/bot-template/executor.py
new file mode 100755
index 0000000..1169b6f
--- /dev/null
+++ b/assets/bot-template/executor.py
@@ -0,0 +1,87 @@
+"""
+executor.py — the money boundary.
+
+Two executors, one interface (open_position / close_position):
+
+  PaperExecutor  — default. Writes simulated fills to the ledger. No key, $0,
+                   no real order ever leaves the machine.
+
+  LiveExecutor   — GATED. Refuses to place a real order unless ALL of:
+                     1. env LIVE=1
+                     2. env SAFE_MODE=0            (safe_mode honored — see below)
+                     3. a confirmation token file exists (data/LIVE_CONFIRMED)
+                     4. a real broker key is present
+                   AND — even then — this template does not ship a wired broker
+                   call. Going live is a Steve-only step: draft a go-live memo to
+                   ~/.claude/yolo-queue/pending-approval/, implement the broker
+                   call, then remove this guard. This is deliberate: a scaffold
+                   should never be one typo away from spending real money.
+
+Why safe_mode matters: an earlier autoTrader in the Ken/Kalshi dashboard once
+placed orders while safe_mode was on. Here safe_mode is a hard precondition, not
+a suggestion — LiveExecutor treats SAFE_MODE!=0 as "do not trade".
+"""
+
+import os
+
+import ledger
+
+
+class PaperExecutor:
+    mode = "paper"
+
+    def open_position(self, market, side, size_usd, reason):
+        return ledger.record_fill(
+            market["id"], market["title"], side, "open",
+            market["price"], size_usd, reason, self.mode,
+        )
+
+    def close_position(self, position, mark_price, reason):
+        return ledger.record_fill(
+            position["market_id"], position["title"], position["side"], "close",
+            mark_price, position["size_usd"], reason, self.mode,
+        )
+
+
+class LiveExecutor:
+    mode = "live"
+
+    def __init__(self, venue):
+        self.venue = venue
+        self._assert_gates()
+
+    @staticmethod
+    def _assert_gates():
+        problems = []
+        if os.environ.get("LIVE") != "1":
+            problems.append("LIVE!=1")
+        if os.environ.get("SAFE_MODE", "1") != "0":
+            problems.append("SAFE_MODE!=0 (safe_mode is ON — refusing to trade)")
+        token = os.path.join(os.path.dirname(__file__), "data", "LIVE_CONFIRMED")
+        if not os.path.exists(token):
+            problems.append("missing data/LIVE_CONFIRMED confirmation token")
+        if not (os.environ.get("POLYMARKET_API_KEY") or os.environ.get("KALSHI_API_KEY")):
+            problems.append("no broker API key in env")
+        if problems:
+            raise PermissionError(
+                "LIVE trading blocked — unmet safety gates: " + "; ".join(problems)
+                + ".\nGoing live is Steve-gated: draft a go-live memo to "
+                "~/.claude/yolo-queue/pending-approval/, wire the broker call, "
+                "and clear these gates deliberately. See references/safety-model.md."
+            )
+
+    def open_position(self, market, side, size_usd, reason):
+        raise NotImplementedError(
+            "Live broker order not wired in the template — implement the venue "
+            "order call here, then get Steve's approval. Gates passed, but the "
+            "money path is intentionally left unimplemented."
+        )
+
+    def close_position(self, position, mark_price, reason):
+        raise NotImplementedError("Live broker close not wired — see open_position.")
+
+
+def get_executor(mode, venue):
+    if mode == "live":
+        return LiveExecutor(venue)
+    return PaperExecutor()
diff --git a/assets/bot-template/ledger.py b/assets/bot-template/ledger.py
new file mode 100755
index 0000000..269f5fd
--- /dev/null
+++ b/assets/bot-template/ledger.py
@@ -0,0 +1,115 @@
+"""
+ledger.py — append-only paper-trading ledger + position/P&L accounting.
+
+Every fill is appended as one JSON line to data/ledger.jsonl (append-only = you
+can always reconstruct state and never silently lose a trade). Open positions and
+realized/unrealized P&L are derived by replaying the log — there is no mutable
+"current state" file to corrupt.
+
+Paper only. The live path (executor.py) is gated and does not write here until a
+real broker fill is confirmed.
+"""
+
+import json
+import os
+from datetime import datetime, timezone
+
+LEDGER_PATH = os.path.join(os.path.dirname(__file__), "data", "ledger.jsonl")
+
+
+def _now():
+    return datetime.now(timezone.utc).isoformat()
+
+
+def _ensure_dir():
+    os.makedirs(os.path.dirname(LEDGER_PATH), exist_ok=True)
+
+
+def record_fill(market_id, title, side, action, price, size_usd, reason, mode):
+    """Append one fill. action is 'open' or 'close'."""
+    _ensure_dir()
+    row = {
+        "ts": _now(), "market_id": market_id, "title": title, "side": side,
+        "action": action, "price": round(float(price), 4),
+        "size_usd": round(float(size_usd), 2), "reason": reason, "mode": mode,
+    }
+    with open(LEDGER_PATH, "a") as f:
+        f.write(json.dumps(row) + "\n")
+    return row
+
+
+def _read_all():
+    if not os.path.exists(LEDGER_PATH):
+        return []
+    with open(LEDGER_PATH) as f:
+        return [json.loads(line) for line in f if line.strip()]
+
+
+def open_positions():
+    """Replay the log → dict of market_id -> open position."""
+    positions = {}
+    for r in _read_all():
+        mid = r["market_id"]
+        if r["action"] == "open":
+            positions[mid] = {
+                "market_id": mid, "title": r["title"], "side": r["side"],
+                "entry_price": r["price"], "size_usd": r["size_usd"],
+                "shares": r["size_usd"] / r["price"] if r["price"] else 0.0,
+                "opened": r["ts"],
+            }
+        elif r["action"] == "close":
+            positions.pop(mid, None)
+    return positions
+
+
+def realized_pnl():
+    """Sum realized P&L across all closed round-trips."""
+    entries, pnl = {}, 0.0
+    for r in _read_all():
+        mid = r["market_id"]
+        if r["action"] == "open":
+            entries[mid] = r
+        elif r["action"] == "close" and mid in entries:
+            e = entries.pop(mid)
+            shares = e["size_usd"] / e["price"] if e["price"] else 0.0
+            pnl += shares * (r["price"] - e["price"])
+    return round(pnl, 2)
+
+
+def total_exposure_usd():
+    return round(sum(p["size_usd"] for p in open_positions().values()), 2)
+
+
+def pnl_today_usd():
+    today = datetime.now(timezone.utc).date().isoformat()
+    entries, pnl = {}, 0.0
+    for r in _read_all():
+        mid = r["market_id"]
+        if r["action"] == "open":
+            entries[mid] = r
+        elif r["action"] == "close" and mid in entries:
+            e = entries.pop(mid)
+            if r["ts"][:10] == today:
+                shares = e["size_usd"] / e["price"] if e["price"] else 0.0
+                pnl += shares * (r["price"] - e["price"])
+    return round(pnl, 2)
+
+
+def summary(mark_fn=None):
+    """Snapshot for the dashboard/CLI. mark_fn(market_id)->price gives unrealized."""
+    pos = open_positions()
+    unreal = 0.0
+    for p in pos.values():
+        mark = mark_fn(p["market_id"]) if mark_fn else None
+        if mark is not None:
+            unreal += p["shares"] * (mark - p["entry_price"])
+            p["mark"] = round(mark, 4)
+            p["unrealized_usd"] = round(p["shares"] * (mark - p["entry_price"]), 2)
+    return {
+        "open_positions": list(pos.values()),
+        "open_count": len(pos),
+        "exposure_usd": total_exposure_usd(),
+        "realized_pnl_usd": realized_pnl(),
+        "unrealized_pnl_usd": round(unreal, 2),
+        "pnl_today_usd": pnl_today_usd(),
+    }
diff --git a/assets/bot-template/requirements.txt b/assets/bot-template/requirements.txt
new file mode 100644
index 0000000..21a1e6c
--- /dev/null
+++ b/assets/bot-template/requirements.txt
@@ -0,0 +1,6 @@
+# Paper mode is pure Python stdlib — no installs required.
+# (urllib/json/ast/http.server all ship with Python 3.8+.)
+#
+# Add broker SDKs here ONLY when wiring live trading, e.g.:
+#   py-clob-client        # Polymarket CLOB orders
+#   kalshi-python         # Kalshi orders
diff --git a/assets/bot-template/safe_eval.py b/assets/bot-template/safe_eval.py
new file mode 100755
index 0000000..7a9ff48
--- /dev/null
+++ b/assets/bot-template/safe_eval.py
@@ -0,0 +1,98 @@
+"""
+safe_eval.py — a deliberately tiny, sandboxed expression evaluator.
+
+Strategy rules ("price <= 0.08 and volume_usd >= 100000") are user/AI authored,
+so we NEVER hand them to Python's built-in eval(). Instead we parse each rule to
+an AST and walk only a whitelist of node types + variable names. Anything else
+(function calls, attribute access, imports, dunder names) is refused.
+
+This is the single most important safety boundary in the bot: a strategy file is
+data, not code. Keep it that way.
+"""
+
+import ast
+import operator
+
+# Only these operators are allowed inside a rule expression.
+_BIN_OPS = {
+    ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
+    ast.Div: operator.truediv, ast.Mod: operator.mod, ast.Pow: operator.pow,
+}
+_CMP_OPS = {
+    ast.Eq: operator.eq, ast.NotEq: operator.ne, ast.Lt: operator.lt,
+    ast.LtE: operator.le, ast.Gt: operator.gt, ast.GtE: operator.ge,
+}
+_BOOL_OPS = {ast.And: all, ast.Or: any}
+_UNARY_OPS = {ast.USub: operator.neg, ast.UAdd: operator.pos, ast.Not: operator.not_}
+
+
+class RuleError(ValueError):
+    """A strategy rule that is malformed or references an unknown variable."""
+
+
+def _eval_node(node, variables):
+    if isinstance(node, ast.Expression):
+        return _eval_node(node.body, variables)
+    if isinstance(node, ast.BoolOp):
+        fn = _BOOL_OPS[type(node.op)]
+        return bool(fn(_eval_node(v, variables) for v in node.values))
+    if isinstance(node, ast.BinOp):
+        op = _BIN_OPS.get(type(node.op))
+        if op is None:
+            raise RuleError(f"operator not allowed: {type(node.op).__name__}")
+        return op(_eval_node(node.left, variables), _eval_node(node.right, variables))
+    if isinstance(node, ast.UnaryOp):
+        op = _UNARY_OPS.get(type(node.op))
+        if op is None:
+            raise RuleError(f"unary operator not allowed: {type(node.op).__name__}")
+        return op(_eval_node(node.operand, variables))
+    if isinstance(node, ast.Compare):
+        left = _eval_node(node.left, variables)
+        for op_node, comparator in zip(node.ops, node.comparators):
+            op = _CMP_OPS.get(type(op_node))
+            if op is None:
+                raise RuleError(f"comparison not allowed: {type(op_node).__name__}")
+            right = _eval_node(comparator, variables)
+            if not op(left, right):
+                return False
+            left = right
+        return True
+    if isinstance(node, ast.Name):
+        if node.id in ("True", "False"):  # py<3.8 safety; harmless otherwise
+            return node.id == "True"
+        if node.id not in variables:
+            raise RuleError(f"unknown variable: {node.id!r}")
+        return variables[node.id]
+    if isinstance(node, ast.Constant):  # numbers, bools, strings
+        return node.value
+    raise RuleError(f"expression element not allowed: {type(node).__name__}")
+
+
+def eval_rule(expr, variables):
+    """Evaluate a strategy rule string against a dict of market variables."""
+    try:
+        tree = ast.parse(expr, mode="eval")
+    except SyntaxError as e:
+        raise RuleError(f"cannot parse rule {expr!r}: {e}") from e
+    return _eval_node(tree, variables)
+
+
+def check_rule(expr, allowed_vars):
+    """Static check: parses the rule and confirms every name is a known variable.
+    Returns (ok, message). Used by the strategy validator before a bot ever runs."""
+    dummy = {name: 0 for name in allowed_vars}
+    try:
+        eval_rule(expr, dummy)
+        return True, "ok"
+    except RuleError as e:
+        return False, str(e)
+
+
+# The variables a rule may reference. venue_adapter.py populates these per market.
+MARKET_VARS = (
+    "price",            # current YES price, 0..1
+    "volume_usd",       # lifetime USD volume
+    "liquidity_usd",    # order-book liquidity
+    "spread",           # best ask - best bid, 0..1
+    "days_to_resolve",  # days until market resolution
+)
diff --git a/assets/bot-template/strategy.json b/assets/bot-template/strategy.json
new file mode 100644
index 0000000..4767721
--- /dev/null
+++ b/assets/bot-template/strategy.json
@@ -0,0 +1,42 @@
+{
+  "name": "longshot-fade",
+  "description": "Plain-English: on Polymarket, buy YES on deep-longshot markets (priced <= 8c) that still have real money in them, take profit at 15c, stop out at 3c. Small fixed bets, capped exposure.",
+  "venue": "polymarket",
+  "mode": "paper",
+  "poll_seconds": 300,
+
+  "universe": {
+    "query": "",
+    "min_volume_usd": 100000,
+    "max_spread": 0.05,
+    "resolves_within_days": 45,
+    "limit": 200
+  },
+
+  "entry": [
+    {
+      "when": "price <= 0.08 and volume_usd >= 100000 and spread <= 0.04",
+      "side": "YES",
+      "reason": "deep longshot with real liquidity"
+    }
+  ],
+
+  "exit": [
+    { "when": "price >= 0.15", "action": "close", "reason": "take profit" },
+    { "when": "price <= 0.03", "action": "close", "reason": "stop loss" },
+    { "when": "days_to_resolve <= 1", "action": "close", "reason": "avoid resolution risk" }
+  ],
+
+  "sizing": {
+    "type": "fixed_usd",
+    "amount": 25,
+    "max_position_usd": 50,
+    "max_open_positions": 10
+  },
+
+  "risk": {
+    "max_daily_loss_usd": 100,
+    "max_total_exposure_usd": 250,
+    "kill_switch": true
+  }
+}
diff --git a/assets/bot-template/venue_adapter.py b/assets/bot-template/venue_adapter.py
new file mode 100755
index 0000000..e8d3ab5
--- /dev/null
+++ b/assets/bot-template/venue_adapter.py
@@ -0,0 +1,151 @@
+"""
+venue_adapter.py — normalizes a trading venue into a single interface the bot uses.
+
+    adapter = get_adapter("polymarket")
+    markets = adapter.fetch_universe(query="", limit=200)   # list[Market]
+    price   = adapter.mark_price(market_id)                  # float 0..1
+
+A Market is a plain dict with the variables safe_eval.MARKET_VARS expects, plus
+id / title for display. Adapters are READ-ONLY here — they never place orders.
+Order routing lives in executor.py so the read path and the money path stay
+separate (and the money path stays gated).
+
+Polymarket uses the PUBLIC Gamma API — no key, $0 per call. This is the same
+data source the `polymtrx` skill scans, so a bot built here and a /polymtrx scan
+see the same market truth.
+"""
+
+import json
+import time
+import urllib.parse
+import urllib.request
+from datetime import datetime, timezone
+
+USER_AGENT = "zero-code-trading-bot/1.0 (paper; +local)"
+
+
+def _get_json(url, timeout=20):
+    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
+    with urllib.request.urlopen(req, timeout=timeout) as resp:
+        return json.loads(resp.read().decode("utf-8"))
+
+
+def _days_until(iso_ts):
+    if not iso_ts:
+        return 9999.0
+    try:
+        end = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
+        return max(0.0, (end - datetime.now(timezone.utc)).total_seconds() / 86400.0)
+    except (ValueError, TypeError):
+        return 9999.0
+
+
+class PolymarketAdapter:
+    """Read-only Polymarket via the public Gamma API (gamma-api.polymarket.com)."""
+
+    name = "polymarket"
+    GAMMA = "https://gamma-api.polymarket.com/markets"
+
+    def fetch_universe(self, query="", limit=200):
+        # order=volumeNum (numeric) — plain order=volume sorts lexicographically,
+        # ranking "$100" above "$154M" and starving the bot of real markets.
+        params = {"closed": "false", "active": "true", "limit": str(min(limit, 500)),
+                  "order": "volumeNum", "ascending": "false"}
+        url = self.GAMMA + "?" + urllib.parse.urlencode(params)
+        raw = _get_json(url)
+        markets = []
+        for m in raw:
+            title = (m.get("question") or m.get("title") or "").strip()
+            if query and query.lower() not in title.lower():
+                continue
+            price = self._yes_price(m)
+            if price is None:
+                continue
+            spread = self._safe_float(m.get("spread"), default=1.0)
+            markets.append({
+                "id": str(m.get("id") or m.get("conditionId") or title),
+                "title": title,
+                "price": price,
+                "volume_usd": self._safe_float(m.get("volume") or m.get("volumeNum")),
+                "liquidity_usd": self._safe_float(m.get("liquidity") or m.get("liquidityNum")),
+                "spread": spread,
+                "days_to_resolve": _days_until(m.get("endDate") or m.get("end_date_iso")),
+                "url": "https://polymarket.com/event/" + str(m.get("slug", "")),
+            })
+        return markets
+
+    def mark_price(self, market_id):
+        """Re-fetch a single market's current YES price (for marking open positions)."""
+        url = self.GAMMA + "?" + urllib.parse.urlencode({"id": market_id})
+        try:
+            raw = _get_json(url)
+        except Exception:
+            return None
+        if not raw:
+            return None
+        return self._yes_price(raw[0])
+
+    @staticmethod
+    def _yes_price(m):
+        prices = m.get("outcomePrices")
+        if isinstance(prices, str):
+            try:
+                prices = json.loads(prices)
+            except json.JSONDecodeError:
+                prices = None
+        if isinstance(prices, list) and prices:
+            try:
+                return round(float(prices[0]), 4)
+            except (ValueError, TypeError):
+                return None
+        best = m.get("bestBid")
+        return round(float(best), 4) if best not in (None, "") else None
+
+    @staticmethod
+    def _safe_float(v, default=0.0):
+        try:
+            return float(v)
+        except (ValueError, TypeError):
+            return default
+
+
+class KalshiDemoAdapter:
+    """
+    Kalshi placeholder. Live Kalshi needs real API credentials (KALSHI_API_KEY /
+    KALSHI_PRIVATE_KEY) — see references/venues.md. Until those are wired and
+    Steve-approved, this adapter runs in DEMO: it returns a clear stub so a bot
+    can be authored and paper-tested against Polymarket first, then re-pointed at
+    Kalshi once credentials exist. (Mirrors the Ken/Kalshi dashboard being DEMO
+    pending prod creds.)
+    """
+
+    name = "kalshi"
+
+    def fetch_universe(self, query="", limit=200):
+        raise NotImplementedError(
+            "Kalshi live data needs API credentials. Author + paper-test the "
+            "strategy on Polymarket (venue='polymarket'), or wire Kalshi creds "
+            "per references/venues.md and get Steve's go-live approval first."
+        )
+
+    def mark_price(self, market_id):
+        return None
+
+
+_ADAPTERS = {"polymarket": PolymarketAdapter, "kalshi": KalshiDemoAdapter}
+
+
+def get_adapter(venue):
+    cls = _ADAPTERS.get((venue or "").lower())
+    if cls is None:
+        raise ValueError(f"unknown venue {venue!r}; supported: {sorted(_ADAPTERS)}")
+    return cls()
+
+
+if __name__ == "__main__":  # quick manual check: python venue_adapter.py
+    a = get_adapter("polymarket")
+    t0 = time.time()
+    ms = a.fetch_universe(limit=50)
+    print(f"fetched {len(ms)} markets in {time.time()-t0:.1f}s  (cost: $0 — public API)")
+    for m in ms[:5]:
+        print(f"  {m['price']:.2f}  vol=${m['volume_usd']:,.0f}  {m['title'][:70]}")
diff --git a/references/safety-model.md b/references/safety-model.md
new file mode 100644
index 0000000..bed0b80
--- /dev/null
+++ b/references/safety-model.md
@@ -0,0 +1,52 @@
+# Safety model — why this bot ships safe
+
+A trading bot touches money, and money actions are Steve-gated. The design rule:
+**the scaffold is never one typo away from spending real funds.** Everything below
+is defense-in-depth so that "paper" stays paper until Steve deliberately, in
+multiple explicit steps, decides otherwise.
+
+## Layers (all must be defeated, in order, to trade real money)
+
+1. **Paper by default.** `strategy.json.mode` scaffolds to `"paper"`. `new_bot.py`
+   force-sets `mode="paper"` even if a compiled strategy said otherwise.
+2. **`SAFE_MODE=1` default.** `LiveExecutor` refuses unless `SAFE_MODE=0`. safe_mode
+   is a *hard precondition*, not advisory — this is the exact failure the Ken/Kalshi
+   autoTrader once had (it traded while safe_mode was on). Here it cannot.
+3. **`LIVE=1` required.** Absent it, `LiveExecutor` refuses.
+4. **`data/LIVE_CONFIRMED` token required.** A human must create this file — an env
+   flag alone is not enough.
+5. **A real broker key required** (`POLYMARKET_API_KEY` / `KALSHI_API_KEY`).
+6. **The broker order call is not wired in the template.** Even with 1–5 cleared,
+   `LiveExecutor.open_position` raises `NotImplementedError`. Wiring it is a
+   deliberate code change, not a config toggle.
+7. **Go-live approval memo.** Before any of the above is cleared, draft a memo to
+   `~/.claude/yolo-queue/pending-approval/` describing the strategy, the caps, the
+   venue, and the expected spend. Steve approves. This is the same gate every
+   customer-facing / money / irreversible action uses.
+
+## Runtime guardrails (active even in paper)
+
+- **Kill switch** — `touch data/KILL` halts all new entries on the next cycle;
+  existing exits still run so positions can wind down.
+- **Daily-loss circuit breaker** — `risk.max_daily_loss_usd`; once tripped, no new
+  entries for the rest of the day.
+- **Exposure cap** — `risk.max_total_exposure_usd`; a candidate is skipped if it
+  would push summed open size over the cap.
+- **Position caps** — `sizing.max_open_positions`, `sizing.max_position_usd`.
+- **Sandboxed rules** — strategy expressions run through `safe_eval.py` (AST
+  whitelist), never Python `eval`. A malicious/typo'd rule cannot execute code.
+- **Append-only ledger** — every fill is one immutable JSONL line; state is
+  replayed, never mutated, so a trade is never silently lost.
+
+## Cost visibility
+
+- Paper mode = **$0** — Polymarket's public Gamma API is free and keyless. The bot
+  prints a `cost:` line every cycle.
+- Live mode = **real money** — the cycle line switches to `cost: REAL MONEY (live)`.
+- Any future paid data/execution API must be logged to the `cost-tracker` ledger.
+
+## What this skill will NOT do autonomously
+
+Place a live order · create the `LIVE_CONFIRMED` token · set `SAFE_MODE=0` · add
+broker keys · deploy a bot to a server · run a live loop. All of those are surfaced
+to Steve. The skill builds, validates, and paper-tests — it stops at the money line.
diff --git a/references/strategy-dsl.md b/references/strategy-dsl.md
new file mode 100644
index 0000000..abde9a0
--- /dev/null
+++ b/references/strategy-dsl.md
@@ -0,0 +1,78 @@
+# Strategy DSL — plain English → `strategy.json`
+
+The "zero code" part: the user describes a strategy in plain English, and Claude
+compiles it to a declarative `strategy.json`. The bot never runs arbitrary code —
+rules are sandboxed expressions over a fixed set of market variables.
+
+## The shape
+
+```jsonc
+{
+  "name": "longshot-fade",
+  "venue": "polymarket",            // polymarket | kalshi
+  "mode": "paper",                  // ALWAYS scaffold as paper; live is gated
+  "poll_seconds": 300,
+
+  "universe": {                     // which markets are even considered
+    "query": "",                    // substring match on market title ("" = all)
+    "min_volume_usd": 100000,
+    "max_spread": 0.05,
+    "resolves_within_days": 45,
+    "limit": 200
+  },
+
+  "entry": [                        // OR-list: first matching rule fires
+    { "when": "price <= 0.08 and volume_usd >= 100000", "side": "YES",
+      "reason": "deep longshot with liquidity" }
+  ],
+
+  "exit": [                         // evaluated on each open position
+    { "when": "price >= 0.15", "action": "close", "reason": "take profit" },
+    { "when": "price <= 0.03", "action": "close", "reason": "stop loss" }
+  ],
+
+  "sizing": {
+    "type": "fixed_usd",
+    "amount": 25,                   // $ per new position
+    "max_position_usd": 50,
+    "max_open_positions": 10
+  },
+
+  "risk": {
+    "max_daily_loss_usd": 100,      // circuit breaker: halt new entries for the day
+    "max_total_exposure_usd": 250,  // hard cap on summed open size
+    "kill_switch": true             // honor a data/KILL file
+  }
+}
+```
+
+## Rule variables (the ONLY names a `when` may use)
+
+| variable          | meaning                                   |
+|-------------------|-------------------------------------------|
+| `price`           | current YES price, 0..1                   |
+| `volume_usd`      | lifetime USD volume                       |
+| `liquidity_usd`   | order-book liquidity                      |
+| `spread`          | best ask − best bid, 0..1                 |
+| `days_to_resolve` | days until the market resolves            |
+
+Operators allowed: `and or not  == != < <= > >=  + - * / % **` and numeric
+constants. **No function calls, no attribute access, no other names** — enforced
+by `safe_eval.py` and re-checked by `scripts/validate_strategy.py`.
+
+## Translating plain English (examples)
+
+| user says | compiles to |
+|-----------|-------------|
+| "buy longshots under 8 cents with real volume, take profit at 15c, stop at 3c" | entry `price <= 0.08 and volume_usd >= 100000`; exits `price >= 0.15` / `price <= 0.03` |
+| "fade near-certain YES over 92c, close if it drops below 85c" | entry `price >= 0.92`, side `NO`; exit `price <= 0.85` |
+| "only tight markets resolving this month" | universe `max_spread: 0.03, resolves_within_days: 31` |
+| "never risk more than $200 total, $20 a bet, 8 positions max" | sizing `amount:20, max_open_positions:8`; risk `max_total_exposure_usd:200` |
+
+## Workflow
+
+1. Draft `strategy.json` from the user's words.
+2. `scripts/validate_strategy.py strategy.json` — must pass.
+3. `scripts/new_bot.py <name> --strategy strategy.json` — scaffold the project (paper).
+4. `python bot.py --once --dry-run` inside it — confirm the rules fire as intended.
+5. Iterate on the rules, re-validate, re-dry-run. Only Steve flips it toward live.
diff --git a/references/venues.md b/references/venues.md
new file mode 100644
index 0000000..b1f9d6b
--- /dev/null
+++ b/references/venues.md
@@ -0,0 +1,45 @@
+# Venues + how this composes with the existing stack
+
+This skill deliberately does **not** reinvent market data or a Kalshi dashboard —
+it sits between the read layer and the execution layer you already have.
+
+## Polymarket (primary, keyless, $0)
+
+- **Data:** public Gamma API `https://gamma-api.polymarket.com/markets`
+  (`closed=false&active=true&order=volume`). No key, free. `venue_adapter.py`
+  normalizes each market to `price / volume_usd / liquidity_usd / spread /
+  days_to_resolve`.
+- **Same truth as `polymtrx`.** The `/polymtrx` skill scans this exact source for
+  "where's the edge". Use `/polymtrx scan <topic>` to *discover* a strategy, then
+  encode it here as `strategy.json`. polymtrx = research; this = the bot that acts
+  on it (paper first).
+- **Live execution** would use the CLOB order API (`py-clob-client`) + a funded
+  wallet — gated, unwired in the template.
+
+## Kalshi (DEMO until credentialed)
+
+- `KalshiDemoAdapter` intentionally raises until real creds exist — mirroring the
+  **Ken / Kalshi dashboard** (`~/Projects/Ken/kalshi-dash`, pm2 `ken` :7810), which
+  is still DEMO pending Steve's prod Kalshi credentials.
+- **Reuse Ken, don't duplicate it.** If a strategy should run on Kalshi, prefer
+  extending Ken's paper-portfolio + `safe_mode`-honoring autoTrader over wiring a
+  second execution path. Author/validate the strategy here, hand execution to Ken.
+- Live Kalshi needs `KALSHI_API_KEY` + `KALSHI_PRIVATE_KEY` (RSA-signed requests)
+  and — as always — Steve's go-live approval.
+
+## Division of labor
+
+| layer | owner | this skill's role |
+|-------|-------|-------------------|
+| discovery / signals | `polymtrx`, `/polymtrx` | consume it to shape rules |
+| strategy authoring | **this skill** | plain English → validated `strategy.json` |
+| paper execution + P&L | **this skill** (template) | scaffold + run paper, show P&L |
+| Kalshi execution | Ken (`:7810`) | hand off; don't re-build |
+| live orders / money | Steve (gated) | draft memo, never auto-fire |
+
+## Adding a venue
+
+Implement an adapter class in `venue_adapter.py` with `fetch_universe(query, limit)`
+→ list of normalized market dicts and `mark_price(id)` → float, register it in
+`_ADAPTERS`, and keep it **read-only**. Order routing stays in `executor.py` behind
+the safety gates.
diff --git a/scripts/new_bot.py b/scripts/new_bot.py
new file mode 100755
index 0000000..a0bb902
--- /dev/null
+++ b/scripts/new_bot.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""
+new_bot.py — scaffold a new paper-first bot project from the template.
+
+    scripts/new_bot.py <bot-name> [--dest DIR] [--strategy path/to/strategy.json]
+
+Copies assets/bot-template/ into DIR/<bot-name>/ (default DIR=~/Projects), drops
+in a compiled strategy.json if one is provided (else the longshot-fade example),
+git-inits the new project, and prints next steps. Never runs the bot; never
+touches live keys.
+"""
+
+import argparse
+import json
+import os
+import shutil
+import subprocess
+import sys
+
+SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+TEMPLATE = os.path.join(SKILL_ROOT, "assets", "bot-template")
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("name", help="bot project name (kebab-case)")
+    ap.add_argument("--dest", default=os.path.expanduser("~/Projects"))
+    ap.add_argument("--strategy", help="path to a strategy.json to inject")
+    ap.add_argument("--no-git", action="store_true")
+    args = ap.parse_args()
+
+    dest = os.path.join(args.dest, args.name)
+    if os.path.exists(dest):
+        sys.exit(f"refusing to overwrite existing path: {dest}")
+    shutil.copytree(TEMPLATE, dest)
+
+    if args.strategy:
+        with open(args.strategy) as f:
+            strat = json.load(f)
+        strat.setdefault("name", args.name)
+        strat["mode"] = "paper"  # always scaffold in paper mode, no exceptions
+        with open(os.path.join(dest, "strategy.json"), "w") as f:
+            json.dump(strat, f, indent=2)
+
+    # data/ is created on first fill; add a keep + gitignore for the ledger.
+    os.makedirs(os.path.join(dest, "data"), exist_ok=True)
+    with open(os.path.join(dest, ".gitignore"), "w") as f:
+        f.write("data/ledger.jsonl\ndata/LIVE_CONFIRMED\ndata/KILL\n.env\n__pycache__/\n")
+
+    if not args.no_git:
+        try:
+            subprocess.run(["git", "init", "-q"], cwd=dest, check=True)
+            subprocess.run(["git", "add", "-A"], cwd=dest, check=True)
+            subprocess.run(
+                ["git", "-c", "user.email=steve@designerwallcoverings.com",
+                 "-c", "user.name=Steve", "commit", "-q", "-m",
+                 f"scaffold {args.name} paper bot"], cwd=dest, check=True)
+        except (subprocess.CalledProcessError, FileNotFoundError) as e:
+            print(f"  (git init skipped: {e})")
+
+    print(f"✅ scaffolded paper bot → {dest}")
+    print("   next:")
+    print(f"     cd {dest}")
+    print("     python venue_adapter.py         # confirm live market data ($0)")
+    print("     python bot.py --once --dry-run   # decide, place no fills")
+    print("     python bot.py --once             # first paper fills")
+    print("     python dashboard.py --serve      # watch paper P&L")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/validate_strategy.py b/scripts/validate_strategy.py
new file mode 100755
index 0000000..635c1a0
--- /dev/null
+++ b/scripts/validate_strategy.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""
+validate_strategy.py — validate a strategy.json BEFORE a bot ever runs it.
+
+    scripts/validate_strategy.py path/to/strategy.json
+
+Checks required keys, sane value ranges, and — critically — that every entry/exit
+rule parses under the sandboxed evaluator and references only known market
+variables. A strategy that fails here would silently misfire at runtime; catch it
+now. Exit code 0 = valid, 1 = invalid.
+"""
+
+import ast
+import json
+import sys
+
+# Kept in sync with assets/bot-template/safe_eval.py MARKET_VARS.
+MARKET_VARS = ("price", "volume_usd", "liquidity_usd", "spread", "days_to_resolve")
+_OK_NODES = (ast.Expression, ast.BoolOp, ast.BinOp, ast.UnaryOp, ast.Compare,
+             ast.Name, ast.Constant, ast.And, ast.Or, ast.Not, ast.USub, ast.UAdd,
+             ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod, ast.Pow,
+             ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.Load)
+
+
+def check_rule(expr):
+    try:
+        tree = ast.parse(expr, mode="eval")
+    except SyntaxError as e:
+        return f"cannot parse: {e}"
+    for node in ast.walk(tree):
+        if not isinstance(node, _OK_NODES):
+            return f"disallowed element {type(node).__name__} in {expr!r}"
+        if isinstance(node, ast.Name) and node.id not in MARKET_VARS + ("True", "False"):
+            return f"unknown variable {node.id!r} in {expr!r} (allowed: {', '.join(MARKET_VARS)})"
+    return None
+
+
+def validate(strat):
+    errs = []
+    for key in ("name", "venue", "entry", "exit", "sizing", "risk"):
+        if key not in strat:
+            errs.append(f"missing required key: {key}")
+    if strat.get("venue") not in ("polymarket", "kalshi"):
+        errs.append(f"venue must be polymarket|kalshi, got {strat.get('venue')!r}")
+    if strat.get("mode", "paper") not in ("paper", "live"):
+        errs.append("mode must be paper|live")
+    for section in ("entry", "exit"):
+        for i, rule in enumerate(strat.get(section, [])):
+            if "when" not in rule:
+                errs.append(f"{section}[{i}] missing 'when'")
+                continue
+            problem = check_rule(rule["when"])
+            if problem:
+                errs.append(f"{section}[{i}]: {problem}")
+    sizing = strat.get("sizing", {})
+    if sizing.get("amount", 0) <= 0:
+        errs.append("sizing.amount must be > 0")
+    if sizing.get("max_position_usd", 0) < sizing.get("amount", 0):
+        errs.append("sizing.max_position_usd must be >= sizing.amount")
+    risk = strat.get("risk", {})
+    if "max_total_exposure_usd" not in risk:
+        errs.append("risk.max_total_exposure_usd is required (exposure cap)")
+    return errs
+
+
+def main():
+    if len(sys.argv) != 2:
+        sys.exit("usage: validate_strategy.py <strategy.json>")
+    with open(sys.argv[1]) as f:
+        strat = json.load(f)
+    errs = validate(strat)
+    if errs:
+        print("❌ INVALID strategy:")
+        for e in errs:
+            print("   - " + e)
+        sys.exit(1)
+    print(f"✅ valid strategy: {strat['name']} ({strat['venue']}, mode={strat.get('mode','paper')})")
+    print(f"   entry rules: {len(strat.get('entry', []))}  exit rules: {len(strat.get('exit', []))}")
+
+
+if __name__ == "__main__":
+    main()

(oldest)  ·  back to Zero Code Trading Bot  ·  (newest)