← back to Zero Code Trading Bot

assets/bot-template/bot.py

169 lines

#!/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()