[object Object]

← back to Demo Longshot Bot

scaffold demo-longshot-bot paper bot

543fa21387cc791f6207890c305921154f591ef5 · 2026-07-17 07:05:47 -0700 · Steve

Files touched

Diff

commit 543fa21387cc791f6207890c305921154f591ef5
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 17 07:05:47 2026 -0700

    scaffold demo-longshot-bot paper bot
---
 .env.example     |  16 ++++++
 .gitignore       |   5 ++
 README.md        |  33 +++++++++++
 bot.py           | 168 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 dashboard.py     |  94 +++++++++++++++++++++++++++++++
 executor.py      |  87 ++++++++++++++++++++++++++++
 ledger.py        | 115 +++++++++++++++++++++++++++++++++++++
 requirements.txt |   6 ++
 safe_eval.py     |  98 ++++++++++++++++++++++++++++++++
 strategy.json    |  42 ++++++++++++++
 venue_adapter.py | 151 +++++++++++++++++++++++++++++++++++++++++++++++++
 11 files changed, 815 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0856ceb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,16 @@
+# ── zero-code-trading-bot — bot instance env ──
+# Copy to .env only if/when you wire live trading. PAPER mode needs NOTHING here.
+
+# Safety switches (defaults are the safe values; the bot assumes these if unset)
+LIVE=0            # 1 = attempt live orders (also requires the gates below)
+SAFE_MODE=1       # 1 = safe_mode ON → live trading refused. Must be 0 to go live.
+
+# Broker credentials — ONLY needed for live trading, never for paper.
+# POLYMARKET_API_KEY=
+# POLYMARKET_API_SECRET=
+# KALSHI_API_KEY=
+# KALSHI_PRIVATE_KEY=
+
+# Going live is Steve-gated. Even with keys + LIVE=1 + SAFE_MODE=0, the executor
+# also requires a data/LIVE_CONFIRMED token file AND a wired broker call.
+# See references/safety-model.md before touching any of this.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..97e04c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+data/ledger.jsonl
+data/LIVE_CONFIRMED
+data/KILL
+.env
+__pycache__/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..65c7bb4
--- /dev/null
+++ b/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/bot.py b/bot.py
new file mode 100755
index 0000000..4dde663
--- /dev/null
+++ b/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/dashboard.py b/dashboard.py
new file mode 100755
index 0000000..520eb02
--- /dev/null
+++ b/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/executor.py b/executor.py
new file mode 100755
index 0000000..1169b6f
--- /dev/null
+++ b/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/ledger.py b/ledger.py
new file mode 100755
index 0000000..269f5fd
--- /dev/null
+++ b/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/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..21a1e6c
--- /dev/null
+++ b/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/safe_eval.py b/safe_eval.py
new file mode 100755
index 0000000..7a9ff48
--- /dev/null
+++ b/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/strategy.json b/strategy.json
new file mode 100644
index 0000000..4767721
--- /dev/null
+++ b/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/venue_adapter.py b/venue_adapter.py
new file mode 100755
index 0000000..e8d3ab5
--- /dev/null
+++ b/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]}")

(oldest)  ·  back to Demo Longshot Bot  ·  auto-save: 2026-07-17T19:47:29 (1 files) — strategy.json 4e62423 →