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