← 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
A .gitignoreA LICENSEA README.mdA SKILL.mdA assets/bot-template/README.mdA assets/bot-template/bot.pyA assets/bot-template/dashboard.pyA assets/bot-template/executor.pyA assets/bot-template/ledger.pyA assets/bot-template/requirements.txtA assets/bot-template/safe_eval.pyA assets/bot-template/strategy.jsonA assets/bot-template/venue_adapter.pyA references/safety-model.mdA references/strategy-dsl.mdA references/venues.mdA scripts/new_bot.pyA scripts/validate_strategy.py
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&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&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)