Skip to content
brandongourleyPublic

About

Automated trading system for esports prediction markets, Real execution/risk architecture, proprietary model stubbed.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

Repository files navigation

BetLab

A personal trading system for esports prediction markets. It prices best-of-N series in a professional esports league, compares those prices to sportsbook lines and Kalshi event-contract books, sizes positions with fractional Kelly under hard risk invariants, and executes as a post-only maker with automated closing-line capture for grading. It is fully automated end-to-end, runs real capital, and is profitable across 4,000+ settled wagers spanning multiple competitive seasons.

This is a public engineering mirror. The pricing model that makes the system profitable is private; this repo ships a clearly labeled textbook placeholder behind the same interface (src/model/placeholder.ts). Everything downstream of the model is the real architecture: series math, de-vigging, sizing, risk clamps, exchange connectivity, execution, and the data pipeline. The whole thing runs end-to-end offline:

npm install
npm run demo     # prices a series, compares to book lines, quotes, executes, captures (no keys needed)
npm test

The record

  • 4,000+ settled wagers across multiple competitive seasons, profitable over that span. The most recent full season ran through this exact architecture unattended on tournament days: model → fair values → de-vigged edge detection → fractional-Kelly sizing → post-only maker execution → automated closing-line grading.
  • The production system is 35,000+ lines of TypeScript integrating half a dozen external services: exchange trading and market-data APIs, sportsbook odds feeds, schedule/results providers, and managed Postgres. This mirror is a distilled rewrite of its execution-critical core.
  • Zero double-posts, zero stranded-quote incidents, zero risk-limit breaches since the invariants documented below were installed. Each one was added after its absence cost real money.
  • Dollar figures, per-market ROI, and the closing-line dataset stay private with the model, deliberately. The volume and the multi-season record are the evidence that the machine works, without teaching anyone how it finds its prices.

The quant side

Most of this README is about execution machinery. This section is about the analytical work that machinery serves: how prices are produced, how they are validated, and how positions are sized. It describes method only. The signals, functional form, and fitted parameters are private, along with everything else listed in What's intentionally not here.

Pricing. Team strength is tracked per game mode by a rating system that updates after every match. Current state is therefore a pure function of preseason priors plus the public match record, and any historical state can be rebuilt by replaying results. Parameters are re-estimated each season, because the game and its player pool change and an old fit decays. Per-map win probabilities compose into a full best-of-N score distribution by enumerating every path a series can take (src/model/series.ts), and every listed market on that series (winner, exact score, map spreads, total maps) is priced from the same joint distribution. Consistency comes from the structure: derived markets cannot contradict the score distribution they came from, and the test suite asserts the distribution sums to one. In production the same engine follows the series format the exchange actually lists, declines to price total-maps thresholds that are impossible for that format, and re-prices the remaining markets from the updated distribution as maps complete during live play.

Validation. Forecast quality is graded in a deliberate order: closing-line value first, calibration second, profit last. The closing price is the most informed forecast a market produces, so beating the de-vigged close (margin removed, probabilities renormalized; src/model/odds.ts) on a persistent basis is evidence of real information, and it accumulates per bet rather than per win or loss. That is what the closing-line pipeline in this repo exists for: every wager is graded against a captured close, automatically. The reference gets checked too: before a market's closing history is used for anything, it is audited for bias. Calibration is checked on two levels, stated probabilities against realized frequencies and stated edges against realized returns, with small subsamples read as suggestive rather than conclusive. Profit ranks last because it converges slowest. At realistic edge sizes, outcome variance dominates a P&L curve for a long time, which is why the record above leans on volume across seasons instead of any short stretch of results.

Research process. Ideas enter a fixed funnel: backtest on held-out history, then paper trading, then small live stakes, and most die at the first stage. Every test is specified before it runs, with the claim, the exact measurement, pass and kill thresholds, and a written list of ways the test could lie. The default null is that the market already prices the idea. The most recent full cycle resolved fifteen pre-registered hypotheses: nine killed, five parked, one promoted to paper trading. Results are not cited until their validation write-up is clean; that rule was installed after a point-in-time data error briefly faked a strong result. A first-pass backtest that shows a market badly mispriced is treated as a bug report on the pipeline. One such result dissolved under inspection into three separate grading errors (a settlement-basis mismatch with the exchange's actual rules, an extrapolation bias from one-sided thin books, and junk quotes in the sample); corrected, the edge measured near zero, the market graded fair, and the idea was dropped. Model variants are compared on paired per-observation log-loss with standard errors, and fits are stress-tested by perturbing the starting priors to measure how much the conclusions lean on them. Final holdout data gets spent exactly once. One candidate passed every earlier gate, failed its single holdout shot, and stays dead, because a holdout you keep retesting is just more training data. Shipping is not permanent either: one calibration adjustment was removed after its effect stopped replicating.

Sizing. Stakes are a configured fraction of full Kelly (src/model/kelly.ts), set well below one. The reasoning has two layers. Full Kelly maximizes long-run growth only when the input probabilities are exactly right, and the cost of overestimating is asymmetric: staking at twice the true Kelly level drives expected growth to zero, while half of it gives up a quarter of the growth rate at half the volatility. A fraction is therefore both a variance choice and a hedge against model error, shrinking the system's opinion toward the market's. Correlation is handled before sizing. Markets on the same event express overlapping opinions, so orders are collapsed into net directional exposure and sized against one Kelly budget per direction (src/execution/risk.ts) instead of leg by leg; the doubled-position story under the engineering notes is what installed that clamp. Everything is bankroll-relative, which keeps drawdown and risk of ruin at a chosen bound instead of an accident of recent results.

Fill quality. A maker's fills are a biased sample of its quotes; the orders that get hit skew toward the ones that were mispriced. Production fills are therefore graded by markout, the standard adverse-selection measure: each fill is marked against the market price at fixed horizons after execution, to measure how often the counterparty had the better price. Those measurements decide which markets are worth quoting, how wide, and at what minimum edge, and they get revisited rather than set once. They have also ended a strategy: one live-quoting variant graded out as consistently adversely selected and was shut down in favor of resting pre-match quotes. Accounting gets the same skepticism as execution. Realized results are reconciled against the exchange ledger, and that practice once surfaced a position-netting subtlety in a settlement endpoint that the system's own bookkeeping would have hidden.

Model risk. The format handling above was earned through an incident. One event ran longer series than the regular season, the pricing path assumed the usual format, and total-maps markets were quoted from fair values that were structurally wrong for several hours before the mismatch was caught and repriced mid-event. The postmortem standard applied there applies everywhere: a fix has to make the error class impossible rather than add a rule someone must remember, so series format is now read from the exchange's own listings, with the impossible-threshold refusal as a backstop.

Decision grading. Closed positions are graded twice, once on outcome and once on process, and the process grade only uses what was knowable at the time. The postmortem record includes winning decisions marked as luck and losing decisions marked as correct play; the second grade is the one that changes behavior. Exits and hedges are priced like entries: a hedge bought at negative expected value is a cost, and it has to buy enough variance reduction to justify itself, so whether to hold is decided by the sizing rule rather than by discomfort. An implausibly large claimed edge is treated as a defect report until proven otherwise, because the usual explanation is a stale input or a blind spot, and almost never free money. Edges also get declined for operational reasons: expected value per hour of operator attention is the number a one-person desk actually maximizes.

What's real here vs. what's replaced

Layer Status
Model: rating→probability function, its parameters, calibration data Replaced by textbook Elo, clearly labeled
Strategy parameters: bankroll, Kelly fraction, minimum quoted edge, lot sizes Replaced by round demo defaults, env-injectable
Series pricing, odds/de-vig math, Kelly formulas Real
Exchange client: RSA-PSS signing, retry taxonomy, V2 order-model normalization Real
Execution: post-only quoting, canary verification, cancel-verify-repost Real
Risk: direction-budget clamps, free-cash guards Real
Pipeline: closing-line capture, idempotent snapshot store Real
Database schema (RLS, idempotency keys) Real, minus seed data

Architecture

flowchart LR
  subgraph inputs [Inputs]
    SCHED[Schedule source]
    RESULTS[Match results]
  end
  subgraph model ["Model (private, interface public)"]
    RATINGS[(Team ratings)]
    PM[PricingModel]
  end
  subgraph pricing [Pricing]
    SERIES[Best-of-N enumeration]
    FAIR[Fair values per market]
  end
  subgraph decide [Decision]
    EDGE[Edge vs book / de-vig]
    KELLY[Fractional Kelly sizing]
    CLAMP[Direction-budget clamp]
  end
  subgraph exec [Execution]
    QUOTE[Post-only quote engine]
    POST[Canary → verify → read-back]
    KALSHI[(Kalshi V2 API)]
  end
  subgraph grade [Grading]
    CLV[Closing-line logger]
    PG[(Postgres + RLS)]
  end
  RESULTS --> RATINGS --> PM --> SERIES --> FAIR --> EDGE --> KELLY --> CLAMP --> QUOTE --> POST --> KALSHI
  SCHED --> CLV --> PG
  KALSHI --> CLV
  POST --> PG
Loading

The model boundary is one interface (src/model/types.ts): per-map win probabilities in, everything else downstream. That boundary is what makes this mirror possible. The system was built so the model is swappable, which is also what made backtesting and paper-trading variants cheap to run.

Engineering highlights

Exchange authentication (RSA-PSS). Every private call is signed: RSA-PSS over timestamp + METHOD + path, salt length 32, with a fresh signature per attempt because the timestamp is bound into it (src/exchange/sign.ts). The browser build of this system does the same with WebCrypto, which only imports PKCS#8. Kalshi issues PKCS#1 keys, so that build wraps the DER in a PKCS#8 envelope byte by byte.

A retry taxonomy instead of a retry loop (src/exchange/client.ts). 429s are safe to retry, since the request was rejected before any order state changed; they get jittered backoff with retry-after honored. CDN-edge 403s from request volume are not retried, because retrying a volume-triggered block adds more volume. That distinction was learned in production. An HTTP layer silently eating cancels is how a maker ends up with quotes resting in a live market, and the fix was fewer, batched, paced requests, plus treating "cancelled" as a claim only an exchange read-back can settle.

Order-model normalization (src/exchange/orders.ts). The V2 API quotes everything from the YES leg as bid/ask with dollar-string prices and fixed-point counts; the legacy model was yes/no × buy/sell with integer cents, and reads can return either shape with prices in any of five fields. One normalization boundary converts everything at the edge so the rest of the system speaks a single representation. Mutations moved to a new endpoint family while reads stayed on the old one; that too lives in exactly one place.

Execution that assumes failure (src/execution/poster.ts). Plans post smallest-first: the cheapest order is the canary, verified actually resting via read-back before the rest go out. A 201 is an acknowledgment, not a resting order. Placements are paced to stay under rate limits, every order carries a client-generated UUID so ambiguous failures can't double-place, and the final report comes from the exchange's read-back instead of local bookkeeping. Repricing is move-don't-stack: cancel everything, verify flat, only then repost (cancelAllVerified).

A risk invariant that survives concurrency (src/execution/risk.ts). Match events list one market per team, so "long team A" is reachable two ways: A-YES or B-NO. Sizing each leg independently once produced exactly 2× the intended position when both filled. The fix is a path-independent clamp at post time: held plus resting plus new exposure in a direction may never exceed that direction's Kelly target at the order's own price, so the invariant holds no matter which code path produced the order.

Closing-line capture, unattended (src/pipeline/closing-lines.ts). Closing-line value is the cleanest grade of forecast quality, but closing prices are not published after the fact; they have to be captured live in the minutes before each match. Rather than precise timing, the design uses idempotent overwrites: every pass upserts snapshots keyed (user, ticker), so the last pass before start is the close. A wide window at a few-minute cadence means a DNS blip or a missed run costs one snapshot instead of the whole match. In production this runs from the OS scheduler (a silent VBS launcher, because Windows Task Scheduler flashes console windows) and has accumulated thousands of market snapshots.

A database that enforces authorization (db/schema.sql). Postgres Row Level Security scopes every row to its owner, with no application-side permission checks to forget. Ingestion is idempotent by construction: externally-synced matches carry the source's id under a partial unique index, so re-syncs are no-ops; snapshot upserts use ON CONFLICT targets that make unattended re-runs safe.

Repo tour

src/
  config.ts             env-driven config (demo placeholders, plainly labeled)
  model/                the public/private boundary
    types.ts            PricingModel interface + series formats
    placeholder.ts      textbook Elo stand-in (NOT the production model)
    series.ts           best-of-N enumeration → score distribution, spreads, totals
    odds.ts             American/decimal/probability conversions, two-way de-vig
    kelly.ts            Kelly fractions + contract sizing with lot rounding
  exchange/
    sign.ts             RSA-PSS request signing
    client.ts           typed Kalshi client, retry taxonomy, pagination
    orders.ts           V2 order mapping, normalization, cancel-all-verified
    tickers.ts          ticker → teams parsing, parameterized by suffix map
    mock.ts             in-memory ExchangeAdapter for demo/tests
  execution/
    quotes.ts           post-only quote pricing (edge floor, join/improve, no self-cross)
    risk.ts             direction keys, Kelly targets, direction-budget clamp
    poster.ts           free-cash guard, canary, pacing, read-back
  pipeline/
    schedule.ts         ScheduleSource boundary (+ fixture impl)
    closing-lines.ts    windowed capture pass, pluggable SnapshotStore
db/schema.sql           RLS schema with idempotency keys
scripts/
  demo.ts               the full offline walkthrough (npm run demo)
  post-orders.ts        real-exchange maker CLI (defaults to paper trading)
  log-closing-lines.ts  capture CLI: --demo / --test / single-pass / --watch
  leak-check.mjs        pre-publish secret scan (npm run leak-check)
tests/core.test.ts      invariants: distributions sum to 1, quotes never breach
                        the edge floor or self-cross, clamps hold, orders round-trip

Running against the real exchange

scripts/post-orders.ts and scripts/log-closing-lines.ts speak to Kalshi with your own API key (.env.example). Two deliberate safety properties: the environment defaults to Kalshi's paper-trading exchange unless KALSHI_ENV=prod is set explicitly, and the poster is plan-only unless --execute is passed. Resting orders block a double-post unless you explicitly --reprice, which cancels, verifies flat against the exchange, and only then reposts. Fair values arrive via --fairs fairs.json; in production that file is written by the private model.

Scale, honestly stated

This is a one-operator system, not exchange infrastructure. It is sized for markets measured in thousands of contracts and engineered for correctness under failure rather than throughput: ~200 modeled matches per season; match-winner, per-map, total-maps, and tournament-futures markets quoted across concurrent events on tournament days; thousands of automated pre-match snapshots. The failure-mode record in "The record" above is the right way to judge the engineering: every invariant in this repo was installed in response to a real incident, and none has been breached since.

What's intentionally not here

  • The production PricingModel: functional form, parameters, calibration data, and the research that selected them.
  • Which esport. The specific league and title stay unnamed: fixtures use fictional teams, and game modes appear as anonymized labels (m1/m2/m3).
  • Strategy parameters in actual use (bankroll, Kelly fraction, minimum quoted edge, sizing ladders), and any fair values the model has produced.
  • Historical results: bets, fills, P&L, and the closing-line dataset.
  • The schedule-provider adapter (a third party's non-public API).
  • The React/Supabase research UI the system is operated from.

If you're evaluating this repo, the interesting part is that the system was designed so the list above could be cut out cleanly. The model is a plug; the machine around it is the work.

Stack

TypeScript (strict, NodeNext) on Node 20+ with zero runtime dependencies: signing is node:crypto, HTTP is global fetch, tests are node:test. Dev tooling: tsx and tsc. Production persistence is Postgres (Supabase) behind the SnapshotStore boundary; the demo uses a JSONL store so nothing external is required to run it.

License

MIT

About

Automated trading system for esports prediction markets, Real execution/risk architecture, proprietary model stubbed.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages