← back to Demo Longshot Bot
scaffold demo-longshot-bot paper bot
543fa21387cc791f6207890c305921154f591ef5 · 2026-07-17 07:05:47 -0700 · Steve
Files touched
A .env.exampleA .gitignoreA README.mdA bot.pyA dashboard.pyA executor.pyA ledger.pyA requirements.txtA safe_eval.pyA strategy.jsonA venue_adapter.py
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&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/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 →