{
  "markdown": "# Prediction Market Edge Engine\n\nA multi-venue inefficiency detection, backtesting, and execution engine for prediction markets.\nIt scans **Polymarket** and **Kalshi** for pricing dislocations, ranks them by a\ntime-to-resolution-aware score, backtests them against recorded depth with honest labeling, and\nexecutes through a single order path that is shared by paper and live trading.\n\n**Status: paper-first research tool.** Live trading is fenced behind two environment variables and a\nkill switch, and is off by default. Read [Money safety](#money-safety) before changing that.\n\n---\n\n## Table of contents\n\n- [What it does](#what-it-does)\n- [Quick start](#quick-start)\n- [Money safety](#money-safety)\n- [Architecture](#architecture)\n- [Venue economics you should know](#venue-economics-you-should-know)\n- [Backtesting: what is trustworthy and what is not](#backtesting-what-is-trustworthy-and-what-is-not)\n- [Known limitations](#known-limitations)\n- [Development](#development)\n- [Project layout](#project-layout)\n- [Documentation map](#documentation-map)\n- [Disclaimer](#disclaimer)\n\n---\n\n## What it does\n\n### Inefficiency detection\n\n**Four strategies reach the live scanner**, all fee-aware and all sourcing rates from\n`app/venues/fees.py` rather than literals:\n\n| Strategy | What it looks for | Venue scope | Live path |\n|---|---|---|---|\n| `binary_complement_arbitrage` | YES + NO priced below \\$1.00 on the same market | single venue | `scan()` |\n| `cross_venue_arbitrage` | The same event priced differently on Polymarket vs Kalshi | cross venue | `scan()` |\n| `multi_outcome_bundle_arbitrage` | All outcomes of an N-way market summing below \\$1.00 | single venue | `scan()` |\n| `settlement_edge` | Near-certain outcomes trading below \\$1.00 with a short lockup | single venue | `near_resolution_pass()` |\n\n**Nine are registered.** The other five are backtest-only, and the split is a real distinction rather\nthan a backlog. The scanner ranks a *riskless, fee-netted, settlement-realized* edge, and only these\nfour produce one — `scoring.py` will not read any other kind of number as if it were that:\n\n| Strategy | Why it is not on a live path |\n|---|---|\n| `favorite_compounder`, `no_bias_exploit` | Publish a **directional mispricing estimate**. Scoring refuses it by design — annualizing a directional punt as riskless arbitrage is the exact failure the edge-basis allowlist exists to prevent. `POST /arbitrage/scan?strategies=…` returns **400** naming them, not an empty list. |\n| `catalyst_momentum`, `correlation_hedging`, `term_structure_spreads` | Publish no edge figure at all, so they score 0.0. Reachable via an explicit `?strategies=`, and left reachable — they score poorly rather than being unscorable. |\n\nAll nine are backtestable via `POST /backtests`; `GET /backtests/strategies` lists them.\n\nTrader-mimicry strategies were **deliberately removed** — copying other accounts is not sustainable\nwith the data available, and the surface (whale tracking, copy trading, trader models and routes) was\ndeleted outright rather than left dormant.\n\n### Opportunity ranking\n\nEvery opportunity carries a seven-component score:\n\n```\ncomposite = annualized_return × fill_confidence × (1 − resolution_risk)\n```\n\nwith `net_edge`, `hours_to_resolution`, `capital_lockup_usd`, `link_status` and `depth_source`\nalongside. **Time-to-resolution is structurally unskippable** — `scoring.py` raises\n`UnscorableIntent` when a market has no resolution timestamp, so there is no code path that produces\na score without it.\n\n### Backtesting\n\nReplays market snapshots through the **same fill engine the paper trader uses**, walking real order\nbook depth level by level, applying per-venue fee models per fill, and settling positions at\nresolution. Produces a **capital sweep**: the same strategy run at multiple capital levels so you can\nsee where an edge dies under size.\n\n### Execution\n\nOne `OrderRouter` drives both paper and live. Multi-leg intents, per-venue capital ledgers,\ncrash-safe pending rows, an unwind path that records its realized loss, and a structural fence that\nmakes it impossible for order placement to live outside three named modules.\n\n---\n\n## Quick start\n\n### Install\n\n```bash\ncd backend && pip install -r requirements.txt\ncd ../frontend && npm ci\n```\n\nPython 3.12. No virtualenv is assumed.\n\n### Run the tests\n\n```bash\ncd backend && python3 -m pytest -q\n```\n\nRuns on SQLite in-memory via `aiosqlite` with **no network access** — every venue interaction in the\nsuite goes through recorded fixtures or `httpx.MockTransport`, enforced by GUARDRAILS §1.4.\n\nThe count is deliberately not quoted here: it has been wrong twice in this file's history, because a\nnumber in prose rots on the next commit while nothing checks it. `pytest -q` prints the current one.\n\n### Run a capital sweep with no database\n\n```bash\ncd backend\npython3 -m app.scripts.sweep --synthetic --levels 500,5000,50000 --out sweep.json\n```\n\nOutput looks like this — note that every row is labeled with the depth it was computed on:\n\n```\n     capital | net_return | annualized | trades | downsized |   util | depth_source | fill_at\n         500 |      4.53% |     71.49% |     12 |     16.7% |  96.1% |    synthetic |    next\n       5,000 |      4.18% |     64.67% |    104 |      0.0% |  91.0% |    synthetic |    next\n      50,000 |      2.45% |     34.20% |    582 |      0.0% |  32.1% |    synthetic |    next\n\nNOTE: No tested level pushed the annualized return below min_viable_annualized (5%); the top\nlevel tested was $50,000. The sweep ceiling is NOT proof the edge survives above that size.\n```\n\nThat closing caveat is printed, not buried in a field. An edge that lives at \\$500 and dies at \\$50k\nis a different product, and the sweep exists to make that visible.\n\n### Run the stack\n\n```bash\ndocker compose up\n```\n\nPostgres + TimescaleDB, Redis, the FastAPI backend, a Celery worker and beat, and the Vite frontend.\n\n### Preflight check\n\nRun this **before** `docker compose up` — it checks everything checkable without touching a venue\n(trading-mode fences, credential presence/shape, database reachability and migration state, the\nRedis/Celery broker, and a few settings this repo has shipped that looked configured and did\nnothing) and reports pass/warn/fail, grouped by concern:\n\n```bash\ncd backend\npython3 -m app.scripts.preflight\n```\n\n`--check-venues` additionally probes both venues' **public** endpoints (Kalshi\n`/exchange/status`, Polymarket Gamma `/markets`) and reports whether the exchange is open. It is\nopt-in so the default run keeps its promise of contacting no venue, and even with it the report still\nsays authentication was not checked — the probe sends no credential.\n\n```bash\npython3 -m app.scripts.preflight --check-venues\n```\n\nExit code is non-zero only on a real **FAIL** (something that would not work); a **WARN** means \"this\nworks, but the configuration probably doesn't mean what it says\" and never blocks the exit code — see\n`app/scripts/preflight.py`'s module docstring for why conflating the two is exactly the mistake to\navoid. Sample output, captured on a machine with no Postgres or Redis running (a fine demonstration —\nit shows the failure path is legible):\n\n```\nPreflight check (app.scripts.preflight)\n==============================================================================\n\n-- Trading mode & fences -----------------------------------------------------\n[PASS] TRADING_MODE='paper'\n[PASS] LIVE_TRADING_CONFIRMATION not set (fine for paper mode).\n[PASS] No kill-switch file at 'TRADING_KILL_SWITCH'.\n[PASS] DECISION: this process would NOT place real orders right now.\n\n-- Credentials (presence and shape only -- never a value) --------------------\n[WARN] POLYMARKET_PRIVATE_KEY: MISSING -- fine for paper mode; required for any Polymarket order ...\n[PASS] POLYMARKET_FUNDER_ADDRESS: not set -- optional, falls back to None.\n[PASS] POLYMARKET_API_KEY/SECRET/PASSPHRASE: none set; will be derived automatically from ...\n[WARN] Kalshi credentials: MISSING -- fine for paper mode; KALSHI_API_KEY_ID and ... both required ...\n\n-- Database ------------------------------------------------------------------\n[FAIL] cannot connect to postgresql+asyncpg://polymarket:***@localhost:5432/polymarket: OSError: ...\n\n-- Redis / Celery broker -----------------------------------------------------\n[FAIL] CELERY_BROKER_URL (redis://localhost:6379/0): unreachable -- ConnectionError: ...\n\n-- Settings that are set but inert -------------------------------------------\n[PASS] No known inert-configuration pattern detected.\n\n-- NOT checked by this tool --------------------------------------------------\n  - Venue connectivity -- Polymarket, Kalshi, and any Polygon RPC are never contacted by this tool ...\n  - Credential VALIDITY -- only presence and coarse shape are checked, never whether a venue accepts it.\n  - Whether the migration FILES apply cleanly to this database ...\n  - Whether a Celery worker or beat process is actually running and consuming from the broker ...\n  - Wallet or account balances at either venue.\n  - Frontend build/typecheck/lint (see Development below).\n\n==============================================================================\nSUMMARY: 12 passed, 2 warning(s), 2 failed -- overall FAIL (exit code 1)\n```\n\nA password embedded in `DATABASE_URL`/broker URLs is always masked (`user:***@`) before display, and a\ncredential is only ever reported as presence-and-shape (`set (PEM, 1704 bytes)`, `MISSING`) — never a\nvalue. `backend/tests/test_preflight.py` has a dedicated test asserting a recognisable fake secret\nnever appears anywhere in rendered output.\n\n---\n\n### Kalshi credentials\n\nKalshi API keys are created in your Kalshi account settings; you get a **Key ID** and download an\n**RSA private key** (shown once). Put them in `.env` yourself — `.gitignore` already covers it, and\nnothing in this repo ever prints a credential value.\n\n```dotenv\nKALSHI_API_KEY_ID=<your key id>\nKALSHI_ENV=prod                    # your kalshi.com account is production\nTRADING_MODE=paper                 # keep this; see Money safety below\n\n# The PEM MUST keep real newlines. Wrap it in double quotes:\nKALSHI_PRIVATE_KEY_PEM=\"-----BEGIN PRIVATE KEY-----\nMIIEvg...\n-----END PRIVATE KEY-----\"\n```\n\nThree traps, all verified rather than guessed:\n\n1. **An unquoted multi-line PEM does not parse.** Double-quoted multi-line works, and so does a\n   single line with `\\n` escapes inside double quotes (dotenv expands them). Unquoted fails.\n2. **`KALSHI_ENV` defaults to `demo`**, which is a *different site with its own account and its own\n   synthetic markets*. A kalshi.com key will not authenticate against it, and cross-venue arbitrage\n   computed against demo prices is meaningless. Set `prod`.\n3. **`KALSHI_ENV=prod` does not enable live trading.** It selects which data you read;\n   `TRADING_MODE` independently gates whether orders are real. With `prod` + `paper`, constructing a\n   live adapter is still refused by the fence (`LiveTradingDisabled`).\n\nVerify with `python3 -m app.scripts.preflight --check-venues`, which reports credential *presence and\nshape* only — never a value.\n\n## Money safety\n\nThese are not style preferences. Two of them were violated by this repo's own configuration and had\nto be corrected.\n\n### Run exactly ONE order-routing process\n\nThe near-resolution bucket cap and the position ledger are serialized by an `asyncio.Lock` scoped to\n**one event loop in one process** (`app/execution/router.py`). A second API worker or Celery worker\nsharing the database can breach the cap *and* lose filled positions to a concurrent-update overwrite.\n\nThis is measured, not hypothetical: a reproduction had the venue fill **1,802 contracts while the\nledger recorded 902**. The portable fix — optimistic concurrency with a `version` column on\n`positions` — **is not implemented**. `docker/backend/Dockerfile` pins `--workers 1` for this reason;\ndo not raise it.\n\n### Live trading requires two variables and the absence of a file\n\n```bash\nTRADING_MODE=live                                    # default: paper\nLIVE_TRADING_CONFIRMATION=I_UNDERSTAND_REAL_MONEY    # default: empty\n```\n\nNeither alone is sufficient. Additionally the kill-switch file must not exist — its path comes from\n`KILL_SWITCH_PATH` (default `TRADING_KILL_SWITCH`). Creating that file refuses all order placement\nuntil it is deleted.\n\n> The variable is `KILL_SWITCH_PATH`, **not** `TRADING_KILL_SWITCH_PATH`. Settings use\n> `extra=\"ignore\"`, so a misspelled name fails *silently* — an operator setting the wrong one during\n> an incident would halt nothing.\n\n### Order placement is structurally fenced\n\nExactly three modules may contain order-placement calls:\n\n- `backend/app/venues/polymarket/live.py`\n- `backend/app/venues/kalshi/live.py`\n- `backend/app/services/polymarket/client.py`\n\n`backend/tests/test_fences.py` enforces this with an **AST walk**, and — importantly — it carries a\n**positive control** that injects a placement call into a copy of a real module and asserts the\nwalker catches it, plus a paired negative control. That is what makes \"the fence passed\" mean \"it\nlooked and found nothing\" rather than \"it looked nowhere.\"\n\n### Never run migrations against a live database from tooling\n\nVerify offline only:\n\n```bash\ncd backend && alembic upgrade head --sql\n```\n\n---\n\n## Architecture\n\n### The venue seam\n\n`app/venues/base.py` defines a `VenueAdapter` protocol; `app/venues/polymarket/` and\n`app/venues/kalshi/` implement it. **Strategies are venue-agnostic** — they receive normalized types\nand never branch on venue identity.\n\nNormalization happens at the adapter boundary and nowhere else:\n\n- Prices are probabilities in `[0, 1]`. Kalshi's integer cents and dollar-strings are converted once,\n  at the adapter.\n- Sizes are contracts; each pays \\$1.00 at resolution.\n- Fees and cash are USD floats.\n- Datetimes are timezone-aware UTC, via `app/utils/time.py`.\n\n`tests/venues/test_adapter_contract.py` runs the same contract suite against both adapters from\nrecorded fixtures, including a test asserting that the unimplemented Kalshi WebSocket raises\n`NotImplementedError` rather than being faked.\n\n### One fill engine, two consumers\n\n`app/execution/fill_engine.py::SimulatedFillEngine` walks book depth level by level, honors tick size\nand minimum order size, charges **one fee call per level** (which matters enormously on Kalshi — see\nbelow), and returns partial fills with a typed decline reason.\n\nBoth the backtester and the paper adapter use it. A backtest and a paper trade of the same\nopportunity price identically, because it is the same code.\n\n### Capital is per venue\n\n`app/execution/ledger.py::CapitalLedger` tracks reserve/release/settle/credit/debit **per venue**.\nThere is no `transfer()`. Summing available balances across venues to size an order is a defect —\nfunds cannot move between Polymarket and Kalshi inside a trade, and the sizing code consumes\n`available_by_venue()` as a mapping and only ever takes a minimum.\n\n### Event linking is human-gated\n\nCross-venue arbitrage requires knowing that two markets describe the same event. The matcher\n(`app/services/matching/`) is deterministic — a vendored Porter stemmer, negation and comparison\ntokens preserved as content — and it **only ever writes `status=\"proposed\"`**. A human approves via\nthe `/links` API, which surfaces both venues' resolution text side by side. `LinkBook` raises on any\nnon-approved link, and the scanner filters to approved before building strategies.\n\nThere is deliberately **no LLM in the matching loop**.\n\n---\n\n## Venue economics you should know\n\nThese were measured against the repo's own fee models, not assumed.\n\n### Kalshi charges its fee ceiling per FILL; Polymarket does not\n\nA 100-contract order at p=0.98:\n\n| Fill shape | Kalshi total fee | Polymarket total fee |\n|---|---:|---:|\n| One block of 100 | **\\$0.14** | \\$0.098 |\n| 100 fills of 1 | **\\$1.00** | \\$0.098 |\n\nPolymarket's fee is `size × rate × p × (1−p)` — linear in size and **flat in fill count**. Kalshi\napplies a whole-cent ceiling **once per fill**, so fragmenting a 100-lot across 100 thin levels\nconsumes half the gross edge in fees alone.\n\n**The practical consequence: on Kalshi, *how* an order fills matters as much as the price it fills\nat.** A depth-walking engine that fragments across thin levels is quietly expensive there and never\non Polymarket. This is why recorded depth changes what Kalshi actually costs.\n\nThe often-quoted \"~40× venue asymmetry\" is **fragmentation-driven**, not a flat per-contract penalty:\nit is ~40× at one contract and ~1.2× at 100 contracts in a single fill.\n\n### Polymarket's fee collapses at the tails\n\n`p × (1−p)` goes to zero as price approaches 0 or 1, which is exactly where near-resolution trades\nlive. At the default 0.05 category rate, 100 contracts cost \\$1.25 at p=0.50 and \\$0.098 at p=0.98.\n\n### Settlement-edge returns look better than they are\n\nA near-certain outcome at 0.98 with 30 hours to resolution annualizes to several hundred percent —\nbut the absolute profit is **under two cents per contract**, you are locking up 98¢ to earn it, and\nyou are short a small, rare, total loss if \"determined\" turns out wrong. High annualized return on a\nshort lockup is a *capital-efficiency* number, not a margin of safety.\n\n---\n\n## Backtesting: what is trustworthy and what is not\n\n### Integrity properties that are enforced\n\n- **No look-ahead.** Fills happen at the *next* snapshot by default (`fill_at=\"next\"`). A recorded\n  book is attached only if its timestamp is at or within the match window *before* the price row —\n  a book one microsecond later is rejected.\n- **Real depth when it exists.** `book_snapshots` stores recorded books; the replayer attaches them\n  and the engine synthesizes only when none exists.\n- **Settlement at resolution**, with redemption gas charged per position.\n- **Every result is labeled.** `depth_source` (`recorded` / `synthetic` / `mixed`) and `fill_at`\n  travel with every number, to the CLI, the JSON, and the UI.\n\n### The labeling rule\n\n> Any metric computed on synthetic depth is labeled as such **wherever it is shown**.\n\nThis is a standing project rule, not a footnote. A `synthetic` badge appears on the results view and\non every sweep row, as visible text rather than a tooltip. A result with no label renders no badge\nrather than defaulting to `recorded` — an unlabeled synthetic run showing a confident \"recorded\"\nbadge would be worse than showing nothing.\n\n### Distinguishing \"no edge\" from \"not measurable\"\n\nA strategy that cannot obtain a book produces zero trades at every capital level — the identical\nsignature to a strategy with genuinely no edge. `CapitalRow.zero_trades_cause` separates them\n(`\"no_signal\"` vs `\"structural: …\"`), and `EdgeDecayReport.unmeasurable_note` fires when\n`edge_dies_at` is anchored to a structural row. **A structural row is not evidence about edge.**\n\n---\n\n## Known limitations\n\nStated plainly, because a limitation you cannot see is worse than one you can.\n\n1. **Multi-outcome bundle strategies cannot be backtested.** `MarketSnapshot.book` holds a single\n   order book and every leg of a bundle shares one market snapshot, so an N-outcome bundle can carry\n   at most one book; the remaining legs get no depth and the all-or-none intent never executes. The\n   **live scanning path handles arbitrary outcome labels correctly** — this is backtest-specific.\n2. **A binary complement's NO leg fills against synthesized depth** even when a recorded NO book\n   exists, for the same single-`book`-field reason. Runs are honestly labeled `mixed`, but \"mixed\"\n   here means \"every complement intent is half-recorded by construction.\"\n3. **`PriceHistory` has no outcome column**, so the DB-backed replayer can only attach a `\"YES\"`\n   book, and every recorded Kalshi book is currently dead data for DB-backed backtests.\n4. **Strategies do not price fees on a uniform basis.** Realized P&L is unaffected (the fill engine\n   charges the true per-level fee), but the *emission gates* differ, so which opportunities exist at\n   all is not calibrated identically across strategies.\n\nSee `HANDOFF.md` for the current remediation queue.\n\n---\n\n## Development\n\n```bash\ncd backend\npython3 -m pytest -q                    # full suite\nruff check app/services/backtesting     # scope lint to what you changed\nmypy app/services/backtesting\nalembic heads                           # expect 007 (head)\nalembic upgrade head --sql              # offline DDL, never against a live DB\n```\n\n```bash\ncd frontend\nnpx tsc -p tsconfig.app.json --noEmit\nnpm run lint\n```\n\n**Scope lint gates to the files you touch.** The repo carries a legacy baseline of ~139 ruff\nfindings; new and changed modules must be clean, untouched legacy files are not a gate.\n\n### Testing conventions\n\n- Every money-math test states its expected number **by hand in a comment**. A test that computes its\n  expectation with the code under test is not a test.\n- Prove new tests **red-green**: revert the fix in a scratch copy, confirm the test fails, restore.\n  This repo has shipped a test that passed vacuously by proving `0 == 0`, and an acceptance criterion\n  satisfied by score keys being \"present and non-null\" while the value was structurally zero.\n- Tests never touch the network, never place orders, and never set `TRADING_MODE=live`.\n\n---\n\n## Project layout\n\n```\nbackend/app/\n├── venues/              # VenueAdapter protocol, types, fee models, registry\n│   ├── polymarket/      #   adapter.py, live.py (order placement allowed)\n│   ├── kalshi/          #   adapter.py, live.py (order placement allowed)\n│   └── paper.py         #   PaperVenueAdapter — simulated fills\n├── execution/           # router.py, ledger.py, fences.py, fill_engine.py, reconcile.py\n├── strategies/          # nine strategies (four on the live scanner) + base types\n├── services/\n│   ├── matching/        # deterministic event matcher (normalize.py, matcher.py)\n│   ├── backtesting/     # engine.py, data_replay.py, metrics.py, sweep.py\n│   ├── scoring.py       # the seven-component opportunity score\n│   ├── scanner.py       # scan() and near_resolution_pass()\n│   └── data_collector.py\n├── models/              # SQLAlchemy 2.0 async models\n├── api/routes/          # FastAPI routes\n└── tasks/               # Celery tasks and beat schedule\n\nfrontend/src/\n├── components/opportunities/    # OpportunitiesTable\n├── components/backtesting/      # EdgeDecayTable, BacktestResults, DepthBadges\n├── hooks/                       # useOpportunities, useEdgeDecay, useTradingMode\n└── services/api.ts\n```\n\n---\n\n## Documentation map\n\n| Document | What is in it |\n|---|---|\n| `CLAUDE.md` | Project structure, tech stack, **money invariants**, environment variables |\n| `HANDOFF.md` | Current state, in-flight work, remediation queue, process lessons |\n| `.claude/kits/market-edge/PLAN.md` | Architecture decisions D1–D13 with rationale, pinned venue API facts |\n| `.claude/kits/market-edge/GUARDRAILS.md` | Absolute money rules (§1), conventions, testing rules |\n| `.claude/kits/market-edge/NOTES.md` | Full execution ledger — every finding, defect and adjudication |\n| `.claude/skills/kalshi-api/SKILL.md` | Kalshi auth, payloads, order book encoding, fees |\n| `.claude/skills/polymarket-api/SKILL.md` | Polymarket endpoints, fee formula, category rate table |\n\n---\n\n## Disclaimer\n\nThis is a research tool, not investment advice.\n\nBacktest results assume execution at recorded or synthesized prices and will not match live results.\nSynthetic depth is invented depth — a number computed on it is a hypothesis, not a measurement. Paper\ntrade first, and understand the regulatory position of prediction markets in your jurisdiction before\ndeploying capital. The authors accept no responsibility for trading losses.\n",
  "bytes": 23761,
  "sha": "9b96173e9ae6c7ac0eaf9128439dd2157c62fc269f928202d6b630dd2d52b889",
  "repo_slug": "agentmc15/polymarket-trader",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/skl_agentmc15_polymarket_trader_trading_stra_b7ce1a87/readme"
}