A walkthrough: what everything is doing
Evan, 2026-09-04: "i want a doc example what everything is doing". This is that: one pass through the service from the outside in, with the real commands and what they print. It reads top to bottom. For the reference, see the API; for the user guide, using the API.
The one-paragraph version
The desk records every trade it takes. This service scores each finished trade twice — what the issued plan would have paid if it had been left alone, and what the account actually got — and calls the difference the trader delta. To score honestly it needs the real tape, so it pulls the consolidated ticks for every window a ride was open from Massive. And because the desk's 20-second lane needs to replay a day rather than score it, the same tape is also written out in the desk's own recorder format and served back over a WebSocket at tape pace. One service, three jobs, one tape.
the desk's ledger ──(Ride)──▶ SCORER ──▶ the store: mechanical, realised, delta
▲
Massive (trades ⨝ NBBO) ─────────┤ the tape
▼
the lake: ticks/day=…/symbol=…/ticks.ndjson.gz
│
┌───────────────┴────────────────┐
GET /ticks/… ws://…/replay?…
(the file, for the desk) (the feed, at tape pace)
0. Start it
On a laptop, from the repo, with nothing configured:
docker compose -f compose.yml -f compose.dev.yml up --build --wait
✔ Container postmortem-store-1 Healthy
✔ Container postmortem-cache-1 Healthy
✔ Container postmortem-seed-1 Exited
✔ Container postmortem-api-1 Healthy
✔ Container postmortem-worker-1 Healthy
Four things came up and one ran and left. store is this service's own
Postgres; cache is Redis; api is the REST and the sockets; worker is
the nightly clock. seed planted 31 real scores and a dev key in the store
and exited 0. Its log:
dev key planted (pm_dev_lapt…)
scores: 31 seeded, 0 already there
seed up: the store has scores and a key; the ledger is seed/rides.json; the tape is seed/tape
Where the data came from is the seed: 31 rides pulled from the
real ledger, their scores computed from the real tape, and 35 minutes of a
real AAPL day. On the box, the same compose runs against the real ledger,
the real bucket and the real vendor; only .env differs.
1. Open the homepage
http://localhost:8470/ in a browser. The dev key is prefilled on
localhost. Pick the day the manifest lists, leave from at 09:29:50, press
Start.
09:29:50.037 quote 326.75 × 45 327.00 × 7
09:29:50.041 quote 326.75 × 45 327.00 × 47
13:30:00.000 STATUS trading trading
13:30:00.000 TRADE 327.00 × 100 buy
13:30:00.001 quote 326.99 × 100 327.00 × 200
…
That is the socket, printed. The book on the right is the last quote; the
counters are rows, trades and quotes; the rate is rows per second on the
wall. Pause sends {op:"pause"} and the clock stops where it is;
Stop closes the socket. Changing the speed while it plays re-pins the
clock. Nothing on the page is special: the footer shows the exact URL a
program would open to get the same frames.
The same page on the box is https://api.slowdesk.io/, and there it asks
for a key.
2. Ask the service what it can do
curl -s localhost:8470/api
{
"service": "postmortem",
"what": "scores every finished trade against the plan and against what happened; the gap is the trader delta",
"url": "http://localhost:8470",
"auth": "public without a key: read, replay. Everything else needs one: … Scopes: read (scores, tape), replay (the socket; 5 min cap without a key), score (anything that spends vendor time or disk).",
"rest": { "GET /health": "liveness and what is configured", "GET /me": "the key you are using, and its scopes", … },
"replay": { "url": "ws://<host>/replay?symbols=AAPL,SPY&day=2026-09-02&from=09:25&to=16:05&speed=1", … },
"ws": { "url": "ws://<host>/ws", "events": ["job.started", "job.progress", "score", "job.finished", "job.failed"] }
}
A browser asking / gets the homepage; anything asking for JSON, or /api,
gets this. /health is the only other open route:
{ "ok": true, "build": "dev", "auth": "on", "store": "configured", "tape_source": "massive", "ledger": "configured", "sockets": 0, "jobs": 0 }
databento: missing is the laptop telling you it can serve what is seeded
and cannot fetch anything new.
3. Present a key
export PM_KEY=pm_dev_laptop_only_000000000000000000000000
curl -s localhost:8470/me -H "Authorization: Bearer $PM_KEY"
{ "key": { "id": 1, "name": "dev", "prefix": "pm_dev_lapt…", "scopes": ["read", "replay", "score"], "createdAt": "…" } }
Without one you are anonymous, and that is enough to look:
curl -s localhost:8470/me
{ "key": null, "scopes": ["read", "replay"], "auth": "anonymous" }
Reading scores and tape and playing a day back need no key. What a key
buys: the replay cap goes away (an anonymous replay ends after five minutes
of wall clock, with an end frame that names the tape instant to reconnect
from), and score opens — the routes that spend vendor time or disk:
curl -s -X POST localhost:8470/ticks/warm -H 'content-type: application/json' -d '{"symbols":["AAPL"],"day":"2026-09-02"}'
{ "error": "the score scope needs an API key: Authorization: Bearer pm_…, X-API-Key, or ?key= on a socket", "docs": "http://localhost:8470/" }
Keys are pm_ plus forty characters, stored as a SHA-256 hash, shown once.
On the box an operator mints them:
docker compose run --rm api node bin/api-keys.ts create --name "lab-k sweep" --scopes read,replay,score
docker compose run --rm api node bin/api-keys.ts list
docker compose run --rm api node bin/api-keys.ts revoke 3
4. Read a score
curl -s localhost:8470/scores/137 -H "Authorization: Bearer $PM_KEY"
{
"ride_id": 137, "symbol": "GPRO", "version": 1,
"mechanical_r": 0.2236, "realised_r": 0.0559, "delta_r": -0.1677, "delta_usd": -1.02,
"risk_per_share": 0.061, "first_door": null, "first_door_at": null,
"walked_until": "2026-09-02T20:00:00.000Z", "horizon_source": "session-close",
"mfe_r": 1.31, "mae_r": -0.44, "capture": 0.04, "run_on_r": null,
"unscoreable": null, "notes": [],
"provenance": { "vendor": "databento", "dataset": "XNAS.ITCH", "schema": "tbbo", "start": "…", "end": "…", "seed": true }
}
Read it as three lines. Mechanical +0.22R: the issued plan, left alone
and walked forward on regular-hours prints until the session closed, would
have made that. Realised +0.06R: the account made that. Delta
−0.17R: the person cost that much against the plan, on this ride. first_door: null says neither the stop nor the target was touched inside the horizon,
so mechanical is the mark at the close. mfe_r 1.31 says the trade was up
1.3R at its best and capture 0.04 says 4% of that was kept.
Four of the 31 are refused rather than guessed at:
curl -s "localhost:8470/scores?limit=50" -H "Authorization: Bearer $PM_KEY" | grep -o '"unscoreable": "[a-z_]*"' | sort | uniq -c
2 "unscoreable": "no_issued_stop"
1 "unscoreable": "no_exit"
1 "unscoreable": "entry_unverified"
entry_unverified is the LHAI ride whose booked fill was 20% away from the
book at the booked instant: a delta from a fill that never happened would be
a confident wrong number attached to a person's name, so it is refused.
5. See the tape that is on file
curl -s localhost:8470/ticks/manifest.json -H "Authorization: Bearer $PM_KEY"
{
"version": 1, "layout": "ticks/day=YYYY-MM-DD/symbol=XXX/ticks.ndjson.gz",
"columns": ["day", "symbol", "channel", "ts", "seq", "source", "price", "size", "conditions", "side", "venue", "trade_id", "sip_seq", "bid", "bid_size", "ask", "ask_size", "bid_venue", "ask_venue", "status", "reason"],
"symbols": {
"AAPL": {
"2026-09-02": { "rows": 127665, "trade": 18729, "quote": 108934, "status": 2,
"first_ts": "2026-09-02T08:00:00.000104823Z", "last_ts": "2026-09-02T13:59:59.999645500Z",
"bytes": 1948582, "sha256": "…", "source": "databento:xnas.itch",
"slice": { "from": "09:25", "to": "10:00", "of_rows": 998903 } }
}
}
}
That is the contract from tradingdesk#777: per symbol, the session dates on
file, and for each the row count per channel and the first and last
timestamp, so a sweep tool can plan and refuse a day with a hole. On the
box the same call lists a year of SPY and QQQ, and AAPL, NVDA and PLTR
days, without slice, because they are whole days.
6. Pull a day
curl -s "localhost:8470/ticks/day=2026-09-02/symbol=AAPL/ticks.ndjson.gz" -H "Authorization: Bearer $PM_KEY" -o AAPL.ndjson.gz
gunzip -c AAPL.ndjson.gz | sed -n '2p;5p' | cut -c1-260
{"day":"2026-09-02","symbol":"AAPL","channel":"quote","ts":"2026-09-02T13:25:00.037434602Z","seq":2,"source":"databento:xnas.itch","price":null,"size":null,"conditions":null,"side":null,"venue":null,"trade_id":null,"sip_seq":93184126,"bid":326.61,"bid_size":100,"ask":326.66,"ask_size":50,…
{"day":"2026-09-02","symbol":"AAPL","channel":"trade","ts":"2026-09-02T13:25:00.264121906Z","seq":5,"source":"databento:xnas.itch","price":326.65,"size":100,"conditions":[],"side":null,"venue":"XNAS","trade_id":null,"sip_seq":93190211,"bid":null,"bid_size":null,"ask":null,…
Every line has the same 21 columns in the same order; the other channels'
columns are null; seq is 1..N; ts is the exchange's own nanosecond
stamp. This is the file the desk's intoSimStream reads. The sidecar
beside it carries the sha256:
curl -s "localhost:8470/ticks/day=2026-09-02/symbol=AAPL/manifest.json" -H "Authorization: Bearer $PM_KEY"
node bin/verify-ticks.ts AAPL 2026-09-02 --out seed/tape re-reads the
bytes and checks every rule and the hash; it is the same checker the
builder ran before it uploaded.
7. Play it back
npx wscat -c "ws://localhost:8470/replay?symbols=AAPL&day=2026-09-02&from=09:29:58&to=09:30:02&speed=1&key=$PM_KEY"
< {"type":"hello","service":"postmortem","what":"replay: one symbol-day per symbol, rows in play order at tape pace",…}
< {"type":"loading","symbol":"AAPL","day":"2026-09-02"}
< {"type":"snapshot","symbol":"AAPL","at":"2026-09-02T13:29:57.028910989Z","quote":{…"bid":326.75,"ask":327…},"trade":{…},"status":{…"status":"trading"…}}
< {"type":"ready","day":"2026-09-02","symbols":["AAPL"],"speed":1,"from":"2026-09-02T13:29:58.000Z","to":"2026-09-02T13:30:02.000Z","days":[{"symbol":"AAPL","rows":127665,…}]}
< {"day":"2026-09-02","symbol":"AAPL","channel":"quote","ts":"2026-09-02T13:29:58.184833227Z",…}
< {"day":"2026-09-02","symbol":"AAPL","channel":"status","ts":"2026-09-02T13:30:00.000069502Z",…"status":"trading","reason":"trading"…}
< {"day":"2026-09-02","symbol":"AAPL","channel":"trade","ts":"2026-09-02T13:30:00.010426255Z",…"price":327,"size":100,…"aggressor":"buy"…}
… 1,685 rows over four seconds of wall clock …
< {"type":"end","day":"2026-09-02","symbols":["AAPL"],"sent":1685,"lastTs":"2026-09-02T13:30:01.994888135Z","counts":{"AAPL":{"trade":588,"quote":1096,"status":1}}}
Read the frames in order. loading is the one wait: the object is fetched
from the store into a local cache, once per box. snapshot is everything
before from, folded into the quote, trade and status standing at that
instant — a client that joins at 09:29:58 starts with a book. ready pins
the clock: from is now. Then rows, each one the archive's own line, each
due at now + (row.ts − from) / speed. end closes.
Type {"op":"pause"} and rows stop; {"op":"resume"} and they continue
from the same tape instant; {"op":"speed","speed":20} and the clock
re-pins at twenty times. speed=0 sends rows as fast as you read them.
Nothing is preloaded into memory. A liquid day is a million rows and decoding runs a hundred times faster than real time, so the reader streams from disk with a bounded lookahead.
8. What the box does that the laptop does not
Builds the tape. Every night, or by hand:
docker compose run --rm worker node bin/build-ticks.ts --symbols NVDA:NASDAQ,TSLA:NASDAQ --from 2025-09-02 --to 2026-09-03
2 symbols × 262 sessions → s3://slowdesk-io/market-history/ticks/day=…/symbol=…/ticks.ndjson.gz
channels: trade+quote from mbp-1, status from status · every fetch priced first · a day is whole or absent
ok NVDA XNAS.ITCH 2025-09-02 612,338 trades 4,411,209 quotes 2 status 71.4 MB
…
Both schemas of every day are priced with the no-spend gate before a byte
is fetched; mbp-1 and status price at $0 inside the free window. A day
is built to a temp file, checked line by line, gzipped and hashed in one
pass, uploaded, and only then gets its sidecar; the manifest is an
aggregate of sidecars. If either channel cannot be had the day is refused
with the channel named, because a day with trades and no quotes would let
the desk's execution simulator price a spread from nothing.
Scores the day after. Databento's historical licence ends at the prior
session's close, so a ride that closed today is scored after midnight ET
when the licence advances. bin/worker.ts fires at 04:30Z, loads the rides
that closed that session from the ledger, fetches each name's tape once,
scores, and writes a new version of each score. Scores are never edited.
Serves through the front door. https://api.slowdesk.io is Caddy on the
box terminating TLS and proxying to the api container on loopback. Anyone
can read and replay; only scoring needs a key. The docs you are reading are
served there too, at /docs. The desk's containers on the same box are
clients like any other.
9. Where each number comes from, and why
| number | source | the trap it avoids |
|---|---|---|
| the issued stop and target | the desk's decision packet, then the append-only entry note, then the row, then the thesis prose — weakest source labels the pair | a stop that was ratcheted is not the stop that was promised; mechanical R is only meaningful against the promise |
| the walk | regular-hours prints only, from entry until the lane's eject, the time stop, or the close | a target touched at 20:31Z after the close is not a door a same-session lane could reach; the scorer once charged −1.22R for one |
| the entry gate | the booked fill must sit within 2% of the book at the booked instant | LHAI booked $1.02 at 13:30:11Z while the tape opened at $1.22 |
| the tape | the primary listing venue's own feed, never a partial-book blend | EQUS.MINI and DBEQ.BASIC measured 68× too wide on HQY |
| the quotes in the replay | mbp-1, every top-of-book change |
tbbo is one quote per trade; a 20-second lane prices its spread between trades |
| the cost of any fetch | metadata.get_cost, known and exactly zero, or no fetch |
an unknown price used to read as free; streaming pulls have no post-hoc bill to reconcile |
10. The files, by job
src/contract.ts |
the Ride and Score shapes; the only vocabulary the arithmetic speaks |
src/ledger.ts |
the one file that knows the desk's schema, and the fixture that stands in for it |
src/score.ts |
the arithmetic; pure |
src/session.ts |
09:30–16:00 ET and 04:00–20:00 ET, resolved through the IANA zone |
src/databento.ts |
the vendor client: datasets, the cost gate, streaming fetch |
src/ticks-day.ts |
records → the recorder's rows; the checker |
src/ticks-store.ts |
S3 or a directory; sidecars and the manifest |
src/replay.ts |
the tape clock, the seek, the merge, the pacing |
src/auth.ts |
keys, scopes, the check |
src/store.ts |
this service's Postgres; scores versioned, never edited |
src/server.ts |
REST, the events socket, the replay socket, the homepage |
bin/build-ticks.ts · bin/verify-ticks.ts |
build a symbol-day; check a delivered one |
bin/score-range.ts · bin/score-ride.ts · bin/worker.ts |
score a range, one ride, or last night |
bin/api-keys.ts · bin/seed.ts |
mint keys; make and plant the seed |
public/index.html |
the homepage: the feed, printed |
deploy/ |
the Caddy site block and the box role's policy |