Back to the catalog

trading-strategies

1.208 instalações · agentmc15/polymarket-trader

Open source Repository Open in the app JSON README (API)

About

Skill publicada por agentmc15/polymarket-trader, com 1.208 instalações medidas pelo skills.sh. Instale com: npx skills add agentmc15/polymarket-trader@trading-strategies

Details

Kind
Agent skills
Topic
Finance & crypto
Publisher
agentmc15
Origin
skillssh
Category
ferramentas
Stars
26
Forks
6
Last push
2026-09-07T04:12:17Z
Repository state
ativo
Language
Python
Added
2026-08-30 15:22:39
Updated
2026-09-01 17:01:48
Origin id
agentmc15/polymarket-trader/trading-strategies

README

# Prediction Market Edge Engine

A multi-venue inefficiency detection, backtesting, and execution engine for prediction markets.
It scans **Polymarket** and **Kalshi** for pricing dislocations, ranks them by a
time-to-resolution-aware score, backtests them against recorded depth with honest labeling, and
executes through a single order path that is shared by paper and live trading.

**Status: paper-first research tool.** Live trading is fenced behind two environment variables and a
kill switch, and is off by default. Read [Money safety](#money-safety) before changing that.

---

## Table of contents

- [What it does](#what-it-does)
- [Quick start](#quick-start)
- [Money safety](#money-safety)
- [Architecture](#architecture)
- [Venue economics you should know](#venue-economics-you-should-know)
- [Backtesting: what is trustworthy and what is not](#backtesting-what-is-trustworthy-and-what-is-not)
- [Known limitations](#known-limitations)
- [Development](#development)
- [Project layout](#project-layout)
- [Documentation map](#documentation-map)
- [Disclaimer](#disclaimer)

---

## What it does

### Inefficiency detection

**Four strategies reach the live scanner**, all fee-aware and all sourcing rates from
`app/venues/fees.py` rather than literals:

| Strategy | What it looks for | Venue scope | Live path |
|---|---|---|---|
| `binary_complement_arbitrage` | YES + NO priced below \$1.00 on the same market | single venue | `scan()` |
| `cross_venue_arbitrage` | The same event priced differently on Polymarket vs Kalshi | cross venue | `scan()` |
| `multi_outcome_bundle_arbitrage` | All outcomes of an N-way market summing below \$1.00 | single venue | `scan()` |
| `settlement_edge` | Near-certain outcomes trading below \$1.00 with a short lockup | single venue | `near_resolution_pass()` |

**Nine are registered.** The other five are backtest-only, and the split is a real distinction rather
than a backlog. The scanner ranks a *riskless, fee-netted, settlement-realized* edge, and only these
four produce one — `scoring.py` will not read any other kind of number as if it were that:

| Strategy | Why it is not on a live path |
|---|---|
| `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. |
| `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. |

All nine are backtestable via `POST /backtests`; `GET /backtests/strategies` lists them.

Trader-mimicry strategies were **deliberately removed** — copying other accounts is not sustainable
with the data available, and the surface (whale tracking, copy trading, trader models and routes) was
deleted outright rather than left dormant.

### Opportunity ranking

Every opportunity carries a seven-component score:

```
composite = annualized_return × fill_confidence × (1 − resolution_risk)
```

with `net_edge`, `hours_to_resolution`, `capital_lockup_usd`, `link_status` and `depth_source`
alongside. **Time-to-resolution is structurally unskippable** — `scoring.py` raises
`UnscorableIntent` when a market has no resolution timestamp, so there is no code path that produces
a score without it.

### Backtesting

Replays market snapshots through the **same fill engine the paper trader uses**, walking real order
book depth level by level, applying per-venue fee models per fill, and settling positions at
resolution. Produces a **capital sweep**: the same strategy run at multiple capital levels so you can
see where an edge dies under size.

### Execution

One `OrderRouter` drives both paper and live. Multi-leg intents, per-venue capital ledgers,
crash-safe pending rows, an unwind path that records its realized loss, and a structural fence that
makes it impossible for order placement to live outside three named modules.

---

## Quick start

### Install

```bash
cd backend && pip install -r requirements.txt
cd ../frontend && npm ci
```

Python 3.12. No virtualenv is assumed.

### Run the tests

```bash
cd backend && python3 -m pytest -q
```

Runs on SQLite in-memory via `aiosqlite` with **no network access** — every venue interaction in the
suite goes through recorded fixtures or `httpx.MockTransport`, enforced by GUARDRAILS §1.4.

The count is deliberately not quoted here: it has been wrong twice in this file's history, because a
number in prose rots on the next commit while nothing checks it. `pytest -q` prints the current one.

### Run a capital sweep with no database

```bash
cd backend
python3 -m app.scripts.sweep --synthetic --levels 500,5000,50000 --out sweep.json
```

Output looks like this — note that every row is labeled with the depth it was computed on:

```
     capital | net_return | annualized | trades | downsized |   util | depth_source | fill_at
         500 |      4.53% |     71.49% |     12 |     16.7% |  96.1% |    synthetic |    next
       5,000 |      4.18% |     64.67% |    104 |      0.0% |  91.0% |    synthetic |    next
      50,000 |      2.45% |     34.20% |    582 |      0.0% |  32.1% |    synthetic |    next

NOTE: No tested level pushed the annualized return below min_viable_annualized (5%); the top
level tested was $50,000. The sweep ceiling is NOT proof the edge survives above that size.
```

That closing caveat is printed, not buried in a field. An edge that lives at \$500 and dies at \$50k
is a different product, and the sweep exists to make that visible.

### Run the stack

```bash
docker compose up
```

Postgres + TimescaleDB, Redis, the FastAPI backend, a Celery worker and beat, and the Vite frontend.

### Preflight check

Run this **before** `docker compose up` — it checks everything checkable without touching a venue
(trading-mode fences, credential presence/shape, database reachability and migration state, the
Redis/Celery broker, and a few settings this repo has shipped that looked configured and did
nothing) and reports pass/warn/fail, grouped by concern:

```bash
cd backend
python3 -m app.scripts.preflight
```

`--check-venues` additionally probes both venues' **public** endpoints (Kalshi
`/exchange/status`, Polymarket Gamma `/markets`) and reports whether the exchange is open. It is
opt-in so the default run keeps its promise of contacting no venue, and even with it the report still
says authentication was not checked — the probe sends no credential.

```bash
python3 -m app.scripts.preflight --check-venues
```

Exit code is non-zero only on a real **FAIL** (something that would not work); a **WARN** means "this
works, but the configuration probably doesn't mean what it says" and never blocks the exit code — see
`app/scripts/preflight.py`'s module docstring for why conflating the two is exactly the mistake to
avoid. Sample output, captured on a machine with no Postgres or Redis running (a fine demonstration —
it shows the failure path is legible):

```
Preflight check (app.scripts.preflight)
==============================================================================

-- Trading mode & fences -----------------------------------------------------
[PASS] TRADING_MODE='paper'
[PASS] LIVE_TRADING_CONFIRMATION not set (fine for paper mode).
[PASS] No kill-switch file at 'TRADING_KILL_SWITCH'.
[PASS] DECISION: this process would NOT place real orders right now.

-- Credentials (presence and shape only -- never a value) --------------------
[WARN] POLYMARKET_PRIVATE_KEY: MISSING -- fine for paper mode; required for any Polymarket order ...
[PASS] POLYMARKET_FUNDER_ADDRESS: not set -- optional, falls back to None.
[PASS] POLYMARKET_API_KEY/SECRET/PASSPHRASE: none set; will be derived automatically from ...
[WARN] Kalshi credentials: MISSING -- fine for paper mode; KALSHI_API_KEY_ID and ... both required ...

-- Database ------------------------------------------------------------------
[FAIL] cannot connect to postgresql+asyncpg://polymarket:***@localhost:5432/polymarket: OSError: ...

-- Redis / Celery broker -----------------------------------------------------
[FAIL] CELERY_BROKER_URL (redis://localhost:6379/0): unreachable -- ConnectionError: ...

-- Settings that are set but inert -------------------------------------------
[PASS] No known inert-configuration pattern detected.

-- NOT checked by this tool --------------------------------------------------
  - Venue connectivity -- Polymarket, Kalshi, and any Polygon RPC are never contacted by this tool ...
  - Credential VALIDITY -- only presence and coarse shape are checked, never whether a venue accepts it.
  - Whether the migration FILES apply cleanly to this database ...
  - Whether a Celery worker or beat process is actually running and consuming from the broker ...
  - Wallet or account balances at either venue.
  - Frontend build/typecheck/lint (see Development below).

==============================================================================
SUMMARY: 12 passed, 2 warning(s), 2 failed -- overall FAIL (exit code 1)
```

A password embedded in `DATABASE_URL`/broker URLs is always masked (`user:***@`) before display, and a
credential is only ever reported as presence-and-shape (`set (PEM, 1704 bytes)`, `MISSING`) — never a
value. `backend/tests/test_preflight.py` has a dedicated test asserting a recognisable fake secret
never appears anywhere in rendered output.

---

### Kalshi credentials

Kalshi API keys are created in your Kalshi account settings; you get a **Key ID** and download an
**RSA private key** (shown once). Put them in `.env` yourself — `.gitignore` already covers it, and
nothing in this repo ever prints a credential value.

```dotenv
KALSHI_API_KEY_ID=<your key id>
KALSHI_ENV=prod                    # your kalshi.com account is production
TRADING_MODE=paper                 # keep this; see Money safety below

# The PEM MUST keep real newlines. Wrap it in double quotes:
KALSHI_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----
MIIEvg...
-----END PRIVATE KEY-----"
```

Three traps, all verified rather than guessed:

1. **An unquoted multi-line PEM does not parse.** Double-quoted multi-line works, and so does a
   single line with `\n` escapes inside double quotes (dotenv expands them). Unquoted fails.
2. **`KALSHI_ENV` defaults to `demo`**, which is a *different site with its own account and its own
   synthetic markets*. A kalshi.com key will not authenticate against it, and cross-venue arbitrage
   computed against demo prices is meaningless. Set `prod`.
3. **`KALSHI_ENV=prod` does not enable live trading.** It selects which data you read;
   `TRADING_MODE` independently gates whether orders are real. With `prod` + `paper`, constructing a
   live adapter is still refused by the fence (`LiveTradingDisabled`).

Verify with `python3 -m app.scripts.preflight --check-venues`, which reports credential *presence and
shape* only — never a value.

## Money safety

These are not style preferences. Two of them were violated by this repo's own configuration and had
to be corrected.

### Run exactly ONE order-routing process

The near-resolution bucket cap and the position ledger are serialized by an `asyncio.Lock` scoped to
**one event loop in one process** (`app/execution/router.py`). A second API worker or Celery worker
sharing the database can breach the cap *and* lose filled positions to a concurrent-update overwrite.

This is measured, not hypothetical: a reproduction had the venue fill **1,802 contracts while the
ledger recorded 902**. The portable fix — optimistic concurrency with a `version` column on
`positions` — **is not implemented**. `docker/backend/Dockerfile` pins `--workers 1` for this reason;
do not raise it.

### Live trading requires two variables and the absence of a file

```bash
TRADING_MODE=live                                    # default: paper
LIVE_TRADING_CONFIRMATION=I_UNDERSTAND_REAL_MONEY    # default: empty
```

Neither alone is sufficient. Additionally the kill-switch file must not exist — its path comes from
`KILL_SWITCH_PATH` (default `TRADING_KILL_SWITCH`). Creating that file refuses all order placement
until it is deleted.

> The variable is `KILL_SWITCH_PATH`, **not** `TRADING_KILL_SWITCH_PATH`. Settings use
> `extra="ignore"`, so a misspelled name fails *silently* — an operator setting the wrong one during
> an incident would halt nothing.

### Order placement is structurally fenced

Exactly three modules may contain order-placement calls:

- `backend/app/venues/polymarket/live.py`
- `backend/app/venues/kalshi/live.py`
- `backend/app/services/polymarket/client.py`

`backend/tests/test_fences.py` enforces this with an **AST walk**, and — importantly — it carries a
**positive control** that injects a placement call into a copy of a real module and asserts the
walker catches it, plus a paired negative control. That is what makes "the fence passed" mean "it
looked and found nothing" rather than "it looked nowhere."

### Never run migrations against a live database from tooling

Verify offline only:

```bash
cd backend && alembic upgrade head --sql
```

---

## Architecture

### The venue seam

`app/venues/base.py` defines a `VenueAdapter` protocol; `app/venues/polymarket/` and
`app/venues/kalshi/` implement it. **Strategies are venue-agnostic** — they receive normalized types
and never branch on venue identity.

Normalization happens at the adapter boundary and nowhere else:

- Prices are probabilities in `[0, 1]`. Kalshi's integer cents and dollar-strings are converted once,
  at the adapter.
- Sizes are contracts; each pays \$1.00 at resolution.
- Fees and cash are USD floats.
- Datetimes are timezone-aware UTC, via `app/utils/time.py`.

`tests/venues/test_adapter_contract.py` runs the same contract suite against both adapters from
recorded fixtures, including a test asserting that the unimplemented Kalshi WebSocket raises
`NotImplementedError` rather than being faked.

### One fill engine, two consumers

`app/execution/fill_engine.py::SimulatedFillEngine` walks book depth level by level, honors tick size
and minimum order size, charges **one fee call per level** (which matters enormously on Kalshi — see
below), and returns partial fills with a typed decline reason.

Both the backtester and the paper adapter use it. A backtest and a paper trade of the same
opportunity price identically, because it is the same code.

### Capital is per venue

`app/execution/ledger.py::CapitalLedger` tracks reserve/release/settle/credit/debit **per venue**.
There is no `transfer()`. Summing available balances across venues to size an order is a defect —
funds cannot move between Polymarket and Kalshi inside a trade, and the sizing code consumes
`available_by_venue()` as a mapping and only ever takes a minimum.

### Event linking is human-gated

Cross-venue arbitrage requires knowing that two markets describe the same event. The matcher
(`app/services/matching/`) is deterministic — a vendored Porter stemmer, negation and comparison
tokens preserved as content — and it **only ever writes `status="proposed"`**. A human approves via
the `/links` API, which surfaces both venues' resolution text side by side. `LinkBook` raises on any
non-approved link, and the scanner filters to approved before building strategies.

There is deliberately **no LLM in the matching loop**.

---

## Venue economics you should know

These were measured against the repo's own fee models, not assumed.

### Kalshi charges its fee ceiling per FILL; Polymarket does not

A 100-contract order at p=0.98:

| Fill shape | Kalshi total fee | Polymarket total fee |
|---|---:|---:|
| One block of 100 | **\$0.14** | \$0.098 |
| 100 fills of 1 | **\$1.00** | \$0.098 |

Polymarket's fee is `size × rate × p × (1−p)` — linear in size and **flat in fill count**. Kalshi
applies a whole-cent ceiling **once per fill**, so fragmenting a 100-lot across 100 thin levels
consumes half the gross edge in fees alone.

**The practical consequence: on Kalshi, *how* an order fills matters as much as the price it fills
at.** A depth-walking engine that fragments across thin levels is quietly expensive there and never
on Polymarket. This is why recorded depth changes what Kalshi actually costs.

The often-quoted "~40× venue asymmetry" is **fragmentation-driven**, not a flat per-contract penalty:
it is ~40× at one contract and ~1.2× at 100 contracts in a single fill.

### Polymarket's fee collapses at the tails

`p × (1−p)` goes to zero as price approaches 0 or 1, which is exactly where near-resolution trades
live. At the default 0.05 category rate, 100 contracts cost \$1.25 at p=0.50 and \$0.098 at p=0.98.

### Settlement-edge returns look better than they are

A near-certain outcome at 0.98 with 30 hours to resolution annualizes to several hundred percent —
but the absolute profit is **under two cents per contract**, you are locking up 98¢ to earn it, and
you are short a small, rare, total loss if "determined" turns out wrong. High annualized return on a
short lockup is a *capital-efficiency* number, not a margin of safety.

---

## Backtesting: what is trustworthy and what is not

### Integrity properties that are enforced

- **No look-ahead.** Fills happen at the *next* snapshot by default (`fill_at="next"`). A recorded
  book is attached only if its timestamp is at or within the match window *before* the price row —
  a book one microsecond later is rejected.
- **Real depth when it exists.** `book_snapshots` stores recorded books; the replayer attaches them
  and the engine synthesizes only when none exists.
- **Settlement at resolution**, with redemption gas charged per position.
- **Every result is labeled.** `depth_source` (`recorded` / `synthetic` / `mixed`) and `fill_at`
  travel with every number, to the CLI, the JSON, and the UI.

### The labeling rule

> Any metric computed on synthetic depth is labeled as such **wherever it is shown**.

This is a standing project rule, not a footnote. A `synthetic` badge appears on the results view and
on every sweep row, as visible text rather than a tooltip. A result with no label renders no badge
rather than defaulting to `recorded` — an unlabeled synthetic run showing a confident "recorded"
badge would be worse than showing nothing.

### Distinguishing "no edge" from "not measurable"

A strategy that cannot obtain a book produces zero trades at every capital level — the identical
signature to a strategy with genuinely no edge. `CapitalRow.zero_trades_cause` separates them
(`"no_signal"` vs `"structural: …"`), and `EdgeDecayReport.unmeasurable_note` fires when
`edge_dies_at` is anchored to a structural row. **A structural row is not evidence about edge.**

---

## Known limitations

Stated plainly, because a limitation you cannot see is worse than one you can.

1. **Multi-outcome bundle strategies cannot be backtested.** `MarketSnapshot.book` holds a single
   order book and every leg of a bundle shares one market snapshot, so an N-outcome bundle can carry
   at most one book; the remaining legs get no depth and the all-or-none intent never executes. The
   **live scanning path handles arbitrary outcome labels correctly** — this is backtest-specific.
2. **A binary complement's NO leg fills against synthesized depth** even when a recorded NO book
   exists, for the same single-`book`-field reason. Runs are honestly labeled `mixed`, but "mixed"
   here means "every complement intent is half-recorded by construction."
3. **`PriceHistory` has no outcome column**, so the DB-backed replayer can only attach a `"YES"`
   book, and every recorded Kalshi book is currently dead data for DB-backed backtests.
4. **Strategies do not price fees on a uniform basis.** Realized P&L is unaffected (the fill engine
   charges the true per-level fee), but the *emission gates* differ, so which opportunities exist at
   all is not calibrated identically across strategies.

See `HANDOFF.md` for the current remediation queue.

---

## Development

```bash
cd backend
python3 -m pytest -q                    # full suite
ruff check app/services/backtesting     # scope lint to what you changed
mypy app/services/backtesting
alembic heads                           # expect 007 (head)
alembic upgrade head --sql              # offline DDL, never against a live DB
```

```bash
cd frontend
npx tsc -p tsconfig.app.json --noEmit
npm run lint
```

**Scope lint gates to the files you touch.** The repo carries a legacy baseline of ~139 ruff
findings; new and changed modules must be clean, untouched legacy files are not a gate.

### Testing conventions

- Every money-math test states its expected number **by hand in a comment**. A test that computes its
  expectation with the code under test is not a test.
- Prove new tests **red-green**: revert the fix in a scratch copy, confirm the test fails, restore.
  This repo has shipped a test that passed vacuously by proving `0 == 0`, and an acceptance criterion
  satisfied by score keys being "present and non-null" while the value was structurally zero.
- Tests never touch the network, never place orders, and never set `TRADING_MODE=live`.

---

## Project layout

```
backend/app/
├── venues/              # VenueAdapter protocol, types, fee models, registry
│   ├── polymarket/      #   adapter.py, live.py (order placement allowed)
│   ├── kalshi/          #   adapter.py, live.py (order placement allowed)
│   └── paper.py         #   PaperVenueAdapter — simulated fills
├── execution/           # router.py, ledger.py, fences.py, fill_engine.py, reconcile.py
├── strategies/          # nine strategies (four on the live scanner) + base types
├── services/
│   ├── matching/        # deterministic event matcher (normalize.py, matcher.py)
│   ├── backtesting/     # engine.py, data_replay.py, metrics.py, sweep.py
│   ├── scoring.py       # the seven-component opportunity score
│   ├── scanner.py       # scan() and near_resolution_pass()
│   └── data_collector.py
├── models/              # SQLAlchemy 2.0 async models
├── api/routes/          # FastAPI routes
└── tasks/               # Celery tasks and beat schedule

frontend/src/
├── components/opportunities/    # OpportunitiesTable
├── components/backtesting/      # EdgeDecayTable, BacktestResults, DepthBadges
├── hooks/                       # useOpportunities, useEdgeDecay, useTradingMode
└── services/api.ts
```

---

## Documentation map

| Document | What is in it |
|---|---|
| `CLAUDE.md` | Project structure, tech stack, **money invariants**, environment variables |
| `HANDOFF.md` | Current state, in-flight work, remediation queue, process lessons |
| `.claude/kits/market-edge/PLAN.md` | Architecture decisions D1–D13 with rationale, pinned venue API facts |
| `.claude/kits/market-edge/GUARDRAILS.md` | Absolute money rules (§1), conventions, testing rules |
| `.claude/kits/market-edge/NOTES.md` | Full execution ledger — every finding, defect and adjudication |
| `.claude/skills/kalshi-api/SKILL.md` | Kalshi auth, payloads, order book encoding, fees |
| `.claude/skills/polymarket-api/SKILL.md` | Polymarket endpoints, fee formula, category rate table |

---

## Disclaimer

This is a research tool, not investment advice.

Backtest results assume execution at recorded or synthesized prices and will not match live results.
Synthetic depth is invented depth — a number computed on it is a hypothesis, not a measurement. Paper
trade first, and understand the regulatory position of prediction markets in your jurisdiction before
deploying capital. The authors accept no responsibility for trading losses.

More