diff --git a/.gitignore b/.gitignore index 5419d4a..a036722 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +docs/superpowers/ +.superpowers/ + # macOS .DS_Store @@ -5,3 +8,8 @@ *.swp .idea/ .vscode/ + +# Python +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e34dab2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +Context for developing **this repo** — the `polymarket` agent skill. +(To *use* the skill, start at [SKILL.md](SKILL.md).) + +## What this is + +An **instruction-only** Claude/OpenClaw skill driving the `poly` CLI + a Polymarket MCP to +query, trade, and run a multi-agent opportunity scan. Strategy logic lives in Markdown specs, +**not code**. The only executable code is three thin helpers: +- `assets/risk_gate.py` — money-guarding decision core (config + Opportunity validation + `decide()` + CLI). +- `assets/poly-mcp.sh` — MCP-over-HTTP transport (handshake → `tools/call`). +- `assets/build_data.py` — orchestration→artifact bridge: maps the scan's universe + gated opportunities (+ account) into the dashboard `DATA` and injects `assets/dashboard-template.html` (pure/stdlib, `tests/test_build_data.py`). + +## Commands + +```bash +# Tests — no pyproject/venv; uv pulls deps inline: +uv run --with pytest --with jsonschema pytest tests/ -v + +# Risk gate (reads an Opportunity JSON on stdin): +echo '' | python3 assets/risk_gate.py decide --run-total +echo '' | python3 assets/risk_gate.py validate + +# Polymarket MCP (read-only market data): +assets/poly-mcp.sh screen_markets '{"sort_by":"volume_spike","interval":"24h","limit":10}' + +# Build the dashboard artifact (maps a scan run -> DATA -> injects template): +python3 assets/build_data.py --inject assets/dashboard-template.html < run.json > dashboard.html +``` + +## Layout + +- `SKILL.md` — skill entry + activation triggers. +- `reference/orchestration.md` — multi-agent scan playbook (scout → 6 strategy agents → gate → report). +- `reference/strategies/*.md` — the six strategy specs (the "logic", as prose). +- `reference/config.md` + `config.example.json` — risk-gate limits (`~/.config/polymarket/agent.json`). +- `reference/commands.md` / `recipes.md` / `mcp.md` — poly catalog, workflows, MCP guide. +- `assets/risk_gate.py` + `assets/poly-mcp.sh` + `assets/build_data.py` — the only code. +- `assets/dashboard-template.html` + `reference/artifacts.md` — the dashboard **artifact** (self-contained HTML + `DATA` schema). Orchestration Step 7 builds it via `build_data.py`. +- `docs/superpowers/specs|plans/` — design spec + implementation plan. + +## Gotchas + +- **Use `assets/poly-mcp.sh`, not native `mcp__polymarket__*`.** The ECC `mcp-health-check` hook + sends an incomplete `Accept` header, gets a 406, and wrongly blocks the (healthy) MCP. The helper + sends `Accept: application/json, text/event-stream`. +- **`risk_gate.decide()` is the only thing that authorizes real money.** Keep it stdlib-only + (`jsonschema` is imported lazily, inside `validate_opportunity` only). Any change to its 8 ordered + checks or comparison operators must update the boundary tests in `tests/test_risk_gate.py`. +- **Auto-execute is allowlisted to structural arbs** (`risk-free-arb`, `multi-outcome-arb`) in + `risk_gate.DEFAULTS` — the single enforcement point. Directional strategies always escalate. +- **Conservative limit defaults live in `risk_gate.DEFAULTS`**; a user `agent.json` overrides them. + Keep the code defaults conservative — they're the safety floor. +- **`build_data.py` never fetches.** The orchestration step hands it the universe, gated opportunities, + enrichment, and (only when a wallet is set up) account — a null `account` makes the dashboard render + wallet-setup steps. Keep it pure/stdlib so `tests/test_build_data.py` stays hermetic. diff --git a/README.md b/README.md index f992dd9..05fa2a5 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ | *"Buy $5 of YES on `` at 0.42."* | dry-run preview → submits with `--yes` | | *"Show my open positions and portfolio value."* | `data positions` · `data value` | | *"Cancel all my open orders."* | `clob cancel-all` | +| *"Scan Polymarket for opportunities."* | runs the multi-agent scan → ranked opportunities, auto-fires structural arbs within limits, escalates the rest | ## 📦 What's inside @@ -68,7 +69,19 @@ poly setup # hidden prompt → ~/.config/polymarket/config.j # or: export POLYMARKET_PRIVATE_KEY=0x... ``` -**3. Install the skill:** +**3. Load the Polymarket MCP** (read-only market data — powers search, screening & the scanner): + +```bash +claude mcp add --transport http --scope user polymarket \ + https://polymarket.mcp.askcloud.ai/mcp \ + --header "Authorization: Bearer " +claude mcp get polymarket # verify → Status: ✔ Connected +``` + +> Swap in your own bearer token. The skill reaches the server through `assets/poly-mcp.sh`, which reads +> the URL + token from `~/.claude.json` and never echoes it — more in [reference/mcp.md](reference/mcp.md). + +**4. Install the skill:** ```bash # Claude Code — drop it in so SKILL.md sits at the folder root @@ -79,6 +92,14 @@ Restart Claude Code; it activates whenever you mention Polymarket, prediction-ma bet. **OpenClaw / clawhub** — the same folder installs as an OpenClaw skill; users just need `poly` on their `PATH` via the one-liner above. +> **Developing the skill itself?** Symlink instead of copying, so edits to your working tree are reflected +> in the installed skill with no re-copy: +> ```bash +> ln -s "$(pwd)/polymarket-skill" ~/.claude/skills/polymarket +> ``` +> Restart Claude Code after edits to reload (skills load at session start). `rm ~/.claude/skills/polymarket` +> removes only the symlink, never your repo. + ## 🆕 New wallet? Activate it first Importing or creating a key only configures *signing*. A wallet that has **never been used on @@ -93,9 +114,12 @@ agent will walk you through this — full steps in the ## 🔒 Safety -Trades spend **real USDC on Polygon.** This skill ships an **autonomous** posture — the agent may submit -live orders with `--yes` without per-order approval — and does **not** enforce spending limits (a -deliberate, thin-by-design choice; the limits in [SKILL.md](SKILL.md) are guidance, not hard caps). +Trades spend **real USDC on Polygon.** The base `buy`/`sell` flow ships an **autonomous** posture — the +agent may submit live orders with `--yes` without per-order approval — and does **not** enforce spending +limits there (a deliberate, thin-by-design choice; the limits in [SKILL.md](SKILL.md) are guidance). The +**multi-agent scanner is different**: its risk gate enforces hard caps (per-order, per-run, liquidity, +depth) and only auto-executes structural arbs — everything else escalates. Tune those caps in +[reference/config.md](reference/config.md). ✅ Prefer a `--dry-run` preview before any live order  ·  ✅ Tell the agent your per-order / per-day limits if you want them honored  ·  ✅ A wallet must be funded **and** approved to fill. diff --git a/SKILL.md b/SKILL.md index 79039da..5ca1621 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: polymarket -description: Query and trade on Polymarket prediction markets — search markets, check live odds/order books, view positions and balances, place and cancel orders. Use when the user mentions Polymarket, prediction-market odds, betting on an event/election/crypto outcome, or wants to check or trade a market's probability. +description: Query and trade on Polymarket prediction markets — search markets, check live odds/order books, view positions and balances, place and cancel orders, and run a multi-agent opportunity scan that hunts mispricings and arbitrage across strategies. Use when the user mentions Polymarket, prediction-market odds, betting on an event/election/crypto outcome, wants to check or trade a market's probability, or asks to scan/find Polymarket opportunities. --- # Polymarket @@ -111,3 +111,42 @@ following as obligations on the agent's behavior: Copy-pasteable multi-step workflows (find→price→preview→submit, check positions, cancel a market's orders, activate a new wallet): [reference/recipes.md](reference/recipes.md). + +## Opportunity scanning (multi-agent) + +When the user asks to **scan / find opportunities** (mispricings, arbitrage, movers worth +trading), run the orchestration playbook in [reference/orchestration.md](reference/orchestration.md). +In one on-demand pass it: scouts a shared candidate universe via the MCP, fans out six +parallel strategy sub-agents (momentum, mean-reversion, multi-outcome-arb, spread-capture, +risk-free-arb, smart-money — see [reference/strategies/](reference/strategies/)), synthesizes +and ranks their Opportunity objects, then runs each through the deterministic risk gate +(`assets/risk_gate.py`). + +**Semi-auto execution.** The risk gate decides per opportunity: **auto-execute** (only the +structural arbs — `risk-free-arb`, `multi-outcome-arb` — when confident and within limits), +**escalate** (everything else, incl. all directional strategies → ask the user), or **skip**. +Auto-executed orders always run `--dry-run` and a preview match before `--yes`. Hard limits +live in `~/.config/polymarket/agent.json` (every key explained in [reference/config.md](reference/config.md)); +conservative defaults apply if absent, and the user may override limits inline for a run. + +**MCP access.** Reach the polymarket MCP through [assets/poly-mcp.sh](assets/poly-mcp.sh) +(see [reference/mcp.md](reference/mcp.md)); native `mcp__polymarket__*` calls may be blocked +by a health-check hook false positive. + +## Dashboard artifact (visualize) + +When the user asks to **show / visualize / "make a dashboard" / "display my positions or markets"**, +render the multi-tab HTML dashboard as an **Artifact** instead of printing JSON tables. The artifact is +sandboxed — it **cannot** run `poly` or call the MCP — so you inject a data snapshot at generation time. +Full procedure, `DATA` schema, and source map: [reference/artifacts.md](reference/artifacts.md). + +1. **Fetch** what they asked to see: Markets → `markets get/search` (+ MCP `get_order_book_depth`, + `get_price_history`, `get_market_stats` for depth / OHLC candles / flow); Recommendations → your own + analysis; Account → `clob balance`, `data value`, `data positions`, `clob orders`, `clob trades`, + `wallet show` (skip and set `account: null` if no key is configured). +2. **Build** one `DATA` object (schema in artifacts.md). Keep numbers as the Decimal strings the sources + return; stamp `meta.generated_at` = now. Unfetched lists → `[]`, skipped objects → `null`. +3. **Inject** — read [assets/dashboard-template.html](assets/dashboard-template.html), replace only the + `POLYMARKET_DATA_START…END` block with your `const DATA = {…}`, emit the file as the artifact. +4. **Caveat**: the dashboard is a snapshot frozen at `generated_at`; tell the user to regenerate for + fresh data. Always render through this template — don't hand-roll one-off dashboard HTML. diff --git a/assets/build_data.py b/assets/build_data.py new file mode 100644 index 0000000..42143cf --- /dev/null +++ b/assets/build_data.py @@ -0,0 +1,246 @@ +"""Bridge: turn an opportunity-scan run into the dashboard artifact's DATA object. + +Deterministic + stdlib-only so it runs with a plain `python3` and is unit-testable +(mirrors risk_gate.py). It does NOT fetch anything — the orchestrator passes in the +universe, the gated opportunities, the per-market enrichment, and (optionally) account +data; this maps them into the `DATA` schema in reference/artifacts.md and (optionally) +injects it into assets/dashboard-template.html to emit the finished artifact. + +Input (JSON on stdin): + { + "universe": [ {condition_id, slug, question, yes_price, volume_24h, liquidity, + spread, best_bid, best_ask, price_change_pct, + token_id_yes, token_id_no, event_id}, ... ], + "opportunities": [ {strategy, condition_id, slug, token_id, outcome, thesis, + proposed_action:{side,order_type,price,size_usd}, edge_estimate, + confidence, liquidity_check:{...}, risks:[...], signal:{...}, + gate:{decision:"auto"|"escalate"|"skip", order_id?}}, ... ], + "enrichment": { "": {url, category, end_date, description, + volume_total, net_flow, depth, candles} }, # for the curated subset + "account": {...} | null, # null/empty when no wallet is set up -> Account tab shows setup steps + "top_n": 24, # extra top-volume universe markets to include beyond the rec'd ones + "generated_at": "", + "wallet_label": "deposit 0x…", + "stats": {...} | null # optional override of the stats strip + } + +Usage: + python3 assets/build_data.py < payload.json # prints DATA json + python3 assets/build_data.py --inject assets/dashboard-template.html < payload.json # prints filled HTML +""" +import json +import re +import sys + +# gate decision -> recommendation status pill +_STATUS = {"auto": "executed", "escalate": "pending", "skip": "skipped"} + + +def _s(v): + """Stringify the way the template expects (decimal strings), leaving None as None.""" + if v is None: + return None + if isinstance(v, str): + return v + if isinstance(v, float): + return repr(v) + return str(v) + + +def _num(v): + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + +def _compact(v): + n = abs(_num(v)) + if n >= 1e9: + return "%.1fB" % (n / 1e9) + if n >= 1e6: + return "%.1fM" % (n / 1e6) + if n >= 1e3: + return "%.1fk" % (n / 1e3) + return str(int(n)) + + +def confidence_band(c): + """Match the risk gate's thresholds: >=0.75 high, >=0.5 medium, else low.""" + c = _num(c) + return "high" if c >= 0.75 else "medium" if c >= 0.5 else "low" + + +def market_from_universe(u, enrich): + """Map a universe row (+ optional enrichment) into a markets[] entry.""" + m = { + "question": u.get("question"), + "slug": u.get("slug"), + "condition_id": u.get("condition_id"), + "yes_token_id": _s(u.get("token_id_yes")), + "no_token_id": _s(u.get("token_id_no")), + "yes_price": _s(u.get("yes_price")), + "volume_24h": _s(u.get("volume_24h")), + "liquidity": _s(u.get("liquidity")), + } + # merge enrichment: real event-slug url, category, end_date, description, volume_total, net_flow, depth, candles + for k in ("url", "category", "end_date", "description", "volume_total", "net_flow", "depth", "candles"): + if enrich and enrich.get(k) is not None: + m[k] = enrich[k] + return m + + +def opportunity_to_recommendation(opp, universe_by_cond, enrich): + """Map a gated Opportunity into a recommendations[] entry.""" + cond = opp.get("condition_id") + u = universe_by_cond.get(cond, {}) + gate = opp.get("gate") or {} + status = _STATUS.get(gate.get("decision", "escalate"), "pending") + side = (opp.get("proposed_action") or {}).get("side", "BUY").upper() + action = "WATCH" if status == "skipped" else side + + signals = [] + lc = opp.get("liquidity_check") or {} + if lc.get("market_liquidity_usd"): + signals.append("liq $" + _compact(lc["market_liquidity_usd"])) + if lc.get("est_slippage") is not None: + signals.append("slippage " + _s(lc["est_slippage"])) + for key, val in (opp.get("signal") or {}).items(): + signals.append("%s %s" % (key, val)) + for risk in (opp.get("risks") or []): + signals.append(risk) + + rec = { + "question": u.get("question") or opp.get("slug"), + "slug": opp.get("slug"), + "outcome": (opp.get("outcome") or "yes").upper(), + "action": action, + "confidence": confidence_band(opp.get("confidence")), + "confidence_score": _s(opp.get("confidence")), + "rationale": opp.get("thesis"), + "signals": signals[:5], + "strategy": opp.get("strategy"), + "status": status, + } + price = (opp.get("proposed_action") or {}).get("price") + if price is not None: + rec["target_price"] = _s(price) + size = (opp.get("proposed_action") or {}).get("size_usd") + if size is not None: + rec["size_usd"] = _s(size) + if opp.get("edge_estimate"): + rec["edge"] = opp["edge_estimate"] + if gate.get("order_id"): + rec["order_id"] = gate["order_id"] + if enrich and enrich.get("url"): + rec["url"] = enrich["url"] + return rec + + +def compute_stats(universe): + """Stats strip from the FULL universe (not just the injected subset).""" + tot = v24 = liq = 0.0 + for u in universe: + v24 += _num(u.get("volume_24h")) + liq += _num(u.get("liquidity")) + tot += _num(u.get("volume_total") if u.get("volume_total") is not None else u.get("volume_24h")) + return {"total_volume": str(int(tot)), "volume_24h": str(int(v24)), + "liquidity": str(int(liq)), "active": len(universe)} + + +def build_data(payload): + universe = payload.get("universe") or [] + opps = payload.get("opportunities") or [] + enrichment = payload.get("enrichment") or {} + top_n = int(payload.get("top_n", 30)) + by_cond = {u.get("condition_id"): u for u in universe} + + # curated subset = every recommended market first, then top-N universe by 24h volume + rec_conds = [o.get("condition_id") for o in opps if o.get("condition_id")] + ranked = sorted(universe, key=lambda u: _num(u.get("volume_24h")), reverse=True) + target = len(set(rec_conds)) + top_n + chosen, seen = [], set() + for cond in rec_conds + [u.get("condition_id") for u in ranked]: + if cond and cond not in seen and cond in by_cond: + seen.add(cond) + chosen.append(by_cond[cond]) + if len(chosen) >= target: + break + + markets = [market_from_universe(u, enrichment.get(u.get("condition_id"))) for u in chosen] + + # Enrich the grid with the most-traded markets in the last 24h (popularity, not anomaly). + # The scouted universe is a filtered/anomaly set; `trending` is a separate broad list the + # orchestrator pulls and ranks by 24h volume. Merge it in, deduped by condition_id, tagged + # so the template's category-driven chip/tag surfaces it with no template change. + trending = payload.get("trending") or [] + trending_n = int(payload.get("trending_n", 12)) + trending_top = sorted(trending, key=lambda u: _num(u.get("volume_24h")), reverse=True)[:trending_n] + market_by_cond = {m.get("condition_id"): m for m in markets} + for u in trending_top: + cond = u.get("condition_id") + if not cond: + continue + existing = market_by_cond.get(cond) + if existing is not None: + existing["trending"] = True # already shown (also scouted) -> just flag it + continue + m = market_from_universe(u, enrichment.get(cond)) + m["trending"] = True + m.setdefault("category", "🔥 Trending") # no real category -> category-driven chip/tag for free + markets.append(m) + market_by_cond[cond] = m + + recommendations = [opportunity_to_recommendation(o, by_cond, enrichment.get(o.get("condition_id"))) for o in opps] + + return { + "meta": { + "generated_at": payload.get("generated_at"), + "wallet_label": payload.get("wallet_label", "no wallet"), + "currency": "USDC", + "stats": payload.get("stats") or compute_stats(universe), + }, + "markets": markets, + "recommendations": recommendations, + # null when the wallet isn't set up (orchestrator passes account only if a key is + # configured) -> the dashboard's Account tab renders wallet-setup steps. Coerce an + # empty {} to null too, so a blank account never renders as a zeroed-out balance. + "account": payload.get("account") or None, + } + + +# whole block: START marker comment … const DATA = {…}; … END marker comment +_DATA_BLOCK = re.compile( + r"/\* === POLYMARKET_DATA_START.*?POLYMARKET_DATA_END === \*/", + re.DOTALL, +) + + +def inject(template_html, data): + """Replace the template's DATA block with the generated DATA object.""" + body = ("/* === POLYMARKET_DATA_START — generated by build_data.py === */\n" + "const DATA = " + json.dumps(data, ensure_ascii=False, indent=2) + ";\n" + "/* === POLYMARKET_DATA_END === */") + # function replacement → returned string is used literally (no backslash/group processing) + new, n = _DATA_BLOCK.subn(lambda _m: body, template_html) + if n != 1: + raise SystemExit("error: expected exactly one POLYMARKET_DATA block, found %d" % n) + return new + + +def main(argv): + inject_path = None + if len(argv) >= 2 and argv[0] == "--inject": + inject_path = argv[1] + payload = json.load(sys.stdin) + data = build_data(payload) + if inject_path: + with open(inject_path) as fh: + html = fh.read() + sys.stdout.write(inject(html, data)) + else: + sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/assets/dashboard-template.html b/assets/dashboard-template.html new file mode 100644 index 0000000..eda8d56 --- /dev/null +++ b/assets/dashboard-template.html @@ -0,0 +1,1110 @@ + + + + + +Polymarket — Odds Terminal + + + + + + +
+ +
No data injected. This template needs a DATA snapshot — + ask your agent to generate the Polymarket dashboard.
+ +
+
+ + Polymarket + Odds Terminal +
+ + + +
+ + + +
+
+
+
+
live · agent terminal
+

Read the odds.
Run the scan.
Place the bet.

+

An agent-run Polymarket terminal. Search live odds, run a six-strategy opportunity scan, and trade — all from chat. Reading is free; trading spends real USDC.

+
+
+ +
+

Get trading-ready

+

Odds, search and dashboards need no setup. Trading takes five steps — once.

+
    +
  1. 1

    Hand your agent the key

    Give it your wallet's private key. It configures the signer (poly setup) to sign orders for you — the key is never printed, echoed, or logged.

  2. +
  3. 2

    Connect the same wallet on the site

    Log in to polymarket.com with that wallet — import the key into a browser wallet → Connect, or use the email/Magic login that owns it.

  4. +
  5. 3

    Enable trading

    Complete the on-screen approvals. This deploys your deposit wallet and grants the USDC + conditional-token allowances the exchange needs — gasless on the site.

  6. +
  7. 4

    Deposit USDC

    Fund your account through the website. The money lives on your deposit wallet, not the signer address.

  8. +
  9. You're ready to trade

    Confirm with the agent: wallet show (deposit wallet matches your site settings) and clob balance (USDC funded).

  10. +
+

One gotcha: a brand-new wallet can sign and pass a dry-run, but live orders fail with InsufficientAllowanceError until steps 2–3 are done on the site. A clean dry-run isn't proof you're ready.

+
+ +
+

How the scan works

+

Ask to "scan for opportunities" and a multi-agent pipeline runs end to end. Only the deterministic risk gate ever spends money.

+
+
scout

~50–150 markets from 4 screens — movers, volume spikes, spreads, liquidity.

+
+
6 strategies · in parallel
momentummean-reversionmulti-outcome-arbspread-capturerisk-free-arbsmart-money
+
+
risk gate
autoescalateskip

validate · dedupe · rank, then decide per trade.

+
+

Safety: only structural arbitrage auto-executes. Every directional call escalates for your approval — nothing speculative trades without you, and per-order / per-run dollar caps live in one place.

+
+ +
+

Ask your agent

+
+
ask anything — or pick a starting point
+
+
+
runs the scan multi-agent · may trade
+ + + + +
+
+
quick reads read-only · instant
+ + + + + + +
+
+
+
+
+
+ + + +
+
+
Snapshot — odds and balances are frozen at generation time. Regenerate for live data.
+ + + + diff --git a/assets/poly-mcp.sh b/assets/poly-mcp.sh new file mode 100755 index 0000000..1f3d616 --- /dev/null +++ b/assets/poly-mcp.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Thin MCP-over-HTTP transport for the polymarket MCP server. +# Usage: poly-mcp.sh [json_args] +# Prints the tool's JSON text result to stdout. Never prints the bearer token. +set -euo pipefail + +TOOL="${1:-}" +ARGS="${2:-}" +if [ -z "$ARGS" ]; then + ARGS='{}' +fi +CONFIG="${POLY_MCP_CONFIG:-$HOME/.claude.json}" + +if [ -z "$TOOL" ]; then + echo '{"error":"usage: poly-mcp.sh [json_args]"}' >&2 + exit 1 +fi + +URL="${POLY_MCP_URL:-}" +AUTH="${POLY_MCP_AUTH:-}" +if [ -z "$URL" ] || [ -z "$AUTH" ]; then + read -r URL AUTH < <(python3 - "$CONFIG" <<'PY' +import json, sys +try: + cfg = json.load(open(sys.argv[1])) + e = cfg["mcpServers"]["polymarket"] + print(e["url"], e["headers"]["Authorization"]) +except Exception: + print("", "") +PY +) +fi + +if [ -z "$URL" ] || [ -z "$AUTH" ]; then + echo '{"error":"no polymarket MCP token/url found in config"}' >&2 + exit 1 +fi + +ACC="Accept: application/json, text/event-stream" +CT="Content-Type: application/json" +AUTHH="Authorization: $AUTH" + +INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"poly-mcp.sh","version":"1"}}}' +SID=$(curl -fsS -D - -o /dev/null -X POST "$URL" -H "$AUTHH" -H "$CT" -H "$ACC" -d "$INIT" \ + | awk -F': ' 'tolower($1)=="mcp-session-id"{print $2}' | tr -d '\r') +if [ -z "$SID" ]; then + echo '{"error":"MCP initialize failed (no session id)"}' >&2 + exit 1 +fi +SIDH="Mcp-Session-Id: $SID" + +curl -fsS -o /dev/null -X POST "$URL" -H "$AUTHH" -H "$CT" -H "$ACC" -H "$SIDH" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' + +REQ=$(python3 - "$TOOL" "$ARGS" <<'PY' +import json, sys +print(json.dumps({"jsonrpc":"2.0","id":2,"method":"tools/call", + "params":{"name":sys.argv[1],"arguments":json.loads(sys.argv[2])}})) +PY +) +RESP=$(curl -fsS -X POST "$URL" -H "$AUTHH" -H "$CT" -H "$ACC" -H "$SIDH" -d "$REQ") +python3 - "$RESP" <<'PY' +import json, sys +raw = sys.argv[1] +data_lines = [ln[6:] for ln in raw.splitlines() if ln.startswith("data: ")] +if not data_lines: + print('{"error":"empty MCP response"}'); sys.exit(1) +d = json.loads(data_lines[-1]) +if "error" in d: + print(json.dumps({"error": d["error"]})); sys.exit(1) +for c in d.get("result", {}).get("content", []): + if c.get("type") == "text": + print(c["text"]) +PY diff --git a/assets/risk_gate.py b/assets/risk_gate.py new file mode 100644 index 0000000..e545ef3 --- /dev/null +++ b/assets/risk_gate.py @@ -0,0 +1,125 @@ +"""Risk-gate decision core + Opportunity validation for the Polymarket scanner. + +decide() is stdlib-only so it runs with a plain `python3`. validate_opportunity() +imports jsonschema lazily (run it via `uv run --with jsonschema ...`). +""" +import copy +import json +import os +from pathlib import Path + +_SCHEMA_PATH = Path(__file__).parent.parent / "reference" / "opportunity.schema.json" + +DEFAULTS = { + "max_notional_per_order_usd": 10, + "max_total_per_run_usd": 50, + "min_confidence_auto": 0.75, + "min_confidence_report": 0.5, + "min_liquidity_usd": 5000, + "min_depth_multiple": 2, + "max_book_take_pct": 25, + "auto_execute_strategies": ["risk-free-arb", "multi-outcome-arb"], +} + +_DEFAULT_CONFIG_PATH = "~/.config/polymarket/agent.json" + + +def load_config(path=None): + """Return DEFAULTS merged with the JSON config file (file wins). Missing file -> DEFAULTS copy.""" + cfg = copy.deepcopy(DEFAULTS) + resolved = os.path.expanduser(path or _DEFAULT_CONFIG_PATH) + if os.path.isfile(resolved): + with open(resolved) as fh: + cfg.update(json.load(fh)) + return cfg + + +def validate_opportunity(obj): + """Return a list of error strings; empty list means valid.""" + import jsonschema + + schema = json.loads(_SCHEMA_PATH.read_text()) + validator = jsonschema.Draft7Validator(schema) + errors = [] + for err in validator.iter_errors(obj): + loc = "/".join(str(p) for p in err.path) or "(root)" + errors.append(f"{loc}: {err.message}") + return errors + + +def decide(opportunity: dict, config: dict, run_total_usd: float) -> dict: + """Return {"decision": "auto"|"escalate"|"skip", "reason": str}. + + Applies 8 decision checks in strict order; first match wins. + """ + pa = opportunity["proposed_action"] + lc = opportunity["liquidity_check"] + size = pa["size_usd"] + conf = opportunity["confidence"] + strat = opportunity["strategy"] + mkt_liq = lc.get("market_liquidity_usd", 0) + depth = lc.get("depth_usd_at_price", 0) + + def result(decision, reason): + return {"decision": decision, "reason": reason} + + # Check 1: confidence < min_confidence_report + if conf < config["min_confidence_report"]: + return result("skip", "confidence below report floor") + + # Check 2: market liquidity below floor + if mkt_liq < config["min_liquidity_usd"]: + return result("skip", "market liquidity below floor") + + # Check 3: insufficient book depth + if depth < config["min_depth_multiple"] * size: + return result("skip", "insufficient book depth at price") + + # Check 4: over per-order cap + if size > config["max_notional_per_order_usd"]: + return result("skip", "order notional over per-order cap") + + # Check 5: would breach per-run cap + if run_total_usd + size > config["max_total_per_run_usd"]: + return result("skip", "would breach per-run total cap") + + # Check 6: takes too much resting depth + if depth > 0 and (size / depth) * 100 > config["max_book_take_pct"]: + return result("skip", "order would take too much resting depth") + + # Check 7: auto-execute if structural arb with high confidence + if strat in config["auto_execute_strategies"] and conf >= config["min_confidence_auto"]: + return result("auto", "structural arb within caps and confident") + + # Check 8: otherwise escalate + return result("escalate", "requires human confirmation") + + +def _main(argv=None): + import argparse + import sys + + parser = argparse.ArgumentParser(description="Polymarket scanner risk gate") + sub = parser.add_subparsers(dest="cmd", required=True) + + d = sub.add_parser("decide") + d.add_argument("--config", default=None) + d.add_argument("--run-total", type=float, default=0.0) + + sub.add_parser("validate") + + args = parser.parse_args(argv) + obj = json.load(sys.stdin) + + if args.cmd == "decide": + cfg = load_config(args.config) + print(json.dumps(decide(obj, cfg, args.run_total))) + return 0 + + errors = validate_opportunity(obj) + print(json.dumps({"errors": errors})) + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/docs/superpowers/plans/2026-06-28-polymarket-multi-agent-scanner.md b/docs/superpowers/plans/2026-06-28-polymarket-multi-agent-scanner.md new file mode 100644 index 0000000..47ae12d --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-polymarket-multi-agent-scanner.md @@ -0,0 +1,1293 @@ +# Polymarket Multi-Agent Opportunity Scanner Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend the instruction-only `polymarket` skill so one request ("scan Polymarket for opportunities") drives a scout pass + six parallel strategy sub-agents, then runs each found opportunity through a deterministic risk gate that auto-executes structural arbs within hard limits or escalates everything else. + +**Architecture:** Pure-instruction orchestration (the main agent follows `reference/orchestration.md`) over two existing tool surfaces — the polymarket MCP (read/analytics, reached via the `assets/poly-mcp.sh` transport helper) and the `poly` CLI (trade/account). Strategy logic lives in Markdown specs that sub-agents follow. The only executable code is two thin helpers: `assets/poly-mcp.sh` (MCP transport) and `assets/risk_gate.py` (the money-guarding decision core — deterministic and unit-tested). + +**Tech Stack:** Markdown skill specs; Bash + curl + python3 (`poly-mcp.sh`); Python 3 stdlib + `jsonschema` (`risk_gate.py`); pytest run via `uv run --with pytest --with jsonschema pytest`. + +## Global Constraints + +- **Instruction-only except two thin helpers.** No strategy logic in code. The only code artifacts are `assets/poly-mcp.sh` (transport) and `assets/risk_gate.py` (risk-gate decision + schema validation). *(Deviation from the spec's "one helper" framing — the spec's Testing section requires a real test of the gate, which requires executable gate logic. Flagged for review.)* +- **Never echo secrets.** `poly-mcp.sh` must never print the MCP bearer token; the skill must never print the `poly` private key. +- **`poly` usage:** always `poly -o json …` (global flag before subcommand); judge success by exit code (`0` ok / `1` fail); on failure parse `{"error": …}`. +- **Execution safety:** every auto-execute path runs `poly … --dry-run`, verifies the preview matches the proposed action, then re-runs with `--yes`. Execution is centralized in the orchestrator, never in sub-agents. +- **Auto-execute allowlist (default):** only `risk-free-arb` and `multi-outcome-arb`. The four directional strategies always escalate. +- **Default limits (verbatim):** `max_notional_per_order_usd=10`, `max_total_per_run_usd=50`, `min_confidence_auto=0.75`, `min_confidence_report=0.5`, `min_liquidity_usd=5000`, `min_depth_multiple=2` (depth must be ≥ 2× order size — this clarifies the spec's "2× order size"), `max_book_take_pct=25`, `auto_execute_strategies=["risk-free-arb","multi-outcome-arb"]`. +- **Tests:** live in `tests/`; run with `uv run --with pytest --with jsonschema pytest tests/ -v`. `risk_gate.py`'s `decide()` uses stdlib only (no deps at runtime); `jsonschema` is imported lazily inside `validate_opportunity()` so the runtime gate call needs no extra packages. +- **Strategy set (6):** `momentum`, `mean-reversion`, `multi-outcome-arb`, `spread-capture`, `risk-free-arb`, `smart-money`. + +--- + +## File Structure + +**Create:** +- `assets/risk_gate.py` — config loader + Opportunity validation + `decide()` + CLI. +- `assets/poly-mcp.sh` — MCP-over-HTTP transport helper. +- `reference/opportunity.schema.json` — JSON Schema for the Opportunity object. +- `reference/config.example.json` — example `agent.json` with all defaults. +- `reference/mcp.md` — how to call the MCP via the helper + the health-check hook fix. +- `reference/orchestration.md` — orchestrator playbook (scout → fan-out → synthesize → gate → execute → report). +- `reference/strategies/{momentum,mean-reversion,multi-outcome-arb,spread-capture,risk-free-arb,smart-money}.md` — six strategy specs. +- `tests/conftest.py` — adds `assets/` to `sys.path`. +- `tests/test_validate_opportunity.py`, `tests/test_config.py`, `tests/test_risk_gate.py`, `tests/test_risk_gate_cli.py`, `tests/test_poly_mcp.py`, `tests/test_strategy_specs.py`, `tests/test_orchestration_doc.py`, `tests/test_skill.py`. +- `tests/fixtures/opportunity.valid.json` — a valid Opportunity used across tests. + +**Modify:** +- `SKILL.md` — add the "Opportunity scanning (multi-agent)" section + trigger language. +- `README.md` — one line pointing to the new capability. + +--- + +## Task 1: Opportunity schema + validator + test scaffolding + +**Files:** +- Create: `reference/opportunity.schema.json` +- Create: `assets/risk_gate.py` +- Create: `tests/conftest.py` +- Create: `tests/fixtures/opportunity.valid.json` +- Test: `tests/test_validate_opportunity.py` + +**Interfaces:** +- Produces: `risk_gate.validate_opportunity(obj: dict) -> list[str]` — returns a list of human-readable error strings; `[]` means valid. Imports `jsonschema` lazily. + +- [ ] **Step 1: Write the JSON Schema** + +Create `reference/opportunity.schema.json`: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Opportunity", + "type": "object", + "required": ["strategy", "condition_id", "slug", "token_id", "outcome", "thesis", "proposed_action", "confidence", "liquidity_check"], + "additionalProperties": true, + "properties": { + "strategy": {"type": "string", "enum": ["momentum", "mean-reversion", "multi-outcome-arb", "spread-capture", "risk-free-arb", "smart-money"]}, + "condition_id": {"type": "string", "minLength": 1}, + "slug": {"type": "string", "minLength": 1}, + "token_id": {"type": "string", "minLength": 1}, + "outcome": {"type": "string", "enum": ["yes", "no"]}, + "thesis": {"type": "string", "minLength": 1}, + "signal": {"type": "object"}, + "proposed_action": { + "type": "object", + "required": ["side", "order_type", "size_usd"], + "properties": { + "side": {"type": "string", "enum": ["BUY", "SELL"]}, + "order_type": {"type": "string", "enum": ["limit", "market"]}, + "price": {"type": "number", "minimum": 0, "maximum": 1}, + "size_usd": {"type": "number", "exclusiveMinimum": 0} + } + }, + "edge_estimate": {"type": "string"}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "liquidity_check": { + "type": "object", + "required": ["market_liquidity_usd", "depth_usd_at_price"], + "properties": { + "market_liquidity_usd": {"type": "number", "minimum": 0}, + "depth_usd_at_price": {"type": "number", "minimum": 0}, + "est_slippage": {"type": "number"} + } + }, + "risks": {"type": "array", "items": {"type": "string"}} + } +} +``` + +- [ ] **Step 2: Write the valid fixture** + +Create `tests/fixtures/opportunity.valid.json`: + +```json +{ + "strategy": "risk-free-arb", + "condition_id": "0xabc", + "slug": "will-x-happen", + "token_id": "12345", + "outcome": "yes", + "thesis": "YES+NO priced at 0.97; buying both locks 3% to resolution.", + "signal": {"sum_of_outcomes": 0.97}, + "proposed_action": {"side": "BUY", "order_type": "limit", "price": 0.48, "size_usd": 8}, + "edge_estimate": "3 cents / ~3%", + "confidence": 0.9, + "liquidity_check": {"market_liquidity_usd": 20000, "depth_usd_at_price": 50, "est_slippage": 0.001}, + "risks": ["resolution dispute"] +} +``` + +- [ ] **Step 3: Write conftest** + +Create `tests/conftest.py`: + +```python +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "assets")) +``` + +- [ ] **Step 4: Write the failing test** + +Create `tests/test_validate_opportunity.py`: + +```python +import json +from pathlib import Path + +import risk_gate + +FIXTURE = Path(__file__).parent / "fixtures" / "opportunity.valid.json" + + +def _valid(): + return json.loads(FIXTURE.read_text()) + + +def test_valid_opportunity_passes(): + assert risk_gate.validate_opportunity(_valid()) == [] + + +def test_missing_required_field_fails(): + obj = _valid() + del obj["confidence"] + errors = risk_gate.validate_opportunity(obj) + assert errors + assert any("confidence" in e for e in errors) + + +def test_bad_outcome_enum_fails(): + obj = _valid() + obj["outcome"] = "maybe" + assert risk_gate.validate_opportunity(obj) + + +def test_confidence_out_of_range_fails(): + obj = _valid() + obj["confidence"] = 1.5 + assert risk_gate.validate_opportunity(obj) +``` + +- [ ] **Step 5: Run test to verify it fails** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_validate_opportunity.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'risk_gate'`. + +- [ ] **Step 6: Write minimal implementation** + +Create `assets/risk_gate.py`: + +```python +"""Risk-gate decision core + Opportunity validation for the Polymarket scanner. + +decide() is stdlib-only so it runs with a plain `python3`. validate_opportunity() +imports jsonschema lazily (run it via `uv run --with jsonschema ...`). +""" +import json +from pathlib import Path + +_SCHEMA_PATH = Path(__file__).parent.parent / "reference" / "opportunity.schema.json" + + +def validate_opportunity(obj): + """Return a list of error strings; empty list means valid.""" + import jsonschema + + schema = json.loads(_SCHEMA_PATH.read_text()) + validator = jsonschema.Draft7Validator(schema) + errors = [] + for err in validator.iter_errors(obj): + loc = "/".join(str(p) for p in err.path) or "(root)" + errors.append(f"{loc}: {err.message}") + return errors +``` + +- [ ] **Step 7: Run test to verify it passes** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_validate_opportunity.py -v` +Expected: PASS (4 passed). + +- [ ] **Step 8: Commit** + +```bash +git add reference/opportunity.schema.json assets/risk_gate.py tests/conftest.py tests/fixtures/opportunity.valid.json tests/test_validate_opportunity.py +git commit -m "feat: add Opportunity schema and validator" +``` + +--- + +## Task 2: Config loader with conservative defaults + +**Files:** +- Modify: `assets/risk_gate.py` +- Create: `reference/config.example.json` +- Test: `tests/test_config.py` + +**Interfaces:** +- Consumes: `risk_gate` module from Task 1. +- Produces: `risk_gate.DEFAULTS: dict` and `risk_gate.load_config(path: str | None = None) -> dict`. `load_config` returns `DEFAULTS` merged with the JSON file at `path` (default `~/.config/polymarket/agent.json`); a missing file yields a copy of `DEFAULTS`; present keys override defaults. + +- [ ] **Step 1: Write the example config** + +Create `reference/config.example.json`: + +```json +{ + "max_notional_per_order_usd": 10, + "max_total_per_run_usd": 50, + "min_confidence_auto": 0.75, + "min_confidence_report": 0.5, + "min_liquidity_usd": 5000, + "min_depth_multiple": 2, + "max_book_take_pct": 25, + "auto_execute_strategies": ["risk-free-arb", "multi-outcome-arb"] +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `tests/test_config.py`: + +```python +import json + +import risk_gate + + +def test_missing_file_returns_defaults(tmp_path): + cfg = risk_gate.load_config(str(tmp_path / "nope.json")) + assert cfg == risk_gate.DEFAULTS + # must be a copy, not the same object + assert cfg is not risk_gate.DEFAULTS + + +def test_partial_file_merges_over_defaults(tmp_path): + p = tmp_path / "agent.json" + p.write_text(json.dumps({"max_total_per_run_usd": 30})) + cfg = risk_gate.load_config(str(p)) + assert cfg["max_total_per_run_usd"] == 30 + assert cfg["max_notional_per_order_usd"] == 10 # default preserved + + +def test_defaults_have_expected_values(): + d = risk_gate.DEFAULTS + assert d["max_notional_per_order_usd"] == 10 + assert d["max_total_per_run_usd"] == 50 + assert d["min_confidence_auto"] == 0.75 + assert d["min_confidence_report"] == 0.5 + assert d["min_liquidity_usd"] == 5000 + assert d["min_depth_multiple"] == 2 + assert d["max_book_take_pct"] == 25 + assert d["auto_execute_strategies"] == ["risk-free-arb", "multi-outcome-arb"] +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_config.py -v` +Expected: FAIL with `AttributeError: module 'risk_gate' has no attribute 'DEFAULTS'`. + +- [ ] **Step 4: Write minimal implementation** + +Add to `assets/risk_gate.py` (below the imports, above `validate_opportunity`): + +```python +import copy +import os + +DEFAULTS = { + "max_notional_per_order_usd": 10, + "max_total_per_run_usd": 50, + "min_confidence_auto": 0.75, + "min_confidence_report": 0.5, + "min_liquidity_usd": 5000, + "min_depth_multiple": 2, + "max_book_take_pct": 25, + "auto_execute_strategies": ["risk-free-arb", "multi-outcome-arb"], +} + +_DEFAULT_CONFIG_PATH = "~/.config/polymarket/agent.json" + + +def load_config(path=None): + """Return DEFAULTS merged with the JSON config file (file wins). Missing file -> DEFAULTS copy.""" + cfg = copy.deepcopy(DEFAULTS) + resolved = os.path.expanduser(path or _DEFAULT_CONFIG_PATH) + if os.path.isfile(resolved): + with open(resolved) as fh: + cfg.update(json.load(fh)) + return cfg +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_config.py -v` +Expected: PASS (3 passed). + +- [ ] **Step 6: Commit** + +```bash +git add assets/risk_gate.py reference/config.example.json tests/test_config.py +git commit -m "feat: add risk-gate config loader with conservative defaults" +``` + +--- + +## Task 3: Risk-gate decision function + +**Files:** +- Modify: `assets/risk_gate.py` +- Test: `tests/test_risk_gate.py` + +**Interfaces:** +- Consumes: `risk_gate.DEFAULTS`, `risk_gate.load_config` from Task 2. +- Produces: `risk_gate.decide(opportunity: dict, config: dict, run_total_usd: float) -> dict` returning `{"decision": "auto" | "escalate" | "skip", "reason": str}`. + +**Decision logic (exact order):** +1. `confidence < min_confidence_report` → **skip** ("confidence below report floor"). +2. `liquidity_check.market_liquidity_usd < min_liquidity_usd` → **skip** ("market liquidity below floor"). +3. `depth_usd_at_price < min_depth_multiple * size_usd` → **skip** ("insufficient book depth"). +4. `size_usd > max_notional_per_order_usd` → **skip** ("over per-order cap"). +5. `run_total_usd + size_usd > max_total_per_run_usd` → **skip** ("would breach per-run cap"). +6. `depth_usd_at_price > 0 and size_usd / depth_usd_at_price * 100 > max_book_take_pct` → **skip** ("takes too much resting depth"). +7. `strategy in auto_execute_strategies and confidence >= min_confidence_auto` → **auto** ("structural arb within caps"). +8. otherwise → **escalate** ("requires human confirmation"). + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_risk_gate.py`: + +```python +import risk_gate + + +def base_opp(): + return { + "strategy": "risk-free-arb", + "condition_id": "0x1", + "slug": "s", + "token_id": "t", + "outcome": "yes", + "thesis": "x", + "proposed_action": {"side": "BUY", "order_type": "limit", "price": 0.5, "size_usd": 8}, + "confidence": 0.9, + "liquidity_check": {"market_liquidity_usd": 20000, "depth_usd_at_price": 100}, + } + + +CFG = risk_gate.DEFAULTS + + +def test_structural_arb_within_caps_auto_executes(): + assert risk_gate.decide(base_opp(), CFG, 0)["decision"] == "auto" + + +def test_directional_strategy_always_escalates(): + opp = base_opp() + opp["strategy"] = "momentum" + assert risk_gate.decide(opp, CFG, 0)["decision"] == "escalate" + + +def test_structural_arb_low_confidence_escalates(): + opp = base_opp() + opp["confidence"] = 0.6 # >= report floor, < auto floor + assert risk_gate.decide(opp, CFG, 0)["decision"] == "escalate" + + +def test_below_report_floor_skips(): + opp = base_opp() + opp["confidence"] = 0.4 + d = risk_gate.decide(opp, CFG, 0) + assert d["decision"] == "skip" + assert "report floor" in d["reason"] + + +def test_low_market_liquidity_skips(): + opp = base_opp() + opp["liquidity_check"]["market_liquidity_usd"] = 1000 + assert risk_gate.decide(opp, CFG, 0)["decision"] == "skip" + + +def test_insufficient_depth_skips(): + opp = base_opp() + opp["liquidity_check"]["depth_usd_at_price"] = 10 # need >= 2*8=16 + assert risk_gate.decide(opp, CFG, 0)["decision"] == "skip" + + +def test_over_per_order_cap_skips(): + opp = base_opp() + opp["proposed_action"]["size_usd"] = 11 # cap 10 + opp["liquidity_check"]["depth_usd_at_price"] = 1000 + assert risk_gate.decide(opp, CFG, 0)["decision"] == "skip" + + +def test_over_run_total_skips(): + # size 8, run_total 45, cap 50 -> 53 > 50 + assert risk_gate.decide(base_opp(), CFG, 45)["decision"] == "skip" + + +def test_book_take_pct_skips(): + opp = base_opp() + opp["proposed_action"]["size_usd"] = 9 + opp["liquidity_check"]["depth_usd_at_price"] = 20 # 9/20=45% > 25%, depth ok (>=18) + d = risk_gate.decide(opp, CFG, 0) + assert d["decision"] == "skip" + assert "resting depth" in d["reason"] + + +def test_at_run_total_boundary_is_allowed(): + # size 8, run_total 42, cap 50 -> 50 not > 50 -> not skipped + assert risk_gate.decide(base_opp(), CFG, 42)["decision"] == "auto" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_risk_gate.py -v` +Expected: FAIL with `AttributeError: module 'risk_gate' has no attribute 'decide'`. + +- [ ] **Step 3: Write minimal implementation** + +Add to `assets/risk_gate.py`: + +```python +def decide(opportunity, config, run_total_usd): + """Return {"decision": "auto"|"escalate"|"skip", "reason": str}.""" + pa = opportunity["proposed_action"] + lc = opportunity["liquidity_check"] + size = pa["size_usd"] + conf = opportunity["confidence"] + strat = opportunity["strategy"] + mkt_liq = lc.get("market_liquidity_usd", 0) + depth = lc.get("depth_usd_at_price", 0) + + def result(decision, reason): + return {"decision": decision, "reason": reason} + + if conf < config["min_confidence_report"]: + return result("skip", "confidence below report floor") + if mkt_liq < config["min_liquidity_usd"]: + return result("skip", "market liquidity below floor") + if depth < config["min_depth_multiple"] * size: + return result("skip", "insufficient book depth at price") + if size > config["max_notional_per_order_usd"]: + return result("skip", "order notional over per-order cap") + if run_total_usd + size > config["max_total_per_run_usd"]: + return result("skip", "would breach per-run total cap") + if depth > 0 and (size / depth) * 100 > config["max_book_take_pct"]: + return result("skip", "order would take too much resting depth") + + if strat in config["auto_execute_strategies"] and conf >= config["min_confidence_auto"]: + return result("auto", "structural arb within caps and confident") + return result("escalate", "requires human confirmation") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_risk_gate.py -v` +Expected: PASS (10 passed). + +- [ ] **Step 5: Commit** + +```bash +git add assets/risk_gate.py tests/test_risk_gate.py +git commit -m "feat: add risk-gate decision logic with boundary tests" +``` + +--- + +## Task 4: risk_gate.py CLI wrapper + +**Files:** +- Modify: `assets/risk_gate.py` +- Test: `tests/test_risk_gate_cli.py` + +**Interfaces:** +- Consumes: `decide`, `validate_opportunity`, `load_config`. +- Produces: a CLI. `python3 assets/risk_gate.py decide --config --run-total ` reads an Opportunity JSON from stdin and prints `{"decision":…,"reason":…}`. `python3 assets/risk_gate.py validate` reads an Opportunity JSON from stdin and prints `{"errors":[…]}`. Exit code `0` always for `decide`; `validate` exits `1` if there are errors. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_risk_gate_cli.py`: + +```python +import json +import subprocess +import sys +from pathlib import Path + +SCRIPT = Path(__file__).parent.parent / "assets" / "risk_gate.py" +FIXTURE = Path(__file__).parent / "fixtures" / "opportunity.valid.json" + + +def run(args, stdin): + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + input=stdin, capture_output=True, text=True, + ) + + +def test_cli_decide_auto(): + r = run(["decide", "--run-total", "0"], FIXTURE.read_text()) + assert r.returncode == 0 + assert json.loads(r.stdout)["decision"] == "auto" + + +def test_cli_decide_over_cap_skips(): + opp = json.loads(FIXTURE.read_text()) + opp["proposed_action"]["size_usd"] = 999 + r = run(["decide", "--run-total", "0"], json.dumps(opp)) + assert json.loads(r.stdout)["decision"] == "skip" + + +def test_cli_validate_rejects_bad_object(): + r = run(["validate"], json.dumps({"strategy": "momentum"})) + assert r.returncode == 1 + assert json.loads(r.stdout)["errors"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_risk_gate_cli.py -v` +Expected: FAIL (the script has no `__main__` handling; `decide`/`validate` produce no output, assertions fail). + +- [ ] **Step 3: Write minimal implementation** + +Append to `assets/risk_gate.py`: + +```python +def _main(argv=None): + import argparse + import sys + + parser = argparse.ArgumentParser(description="Polymarket scanner risk gate") + sub = parser.add_subparsers(dest="cmd", required=True) + + d = sub.add_parser("decide") + d.add_argument("--config", default=None) + d.add_argument("--run-total", type=float, default=0.0) + + sub.add_parser("validate") + + args = parser.parse_args(argv) + obj = json.load(sys.stdin) + + if args.cmd == "decide": + cfg = load_config(args.config) + print(json.dumps(decide(obj, cfg, args.run_total))) + return 0 + + errors = validate_opportunity(obj) + print(json.dumps({"errors": errors})) + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(_main()) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_risk_gate_cli.py -v` +Expected: PASS (3 passed). + +- [ ] **Step 5: Run the full suite** + +Run: `uv run --with pytest --with jsonschema pytest tests/ -v` +Expected: PASS (all tests from Tasks 1–4). + +- [ ] **Step 6: Commit** + +```bash +git add assets/risk_gate.py tests/test_risk_gate_cli.py +git commit -m "feat: add risk_gate CLI (decide/validate over stdin)" +``` + +--- + +## Task 5: MCP transport helper + reference/mcp.md + +**Files:** +- Create: `assets/poly-mcp.sh` +- Create: `reference/mcp.md` +- Test: `tests/test_poly_mcp.py` + +**Interfaces:** +- Produces: `assets/poly-mcp.sh [json_args]` — prints the MCP tool's JSON text result to stdout; exit `1` with `{"error":…}` on failure. Reads URL + bearer token from `$POLY_MCP_CONFIG` (default `~/.claude.json`, key `mcpServers.polymarket`); env vars `POLY_MCP_URL` / `POLY_MCP_AUTH` override for testing. Never prints the token. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_poly_mcp.py`: + +```python +import os +import subprocess +from pathlib import Path + +SCRIPT = Path(__file__).parent.parent / "assets" / "poly-mcp.sh" + + +def test_usage_without_tool(): + r = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True) + assert r.returncode == 1 + assert "usage" in (r.stdout + r.stderr).lower() + + +def test_errors_without_token(tmp_path): + empty = tmp_path / "empty.json" + empty.write_text("{}") + env = {**os.environ, "POLY_MCP_CONFIG": str(empty), "POLY_MCP_URL": "", "POLY_MCP_AUTH": ""} + r = subprocess.run( + ["bash", str(SCRIPT), "screen_markets", "{}"], + env=env, capture_output=True, text=True, + ) + assert r.returncode == 1 + assert "no polymarket mcp token" in (r.stdout + r.stderr).lower() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_poly_mcp.py -v` +Expected: FAIL (script does not exist yet → non-zero but no matching message / file-not-found). + +- [ ] **Step 3: Write the helper** + +Create `assets/poly-mcp.sh`: + +```bash +#!/usr/bin/env bash +# Thin MCP-over-HTTP transport for the polymarket MCP server. +# Usage: poly-mcp.sh [json_args] +# Prints the tool's JSON text result to stdout. Never prints the bearer token. +set -euo pipefail + +TOOL="${1:-}" +ARGS="${2:-{}}" +CONFIG="${POLY_MCP_CONFIG:-$HOME/.claude.json}" + +if [ -z "$TOOL" ]; then + echo '{"error":"usage: poly-mcp.sh [json_args]"}' >&2 + exit 1 +fi + +URL="${POLY_MCP_URL:-}" +AUTH="${POLY_MCP_AUTH:-}" +if [ -z "$URL" ] || [ -z "$AUTH" ]; then + read -r URL AUTH < <(python3 - "$CONFIG" <<'PY' +import json, sys +try: + cfg = json.load(open(sys.argv[1])) + e = cfg["mcpServers"]["polymarket"] + print(e["url"], e["headers"]["Authorization"]) +except Exception: + print("", "") +PY +) +fi + +if [ -z "$URL" ] || [ -z "$AUTH" ]; then + echo '{"error":"no polymarket MCP token/url found in config"}' >&2 + exit 1 +fi + +ACC="Accept: application/json, text/event-stream" +CT="Content-Type: application/json" +AUTHH="Authorization: $AUTH" + +INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"poly-mcp.sh","version":"1"}}}' +SID=$(curl -fsS -D - -o /dev/null -X POST "$URL" -H "$AUTHH" -H "$CT" -H "$ACC" -d "$INIT" \ + | awk -F': ' 'tolower($1)=="mcp-session-id"{print $2}' | tr -d '\r') +if [ -z "$SID" ]; then + echo '{"error":"MCP initialize failed (no session id)"}' >&2 + exit 1 +fi +SIDH="Mcp-Session-Id: $SID" + +curl -fsS -o /dev/null -X POST "$URL" -H "$AUTHH" -H "$CT" -H "$ACC" -H "$SIDH" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' + +REQ=$(python3 - "$TOOL" "$ARGS" <<'PY' +import json, sys +print(json.dumps({"jsonrpc":"2.0","id":2,"method":"tools/call", + "params":{"name":sys.argv[1],"arguments":json.loads(sys.argv[2])}})) +PY +) +curl -fsS -X POST "$URL" -H "$AUTHH" -H "$CT" -H "$ACC" -H "$SIDH" -d "$REQ" \ + | sed -n 's/^data: //p' \ + | python3 - <<'PY' +import json, sys +raw = sys.stdin.read().strip() +if not raw: + print('{"error":"empty MCP response"}'); sys.exit(1) +d = json.loads(raw.splitlines()[-1]) +if "error" in d: + print(json.dumps({"error": d["error"]})); sys.exit(1) +for c in d.get("result", {}).get("content", []): + if c.get("type") == "text": + print(c["text"]) +PY +``` + +Then: `chmod +x assets/poly-mcp.sh`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_poly_mcp.py -v` +Expected: PASS (2 passed). + +- [ ] **Step 5: Live smoke test (manual, networked)** + +Run: `assets/poly-mcp.sh search_markets '{"query":"bitcoin","limit":2}'` +Expected: JSON with a `markets` array. If it errors, confirm the polymarket MCP entry exists in `~/.claude.json`. (This step is manual; do not add a networked assertion to the suite.) + +- [ ] **Step 6: Write reference/mcp.md** + +Create `reference/mcp.md`: + +```markdown +# Calling the Polymarket MCP + +The polymarket MCP server (`polymarket-data`) provides read-only market analytics: +`list_events`, `list_markets`, `get_market`, `search_markets`, `screen_markets`, +`get_price_history`, `get_trades`, `get_market_stats`, `get_order_book`, `get_order_book_depth`. + +## Use the transport helper + +Native `mcp__polymarket__*` tool calls may be blocked by the ECC plugin's +`mcp-health-check.js` hook (a false positive — see below). Always reach the server +through the helper, which works regardless of the hook: + + assets/poly-mcp.sh '' + # e.g. + assets/poly-mcp.sh screen_markets '{"sort_by":"volume_spike","interval":"24h","min_liquidity":20000,"min_volume_24h":100000,"limit":10}' + assets/poly-mcp.sh get_order_book_depth '{"token_id":"123...","notional":100}' + +The helper reads the URL + bearer token from `~/.claude.json` (`mcpServers.polymarket`) +and never prints the token. It prints the tool's JSON result to stdout, or +`{"error":…}` with exit 1 on failure. + +## Why native calls 406 (and the optional fix) + +The MCP endpoint is healthy. The health-check hook probes it with +`Accept: application/json` only; the server correctly requires +`Accept: application/json, text/event-stream` and returns HTTP 406, so the hook +wrongly marks the server unavailable and blocks the tools. + +Optional fix for interactive native calls: whitelist `polymarket` in the +health-check hook (or correct its `Accept` header). Not required — the helper is +the supported path for this skill. +``` + +- [ ] **Step 7: Commit** + +```bash +git add assets/poly-mcp.sh reference/mcp.md tests/test_poly_mcp.py +git commit -m "feat: add poly-mcp.sh MCP transport helper and mcp.md" +``` + +--- + +## Task 6: Six strategy specs + structural test + +**Files:** +- Create: `reference/strategies/momentum.md`, `mean-reversion.md`, `multi-outcome-arb.md`, `spread-capture.md`, `risk-free-arb.md`, `smart-money.md` +- Test: `tests/test_strategy_specs.py` + +**Interfaces:** +- Produces: six strategy spec files, each containing the required sections so the structural test passes. Each spec instructs a sub-agent to emit Opportunity objects matching `reference/opportunity.schema.json`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_strategy_specs.py`: + +```python +from pathlib import Path + +import pytest + +STRAT_DIR = Path(__file__).parent.parent / "reference" / "strategies" +NAMES = ["momentum", "mean-reversion", "multi-outcome-arb", "spread-capture", "risk-free-arb", "smart-money"] +SECTIONS = ["**Goal:**", "## Data to pull", "## Signal logic", "## Disqualifiers", "## Confidence rubric", "## Output mapping"] + + +@pytest.mark.parametrize("name", NAMES) +def test_strategy_spec_complete(name): + path = STRAT_DIR / f"{name}.md" + assert path.exists(), f"missing {path}" + text = path.read_text() + for section in SECTIONS: + assert section in text, f"{name}.md missing section: {section}" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_strategy_specs.py -v` +Expected: FAIL (6 failures — files missing). + +- [ ] **Step 3: Write `reference/strategies/momentum.md`** + +```markdown +# Momentum / news-repricing strategy + +**Goal:** Catch markets repricing hard on fresh information and ride the continuation +before the book fully adjusts. +**Auto-execute:** no — always escalate (directional). + +## Data to pull +- From the shared universe: candidates with high `price_change_pct` and `volume_ratio`. +- `assets/poly-mcp.sh get_market_stats '{"condition_id":"…","interval":"24h"}'` — buy/sell flow. +- `assets/poly-mcp.sh get_price_history '{"token_id":"…","interval":"1h"}'` — confirm a sustained move, not a single spike. +- `assets/poly-mcp.sh get_order_book_depth '{"token_id":"…","notional":100}'` — depth/slippage for sizing. + +## Signal logic +- Move is recent, large (`price_change_pct` well above the universe median), and backed by `volume_ratio > 3`. +- Net flow (`get_market_stats`) is directionally consistent with the move (buys lifting YES, etc.). +- Price action shows follow-through across the last several 1h candles, not a wick that reverted. + +## Disqualifiers +- Penny markets (`open_price < 0.05`) where a 1¢ tick reads as a huge % — exclude. +- Move already at an extreme (`yes_price > 0.95` or `< 0.05`) with little room left. +- Flow contradicts the price move (likely a squeeze/illiquid print). + +## Confidence rubric +- 0.8+: large move + `volume_ratio > 10` + consistent flow + multi-candle follow-through. +- 0.6–0.8: solid move and volume, mixed follow-through. +- < 0.6: thin or contradictory — drop. + +## Output mapping +- `proposed_action`: BUY the side the move favors, `order_type` "limit" at/just inside best ask (BUY) or best bid (SELL); `size_usd` ≤ config per-order cap. +- `signal`: `{ "price_change_pct", "volume_ratio", "net_flow", "candles_following" }`. +``` + +- [ ] **Step 4: Write `reference/strategies/mean-reversion.md`** + +```markdown +# Mean-reversion / overreaction strategy + +**Goal:** Fade sharp moves that lack informational backing — bet on reversion toward the prior level. +**Auto-execute:** no — always escalate (directional). + +## Data to pull +- From the shared universe: candidates with large `price_change_pct` but modest `volume_ratio`. +- `assets/poly-mcp.sh get_price_history '{"token_id":"…","interval":"1h"}'` — locate the pre-spike level. +- `assets/poly-mcp.sh get_trades '{"token_id":"…","limit":100}'` — is the move a few large prints or broad? +- `assets/poly-mcp.sh get_market_stats '{"condition_id":"…","interval":"24h"}'` — flow balance. + +## Signal logic +- Sharp price move on **thin** volume (`volume_ratio` near or below 1) or driven by a handful of prints. +- No corroborating sustained flow; the move looks like an air-pocket, not repricing. +- A clear prior level to revert toward in the price history. + +## Disqualifiers +- High `volume_ratio` with consistent flow (that is momentum, not overreaction). +- Markets near a resolution deadline where the move may be correct, late information. +- Illiquid markets where reversion can't be exited (`market_liquidity_usd` below floor). + +## Confidence rubric +- 0.8+: big move, `volume_ratio < 1`, move traced to 1–3 prints, clean prior level. +- 0.6–0.8: thin-ish move, plausible reversion. +- < 0.6: ambiguous — drop. + +## Output mapping +- `proposed_action`: trade **against** the move (BUY the side that dropped / SELL the side that spiked), `order_type` "limit" near the prior level; `size_usd` ≤ cap. +- `signal`: `{ "price_change_pct", "volume_ratio", "prior_level", "print_concentration" }`. +``` + +- [ ] **Step 5: Write `reference/strategies/multi-outcome-arb.md`** + +```markdown +# Multi-outcome arbitrage strategy + +**Goal:** In a mutually-exclusive multi-outcome event, exploit the YES prices summing to ≠ 100%. +**Auto-execute:** yes — structural arb (in the default allowlist). + +## Data to pull +- `assets/poly-mcp.sh list_markets '{"event_id":"…"}'` — all markets in the event. +- `assets/poly-mcp.sh get_order_book '{"token_id":"…"}'` for each outcome's YES — real fillable prices, not mid. +- `assets/poly-mcp.sh get_order_book_depth '{"token_id":"…","notional":100}'` — depth at the executable price. + +## Signal logic +- Sum of best-ask YES prices across all mutually-exclusive outcomes `< 1 − fees/slippage` → buy the basket (each YES) to lock a gain at resolution. +- Or sum of best-bid YES prices `> 1 + costs` → sell the basket. +- Only count it if each leg is **fillable** at the quoted price for the intended size. + +## Disqualifiers +- Outcomes not actually mutually exclusive / collectively exhaustive (read the event carefully). +- Edge smaller than estimated total slippage + any fees. +- Any leg too thin to fill (`depth_usd_at_price` below the gate's requirement). + +## Confidence rubric +- 0.9+: complete outcome set, all legs fillable, edge ≥ 2× estimated costs. +- 0.75–0.9: edge positive but thinner margin over costs. +- < 0.75: do not auto-execute (escalate) — edge too close to costs. + +## Output mapping +- Emit one Opportunity **per leg** (each YES leg to buy), sharing the basket thesis; the orchestrator gates each leg. +- `proposed_action`: BUY YES, `order_type` "limit" at the leg's best ask; `size_usd` matched across legs and ≤ cap. +- `signal`: `{ "sum_of_outcomes", "legs", "est_total_cost", "edge_after_cost" }`. +``` + +- [ ] **Step 6: Write `reference/strategies/spread-capture.md`** + +```markdown +# Spread-capture / liquidity-provision strategy + +**Goal:** On wide-spread but liquid markets, post passive limit orders inside the spread to capture it. +**Auto-execute:** no — always escalate (directional/inventory risk). + +## Data to pull +- From the shared universe: candidates surfaced by `screen_markets sort_by="spread"` with adequate `liquidity`. +- `assets/poly-mcp.sh get_order_book '{"token_id":"…"}'` — best bid/ask and resting sizes. +- `assets/poly-mcp.sh get_market_stats '{"condition_id":"…","interval":"24h"}'` — two-sided activity (will the post get filled?). + +## Signal logic +- `spread` materially wide (e.g. ≥ 3¢) with `market_liquidity_usd` above floor and steady two-sided trade_count. +- Room to post inside the spread and still leave edge after the expected adverse-selection cost. +- Not trending hard (a wide spread on a fast mover is adverse selection, not capture). + +## Disqualifiers +- Thin or one-sided flow (post won't fill, or fills only when wrong). +- Near resolution / strong momentum (adverse selection dominates). +- Spread already tight relative to tick size. + +## Confidence rubric +- 0.8+: wide stable spread, balanced two-sided flow, no trend. +- 0.6–0.8: workable but thinner or slightly trending. +- < 0.6: drop. + +## Output mapping +- `proposed_action`: `order_type` "limit" posted inside the spread (BUY just above best bid or SELL just below best ask); `size_usd` ≤ cap. +- `signal`: `{ "spread", "best_bid", "best_ask", "two_sided_trade_count" }`. +- Note in `risks`: requires later cancel/timeout management (out of scope for v1 auto-fire — escalate). +``` + +- [ ] **Step 7: Write `reference/strategies/risk-free-arb.md`** + +```markdown +# Risk-free / structural arbitrage strategy + +**Goal:** Lock guaranteed (or near-guaranteed) value from structural mispricings within a single market. +**Auto-execute:** yes — structural arb (in the default allowlist). + +## Data to pull +- `assets/poly-mcp.sh get_order_book '{"token_id":"…"}'` for both YES and NO tokens of the market. +- `assets/poly-mcp.sh get_order_book_depth '{"token_id":"…","notional":100}'` — fillable depth per leg. + +## Signal logic +- **YES+NO < 1:** best-ask(YES) + best-ask(NO) `< 1 − costs` → buy both; one resolves to 1, locking the difference. +- **Cross-market logical arb:** two markets whose outcomes are logically linked are priced inconsistently (e.g. "X by June" must be ≤ "X by Dec"). +- **negRisk redemption:** a complete negRisk set buyable below redemption value. +- Count only when every required leg is fillable at the quoted price for the size. + +## Disqualifiers +- Edge below estimated slippage + fees. +- Any leg unfillable at size (`depth_usd_at_price` below gate requirement). +- Hidden conditionality that breaks the "guaranteed" assumption (read resolution terms). + +## Confidence rubric +- 0.9+: single-market YES+NO with both legs fillable and edge ≥ 2× costs. +- 0.75–0.9: cross-market logical arb with a sound but not airtight link. +- < 0.75: escalate rather than auto-fire. + +## Output mapping +- Emit one Opportunity per leg; orchestrator gates each. +- `proposed_action`: BUY the underpriced leg(s), `order_type` "limit" at best ask; `size_usd` matched and ≤ cap. +- `signal`: `{ "type": "yes_no_sum|cross_market|negrisk", "sum_or_relation", "est_total_cost", "edge_after_cost" }`. +``` + +- [ ] **Step 8: Write `reference/strategies/smart-money.md`** + +```markdown +# Smart-money / informed-flow strategy + +**Goal:** Detect large, informed directional flow and follow it before price fully reflects it. +**Auto-execute:** no — always escalate (directional). + +## Data to pull +- From the shared universe: markets with elevated `volume_ratio`. +- `assets/poly-mcp.sh get_trades '{"token_id":"…","limit":200}'` — large prints, direction, counterparties if exposed. +- `assets/poly-mcp.sh get_market_stats '{"condition_id":"…","interval":"6h"}'` — net buy/sell flow. +- `poly -o json data positions
` and `poly -o json data value
` — profile a wallet behind notable flow (any address is readable). + +## Signal logic +- Concentrated large prints on one side (not retail-sized noise) with net flow confirming direction. +- Optional corroboration: the wallet driving it shows a sizeable / historically directional portfolio. +- Price hasn't yet fully moved to where the flow implies (room to follow). + +## Disqualifiers +- Flow is small / evenly two-sided (no signal). +- Price already gapped to the implied level (no edge left). +- Single wallet with no track record and no corroborating flow (could be noise or manipulation). + +## Confidence rubric +- 0.8+: large one-sided prints + confirming net flow + (optional) credible wallet, with room left. +- 0.6–0.8: decent flow signal, limited corroboration. +- < 0.6: drop. + +## Output mapping +- `proposed_action`: BUY the side the informed flow favors, `order_type` "limit" near best price; `size_usd` ≤ cap. +- `signal`: `{ "large_print_count", "net_flow", "wallet_address", "wallet_value_usd", "implied_vs_current_gap" }`. +``` + +- [ ] **Step 9: Run test to verify it passes** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_strategy_specs.py -v` +Expected: PASS (6 passed). + +- [ ] **Step 10: Commit** + +```bash +git add reference/strategies tests/test_strategy_specs.py +git commit -m "feat: add six strategy specs with structural completeness test" +``` + +--- + +## Task 7: Orchestrator playbook (reference/orchestration.md) + +**Files:** +- Create: `reference/orchestration.md` +- Test: `tests/test_orchestration_doc.py` + +**Interfaces:** +- Produces: the orchestrator playbook the main agent follows. References `assets/poly-mcp.sh` and `assets/risk_gate.py` by path, defines the scout scan, the fan-out protocol, synthesis/dedup, the gate-call contract, the execution protocol, and the output format. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_orchestration_doc.py`: + +```python +from pathlib import Path + +DOC = Path(__file__).parent.parent / "reference" / "orchestration.md" + + +def test_orchestration_covers_flow_and_tools(): + text = DOC.read_text() + for needle in [ + "poly-mcp.sh", "risk_gate.py", "Scout", "Fan-out", + "auto", "escalate", "skip", "--dry-run", "--yes", + ]: + assert needle in text, f"orchestration.md missing: {needle}" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_orchestration_doc.py -v` +Expected: FAIL (file missing). + +- [ ] **Step 3: Write `reference/orchestration.md`** + +```markdown +# Opportunity-scan orchestration playbook + +The main agent runs this on demand ("scan Polymarket for opportunities"). Sub-agents +only research and return data; **only the orchestrator places orders**, so the risk +gate and the per-run total are enforced in exactly one place. + +## 0. Preflight +- `poly -o json wallet show` — confirm a signer key is configured (needed to trade). +- `assets/poly-mcp.sh search_markets '{"query":"test","limit":1}'` — confirm MCP reachability. +- Load limits: read the config once with the defaults baked into `assets/risk_gate.py` + (`~/.config/polymarket/agent.json` if present). Apply any inline override the user gave + for this run (e.g. "only $30 total today"). + +## 1. Scout (one shared scan) +Build a candidate universe with four screens (drop penny noise with min filters): + + assets/poly-mcp.sh screen_markets '{"sort_by":"price_change","interval":"24h","min_liquidity":5000,"min_volume_24h":50000,"min_trade_count":100,"limit":25}' + assets/poly-mcp.sh screen_markets '{"sort_by":"volume_spike","interval":"24h","min_liquidity":20000,"min_volume_24h":100000,"min_trade_count":50,"limit":25}' + assets/poly-mcp.sh screen_markets '{"sort_by":"spread","interval":"24h","min_liquidity":10000,"limit":25}' + assets/poly-mcp.sh screen_markets '{"sort_by":"liquidity","interval":"24h","limit":25}' + +Merge + dedupe by `condition_id` into one universe (≈50–150 markets), each carrying the +fields from the screener (`yes_price`, `volume_24h`, `liquidity`, `spread`, `best_bid/ask`, +`price_change_pct`, `volume_ratio`, `open_price`, `close_price`, `token_id_yes/no`, `event_id`). + +## 2. Fan-out (six parallel sub-agents) +Dispatch six sub-agents **in parallel**, one per strategy in `reference/strategies/`. +Give each: the candidate universe, its strategy spec, the limits, and access to +`assets/poly-mcp.sh` + the `poly` CLI for read-only enrichment. The arb and smart-money +agents may do small targeted extra pulls beyond the universe (scout blind-spot coverage). +Each returns an array of Opportunity objects matching `reference/opportunity.schema.json`. + +## 3. Synthesize +- Validate each returned object: `python3 assets/risk_gate.py validate` (drop invalid ones, note them). +- Dedupe by `(condition_id, outcome)`; if two strategies surface the same one, keep the + higher `confidence` and record both strategy names. +- Rank by `confidence` (tie-break by `edge_estimate`). + +## 4. Risk gate (call the deterministic core per opportunity) +Track a running `run_total_usd`, starting at 0. For each ranked opportunity: + + echo '' | python3 assets/risk_gate.py decide --run-total + +The output is `{"decision":"auto"|"escalate"|"skip","reason":"…"}`: +- **skip** — record the reason; do nothing. +- **escalate** — add to the escalation list (present to the user; do not execute now). +- **auto** — execute (Step 5), then add the filled `size_usd` to `run_total_usd`. + +## 5. Execute (auto only — dry-run first, always) +For each `auto` opportunity, build the matching `poly` order from `proposed_action`: + + # 1) preview — never submits + poly -o json buy --token-id --usd --price --dry-run # limit example + # verify the dry-run preview: token_id, side, price, and ~notional match proposed_action + # 2) submit only if the preview matches + poly -o json buy --token-id --usd --price --yes + +Use SELL / `--market` / `--size` forms per `reference/commands.md` when the action calls +for them (market BUY spends `--usd`, market SELL delivers `--size`). If the preview does +**not** match, abort that order and move it to escalations. Re-check best price in the +preview; if the market moved beyond the proposed price, abort (stale-snapshot guard). + +## 6. Report +- **Ranked table:** rank · strategy · market · action · edge · confidence · liquidity · gate decision. +- **Executed:** each auto order's `order_id` + status from `{"result":"ACCEPTED order_id=… status=…"}`. +- **Escalations:** proposed orders awaiting the user's go, each with thesis + dry-run preview. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_orchestration_doc.py -v` +Expected: PASS (1 passed). + +- [ ] **Step 5: Commit** + +```bash +git add reference/orchestration.md tests/test_orchestration_doc.py +git commit -m "feat: add orchestrator playbook for opportunity scanning" +``` + +--- + +## Task 8: Wire into SKILL.md + README + final suite + +**Files:** +- Modify: `SKILL.md` +- Modify: `README.md` +- Test: `tests/test_skill.py` + +**Interfaces:** +- Consumes: all prior artifacts (links them from `SKILL.md`). +- Produces: the activation surface — `SKILL.md` gains an "Opportunity scanning (multi-agent)" section + trigger language so the skill fires on "scan/find opportunities", linking `reference/orchestration.md`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_skill.py`: + +```python +from pathlib import Path + +ROOT = Path(__file__).parent.parent + + +def test_skill_has_scanning_section(): + text = (ROOT / "SKILL.md").read_text() + assert "Opportunity scanning" in text + assert "reference/orchestration.md" in text + assert "scan" in text.lower() + + +def test_readme_mentions_scanning(): + text = (ROOT / "README.md").read_text() + assert "scan" in text.lower() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_skill.py -v` +Expected: FAIL (section/links not present yet). + +- [ ] **Step 3: Update the SKILL.md frontmatter description** + +In `SKILL.md`, replace the `description:` line so the trigger covers scanning. New line: + +```yaml +description: Query and trade on Polymarket prediction markets — search markets, check live odds/order books, view positions and balances, place and cancel orders, and run a multi-agent opportunity scan that hunts mispricings and arbitrage across strategies. Use when the user mentions Polymarket, prediction-market odds, betting on an event/election/crypto outcome, wants to check or trade a market's probability, or asks to scan/find Polymarket opportunities. +``` + +- [ ] **Step 4: Add the scanning section to SKILL.md** + +Append this section to `SKILL.md` (after the "Recipes" section): + +```markdown +## Opportunity scanning (multi-agent) + +When the user asks to **scan / find opportunities** (mispricings, arbitrage, movers worth +trading), run the orchestration playbook in [reference/orchestration.md](reference/orchestration.md). +In one on-demand pass it: scouts a shared candidate universe via the MCP, fans out six +parallel strategy sub-agents (momentum, mean-reversion, multi-outcome-arb, spread-capture, +risk-free-arb, smart-money — see [reference/strategies/](reference/strategies/)), synthesizes +and ranks their Opportunity objects, then runs each through the deterministic risk gate +(`assets/risk_gate.py`). + +**Semi-auto execution.** The risk gate decides per opportunity: **auto-execute** (only the +structural arbs — `risk-free-arb`, `multi-outcome-arb` — when confident and within limits), +**escalate** (everything else, incl. all directional strategies → ask the user), or **skip**. +Auto-executed orders always run `--dry-run` and a preview match before `--yes`. Hard limits +live in `~/.config/polymarket/agent.json` (see [reference/config.example.json](reference/config.example.json)); +conservative defaults apply if absent, and the user may override limits inline for a run. + +**MCP access.** Reach the polymarket MCP through [assets/poly-mcp.sh](assets/poly-mcp.sh) +(see [reference/mcp.md](reference/mcp.md)); native `mcp__polymarket__*` calls may be blocked +by a health-check hook false positive. +``` + +- [ ] **Step 5: Add a README line** + +In `README.md`, add a row to the "Talk to it" table (after the existing rows): + +```markdown +| *"Scan Polymarket for opportunities."* | runs the multi-agent scan → ranked opportunities, auto-fires structural arbs within limits, escalates the rest | +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `uv run --with pytest --with jsonschema pytest tests/test_skill.py -v` +Expected: PASS (2 passed). + +- [ ] **Step 7: Run the full suite** + +Run: `uv run --with pytest --with jsonschema pytest tests/ -v` +Expected: PASS (all tests across Tasks 1–8). + +- [ ] **Step 8: Commit** + +```bash +git add SKILL.md README.md tests/test_skill.py +git commit -m "feat: wire multi-agent opportunity scan into SKILL.md and README" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Scout-then-specialists flow → Task 7 (orchestration.md §1–2). ✓ +- Six strategy specs → Task 6. ✓ +- Opportunity schema/data contract → Task 1. ✓ +- Risk gate (config + decision + boundaries) → Tasks 2–4, called from Task 7 §4. ✓ +- Auto-execute allowlist + dry-run-before-live → Task 3 logic + Task 7 §5 + Global Constraints. ✓ +- Config file + defaults + inline override → Task 2 + Task 7 §0. ✓ +- MCP transport helper + hook explanation → Task 5. ✓ +- Output format (ranked table / executed / escalations) → Task 7 §6. ✓ +- SKILL.md activation + triggers → Task 8. ✓ +- Testing (gate unit tests, schema validation, execution-safety wording) → Tasks 1,3,4 + Global Constraints. ✓ +- Non-goals (no scheduling/state/notifications in v1) → not built. ✓ + +**Placeholder scan:** No "TBD/TODO"; every code and doc step contains full content. The `…` +inside doc examples are illustrative placeholders *within generated documentation* (e.g. +`"condition_id":"…"`), not plan gaps. + +**Type consistency:** `validate_opportunity(obj)->list[str]`, `load_config(path=None)->dict`, +`DEFAULTS` keys, and `decide(opportunity, config, run_total_usd)->{"decision","reason"}` are +used identically in Tasks 1–4, the CLI (Task 4), and the orchestration calls (Task 7). Config +key `min_depth_multiple` is used consistently (defined Task 2, applied Task 3). Strategy +`name` list matches between the schema enum (Task 1), the specs (Task 6), and the structural +test (Task 6). + +**Deviation flagged:** `assets/risk_gate.py` is a second helper beyond the spec's "one thin +helper" framing, required to make the gate testable per the spec's Testing section. Surfaced +in Global Constraints for review. diff --git a/docs/superpowers/specs/2026-06-28-polymarket-multi-agent-scanner-design.md b/docs/superpowers/specs/2026-06-28-polymarket-multi-agent-scanner-design.md new file mode 100644 index 0000000..716fad0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-polymarket-multi-agent-scanner-design.md @@ -0,0 +1,199 @@ +# Polymarket Multi-Agent Opportunity Scanner — Design + +**Date:** 2026-06-28 +**Status:** Approved design (pre-implementation) +**Project:** polymarket-skill + +## Summary + +Extend the existing instruction-only `polymarket` skill so a single on-demand request +("scan Polymarket for opportunities") drives a **scout pass** plus **six parallel strategy +sub-agents** over the two existing tool surfaces (polymarket MCP for read/analytics, `poly` +CLI for trade/account). Sub-agents return uniform `Opportunity` objects; the orchestrator +deduplicates, ranks, and runs each through a **risk gate** that either auto-executes a real +order within hard limits, escalates it for human confirmation, or skips it. + +The skill stays instruction-only except for one **thin transport helper** (`assets/poly-mcp.sh`) +that performs the MCP-over-HTTP handshake. No strategy logic lives in code — strategies are +specs the sub-agents follow. + +## Goals + +- Turn the two tool surfaces into an orchestrated opportunity hunt across six strategy lenses. +- Keep the skill thin: instructions + specs + config + one transport helper. +- Semi-autonomous execution: deterministic structural arbs may auto-fire within hard limits; + judgment-heavy directional trades escalate for human confirmation. +- Safe by default: conservative limits, dry-run-before-live, depth/liquidity checks. + +## Non-Goals (v1 — explicitly deferred) + +- Scheduling / recurring loops (use `/schedule` or `/loop` later). +- Cross-run state, dedup memory, or new-opportunity notifications. +- Backtesting, PnL attribution, strategy performance tracking. +- Auto-firing directional strategies (momentum, mean-reversion, spread-capture, smart-money). + +## Decisions (locked during brainstorming) + +| Decision | Choice | +|---|---| +| System boundary | **Semi-auto execution** — real orders within hard limits; escalate over-limit / low-confidence | +| Strategy set | 6 archetypes (below) | +| Orchestration | **Pure instruction** — SKILL.md drives sub-agents; no new runtime | +| Data flow | **Scout-then-specialists** — one shared scan → fan out to specialists | +| Limit config | **Config file + conservative defaults + inline override** | +| Run cadence | **On-demand one-shot** (v1) | +| MCP transport | **Thin helper** `assets/poly-mcp.sh` (curl handshake) | +| Auto-execute allowlist | **Only structural arbs** (`risk-free-arb`, `multi-outcome-arb`); 4 directional strategies escalate | + +## Architecture — orchestration flow + +``` +[0] Preflight → load config; verify poly key (wallet show) + MCP reachability (poly-mcp.sh init) +[1] Scout → ONE shared scan: screen_markets ×4 (price_change / volume_spike / spread / + liquidity) + list_events → candidate universe (≈50–150 markets with stats) +[2] Fan-out → dispatch 6 strategy sub-agents IN PARALLEL, each receives + { universe, its strategy spec, tool access, config limits } +[3] Collect → each sub-agent returns Opportunity[] +[4] Synthesize → orchestrator dedups (by condition_id+outcome) + scores + ranks → ledger +[5] Risk gate → per opportunity: auto-execute | escalate | skip +[6] Execute → poly --dry-run preview → verify preview matches → poly --yes (within limits only) +[7] Report → ranked table + theses + execution outcomes + escalations awaiting confirmation +``` + +The orchestrator is the main agent following `reference/orchestration.md`. Sub-agents are +dispatched with the Agent tool; each runs read-only discovery/enrichment and returns data +(no sub-agent places orders — execution is centralized in the orchestrator so the risk gate +and per-run total are enforced in one place). + +## Components + +All artifacts are instructions/specs/config except the one transport helper. + +| Artifact | Role | +|---|---| +| `SKILL.md` (new section) | "Opportunity scanning (multi-agent)" section + triggers ("find/scan opportunities") | +| `reference/orchestration.md` | Scout spec, fan-out protocol, synthesis/dedup/scoring, risk gate, output format | +| `reference/strategies/momentum.md` | News/repricing continuation | +| `reference/strategies/mean-reversion.md` | Overreaction fade on thin-info spikes | +| `reference/strategies/multi-outcome-arb.md` | Mutually-exclusive outcomes summing ≠ 100% | +| `reference/strategies/spread-capture.md` | Wide spread + liquidity → passive limit orders | +| `reference/strategies/risk-free-arb.md` | YES+NO < 1, cross-market logical arb, negRisk redemption | +| `reference/strategies/smart-money.md` | Follow informed directional flow (trades, market_stats, address positions) | +| `reference/mcp.md` | How to call the MCP + health-check hook fix | +| `assets/poly-mcp.sh` | Thin MCP-over-HTTP transport helper (handshake → session → tools/call). **No strategy logic.** | +| `~/.config/polymarket/agent.json` | Limits + scan params; conservative defaults if absent | + +## Data contracts + +### Candidate universe item (scout output) + +From `screen_markets`: `condition_id`, `slug`, `question`, `event_id`, `token_id_yes`, +`token_id_no`, `yes_price`, `volume_24h`, `liquidity`, `spread`, `best_bid`, `best_ask`, +`trade_count`, `price_change_pct`, `volume_ratio`, `open_price`, `close_price`. + +### Opportunity object (every strategy sub-agent returns) + +Uniform schema so the orchestrator can rank and the gate can decide: + +```jsonc +{ + "strategy": "momentum", + "condition_id": "0x…", + "slug": "…", + "token_id": "…", + "outcome": "yes", // yes | no + "thesis": "", + "signal": { /* strategy-specific metrics, e.g. volume_ratio, sum_of_outcomes */ }, + "proposed_action": { + "side": "BUY", // BUY | SELL + "order_type": "limit", // limit | market + "price": 0.62, // required for limit + "size_usd": 8 + }, + "edge_estimate": "", + "confidence": 0.0, // 0.0–1.0, per the strategy's confidence rubric + "liquidity_check": { "depth_usd_at_price": 0, "est_slippage": 0 }, + "risks": ["…"] +} +``` + +Each strategy spec defines: goal · which MCP/CLI data to pull · the signal logic · disqualifiers · +the `signal` fields it populates · a confidence rubric. + +## Risk gate (semi-auto core) + +### Config (`~/.config/polymarket/agent.json`) with conservative defaults + +| Key | Default | Meaning | +|---|---|---| +| `max_notional_per_order_usd` | 10 | Per-order cap | +| `max_total_per_run_usd` | 50 | Cumulative cap across one scan run | +| `min_confidence_auto` | 0.75 | Below → escalate (still reported) | +| `min_confidence_report` | 0.5 | Below → dropped as noise (not reported) | +| `min_liquidity_usd` | 5000 | Market-level liquidity floor | +| `min_depth_usd_at_price` | 2× order size | Book depth at the order price | +| `max_book_take_pct` | 25 | Max % of resting depth a single order may take | +| `auto_execute_strategies` | `["risk-free-arb", "multi-outcome-arb"]` | Allowlist for auto-fire | + +Inline overrides (natural language, e.g. "only $30 total today") take precedence for that run. + +### Decision per opportunity + +- **skip** — confidence < `min_confidence_report` (dropped as noise), OR liquidity/depth fails, + OR notional > `max_notional_per_order_usd`, OR running total would breach `max_total_per_run_usd`, + OR order would take > `max_book_take_pct` of depth. (Opportunities with + `min_confidence_report ≤ confidence < min_confidence_auto` are reported and escalated, not skipped.) +- **auto-execute** — `strategy ∈ auto_execute_strategies` AND `confidence ≥ min_confidence_auto` + AND within all caps → `poly -o json … --dry-run` → assert preview (token, side, price, ~notional, + wallet) matches `proposed_action` → re-run with `--yes`. Add filled notional to the run total. +- **escalate** — everything else (all directional strategies; any structural arb that fails an + auto condition) → present the proposed order to the user and wait for explicit confirmation. + +Execution is centralized in the orchestrator (not the sub-agents) so the per-run total and the +gate are enforced in exactly one place. + +## Output (v1: chat) + +1. **Ranked table:** `rank · strategy · market · action · edge · confidence · liquidity · gate decision`. +2. **Executed orders:** for each auto-executed opportunity, `order_id` + status from the + `{"result": "ACCEPTED order_id=… status=…"}` payload. +3. **Escalations:** proposed orders awaiting the user's go, each with thesis + preview. + +No persisted run file in v1. + +## MCP prerequisite (must address first) + +Native `mcp__polymarket__*` tools are blocked by the ECC plugin's `mcp-health-check.js` +**false-positive**: the hook probes the endpoint with `Accept: application/json` only, the server +correctly rejects with HTTP 406 ("must accept both application/json and text/event-stream"), and +the hook declares the (healthy) server unavailable and blocks all calls. Verified: with the +correct `Accept` header the server returns 200 (`serverInfo: polymarket-data v1.28.1`). + +Two-part handling: +1. **Transport (required):** `assets/poly-mcp.sh` performs the full handshake (initialize → + capture `Mcp-Session-Id` → `notifications/initialized` → `tools/call`) with correct headers, + reading the bearer token from the configured MCP entry. Sub-agents and the scout call MCP + through this helper, so the system works regardless of the hook. +2. **Native fix (optional, documented):** whitelist `polymarket` in the health-check, or fix its + `Accept` header, so interactive native `mcp__polymarket__*` calls also work. Documented in + `reference/mcp.md`; not required for the scanner to function. + +## Testing + +- **Scout:** `poly-mcp.sh` returns parseable JSON; scout assembles a well-formed universe. +- **Strategy specs:** run each against a captured universe fixture → assert valid `Opportunity` + objects (schema + required fields). +- **Risk gate (the money-guarding piece — real test):** feed synthetic opportunities + a config + to the gate logic, assert auto / escalate / skip decisions across boundary cases (at cap, over + cap, just-below confidence, depth fail, non-allowlisted strategy). +- **Execution safety:** assert every auto-execute path runs `--dry-run` and a preview match + before `--yes`. + +## Risks & mitigations + +- **Scout blind spot** (a market no screener surfaced) → arb and smart-money agents get a small + targeted-pull allowance beyond the shared universe. +- **Stale snapshot between scout and execution** → re-check order book / best price at execution + time in the dry-run preview; reject if it moved beyond tolerance. +- **MCP token handling** → helper reads the token from config; never echoes it to logs/chat. +- **Over-trading** → centralized per-run total cap + per-order cap + depth-take cap. diff --git a/reference/artifacts.md b/reference/artifacts.md new file mode 100644 index 0000000..84155c6 --- /dev/null +++ b/reference/artifacts.md @@ -0,0 +1,204 @@ +# Artifacts — the dashboard template + +How to turn live Polymarket data into the visual **dashboard artifact** +([`assets/dashboard-template.html`](../assets/dashboard-template.html)). + +The artifact is **sandboxed HTML**: it cannot run `poly`, cannot call the MCP, and cannot make network +requests. So you fetch the data here, normalize it into one `DATA` object, **inject** it into the +template, and emit the result. The dashboard is a **frozen snapshot** — there is no live refresh. + +--- + +## Procedure + +1. **Fetch** only what the user asked to see (skip sections you don't need): + - **Markets** — `poly -o json markets get/search` for `question/slug/condition_id/yes_token_id/yes_price`. + Enrich each with the polymarket MCP (optional but recommended): + `get_order_book_depth(token_id=)` → `depth`, + `get_price_history(token_id=)` → `candles` (map each candle's + `open/high/low/close/volume` → `{o,h,l,c,v}`; renders as candlesticks + a volume strip), + `get_market_stats(condition_id=…)` → `volume_24h`, `net_flow`. + For the explorer (category chips, search, sort, time-left badges, click-through detail + modal), also fill the optional `category` / `end_date` / `liquidity` / `volume_total` / + `description` per market. The Markets tab filters/sorts entirely client-side on the snapshot. + - **Recommendations** — your own analysis. Fill each with `outcome`, `action`, `target_price`, + `confidence`, a cited `rationale`, and `signals[]`. No single data source. + - **Account** — gather only when the wallet is set up (check `wallet show` first): `clob balance + --asset-type collateral`, `data value`, `data positions`, `clob orders`, `clob trades`, `wallet show`. + **No key / not set up → set `account: null`**; the Account tab then renders wallet-setup steps, and the + markets/recommendations tabs still work with no wallet. +2. **Build** the `DATA` object (schema below). Keep every number as the **Decimal string** the source + returns (`"0.67"`, not `0.67`). Stamp `meta.generated_at` with the current UTC ISO timestamp. +3. **Inject** — read `assets/dashboard-template.html`, replace **only** the block between + `POLYMARKET_DATA_START` and `POLYMARKET_DATA_END` with your `const DATA = {…};`, and emit the whole + file as the artifact. Change nothing else. +4. **Caveat** the user: the dashboard is frozen at `generated_at`; ask them to say "refresh my + Polymarket dashboard" (or click ↻ regenerate) to rebuild with fresh data. + +**Empty-state rule:** any list you don't fill → `[]`; any object you skip → `null`. Never omit a key — +the template renders an explicit empty state ("No order book"; a null `account` → wallet-setup steps) instead of breaking. + +--- + +## DATA schema + +```js +const DATA = { + meta: { generated_at: "", wallet_label: "deposit 0x…", currency: "USDC" }, + + markets: [{ // Tab A — one entry per market to surface + question, slug, condition_id, yes_token_id, no_token_id, + url, // optional — "Trade on Polymarket" + modal "View on Polymarket"; falls back to /event/ + category, // optional — "Sports"|"Politics"|"Crypto"|"Tech"|"Finance"|"Weather"|"Other" → filter chips + tag + end_date, // optional — ISO date; powers the time-left badge ("6mo left"/"Ended") + "Ending Soon" sort + liquidity, // optional — stats strip + sort + modal + volume_total, // optional — modal "Total Volume" + "Total Volume" sort (falls back to volume_24h) + description, // optional — resolution text shown in the click-through detail modal + trending, // optional — true if merged from the 24h-popularity list (build_data.py); tags the card, and lacking a real category gets a "🔥 Trending" filter chip + yes_price, // "0.67" (probability = ×100) + volume_24h, net_flow, // optional, MCP get_market_stats ("" or omit→hidden) + depth: { bids:[[price,size,cum]], asks:[[price,size,cum]] }, // MCP get_order_book_depth, ~6 levels/side; [] if none + candles: [{o,h,l,c,v}, …] // MCP get_price_history OHLCV, oldest→newest, ~20-40 bars; [] if none + // (fallback: sparkline: ["0.61","0.62", …] — closes only, renders a line if you have no OHLC) + }], + + recommendations: [{ // Tab B — your analysis / scanner output + question, slug, + url, // optional — "View on Polymarket" link on the card (real event slug) + outcome, // "YES" | "NO" + action, // "BUY" | "SELL" | "HOLD" | "WATCH" (drives badge color) + target_price, // "0.67" + confidence, // "high" | "medium" | "low" (badge + meter color) + confidence_score, // "0.78" (0–1, fills the meter) + rationale, // one or two sentences + signals: ["net_flow +45k/24h", "spread 2¢"], // monospace chips + // optional — from an opportunity-scan run (see build_data.py): + status, // "executed" | "pending" | "skipped" → status pill + strategy, // e.g. "risk-free-arb" → meta line + size_usd, // proposed order size → meta line + edge, // e.g. "~3%" → meta line + order_id // filled order id (when status="executed") → footer + }], + + account: { // Tab C — poly CLI; set the whole object null if no key + wallet: { signer_eoa, deposit_wallet, note }, // wallet show (api_wallet → deposit_wallet) + balance: { cash, raw }, // clob balance --asset-type collateral + value: { total, user }, // map from `data value` → [{user, value}]: total = [0].value + positions: [{ title, outcome, size, avg_price, cur_price, current_value, cash_pnl, percent_pnl }], // data positions + orders: [{ id, side, outcome, price, original_size, size_matched, status }], // clob orders + trades: [{ matched_at, side, outcome, price, size, status }] // clob trades + } +}; +``` + +### Source map + +| DATA path | Origin | +|---|---| +| `markets[].{question,slug,condition_id,yes_token_id,no_token_id,yes_price}` | `poly -o json markets get/search` **or** MCP `search_markets`/`get_market` | +| `markets[].url` | the market's Polymarket page → `https://polymarket.com/event/` (powers the "Trade on Polymarket" button; optional — template builds `/event/` if omitted, and only renders real `polymarket.com` links) | +| `markets[].{volume_24h,net_flow}` | MCP `get_market_stats(condition_id)` | +| `markets[].depth` | MCP `get_order_book_depth(token_id=yes_token_id)` — `[price,size,cumulative]`, trim ~6 levels/side | +| `markets[].candles` | MCP `get_price_history(token_id=yes_token_id)` → `{o,h,l,c,v}` per candle (open/high/low/close/volume) | +| `markets[].category` | classify the market (or read the event tag); drives the category filter chips + the on-card tag | +| `markets[].end_date` | the market's resolution/close date (ISO); drives the time-left badge and the "Ending Soon" sort | +| `markets[].liquidity` | MCP `get_market` / `get_market_stats` (or Gamma) — total book liquidity | +| `markets[].volume_total` | lifetime volume (Gamma `volume` / `get_market`); modal "Total Volume" + sort | +| `markets[].description` | the market's resolution criteria text (Gamma `description`); shown in the detail modal | +| `markets[].trending` | the 24h-popularity merge in `build_data.py` (rows from `run.json.trending`, ranked by 24h volume) | +| `meta.stats` (optional) | `{total_volume, volume_24h, liquidity, active}` to override the stats strip; omit and the template sums them from `markets[]` | +| `recommendations[]` | your analysis, **or** an opportunity-scan run mapped by `assets/build_data.py` (see below) | +| `account.wallet` | `poly -o json wallet show` (`api_wallet` → `deposit_wallet`) | +| `account.balance` | `poly -o json clob balance --asset-type collateral` (`balance` → `cash`) | +| `account.value.total` | `poly -o json data value` → **returns `[{user, value}]`** (a list, field `value`), so `total = result[0].value`. The template also falls back to summing `positions[].current_value` if `total` is missing/0. | +| `account.positions` / `orders` / `trades` | `poly -o json data positions` / `clob orders` / `clob trades` (pass through) | + +--- + +## From an opportunity-scan run (orchestration → artifact) + +When the artifact is the visual output of the [orchestration playbook](orchestration.md), +**don't hand-build `DATA`** — let `assets/build_data.py` map the run deterministically. It takes +the scan's universe + gated opportunities + per-market enrichment (+ account) and emits the filled +HTML: + +```bash +python3 assets/build_data.py --inject assets/dashboard-template.html < run.json > dashboard.html +``` + +`run.json` (you assemble it from the run): + +```jsonc +{ + "generated_at": "", "wallet_label": "deposit 0x…", "top_n": 24, "trending_n": 12, + "universe": [ /* the merged scout universe rows (Step 1) */ ], + "trending": [ /* a separate broad pull ranked by 24h volume (popularity, NOT the anomaly screens); same row shape as universe */ ], + "opportunities": [ /* validated opportunities, each with the gate result attached: + "gate": {"decision":"auto"|"escalate"|"skip", "order_id": ""} */ ], + "enrichment": { "": { "url", "category", "end_date", "description", + "volume_total", "net_flow", "depth", "candles" } }, + "account": { /* artifacts.md Account shape, or null */ } +} +``` + +What the mapper does: +- **Curated subset** — `markets[]` = every recommended market **+** the top-`top_n` universe rows by + 24h volume (deduped). Enrich only this subset with `candles`/`depth`/etc. via the MCP — never all + 50–150, or the payload explodes. +- **Trending merge** — `markets[]` is then enriched with the top-`trending_n` of the `trending` list + (a broad, by-24h-volume pull — popularity, *not* the anomaly screens), deduped by `condition_id` and + tagged `trending: true`. A hot market with no real `category` gets `category: "🔥 Trending"` so the + template's category chip/tag surfaces it (no template change). `screen_markets` has no raw-volume + sort, so pull a broad set (e.g. `sort_by="liquidity"`, high `limit`) and rank it by `volume_24h`. +- **Opportunity → recommendation** mapping: + + | Opportunity | → recommendation | rule | + |---|---|---| + | `gate.decision` auto/escalate/skip | `status` executed/pending/skipped | status pill | + | `proposed_action.side` | `action` | `WATCH` if skipped, else the side | + | `proposed_action.price` / `size_usd` | `target_price` / `size_usd` | passthrough | + | `confidence` (0–1) | `confidence_score` + `confidence` band | ≥0.75 high · ≥0.5 medium · else low | + | `thesis` | `rationale` | passthrough | + | `strategy`, `edge_estimate` | `strategy`, `edge` | passthrough | + | `gate.order_id` | `order_id` | shown when executed | + | `liquidity_check` + `signal` + `risks` | `signals[]` | composed chips | + | — (joined from universe by `condition_id`) | `question` | Opportunity has no title | + | `enrichment[cond].url` | `url` | **real event slug** so the link resolves | + +- **`meta.stats`** is summed from the **full** universe (not the injected subset), so the stats strip + reflects everything scanned. + +**You still must fetch the enrichment** (event slug + category/end_date/description from Gamma; `depth` ++ `candles` from the MCP) for the curated subset and pass it in — the mapper is pure and does no I/O. + +--- + +## Risks & caveats + +- **Snapshot staleness** — odds and balances are frozen at `generated_at`; there is no in-artifact live + refresh. Always tell the user, and regenerate on request. +- **Sandbox can't fetch** — if you forget to inject `DATA`, the dashboard shows a "No data injected" + banner. Always replace the placeholder block. +- **Account needs a key** — without a configured, funded, activated wallet the Account tab renders + wallet-setup steps instead of balances. Never echo the private key; if there's no key, set + `account: null` (markets/recs still render). +- **Use the MARKET token id** — `depth`/`candles` are keyed by the market's `yes_token_id`, not an + *event* id (see the "event slug ≠ market slug" note in [../SKILL.md](../SKILL.md)). +- **Trade link** — set `url` to the real Polymarket page `https://polymarket.com/event/`, + using the canonical **event slug** (e.g. Gamma API `https://gamma-api.polymarket.com/events…` → + `events[].slug`, or the market's share URL). A *market* slug or an invented slug **404s** — the + template validates the domain (`polymarket.com`) but can't check the slug exists, so a wrong slug + still renders a button that leads nowhere. If you can't get the real event slug, omit both `url` and + `slug` so no broken link shows (the card/modal then say "no Polymarket link"). +- **Payload size** — trim `depth` to ~6 levels/side and `candles` to ~20–40 bars so the injected + `DATA` stays small and renders fast. +- **Keep Decimal strings** — inject the strings the sources emit; the template `parseFloat`s at render. + Don't pre-round or convert to numbers, or you lose source precision. + +## Verify a build + +Open the filled file in a browser (or the launch preview). Check: each tab renders; Markets cards show +candlesticks + a volume strip + depth ladder (and a clean empty state for a market with no book or no +candles); Account tables populate and +**sort** on header click without disturbing other tabs; the browser console shows **zero** errors and +**zero** network requests. The unfilled template ships with sample data so it always renders standalone. diff --git a/reference/config.example.json b/reference/config.example.json new file mode 100644 index 0000000..4a1464b --- /dev/null +++ b/reference/config.example.json @@ -0,0 +1,10 @@ +{ + "max_notional_per_order_usd": 10, + "max_total_per_run_usd": 50, + "min_confidence_auto": 0.75, + "min_confidence_report": 0.5, + "min_liquidity_usd": 5000, + "min_depth_multiple": 2, + "max_book_take_pct": 25, + "auto_execute_strategies": ["risk-free-arb", "multi-outcome-arb"] +} diff --git a/reference/config.md b/reference/config.md new file mode 100644 index 0000000..67b5a3a --- /dev/null +++ b/reference/config.md @@ -0,0 +1,94 @@ +# Scanner configuration (`agent.json`) + +The opportunity scanner's risk gate reads its hard limits from a JSON config file. Tuning these +controls how much the agent may trade and when it auto-executes vs. asks you. + +## Where it lives + +``` +~/.config/polymarket/agent.json +``` + +The file is **optional**. If it's absent, the gate uses the conservative defaults baked into +`assets/risk_gate.py` (shown below). If present, your values override the defaults; you only need to +include the keys you want to change — omitted keys keep their default. + +## Set it up + +```bash +mkdir -p ~/.config/polymarket +cp reference/config.example.json ~/.config/polymarket/agent.json +# then edit the values +``` + +To go back to the conservative defaults at any time, just delete the file: + +```bash +rm ~/.config/polymarket/agent.json +``` + +You can also override limits **inline for a single run** — e.g. tell the agent "scan, but only $30 +total today" — without editing the file. + +## Keys + +| Key | Default | Meaning | Effect of changing it | +|---|---|---|---| +| `max_notional_per_order_usd` | `10` | Max USD a single auto-executed order may spend. | ↑ bigger individual bets; ↓ smaller. Must be > 0. | +| `max_total_per_run_usd` | `50` | Max cumulative USD auto-executed across one scan run (the per-run ceiling). | ↑ more total capital deployed per scan; ↓ less. | +| `min_confidence_auto` | `0.75` | Min strategy confidence (0–1) for an auto-eligible opportunity to **auto-execute**; below this it **escalates** to you instead. | ↓ auto-fires on weaker signals (more aggressive); ↑ only the most confident. | +| `min_confidence_report` | `0.5` | Below this confidence an opportunity is **dropped as noise** (not even reported). | ↓ surfaces weaker ideas; ↑ only stronger ones reach you. | +| `min_liquidity_usd` | `5000` | Market-level liquidity floor; thinner markets are **skipped**. | ↓ allows trading thinner markets (riskier fills/exits); ↑ stricter. | +| `min_depth_multiple` | `2` | Required order-book depth **at the order price**, as a multiple of the order size (2 = need ≥ 2× your size resting). | ↓ accepts thinner books; ↑ demands deeper books. | +| `max_book_take_pct` | `25` | Max % of the resting depth at price that a single order may consume (caps your market impact). | ↑ allows orders that move the book more; ↓ gentler. | +| `auto_execute_strategies` | `["risk-free-arb", "multi-outcome-arb"]` | Allowlist of strategies that may **auto-execute** within limits. Every other strategy **escalates** for human confirmation regardless of confidence. | Add strategies to let them auto-fire (see safety note). | + +The six strategy names are: `momentum`, `mean-reversion`, `multi-outcome-arb`, `spread-capture`, +`risk-free-arb`, `smart-money`. + +## How the gate uses these + +For each opportunity the gate (`assets/risk_gate.py`) returns one of three decisions, in this order: + +- **skip** — confidence below `min_confidence_report`, OR market liquidity below `min_liquidity_usd`, + OR depth below `min_depth_multiple × size`, OR order over `max_notional_per_order_usd`, OR the run + total would exceed `max_total_per_run_usd`, OR the order would take more than `max_book_take_pct` of + resting depth. +- **auto-execute** — the strategy is in `auto_execute_strategies` **and** confidence ≥ + `min_confidence_auto` **and** all the caps above are satisfied. The order still runs `--dry-run` and + a preview match before `--yes`. +- **escalate** — everything else (all directional strategies, and any allowlisted arb that misses an + auto condition) → presented to you for confirmation. + +## Safety notes + +- **Real money.** Auto-execution spends real USDC on Polygon. These limits are the enforcement layer — + the gate is deterministic code, not a suggestion to the LLM. +- **Adding directional strategies to `auto_execute_strategies` removes the human-in-the-loop** for + judgment trades: `momentum`, `mean-reversion`, `spread-capture`, and `smart-money` would then place + real orders **without asking you**. The default keeps only the two deterministic structural-arb + strategies on auto. +- **`spread-capture` needs order management v1 doesn't have** (cancel/timeout of resting limit orders). + Auto-firing it can leave dangling orders — keep it on escalate unless you're managing orders yourself. +- **Wallet readiness still applies.** Even an auto-execute decision will fail with + `InsufficientAllowanceError` unless the deposit wallet is funded and trading-approved (see + [recipes.md](recipes.md) "Activate a brand-new wallet"). + +## Example + +A moderately aggressive config that still keeps directional trades on escalate: + +```json +{ + "max_notional_per_order_usd": 25, + "max_total_per_run_usd": 150, + "min_confidence_auto": 0.70, + "min_confidence_report": 0.5, + "min_liquidity_usd": 3000, + "min_depth_multiple": 1.5, + "max_book_take_pct": 35, + "auto_execute_strategies": ["risk-free-arb", "multi-outcome-arb"] +} +``` + +The committed defaults live in [config.example.json](config.example.json). diff --git a/reference/mcp.md b/reference/mcp.md new file mode 100644 index 0000000..ce9d657 --- /dev/null +++ b/reference/mcp.md @@ -0,0 +1,31 @@ +# Calling the Polymarket MCP + +The polymarket MCP server (`polymarket-data`) provides read-only market analytics: +`list_events`, `list_markets`, `get_market`, `search_markets`, `screen_markets`, +`get_price_history`, `get_trades`, `get_market_stats`, `get_order_book`, `get_order_book_depth`. + +## Use the transport helper + +Native `mcp__polymarket__*` tool calls may be blocked by the ECC plugin's +`mcp-health-check.js` hook (a false positive — see below). Always reach the server +through the helper, which works regardless of the hook: + + assets/poly-mcp.sh '' + # e.g. + assets/poly-mcp.sh screen_markets '{"sort_by":"volume_spike","interval":"24h","min_liquidity":20000,"min_volume_24h":100000,"limit":10}' + assets/poly-mcp.sh get_order_book_depth '{"token_id":"123...","notional":100}' + +The helper reads the URL + bearer token from `~/.claude.json` (`mcpServers.polymarket`) +and never prints the token. It prints the tool's JSON result to stdout, or +`{"error":…}` with exit 1 on failure. + +## Why native calls 406 (and the optional fix) + +The MCP endpoint is healthy. The health-check hook probes it with +`Accept: application/json` only; the server correctly requires +`Accept: application/json, text/event-stream` and returns HTTP 406, so the hook +wrongly marks the server unavailable and blocks the tools. + +Optional fix for interactive native calls: whitelist `polymarket` in the +health-check hook (or correct its `Accept` header). Not required — the helper is +the supported path for this skill. diff --git a/reference/opportunity.schema.json b/reference/opportunity.schema.json new file mode 100644 index 0000000..0bf81b8 --- /dev/null +++ b/reference/opportunity.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Opportunity", + "type": "object", + "required": ["strategy", "condition_id", "slug", "token_id", "outcome", "thesis", "proposed_action", "confidence", "liquidity_check"], + "additionalProperties": true, + "properties": { + "strategy": {"type": "string", "enum": ["momentum", "mean-reversion", "multi-outcome-arb", "spread-capture", "risk-free-arb", "smart-money"]}, + "condition_id": {"type": "string", "minLength": 1}, + "slug": {"type": "string", "minLength": 1}, + "token_id": {"type": "string", "minLength": 1}, + "outcome": {"type": "string", "enum": ["yes", "no"]}, + "thesis": {"type": "string", "minLength": 1}, + "signal": {"type": "object"}, + "proposed_action": { + "type": "object", + "required": ["side", "order_type", "size_usd"], + "properties": { + "side": {"type": "string", "enum": ["BUY", "SELL"]}, + "order_type": {"type": "string", "enum": ["limit", "market"]}, + "price": {"type": "number", "minimum": 0, "maximum": 1}, + "size_usd": {"type": "number", "exclusiveMinimum": 0} + }, + "allOf": [ + { + "if": {"properties": {"order_type": {"const": "limit"}}, "required": ["order_type"]}, + "then": {"required": ["price"]} + } + ] + }, + "edge_estimate": {"type": "string"}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "liquidity_check": { + "type": "object", + "required": ["market_liquidity_usd", "depth_usd_at_price"], + "properties": { + "market_liquidity_usd": {"type": "number", "minimum": 0}, + "depth_usd_at_price": {"type": "number", "minimum": 0}, + "est_slippage": {"type": "number"} + } + }, + "risks": {"type": "array", "items": {"type": "string"}} + } +} diff --git a/reference/orchestration.md b/reference/orchestration.md new file mode 100644 index 0000000..8eec045 --- /dev/null +++ b/reference/orchestration.md @@ -0,0 +1,92 @@ +# Opportunity-scan orchestration playbook + +The main agent runs this on demand ("scan Polymarket for opportunities"). Sub-agents +only research and return data; **only the orchestrator places orders**, so the risk +gate and the per-run total are enforced in exactly one place. + +## 0. Preflight +- `poly -o json wallet show` — confirm a signer key is configured (needed to trade). +- `assets/poly-mcp.sh search_markets '{"query":"test","limit":1}'` — confirm MCP reachability. +- Load limits: read the config once with the defaults baked into `assets/risk_gate.py` + (`~/.config/polymarket/agent.json` if present). Apply any inline override the user gave + for this run (e.g. "only $30 total today"). + +## 1. Scout (one shared scan) +Build a candidate universe with four screens (drop penny noise with min filters): + + assets/poly-mcp.sh screen_markets '{"sort_by":"price_change","interval":"24h","min_liquidity":5000,"min_volume_24h":50000,"min_trade_count":100,"limit":25}' + assets/poly-mcp.sh screen_markets '{"sort_by":"volume_spike","interval":"24h","min_liquidity":20000,"min_volume_24h":100000,"min_trade_count":50,"limit":25}' + assets/poly-mcp.sh screen_markets '{"sort_by":"spread","interval":"24h","min_liquidity":10000,"limit":25}' + assets/poly-mcp.sh screen_markets '{"sort_by":"liquidity","interval":"24h","limit":25}' + +Merge + dedupe by `condition_id` into one universe (≈50–150 markets), each carrying the +fields from the screener (`yes_price`, `volume_24h`, `liquidity`, `spread`, `best_bid/ask`, +`price_change_pct`, `volume_ratio`, `open_price`, `close_price`, `token_id_yes/no`, `event_id`). + +## 2. Fan-out (six parallel sub-agents) +Dispatch six sub-agents **in parallel**, one per strategy in `reference/strategies/`. +Give each: the candidate universe, its strategy spec, the limits, and access to +`assets/poly-mcp.sh` + the `poly` CLI for read-only enrichment. The arb and smart-money +agents may do small targeted extra pulls beyond the universe (scout blind-spot coverage). +Each returns an array of Opportunity objects matching `reference/opportunity.schema.json`. + +## 3. Synthesize +- Validate each returned object: `echo '' | python3 assets/risk_gate.py validate` (drop invalid ones, note them). +- Dedupe by `(condition_id, outcome)`; if two strategies surface the same one, keep the + higher `confidence` and record both strategy names. +- Rank by `confidence` (tie-break by `edge_estimate`). + +## 4. Risk gate (call the deterministic core per opportunity) +Track a running `run_total_usd`, starting at 0. For each ranked opportunity: + + echo '' | python3 assets/risk_gate.py decide --run-total + +The output is `{"decision":"auto"|"escalate"|"skip","reason":"…"}`: +- **skip** — record the reason; do nothing. +- **escalate** — add to the escalation list (present to the user; do not execute now). +- **auto** — execute (Step 5), then add the filled `size_usd` to `run_total_usd`. + +**Safety invariant:** `decide` only ever returns `auto` for the structural-arb strategies (`risk-free-arb`, `multi-outcome-arb`) — the gate's allowlist enforces this in one place. Never auto-execute a directional opportunity (momentum, mean-reversion, spread-capture, smart-money); if one ever shows `auto`, treat it as `escalate`. + +## 5. Execute (auto only — dry-run first, always) +For each `auto` opportunity, build the matching `poly` order from `proposed_action`: + + # 1) preview — never submits + poly -o json buy --token-id --usd --price --dry-run # limit example + # verify the dry-run preview: token_id, side, price, and ~notional match proposed_action + # 2) submit only if the preview matches + poly -o json buy --token-id --usd --price --yes + +Use SELL / `--market` / `--size` forms per `reference/commands.md` when the action calls +for them (market BUY spends `--usd`, market SELL delivers `--size`). If the preview does +**not** match, abort that order and move it to escalations. Re-check best price in the +preview; if the market moved beyond the proposed price, abort (stale-snapshot guard). + +## 6. Report +- **Ranked table:** rank · strategy · market · action · edge · confidence · liquidity · gate decision. +- **Executed:** each auto order's `order_id` + status from `{"result":"ACCEPTED order_id=… status=…"}`. +- **Escalations:** proposed orders awaiting the user's go, each with thesis + dry-run preview. + +## 7. Emit dashboard artifact +Turn the run into the visual dashboard instead of leaving it as a text table. Attach each gate +result to its opportunity (`"gate": {"decision": …, "order_id": …}`), then let the deterministic +mapper assemble + inject `DATA` — **don't hand-write the JSON**: + +1. **Curate + enrich.** Pick the subset to show = every opportunity's market + the top ~30 universe + rows by 24h volume (`top_n`, default 30 — raise it to show more). For *that subset only*, fetch enrichment: + - real **event slug** → `url` (`assets/poly-mcp.sh get_market …` or Gamma `events[].slug`; a market/guessed slug 404s), + - `candles` (`get_price_history`), `depth` (`get_order_book_depth`), `net_flow` (`get_market_stats`), + - `category` / `end_date` / `description` / `volume_total` (Gamma). + Enriching only the subset (not all 50–150) keeps the payload small. +2. **Assemble `run.json`** = `{generated_at, wallet_label, top_n, universe, opportunities(+gate), enrichment, account}`. + For `account`, check the wallet first with `poly -o json wallet show`: **set up → gather it** (`clob balance + --asset-type collateral`, `data value`, `data positions`, `clob orders`, `clob trades`); **no key / errors → + set `account: null`** so the Account tab renders wallet-setup steps (Markets/Recommendations still work). + Full shape: [artifacts.md](artifacts.md). +3. **Build + inject:** + + python3 assets/build_data.py --inject assets/dashboard-template.html < run.json > dashboard.html + + Emit `dashboard.html` as the artifact. The mapper maps opportunities→recommendations (gate decision → + `executed`/`pending`/`skipped` pill) and sums `meta.stats` from the full universe. +4. **Caveat** the user: the dashboard is a snapshot frozen at `generated_at`; regenerate to refresh. diff --git a/reference/strategies/mean-reversion.md b/reference/strategies/mean-reversion.md new file mode 100644 index 0000000..255e026 --- /dev/null +++ b/reference/strategies/mean-reversion.md @@ -0,0 +1,29 @@ +# Mean-reversion / overreaction strategy + +**Goal:** Fade sharp moves that lack informational backing — bet on reversion toward the prior level. +**Auto-execute:** no — always escalate (directional). + +## Data to pull +- From the shared universe: candidates with large `price_change_pct` but modest `volume_ratio`. +- `assets/poly-mcp.sh get_price_history '{"token_id":"…","interval":"1h"}'` — locate the pre-spike level. +- `assets/poly-mcp.sh get_trades '{"token_id":"…","limit":100}'` — is the move a few large prints or broad? +- `assets/poly-mcp.sh get_market_stats '{"condition_id":"…","interval":"24h"}'` — flow balance. + +## Signal logic +- Sharp price move on **thin** volume (`volume_ratio` near or below 1) or driven by a handful of prints. +- No corroborating sustained flow; the move looks like an air-pocket, not repricing. +- A clear prior level to revert toward in the price history. + +## Disqualifiers +- High `volume_ratio` with consistent flow (that is momentum, not overreaction). +- Markets near a resolution deadline where the move may be correct, late information. +- Illiquid markets where reversion can't be exited (`market_liquidity_usd` below floor). + +## Confidence rubric +- 0.8+: big move, `volume_ratio < 1`, move traced to 1–3 prints, clean prior level. +- 0.6–0.8: thin-ish move, plausible reversion. +- < 0.6: ambiguous — drop. + +## Output mapping +- `proposed_action`: trade **against** the move (BUY the side that dropped / SELL the side that spiked), `order_type` "limit" near the prior level; `size_usd` ≤ cap. +- `signal`: `{ "price_change_pct", "volume_ratio", "prior_level", "print_concentration" }`. diff --git a/reference/strategies/momentum.md b/reference/strategies/momentum.md new file mode 100644 index 0000000..d3b54c2 --- /dev/null +++ b/reference/strategies/momentum.md @@ -0,0 +1,30 @@ +# Momentum / news-repricing strategy + +**Goal:** Catch markets repricing hard on fresh information and ride the continuation +before the book fully adjusts. +**Auto-execute:** no — always escalate (directional). + +## Data to pull +- From the shared universe: candidates with high `price_change_pct` and `volume_ratio`. +- `assets/poly-mcp.sh get_market_stats '{"condition_id":"…","interval":"24h"}'` — buy/sell flow. +- `assets/poly-mcp.sh get_price_history '{"token_id":"…","interval":"1h"}'` — confirm a sustained move, not a single spike. +- `assets/poly-mcp.sh get_order_book_depth '{"token_id":"…","notional":100}'` — depth/slippage for sizing. + +## Signal logic +- Move is recent, large (`price_change_pct` well above the universe median), and backed by `volume_ratio > 3`. +- Net flow (`get_market_stats`) is directionally consistent with the move (buys lifting YES, etc.). +- Price action shows follow-through across the last several 1h candles, not a wick that reverted. + +## Disqualifiers +- Penny markets (`open_price < 0.05`) where a 1¢ tick reads as a huge % — exclude. +- Move already at an extreme (`yes_price > 0.95` or `< 0.05`) with little room left. +- Flow contradicts the price move (likely a squeeze/illiquid print). + +## Confidence rubric +- 0.8+: large move + `volume_ratio > 10` + consistent flow + multi-candle follow-through. +- 0.6–0.8: solid move and volume, mixed follow-through. +- < 0.6: thin or contradictory — drop. + +## Output mapping +- `proposed_action`: BUY the side the move favors, `order_type` "limit" at/just inside best ask (BUY) or best bid (SELL); `size_usd` ≤ config per-order cap. +- `signal`: `{ "price_change_pct", "volume_ratio", "net_flow", "candles_following" }`. diff --git a/reference/strategies/multi-outcome-arb.md b/reference/strategies/multi-outcome-arb.md new file mode 100644 index 0000000..ff4f95b --- /dev/null +++ b/reference/strategies/multi-outcome-arb.md @@ -0,0 +1,29 @@ +# Multi-outcome arbitrage strategy + +**Goal:** In a mutually-exclusive multi-outcome event, exploit the YES prices summing to ≠ 100%. +**Auto-execute:** yes — structural arb (in the default allowlist). + +## Data to pull +- `assets/poly-mcp.sh list_markets '{"event_id":"…"}'` — all markets in the event. +- `assets/poly-mcp.sh get_order_book '{"token_id":"…"}'` for each outcome's YES — real fillable prices, not mid. +- `assets/poly-mcp.sh get_order_book_depth '{"token_id":"…","notional":100}'` — depth at the executable price. + +## Signal logic +- Sum of best-ask YES prices across all mutually-exclusive outcomes `< 1 − fees/slippage` → buy the basket (each YES) to lock a gain at resolution. +- Or sum of best-bid YES prices `> 1 + costs` → sell the basket. +- Only count it if each leg is **fillable** at the quoted price for the intended size. + +## Disqualifiers +- Outcomes not actually mutually exclusive / collectively exhaustive (read the event carefully). +- Edge smaller than estimated total slippage + any fees. +- Any leg too thin to fill (`depth_usd_at_price` below the gate's requirement). + +## Confidence rubric +- 0.9+: complete outcome set, all legs fillable, edge ≥ 2× estimated costs. +- 0.75–0.9: edge positive but thinner margin over costs. +- < 0.75: do not auto-execute (escalate) — edge too close to costs. + +## Output mapping +- Emit one Opportunity **per leg** (each YES leg to buy), sharing the basket thesis; the orchestrator gates each leg. +- `proposed_action`: BUY YES, `order_type` "limit" at the leg's best ask; `size_usd` matched across legs and ≤ cap. +- `signal`: `{ "sum_of_outcomes", "legs", "est_total_cost", "edge_after_cost" }`. diff --git a/reference/strategies/risk-free-arb.md b/reference/strategies/risk-free-arb.md new file mode 100644 index 0000000..05b8067 --- /dev/null +++ b/reference/strategies/risk-free-arb.md @@ -0,0 +1,29 @@ +# Risk-free / structural arbitrage strategy + +**Goal:** Lock guaranteed (or near-guaranteed) value from structural mispricings within a single market. +**Auto-execute:** yes — structural arb (in the default allowlist). + +## Data to pull +- `assets/poly-mcp.sh get_order_book '{"token_id":"…"}'` for both YES and NO tokens of the market. +- `assets/poly-mcp.sh get_order_book_depth '{"token_id":"…","notional":100}'` — fillable depth per leg. + +## Signal logic +- **YES+NO < 1:** best-ask(YES) + best-ask(NO) `< 1 − costs` → buy both; one resolves to 1, locking the difference. +- **Cross-market logical arb:** two markets whose outcomes are logically linked are priced inconsistently (e.g. "X by June" must be ≤ "X by Dec"). +- **negRisk redemption:** a complete negRisk set buyable below redemption value. +- Count only when every required leg is fillable at the quoted price for the size. + +## Disqualifiers +- Edge below estimated slippage + fees. +- Any leg unfillable at size (`depth_usd_at_price` below gate requirement). +- Hidden conditionality that breaks the "guaranteed" assumption (read resolution terms). + +## Confidence rubric +- 0.9+: single-market YES+NO with both legs fillable and edge ≥ 2× costs. +- 0.75–0.9: cross-market logical arb with a sound but not airtight link. +- < 0.75: escalate rather than auto-fire. + +## Output mapping +- Emit one Opportunity per leg; orchestrator gates each. +- `proposed_action`: BUY the underpriced leg(s), `order_type` "limit" at best ask; `size_usd` matched and ≤ cap. +- `signal`: `{ "type": "yes_no_sum|cross_market|negrisk", "sum_or_relation", "est_total_cost", "edge_after_cost" }`. diff --git a/reference/strategies/smart-money.md b/reference/strategies/smart-money.md new file mode 100644 index 0000000..aa2ca67 --- /dev/null +++ b/reference/strategies/smart-money.md @@ -0,0 +1,29 @@ +# Smart-money / informed-flow strategy + +**Goal:** Detect large, informed directional flow and follow it before price fully reflects it. +**Auto-execute:** no — always escalate (directional). + +## Data to pull +- From the shared universe: markets with elevated `volume_ratio`. +- `assets/poly-mcp.sh get_trades '{"token_id":"…","limit":200}'` — large prints, direction, counterparties if exposed. +- `assets/poly-mcp.sh get_market_stats '{"condition_id":"…","interval":"6h"}'` — net buy/sell flow. +- `poly -o json data positions
` and `poly -o json data value
` — profile a wallet behind notable flow (any address is readable). + +## Signal logic +- Concentrated large prints on one side (not retail-sized noise) with net flow confirming direction. +- Optional corroboration: the wallet driving it shows a sizeable / historically directional portfolio. +- Price hasn't yet fully moved to where the flow implies (room to follow). + +## Disqualifiers +- Flow is small / evenly two-sided (no signal). +- Price already gapped to the implied level (no edge left). +- Single wallet with no track record and no corroborating flow (could be noise or manipulation). + +## Confidence rubric +- 0.8+: large one-sided prints + confirming net flow + (optional) credible wallet, with room left. +- 0.6–0.8: decent flow signal, limited corroboration. +- < 0.6: drop. + +## Output mapping +- `proposed_action`: BUY the side the informed flow favors, `order_type` "limit" near best price; `size_usd` ≤ cap. +- `signal`: `{ "large_print_count", "net_flow", "wallet_address", "wallet_value_usd", "implied_vs_current_gap" }`. diff --git a/reference/strategies/spread-capture.md b/reference/strategies/spread-capture.md new file mode 100644 index 0000000..da877be --- /dev/null +++ b/reference/strategies/spread-capture.md @@ -0,0 +1,29 @@ +# Spread-capture / liquidity-provision strategy + +**Goal:** On wide-spread but liquid markets, post passive limit orders inside the spread to capture it. +**Auto-execute:** no — always escalate (directional/inventory risk). + +## Data to pull +- From the shared universe: candidates surfaced by `screen_markets sort_by="spread"` with adequate `liquidity`. +- `assets/poly-mcp.sh get_order_book '{"token_id":"…"}'` — best bid/ask and resting sizes. +- `assets/poly-mcp.sh get_market_stats '{"condition_id":"…","interval":"24h"}'` — two-sided activity (will the post get filled?). + +## Signal logic +- `spread` materially wide (e.g. ≥ 3¢) with `market_liquidity_usd` above floor and steady two-sided trade_count. +- Room to post inside the spread and still leave edge after the expected adverse-selection cost. +- Not trending hard (a wide spread on a fast mover is adverse selection, not capture). + +## Disqualifiers +- Thin or one-sided flow (post won't fill, or fills only when wrong). +- Near resolution / strong momentum (adverse selection dominates). +- Spread already tight relative to tick size. + +## Confidence rubric +- 0.8+: wide stable spread, balanced two-sided flow, no trend. +- 0.6–0.8: workable but thinner or slightly trending. +- < 0.6: drop. + +## Output mapping +- `proposed_action`: `order_type` "limit" posted inside the spread (BUY just above best bid or SELL just below best ask); `size_usd` ≤ cap. +- `signal`: `{ "spread", "best_bid", "best_ask", "two_sided_trade_count" }`. +- Note in `risks`: requires later cancel/timeout management (out of scope for v1 auto-fire — escalate). diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bea2182 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "assets")) diff --git a/tests/fixtures/opportunity.valid.json b/tests/fixtures/opportunity.valid.json new file mode 100644 index 0000000..43b277d --- /dev/null +++ b/tests/fixtures/opportunity.valid.json @@ -0,0 +1,14 @@ +{ + "strategy": "risk-free-arb", + "condition_id": "0xabc", + "slug": "will-x-happen", + "token_id": "12345", + "outcome": "yes", + "thesis": "YES+NO priced at 0.97; buying both locks 3% to resolution.", + "signal": {"sum_of_outcomes": 0.97}, + "proposed_action": {"side": "BUY", "order_type": "limit", "price": 0.48, "size_usd": 8}, + "edge_estimate": "3 cents / ~3%", + "confidence": 0.9, + "liquidity_check": {"market_liquidity_usd": 20000, "depth_usd_at_price": 50, "est_slippage": 0.001}, + "risks": ["resolution dispute"] +} diff --git a/tests/test_build_data.py b/tests/test_build_data.py new file mode 100644 index 0000000..a9ea5f9 --- /dev/null +++ b/tests/test_build_data.py @@ -0,0 +1,159 @@ +import json +from pathlib import Path + +import build_data + +TEMPLATE = Path(__file__).parent.parent / "assets" / "dashboard-template.html" + + +def universe(): + return [ + {"condition_id": "0xA", "slug": "arb-mkt", "question": "Arb market?", "yes_price": "0.48", + "volume_24h": "50000", "liquidity": "20000", "token_id_yes": "111", "token_id_no": "112"}, + {"condition_id": "0xB", "slug": "big-mkt", "question": "Big market?", "yes_price": "0.30", + "volume_24h": "900000", "liquidity": "80000", "volume_total": "5000000", + "token_id_yes": "211", "token_id_no": "212"}, + {"condition_id": "0xC", "slug": "small-mkt", "question": "Small market?", "yes_price": "0.10", + "volume_24h": "1000", "liquidity": "3000", "token_id_yes": "311", "token_id_no": "312"}, + ] + + +def opp(decision="auto"): + return { + "strategy": "risk-free-arb", "condition_id": "0xA", "slug": "arb-mkt", "token_id": "111", + "outcome": "yes", "thesis": "YES+NO under 1.00 locks 3%.", + "proposed_action": {"side": "BUY", "order_type": "limit", "price": 0.48, "size_usd": 8}, + "edge_estimate": "3 cents / ~3%", "confidence": 0.9, + "liquidity_check": {"market_liquidity_usd": 20000, "depth_usd_at_price": 50, "est_slippage": 0.001}, + "signal": {"sum_of_outcomes": 0.97}, "risks": ["resolution dispute"], + "gate": {"decision": decision, "order_id": "0xORDER" if decision == "auto" else None}, + } + + +# ---- confidence banding matches the risk gate thresholds ---- +def test_confidence_band(): + assert build_data.confidence_band(0.9) == "high" + assert build_data.confidence_band(0.75) == "high" + assert build_data.confidence_band(0.6) == "medium" + assert build_data.confidence_band(0.4) == "low" + + +# ---- opportunity -> recommendation mapping ---- +def test_gate_decision_maps_to_status(): + by = {u["condition_id"]: u for u in universe()} + assert build_data.opportunity_to_recommendation(opp("auto"), by, None)["status"] == "executed" + assert build_data.opportunity_to_recommendation(opp("escalate"), by, None)["status"] == "pending" + assert build_data.opportunity_to_recommendation(opp("skip"), by, None)["status"] == "skipped" + + +def test_recommendation_fields(): + by = {u["condition_id"]: u for u in universe()} + rec = build_data.opportunity_to_recommendation(opp("auto"), by, {"url": "https://polymarket.com/event/arb-mkt"}) + assert rec["question"] == "Arb market?" # joined from universe + assert rec["outcome"] == "YES" # upper-cased + assert rec["action"] == "BUY" + assert rec["confidence"] == "high" + assert rec["confidence_score"] == "0.9" + assert rec["target_price"] == "0.48" + assert rec["size_usd"] == "8" + assert rec["strategy"] == "risk-free-arb" + assert rec["order_id"] == "0xORDER" + assert rec["url"] == "https://polymarket.com/event/arb-mkt" + assert "resolution dispute" in rec["signals"] + + +def test_skipped_action_is_watch(): + by = {u["condition_id"]: u for u in universe()} + assert build_data.opportunity_to_recommendation(opp("skip"), by, None)["action"] == "WATCH" + + +# ---- stats come from the FULL universe ---- +def test_compute_stats_full_universe(): + st = build_data.compute_stats(universe()) + assert st["active"] == 3 + assert st["volume_24h"] == str(50000 + 900000 + 1000) + # 0xB has volume_total 5,000,000; others fall back to volume_24h + assert st["total_volume"] == str(50000 + 5000000 + 1000) + + +# ---- curation: recommended markets always included; subset, not whole universe ---- +def test_curation_includes_recommended_and_top_n(): + payload = {"universe": universe(), "opportunities": [opp("auto")], "top_n": 1, + "enrichment": {"0xA": {"url": "https://polymarket.com/event/arb-mkt", + "category": "Politics", "candles": [], "depth": {"bids": [], "asks": []}}}} + data = build_data.build_data(payload) + conds = [m["condition_id"] for m in data["markets"]] + assert "0xA" in conds # recommended market always present + assert "0xB" in conds # highest-volume top_n + enriched = [m for m in data["markets"] if m["condition_id"] == "0xA"][0] + assert enriched["category"] == "Politics" # enrichment merged + assert data["meta"]["stats"]["active"] == 3 # stats reflect full universe, not the subset + assert data["recommendations"][0]["status"] == "executed" + + +# ---- trending: 24h-hot markets merged into the grid, deduped + tagged ---- +def trending(): + return [ + {"condition_id": "0xHOT", "slug": "hot-mkt", "question": "Hot market?", "yes_price": "0.55", + "volume_24h": "3000000", "liquidity": "120000", "token_id_yes": "411", "token_id_no": "412"}, + {"condition_id": "0xB", "slug": "big-mkt", "question": "Big market?", "yes_price": "0.30", + "volume_24h": "900000", "token_id_yes": "211", "token_id_no": "212"}, # also scouted -> dedup + ] + + +def test_trending_markets_merged_and_tagged(): + payload = {"universe": universe(), "opportunities": [opp("auto")], "top_n": 1, + "trending": trending(), "trending_n": 5} + data = build_data.build_data(payload) + by = {m["condition_id"]: m for m in data["markets"]} + # the hot-only market is added and tagged (no real category -> chip/tag for free) + assert "0xHOT" in by + assert by["0xHOT"]["trending"] is True + assert by["0xHOT"]["category"] == "🔥 Trending" + # a market that is BOTH scouted and trending is flagged, not duplicated + assert [m["condition_id"] for m in data["markets"]].count("0xB") == 1 + assert by["0xB"]["trending"] is True + + +def test_trending_respects_trending_n(): + payload = {"universe": universe(), "opportunities": [], "top_n": 0, + "trending": trending(), "trending_n": 1} + data = build_data.build_data(payload) + # only the single highest-volume trending market (0xHOT) is merged + assert any(m["condition_id"] == "0xHOT" for m in data["markets"]) + + +def test_trending_absent_is_noop(): + data = build_data.build_data({"universe": universe(), "opportunities": [opp("auto")], "top_n": 1}) + assert all("trending" not in m for m in data["markets"]) + + +# ---- account: null unless a wallet is set up (template then shows setup steps) ---- +def test_account_absent_is_null(): + data = build_data.build_data({"universe": universe(), "opportunities": []}) + assert data["account"] is None + + +def test_account_empty_object_coerced_to_null(): + data = build_data.build_data({"universe": universe(), "opportunities": [], "account": {}}) + assert data["account"] is None + + +def test_account_passed_through_when_set_up(): + acc = {"wallet": {"deposit_wallet": "0x9377"}, "balance": {"cash": "100"}} + data = build_data.build_data({"universe": universe(), "opportunities": [], "account": acc}) + assert data["account"] == acc + + +# ---- injection produces exactly one valid DATA block ---- +def test_inject_replaces_data_block_once(): + html = TEMPLATE.read_text() + data = build_data.build_data({"universe": universe(), "opportunities": [opp("auto")]}) + out = build_data.inject(html, data) + assert out.count("POLYMARKET_DATA_START") == 1 + assert out.count("POLYMARKET_DATA_END") == 1 + assert "generated by build_data.py" in out + # the injected object parses as JSON + blob = out.split("const DATA = ", 1)[1].rsplit(";\n/* === POLYMARKET_DATA_END", 1)[0] + parsed = json.loads(blob) + assert parsed["recommendations"][0]["question"] == "Arb market?" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..6c5f1f5 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,30 @@ +import json + +import risk_gate + + +def test_missing_file_returns_defaults(tmp_path): + cfg = risk_gate.load_config(str(tmp_path / "nope.json")) + assert cfg == risk_gate.DEFAULTS + # must be a copy, not the same object + assert cfg is not risk_gate.DEFAULTS + + +def test_partial_file_merges_over_defaults(tmp_path): + p = tmp_path / "agent.json" + p.write_text(json.dumps({"max_total_per_run_usd": 30})) + cfg = risk_gate.load_config(str(p)) + assert cfg["max_total_per_run_usd"] == 30 + assert cfg["max_notional_per_order_usd"] == 10 # default preserved + + +def test_defaults_have_expected_values(): + d = risk_gate.DEFAULTS + assert d["max_notional_per_order_usd"] == 10 + assert d["max_total_per_run_usd"] == 50 + assert d["min_confidence_auto"] == 0.75 + assert d["min_confidence_report"] == 0.5 + assert d["min_liquidity_usd"] == 5000 + assert d["min_depth_multiple"] == 2 + assert d["max_book_take_pct"] == 25 + assert d["auto_execute_strategies"] == ["risk-free-arb", "multi-outcome-arb"] diff --git a/tests/test_orchestration_doc.py b/tests/test_orchestration_doc.py new file mode 100644 index 0000000..6585767 --- /dev/null +++ b/tests/test_orchestration_doc.py @@ -0,0 +1,12 @@ +from pathlib import Path + +DOC = Path(__file__).parent.parent / "reference" / "orchestration.md" + + +def test_orchestration_covers_flow_and_tools(): + text = DOC.read_text() + for needle in [ + "poly-mcp.sh", "risk_gate.py", "Scout", "Fan-out", + "auto", "escalate", "skip", "--dry-run", "--yes", + ]: + assert needle in text, f"orchestration.md missing: {needle}" diff --git a/tests/test_poly_mcp.py b/tests/test_poly_mcp.py new file mode 100644 index 0000000..e110d9e --- /dev/null +++ b/tests/test_poly_mcp.py @@ -0,0 +1,23 @@ +import os +import subprocess +from pathlib import Path + +SCRIPT = Path(__file__).parent.parent / "assets" / "poly-mcp.sh" + + +def test_usage_without_tool(): + r = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True) + assert r.returncode == 1 + assert "usage" in (r.stdout + r.stderr).lower() + + +def test_errors_without_token(tmp_path): + empty = tmp_path / "empty.json" + empty.write_text("{}") + env = {**os.environ, "POLY_MCP_CONFIG": str(empty), "POLY_MCP_URL": "", "POLY_MCP_AUTH": ""} + r = subprocess.run( + ["bash", str(SCRIPT), "screen_markets", "{}"], + env=env, capture_output=True, text=True, + ) + assert r.returncode == 1 + assert "no polymarket mcp token" in (r.stdout + r.stderr).lower() diff --git a/tests/test_risk_gate.py b/tests/test_risk_gate.py new file mode 100644 index 0000000..748df12 --- /dev/null +++ b/tests/test_risk_gate.py @@ -0,0 +1,123 @@ +import risk_gate + + +def base_opp(): + return { + "strategy": "risk-free-arb", + "condition_id": "0x1", + "slug": "s", + "token_id": "t", + "outcome": "yes", + "thesis": "x", + "proposed_action": {"side": "BUY", "order_type": "limit", "price": 0.5, "size_usd": 8}, + "confidence": 0.9, + "liquidity_check": {"market_liquidity_usd": 20000, "depth_usd_at_price": 100}, + } + + +CFG = risk_gate.DEFAULTS + + +def test_structural_arb_within_caps_auto_executes(): + assert risk_gate.decide(base_opp(), CFG, 0)["decision"] == "auto" + + +def test_directional_strategy_always_escalates(): + opp = base_opp() + opp["strategy"] = "momentum" + assert risk_gate.decide(opp, CFG, 0)["decision"] == "escalate" + + +def test_structural_arb_low_confidence_escalates(): + opp = base_opp() + opp["confidence"] = 0.6 # >= report floor, < auto floor + assert risk_gate.decide(opp, CFG, 0)["decision"] == "escalate" + + +def test_below_report_floor_skips(): + opp = base_opp() + opp["confidence"] = 0.4 + d = risk_gate.decide(opp, CFG, 0) + assert d["decision"] == "skip" + assert "report floor" in d["reason"] + + +def test_low_market_liquidity_skips(): + opp = base_opp() + opp["liquidity_check"]["market_liquidity_usd"] = 1000 + assert risk_gate.decide(opp, CFG, 0)["decision"] == "skip" + + +def test_insufficient_depth_skips(): + opp = base_opp() + opp["liquidity_check"]["depth_usd_at_price"] = 10 # need >= 2*8=16 + assert risk_gate.decide(opp, CFG, 0)["decision"] == "skip" + + +def test_over_per_order_cap_skips(): + opp = base_opp() + opp["proposed_action"]["size_usd"] = 11 # cap 10 + opp["liquidity_check"]["depth_usd_at_price"] = 1000 + assert risk_gate.decide(opp, CFG, 0)["decision"] == "skip" + + +def test_over_run_total_skips(): + # size 8, run_total 45, cap 50 -> 53 > 50 + assert risk_gate.decide(base_opp(), CFG, 45)["decision"] == "skip" + + +def test_book_take_pct_skips(): + opp = base_opp() + opp["proposed_action"]["size_usd"] = 9 + opp["liquidity_check"]["depth_usd_at_price"] = 20 # 9/20=45% > 25%, depth ok (>=18) + d = risk_gate.decide(opp, CFG, 0) + assert d["decision"] == "skip" + assert "resting depth" in d["reason"] + + +def test_at_run_total_boundary_is_allowed(): + # size 8, run_total 42, cap 50 -> 50 not > 50 -> not skipped + assert risk_gate.decide(base_opp(), CFG, 42)["decision"] == "auto" + + +def test_confidence_at_report_floor_not_skipped(): + opp = base_opp() + opp["confidence"] = 0.5 # == min_confidence_report; strict < means NOT skipped + assert risk_gate.decide(opp, CFG, 0)["decision"] == "escalate" + + +def test_confidence_at_auto_floor_auto_executes(): + opp = base_opp() + opp["confidence"] = 0.75 # == min_confidence_auto; >= qualifies for auto + opp["liquidity_check"]["depth_usd_at_price"] = 1000 + assert risk_gate.decide(opp, CFG, 0)["decision"] == "auto" + + +def test_size_at_per_order_cap_not_skipped(): + opp = base_opp() + opp["proposed_action"]["size_usd"] = 10 # == cap; strict > means NOT skipped + opp["liquidity_check"]["depth_usd_at_price"] = 1000 + assert risk_gate.decide(opp, CFG, 0)["decision"] == "auto" + + +def test_book_take_at_boundary_not_skipped(): + opp = base_opp() + opp["proposed_action"]["size_usd"] = 5 + opp["liquidity_check"]["depth_usd_at_price"] = 20 # 5/20 = 25% == cap; strict > means NOT skipped + assert risk_gate.decide(opp, CFG, 0)["decision"] == "auto" + + +def test_depth_at_floor_not_skipped(): + import copy + cfg = copy.deepcopy(risk_gate.DEFAULTS) + cfg["max_book_take_pct"] = 100 # raise book-take cap to isolate the depth check + opp = base_opp() + opp["proposed_action"]["size_usd"] = 8 + opp["liquidity_check"]["depth_usd_at_price"] = 16 # == 2*size; strict < means NOT skipped on depth + assert risk_gate.decide(opp, cfg, 0)["decision"] == "auto" + + +def test_multi_outcome_arb_auto_executes(): + opp = base_opp() + opp["strategy"] = "multi-outcome-arb" # the OTHER allowlisted structural arb + assert risk_gate.decide(opp, CFG, 0)["decision"] == "auto" diff --git a/tests/test_risk_gate_cli.py b/tests/test_risk_gate_cli.py new file mode 100644 index 0000000..017cb50 --- /dev/null +++ b/tests/test_risk_gate_cli.py @@ -0,0 +1,33 @@ +import json +import subprocess +import sys +from pathlib import Path + +SCRIPT = Path(__file__).parent.parent / "assets" / "risk_gate.py" +FIXTURE = Path(__file__).parent / "fixtures" / "opportunity.valid.json" + + +def run(args, stdin): + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + input=stdin, capture_output=True, text=True, + ) + + +def test_cli_decide_auto(): + r = run(["decide", "--run-total", "0"], FIXTURE.read_text()) + assert r.returncode == 0 + assert json.loads(r.stdout)["decision"] == "auto" + + +def test_cli_decide_over_cap_skips(): + opp = json.loads(FIXTURE.read_text()) + opp["proposed_action"]["size_usd"] = 999 + r = run(["decide", "--run-total", "0"], json.dumps(opp)) + assert json.loads(r.stdout)["decision"] == "skip" + + +def test_cli_validate_rejects_bad_object(): + r = run(["validate"], json.dumps({"strategy": "momentum"})) + assert r.returncode == 1 + assert json.loads(r.stdout)["errors"] diff --git a/tests/test_skill.py b/tests/test_skill.py new file mode 100644 index 0000000..e6b35be --- /dev/null +++ b/tests/test_skill.py @@ -0,0 +1,15 @@ +from pathlib import Path + +ROOT = Path(__file__).parent.parent + + +def test_skill_has_scanning_section(): + text = (ROOT / "SKILL.md").read_text() + assert "Opportunity scanning" in text + assert "reference/orchestration.md" in text + assert "scan" in text.lower() + + +def test_readme_mentions_scanning(): + text = (ROOT / "README.md").read_text() + assert "scan" in text.lower() diff --git a/tests/test_strategy_specs.py b/tests/test_strategy_specs.py new file mode 100644 index 0000000..d846451 --- /dev/null +++ b/tests/test_strategy_specs.py @@ -0,0 +1,32 @@ +from pathlib import Path + +import pytest + +STRAT_DIR = Path(__file__).parent.parent / "reference" / "strategies" +NAMES = ["momentum", "mean-reversion", "multi-outcome-arb", "spread-capture", "risk-free-arb", "smart-money"] +SECTIONS = ["**Goal:**", "## Data to pull", "## Signal logic", "## Disqualifiers", "## Confidence rubric", "## Output mapping"] + + +@pytest.mark.parametrize("name", NAMES) +def test_strategy_spec_complete(name): + path = STRAT_DIR / f"{name}.md" + assert path.exists(), f"missing {path}" + text = path.read_text() + for section in SECTIONS: + assert section in text, f"{name}.md missing section: {section}" + + +AUTO_YES = ["multi-outcome-arb", "risk-free-arb"] +AUTO_NO = ["momentum", "mean-reversion", "spread-capture", "smart-money"] + + +@pytest.mark.parametrize("name", AUTO_YES) +def test_structural_arb_specs_marked_auto_yes(name): + text = (STRAT_DIR / f"{name}.md").read_text() + assert "**Auto-execute:** yes" in text + + +@pytest.mark.parametrize("name", AUTO_NO) +def test_directional_specs_marked_auto_no(name): + text = (STRAT_DIR / f"{name}.md").read_text() + assert "**Auto-execute:** no" in text diff --git a/tests/test_validate_opportunity.py b/tests/test_validate_opportunity.py new file mode 100644 index 0000000..b0f8177 --- /dev/null +++ b/tests/test_validate_opportunity.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path + +import risk_gate + +FIXTURE = Path(__file__).parent / "fixtures" / "opportunity.valid.json" + + +def _valid(): + return json.loads(FIXTURE.read_text()) + + +def test_valid_opportunity_passes(): + assert risk_gate.validate_opportunity(_valid()) == [] + + +def test_missing_required_field_fails(): + obj = _valid() + del obj["confidence"] + errors = risk_gate.validate_opportunity(obj) + assert errors + assert any("confidence" in e for e in errors) + + +def test_bad_outcome_enum_fails(): + obj = _valid() + obj["outcome"] = "maybe" + assert risk_gate.validate_opportunity(obj) + + +def test_confidence_out_of_range_fails(): + obj = _valid() + obj["confidence"] = 1.5 + assert risk_gate.validate_opportunity(obj) + + +def test_limit_order_without_price_fails(): + obj = _valid() + obj["proposed_action"]["order_type"] = "limit" + obj["proposed_action"].pop("price", None) + errors = risk_gate.validate_opportunity(obj) + assert errors + assert any("price" in e for e in errors) + + +def test_market_order_without_price_passes(): + obj = _valid() + obj["proposed_action"]["order_type"] = "market" + obj["proposed_action"].pop("price", None) + assert risk_gate.validate_opportunity(obj) == []