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
- 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.
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.
| 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 |
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
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.
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.
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
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.
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.
- 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.
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.
MIT