Trade crypto up/down prediction markets on Polymarket. Multiple market durations (e.g. btc-updown-5m-, btc-updown-15m-).
Rust (src/) — strategies, backtester, position math, volatility, session cache, live collector + bot workers.
Sports (src/sports/) — discovery, live feeds, fair value, adaptive cadence, per-game storage.
Dashboard (dashboard/) — Next.js 15 (App Router) + tRPC + Express, port 3456.
clob-bridge (clob-bridge/) — Python Flask wrapper around py_clob_client, port 8421.
model-server (model-server/) — Python Dixon-Coles fitting, serves rate params, port 8422.
claim-server (claim-server/) — Python claim/redeem service, port 8080.
See the /architecture page in the dashboard for the full visual reference. If you change the architecture (new binaries, IPC, modules, ports), update dashboard/src/components/Architecture.tsx.
collector— 1 per market, RTDS + CLOB polling, writes sessions, broadcasts ticks via Unix socketbot-worker— 1 per strategy, reads ticks from collector's socket, trades via clob-bridgeorchestrator— optional process manager (:8420), spawns collector/workers/clob-bridgebacktest— offline param sweeps with rayon parallelismsports-collector— 1 per league, N concurrent games, adaptive cadence, read-onlysports-bot-worker— 1 per strategy per league, owns the allocator + confirmation gatesports-backtest— overlapping-game simulator, depth-aware fills, CLV/calibration
Collectors and bot-workers run independently — the orchestrator is optional convenience.
New market every 5 min. Up and Down tokens — winner pays $1, loser $0 (Chainlink oracle). Book is thin (best_bid=0.01, best_ask=0.99). The midpoint is the real signal (~0.50 at open -> ~0.99 on winning side). Edge comes from picking direction, not better fills.
Limit orders, not FAK. Always use limit orders with expiration_seconds and cancel_after_s.
- All trades through
ClobExecutor— no ad-hoc order scripts - Read before modifying execution code (
execute_trade,compute_trade_size,apply_trade,resolve,process_tick) - Position in sync —
apply_trade()only after successful CLOB order - Private keys sacred —
.env, never logged/committed - Log everything — trades, sessions, API calls, backtests all to
data/ - When in doubt, do not trade
A game is a session. Legs are indexed: 0=home, 1=away, 2=draw (trader::leg_index_for_key).
- Never trade an unjoined game. A
MarketGroupwithout agame_idhas no live state behind it.is_tradeable()enforces this; an ambiguous team-name join is refused, not guessed. - Never trade on fallback model params.
FairValueSource::ModelFallbackmeans league averages with no opinion about who is better — an "edge" against them is an artefact. Strategies withrequires_model()refuse it. - Record book depth, always. The backtest fill model walks recorded levels; depth cannot be backfilled.
- ≤3 tunable params per sports strategy. 272 NFL games a year cannot support more; a registry test enforces it.
- Judge on calibration and CLV, not PnL. PnL has almost no statistical power at sports sample sizes. Walk-forward splits by season, never randomly.
- Confirmation before action. A goal can be disallowed and a touchdown reversed.
ConfirmationGateapplies a delay, two-feed agreement, and review suspension. - Staleness is not flatline. Crypto's
FlatlineDetectortreats a frozen book as broken data and suppresses trading. Sportsstalenesstreats it as the opportunity — gate it on the book being alive. - Selection is flags only, never a config file.
--team,--exclude-team,--game,--min-start/--max-start,--market-kind.--gameoverrides team filters. Bad flag values exit 2 before anything starts. - Never fill the concurrency cap by iteration order.
sessionsis aHashMapand Rust randomises its order per process, so first-come gave a different arbitrary subset every restart. Rank withselection::rank—--prefer randomis seeded and reproducible;edge/scheduled/liquidityare deterministic. Open positions always outrank contenders so they can exit. - Strategies iterate
ctx.tradeable_legs(), notgroup.moneyline_legs(), so--market-kindactually reaches spread and total legs. - Every rejected game is logged to
markets/<league>/skipped.jsonlwith a reason. - Storage is append-only JSONL.
write_live_sessionrewrites the whole session and is quadratic over a 3-hour game.
- Trading logic in
src/— strategies, position math, trade sizing, simulation all live in Rust. - Bump strategy version on logic changes (once per editing session)
--recent 30for coarse grid sweeps,--recent 500for final validation- Rust toolchain:
cargo build --release,cargo test,cargo run --bin backtest - Strategies: trait at
src/strategy.rs, implementations atsrc/strategies/, registry atsrc/strategies/mod.rs(all_strategies()). - Regime-gated strategies check vol/trend regime at session start and set
_regime_active. Inon_tick, returnhold()when inactive. Regime thresholds are NOT tunable params. requires_full_session = truewhen strategy accumulates state from tick 0 (jump counts, warmup windows, running sums)- No magic numbers — global constants in
src/defaults.rs; market-specific insrc/markets.rs. - Tests must pass after changes. Run
cargo testand fix any failures before considering work done. - The golden backtest guards the execution path.
tests/golden_backtest.rssnapshots per-session PnL and trade tapes for every strategy against synthetic sessions. If it fails, trading behaviour changed. Regenerate only deliberately:UPDATE_GOLDEN=1 cargo test --test golden_backtest. - Per-market economics. Fees, tick size and min order size live in
MarketEconomics, not global constants. Do not reintroduce hardcodedPRICE_DECIMALS/CLOB_MIN_ORDER_TOKENSin execution paths. - Dashboard: Next.js 15 App Router, tRPC, Tailwind v4. Pages in
dashboard/src/app/, components indashboard/src/components/, tRPC routers indashboard/src/server/routers/.
Backtest CLI: ./target/release/backtest <data_dir> [options]
# Run all strategies on 30 recent sessions
./target/release/backtest data/markets/btc-updown-5m/sessions --strategy all --recent 30
# Single strategy with param sweep
./target/release/backtest data/markets/btc-updown-5m/sessions --strategy PoissonKelly --sweep --recent 30
# Param overrides
./target/release/backtest data/markets/btc-updown-5m/sessions --strategy DriftSniper --recent 30 \
--param min_elapsed=0.4,0.5,0.6 --param stop_loss_pct=0.05,0.10
# Compound mode
./target/release/backtest data/markets/btc-updown-5m/sessions --strategy PoissonKelly --recent 700 \
--cash 100 --session-cash-ratio 0.20:99999Sports CLI:
Game selection is flags only — there is no persisted config. To change what
a bot trades, kill it and restart with different flags. --list-games prints
the slate a command would select and exits, so it is always safe to run first.
# See what a command would pick, without starting anything
./target/release/sports-collector --league nfl --list-games
# Collect a league (read-only; no trading)
./target/release/sports-collector --league nfl --log-file
# Narrow the slate
./target/release/sports-collector --league nfl --team Chiefs --team Bills
./target/release/sports-collector --league epl --exclude-team "Sheffield"
./target/release/sports-collector --league nfl --game 5127839
./target/release/sports-collector --league nfl --game kc-phi --game "eagles vs"
./target/release/sports-collector --league nfl --min-start 18:00 --max-start 23:59
./target/release/sports-collector --league nfl --market-kind moneyline --market-kind total
# Backtest with season-split walk-forward
./target/release/sports-backtest --league nfl --walk-forward
# Dry-run a strategy live (add --live to trade for real)
./target/release/sports-bot-worker --league epl --strategy ModelEdge --cash 500
# Choose how the concurrency cap is filled when more games are eligible
./target/release/sports-bot-worker --league nfl --strategy ModelEdge --prefer edge
./target/release/sports-bot-worker --league nfl --strategy StaleBook --prefer random --prefer-seed 7
# Fit soccer team strengths from football-data.co.uk CSVs
cd model-server && uv run python main.py --fit-dir ./data/epl --league eplCollectors run independently — no orchestrator required.
Start: ./target/release/collector --slug-prefix btc-updown-5m-
Signals: SIGTERM/SIGINT = stop immediately. SIGUSR1 = finish current session then exit.
Dashboard API (port 3456):
# Start collector
curl -X POST localhost:3456/api/collector/start -H 'Content-Type: application/json' \
-d '{"slug_prefix": "btc-updown-5m-"}'
# Stop (immediate or after session)
curl -X POST localhost:3456/api/collector/stop -H 'Content-Type: application/json' \
-d '{"slug_prefix": "btc-updown-5m-", "after_session": true}'
# Restart at next window boundary (rebuild binary from latest code)
curl -X POST localhost:3456/api/collector/restart -H 'Content-Type: application/json' \
-d '{"slug_prefix": "btc-updown-5m-", "rebuild": true}'- Stuck state on expired buy orders. If a limit buy expires, the strategy must detect
entry_midpoint > 0butheld_side() is Noneand reset. Usecheck_expired_entry(). - Use
entry_countinstead of boolean flags. - Use
cancel_after_sfor soft cancellation. Setcancel_after_s: Some(10.0)on buy TradeActions. - Use
held_side()andhas_position()fromstrategy.rs. Don't reimplement. - Add
no_new_afterguard. Stop new entries at ~95% elapsed. - Sell retry: use time-based cooldown. Throttled retry via elapsed seconds.
- No tick-based decisions. Use
ctx.elapsed_seconds/ctx.elapsed_fraction, neverctx.tick_index. - Circuit breaker. Track recent outcomes across sessions. Suspend after losses, shadow-pick to validate recovery.
Configured in settings.json. Two mediums: desktop (macOS Notification Center) and ntfy (mobile push via ntfy.sh). Each medium has enabled + events list. Events are hierarchical (trades catches trades:buys + trades:sells). Full docs in NOTIFICATIONS.md.
Event types: trades, trades:buys, trades:sells, session:end, session:end:win, session:end:loss, error, process:start, process:stop, clob_down, report:hourly.
Each process loads NotificationManager::load() independently — no central notification service. Bot workers and orchestrator both send their own notifications.
FlatlineDetector (src/trader.rs) suppresses trades when both up/down midpoints are unchanged for FLATLINE_THRESHOLD_SECONDS (15s, in src/defaults.rs). This indicates the CLOB API is stale. Trading resumes automatically as soon as midpoints move again — it's temporary, not session-scoped. The strategy's on_tick still runs during flatline so state stays consistent; only trade execution is suppressed. Triggers a clob_down notification.
--latency MS models network round-trip (~200ms). Strategy decides on tick T's data but order executes at tick T+delay's prices. Cash/tokens reserved immediately. Deferred actions cancelled at session end.
--screen 50for large grids: screens on 1/3 of sessions, validates top 50 on full set.- Iterative refinement: Coarse -> Refine -> Polish. Stop when top results cluster within Sharpe 0.05.
- Rayon parallelism: Rust backtester uses all cores automatically via rayon.
--recent 15for exploration,--recent 30for sweeps,--recent 500for validation.
- Entry% (
entry_rate): fraction of sessions with at least one trade. - Win% (
win_rate): fraction of traded sessions with positive PnL. - These are distinct: 20/700 sessions traded, 17 won -> Entry=2.9%, Win=85%.