Ledger Technical Reference

How Ledger is built: one page with ordered, build-free scripts, the trade-reconstruction and analytics engine, the sync protocol, the zero-dependency server, and the extraction trick that keeps the server, the tests, and the app running the exact same code.

← Back to Ledger User guide →

01

Architecture overview

Three deliberate constraints shape everything:

  • One page, ordered scripts, no build. ledger.html holds the markup, styles and fonts. It loads vendored Chart.js and the app's seventeen parts from app/ as classic scripts, in order (core.js, engine.js, … boot.js last), sharing one global scope as the single inline script once did. Rule: code that runs while a part loads may only use names from that part or an earlier one. A function body called later may use anything. It works opened from disk (file://, with app/ beside it), from any static host, or served by the companion server, which sends each part gzipped under a content hash (?v=, cached immutable for a year). There is no build step, no bundler, no framework.
  • Zero dependencies. server.js uses only Node built-ins (http, fs, zlib, node:vm, global fetch). npm install installs nothing.
  • Single source of truth. The server and the test suites do not reimplement any math. Both extract the pure functions out of the app's source text (app-source.js returns ledger.html with every app/ part inlined in load order) and evaluate them — the server into a node:vm context, the tests into ephemeral ES modules. When the app's logic changes, the API's answers and the tests' subjects change with it automatically.

The client is read-only against Hyperliquid's public info API. It never holds keys and has no code path that could place an order.

02

Repository layout

PathRole
ledger.htmlThe app: UI, analytics engine, worker source, vendored Chart.js + fonts (≈6,800 lines). The first script block is vendor code; the second is the app.
server.jsCompanion server: persistence, /api/v1 analytics, scheduled refresh, alerts, digests, Telegram bot, backups, metrics (≈1,800 lines).
help.html · tech.htmlThis documentation, served at /help and /docs. Self-contained, no external assets.
tests/18 suites + harness; run with npm test. Extract functions from ledger.html and hit the server over real HTTP.
package.json · railway.jsonStart script (node server.js) and host config. No dependencies.
README.md · README-deploy.mdFull feature docs and deployment guide.
AUDIT.md · AUDIT-2.md · AUDIT-3.mdRecords of the three audit/build cycles, each marked addressed.
.github/workflows/test.ymlCI: the full suite on Node 18 and 22 for every push and PR.

03

Client architecture

Boot sequence

  1. initServerSync() probes /api/health. If a companion server answers, its snapshot is applied before any local reads, so the synced state wins.
  2. Journal, settings, and saved MAE/MFE measurements hydrate from the active store (server snapshot, linked file, or IndexedDB/localStorage).
  3. If wallets are remembered, loadAll({auto:true}) rebuilds from cached fills and fetches only what is new; a 3-minute auto-refresh keeps it current while the tab is open.

Core state

GlobalHolds
allTradesEvery reconstructed trade (perp + spot), open and closed, all wallets pooled.
journalOne object for every journal level, keyed by trade id, day:YYYY-MM-DD, or week:GGGG-Www.
settingsWallets, view, tz, break-even band, R basis, rules, goals, theme.
openPositions · spotHoldings · accountValueLatest exchange snapshot for the risk panel, tripwire, and sizer.
_excMPer-trade MAE/MFE measurements (persisted separately from the candle cache).

render() is the single entry point: it filters by view/period, then fans out to the panel renderers (stats, charts, calendar, guardrails, tripwire, positions…). Derived caches are reset centrally by resetDerivedState() whenever the loaded wallet set changes (load, paste, demo, wallet removal), so no stale aggregate can leak across data sources.

04

Trade reconstruction

reconstructTrades(fills, addr, market) turns the raw fill stream into round trips. Fills are filtered per market (a coin containing / or starting with @ is spot), sorted by time, and folded through a per-coin position state machine:

  • A fill that moves the position away from zero opens or adds; one that moves it toward zero closes (partially or fully); one that crosses zero closes the old trade and opens a flip.
  • Every trade accumulates open/close notionals, fees split by maker/taker, exchange-reported closedPnl, max size, liquidation flags, and a compact per-fill event list [time, px, sz, ±1] that later powers the replay chart, add-to-loser detection, and exact 14-day fee volume.
  • attributeFunding assigns funding rows to whichever trade held the position when the payment occurred.
  • A trade whose opening fills predate the available history is flagged partialHistory instead of being silently miscounted.

Trade ids are <wallet>:<coin>:<openTime> — stable across rebuilds, which is what lets journal entries survive any re-fetch.

Other venues (app/venues.js)

A wallet's venue is in its id: Hyperliquid wallets are plain 0x… addresses, the others are prefixed: lighter:0x…, and bybit:<key id> or binance:<key id>, where the key id is the first 12 hex characters of SHA-256 of the API key. Every per-wallet cache, filter, journal id and sync path works unchanged, and Hyperliquid-only code skips the prefixed ones (the server's refresh accepts plain addresses only). Each loader turns its venue's records into Hyperliquid-shaped fills (ltNormTrade, bybitNormExec, binanceNormTrade in engine.js), so reconstruction, stats, the journal, Pulse and the tax export see one kind of trade.

  • Lighter. Read from the browser (Lighter allows cross-origin calls). accountsByL1Address finds the sub-accounts, and trades pages newest-first down to the cache watermark. Requests go through one queue about 3 a second, with retries, because Lighter rate-limits bursts. Each trade carries both sides' position and entry cost before it, so startPosition and closedPnl are exact. The fee is usd_amount × fee / 10⁶. Fills sort by (time, trade id), since one millisecond can hold several fills and the wrong order breaks position continuity. Funding is the public hourly rate times the position held (ltFundingEstimate), checked against Lighter's own total_funding_paid_out. Sub-accounts are separate position streams (ids carry #<index>).
  • Bybit / Binance. The secret stays in IndexedDB (cexcred:<id>), never in settings, sync or backups. Requests are signed with WebCrypto HMAC-SHA256 (Bybit: ts + key + recvWindow + query; Binance: the query string), against a clock offset read from the exchange. They go to the server's /api/cex/relay. Bybit: executions in 7-day windows over 2 years (linear and spot), funding from the transaction log's SETTLEMENT rows. Binance USD-M: the income history gives funding and the symbols traded, then userTrades per symbol in 7-day windows over 89 days. realizedPnl is the closed P&L. Hedge-mode legs are separate streams (#LONG, #SHORT), and each funding row goes to the leg holding the larger position at that moment. Positions held before the history begins are seeded as today's position minus the window's net (initialPositions). The seed is cached beside the fills, so an offline start agrees with the last load.
  • Opening offline. venueBootTrades rebuilds from the caches alone, the same way bootFromCache does for Hyperliquid. Candles for excursions and replay come from the trade's own venue (venueFetchCandles), cached under a venue-prefixed key.

05

Analytics engine

  • Scratch handling. isWin/isLoss/isBE classify against the configurable break-even band (_be, default $50); scratches are excluded from win rate and never break streaks.
  • computeStats returns the full headline block in one pass: net/fees/funding/volume, win rate, profit factor, payoff, expectancy, drawdown (absolute and % of high-water mark), streaks, R aggregates, hold times, Sharpe/Sortino from the calendar daily series, green days, median.
  • Determinism. All Monte Carlo (projections, drawdown expectations, variance card, alert p95) runs on a seeded PRNG: _srand(_hashSeed(label)). Same inputs, same numbers, in the app, the worker, the server, and the tests.
  • Significance. Edge significance uses a Student-t CDF built from _lgamma/_ibetaReg (tested against reference values); the pattern miner applies Benjamini-Hochberg FDR correction across all mined conditions so multiple testing cannot manufacture an edge.
  • Capital model. Deposits/withdrawals from the ledger stream feed a time-weighted return (flows removed) and a money-weighted XIRR solved by bisection, clamped to ±[−99.99%, +1000%] with explicit display labels at the clamps.
  • Charts. Large series are min-max decimated (decimateIdx) before Chart.js sees them, preserving spikes at a fraction of the points.

06

The runtime worker

Heavy jobs (reconstruction, mining, projections, diagnostic Monte Carlo) run off the main thread — without a separate worker file to keep in step. At startup the app builds a Blob worker from its own source:

  • _WORKER_LIB names the top-level functions to lift; their source text is concatenated with _WORKER_PRELUDE (the handful of consts they close over) and a _WORKER_DISPATCH message router into a Blob URL.
  • Every job has a synchronous fallback: if workers are unavailable or a job exceeds the 120-second stall watchdog, the same function runs inline. Results are byte-identical by construction — it is literally the same source.
  • Engine state the functions need (settings, journal, break-even band, 1R basis) is posted with each job, never shared.

07

Time & timezone layer

All calendar logic goes through one tz-aware layer — tzParts, tzMidnight, addDays, dayKey, dateBound — driven by the UTC/local toggle in settings. Day stepping samples mid-day and re-normalizes rather than adding 24-hour constants, so DST's 23- and 25-hour days neither skip nor double a date. The day journal (day: keys), weekly review (week: ISO keys), calendar heatmap, day×hour matrix, and daily PnL all agree on what "a day" is because they share this layer.

08

Excursions engine (MAE/MFE)

  • Interval selection is duration- and age-aware: Hyperliquid retains roughly the last ~5,000 candles per interval, so short-but-old trades automatically request coarser candles instead of asking for data that no longer exists; unmeasured trades retry one interval coarser on the next pass.
  • Candles cache in IndexedDB per coin+interval with covered-range tracking — re-runs and overlapping selections fetch only the gaps.
  • Measurements (maePct/mfePct per trade) persist separately from the candle cache and ride the sync/backup pipeline; the computation itself is a linear min/max pass, so the fetch dominates.

09

Persistence & sync protocol

The synced unit is one snapshot: {app:'ledger', version, wallets, settings, journal, excursions…} under a monotonically increasing rev.

  1. Client edits mark dirty state: per-journal-id edit counters (_dirtyJ) and a settings baseline _lastSyncedS captured at the last successful sync.
  2. Writes are debounced ~800 ms into PUT /api/data {rev, snapshot}. The server accepts only if rev matches its current revision; otherwise it answers 409 with the newer state.
  3. On 409 the client applies the server state, then re-applies its own unsynced edits on top: journal ids whose edit counters advanced since the last sync, and settings fields that differ from the _lastSyncedS baseline (field-level, so two devices editing different settings both win). It then re-syncs at the new revision and rebases the baseline — including when the conflict produced no local merge, so a stale baseline can never re-push server-origin values later.
  4. The server persists atomically (tmp file + rename, previous revision kept as .bak) and writes a rotating daily snapshot (14 kept) on every accepted write.

Journal image attachments sync separately (/api/att/<base64url-id>, size-capped per trade and store-wide); candle caches deliberately never sync.

10

Journal data model

Key shapeEntry
<wallet>:<coin>:<openTime>Per-trade: notes, tags[], setup, rating, mistakes[], risk, plan {entry, stop, target}, attachments flag.
day:YYYY-MM-DDDay journal: bias, plan, maxLoss (arms the tripwire), review, adherence.
week:GGGG-WwwWeekly review: repeat, change, lesson (feeds the lessons library). ISO-8601 week, tz-aware.

All three shapes live in the same object and are treated uniformly by sync, conflict merge, backup, and export. The full backup JSON (version 9) additionally carries per-wallet fill caches and excursion rows; the importer restores any older version it recognizes.

11

Server architecture

Engine extraction

At boot, buildEngine(htmlPath) reads the served HTML, brace-matches every function named in ENGINE_FNS (plus a few one-line consts in ENGINE_SHIMS), and evaluates them in an isolated node:vm context E. Mutable knobs (E.settings, E.journal, E._be, E._oneR) are set per request by setEngineState(). If the served HTML predates a needed function, /api/v1 analytics return 503 naming what is missing while persistence keeps working.

Caches & refresh

  • Per-wallet gzip JSON caches (fills/ funding/ ledger/) plus one market.json positions snapshot; trades are memoized against a cache signature and rebuilt only when inputs change.
  • POST /api/v1/refresh mirrors the client's loadAll: incremental fill fetch with the same dedupe key, funding, positions (HIP-3 dexs derived from fills), spot, portfolio PnL. It is mutexed, rate-limited (15 s unless force), and guarded by a 5-minute watchdog plus a generation counter: a timed-out zombie refresh dies at its next cache write instead of overwriting newer data with stale bytes.

Access control

  • AUTH_TOKEN — everything, compared with a timing-safe equality check; 401 responses carry a flat 300 ms delay to blunt online brute force, and wrong tokens are counted per client address: AUTH_FAIL_MAX (20) inside 10 minutes locks the address out for AUTH_LOCK_MIN (15) minutes — a 429 with Retry-After, right token included, so parallel guessing gets nowhere.
  • READ_TOKEN — exactly GET /api/v1/*; it can never touch /api/data, attachments, snapshots, backups, or refresh.
  • CORS_ORIGIN — one exact origin, off by default. Responses set nosniff and no-store; CSV exports guard against spreadsheet formula injection.

12

Automation internals

  • Alerts. gatherAlertState() builds a plain snapshot (risk rows, today's net, funding 24h, current drawdown vs a seeded MC p95); the pure, exported alertsFrom(state, cfg) derives alert lines with dedupe keys (per position, per day, cooldown-scoped). Sent-state persists to alert-state.json so redeploys don't re-fire, and a failed delivery re-arms the key.
  • Ops health. Each scheduled run records its outcome (a throw, or every wallet erroring, is a failure; "no wallets saved" and "superseded" are not). The pure, exported healthAlertsFrom(h, cfg) turns the failure streak and a statfs of DATA_DIR into health:refresh / health:disk lines, deduped through the same persisted sent-state with a 24 h cooldown; clearing a sent health:refresh posts one recovery line.
  • Delivery. Webhook and Telegram are peer channels behind one deliver(); success on either counts. Digests record webhookSent and are retried on the next scheduled run if delivery failed.
  • Telegram. A long-poll loop (getUpdates?timeout=50, 10 s backoff on network trouble) that answers only allowlisted chat ids. Replies come from the pure telegramReply(cmd, state) router over buildBotState() — engine analytics reduced to plain data, so the wording is unit-testable without a bot or network.
  • Backups. POST /api/backup shape-checks (app:'ledger'), writes gzip atomically, prunes to the newest 10.
  • Exchange relay. cex-relay.js forwards browser-signed Bybit and Binance requests: GET only, to the exchanges' hosts, on an allowlist of read-only endpoints, with only the signing headers (values matched against a strict character class, so nothing can inject headers). Redirects aren't followed, answers are capped at 8 MB, and each caller (owner, or m:<member id>) has a per-minute budget. A geo refusal (Binance's 451, or Bybit's CloudFront 403 page) comes back as {geo:true} rather than an exchange error. With CEX_RELAY_URL the server passes requests to a second copy running CEX_RELAY_ONLY=1, which checks X-Relay-Secret in constant time and answers nothing else.
  • Off-site. offsite.js (zero dependencies) encrypts each object with AES-256-GCM. The key comes from OFFSITE_KEY via scrypt (N=215), with a fresh salt and IV per object (LDGRE1 | salt | iv | ct | tag). Objects are PUT to a path-style S3 URL signed with a hand-written SigV4, which is checked in the tests against vectors generated by botocore. Server backups are mirrored as they're made. An hourly timer ships a DATA_DIR bundle once per OFFSITE_EVERY_H (the cadence is saved in offsite-state.json, so redeploys don't re-upload). Bundle format: gzip of repeated {p,n,m} JSON header lines, each followed by n raw bytes. That avoids tar's path-length limits, since attachment keys run to 200 characters. Pruning lists the prefix and deletes the oldest past OFFSITE_KEEP. Restore refuses any path outside its target directory.
  • Metrics. GET /api/v1/metrics flattens the headline numbers; ?format=prom emits ledger_* gauges, numeric fields only.

13

API reference

GET /api/v1 returns a machine-readable index of everything, including auth modes and filter docs. Summary:

EndpointAuthReturns
GET /api/healthnone{ok, auth, appSyncCapable} — the client's server-detection probe.
GET/PUT /api/datafullThe synced snapshot; PUT is revision-checked (409 on conflict).
GET /api/snapshots[/date]fullRotating daily snapshots (14 kept).
GET/PUT/DELETE /api/att/:keyfullJournal image attachments.
POST /api/backup · GET /api/backups[/:name]fullServer-held full backups (gzip, newest 10).
POST /api/v1/refreshfullPull fills/funding/positions into server caches.
GET /api/v1/meta · trades[/:id] · stats · equity · calendar · breakdown · projection · kelly · capital · walkforward · risk · positions · spot/lots · whatif · digests · journal · tags · export/trades.csv · metricsreadThe analytics surface — same engine functions the app runs.
DELETE /api/v1/cache/:addrfullEvict one wallet's server caches.
GET /help · GET /docsnoneThis documentation.

Shared filters: market, wallet, coin, dir, status, outcome (uses the saved break-even band), tag, q, from/to, tz=utc|local. The 1R basis is pinned to the filtered closed set, mirroring the app.

14

Contributor constraints

These are load-bearing. The extraction pipeline and CI enforce most of them; breaking one usually fails the suite immediately.
  • Extractable functions stay top-level. Anything in ENGINE_FNS or _WORKER_LIB, and anything the tests extract, must remain a top-level function name(…) declaration with balanced braces — no unbalanced {/} inside its string or regex literals, since both the server and the test harness brace-match source text.
  • Keep pure logic pure. New analytics take plain data in and return plain data out; DOM access lives in the render layer. Pure functions get extracted and tested; render glue gets source-assertion tests.
  • No dependencies, no build step. Vendoring (as with Chart.js) is the pattern; package.json stays empty of deps.
  • The worker is source text. A function used in the worker must not close over module state beyond what _WORKER_PRELUDE carries.
  • Seed all randomness. Any Monte Carlo goes through _srand(_hashSeed(…)) so results are reproducible everywhere.
  • CSP and file:// support. The app's strict CSP allows only the exchange API; no external scripts, fonts, or beacons. Every feature must still work opened from disk (server-dependent UI reveals itself only when the server is detected).
  • Sync-safe writes. Any new journal write calls markJEdit(key) so the conflict merge can protect it; any new derived cache registers with resetDerivedState().

15

Testing

npm test runs 33 suites (~620 tests) via tests/run-all.mjs, including a size budget for ledger.html (test-budget). npm run test:e2e runs the browser smoke tests in e2e/run.mjs: real Chromium against the real server, with all off-origin requests blocked. It covers boot, sample data, every tab, a journal note's round trip through sync and a reload, Pulse at phone width and every admin tab, failing on any uncaught page error or a blown time budget. CI runs it as a separate job, with Playwright installed only there. The harness (tests/harness.mjs) provides makeExtractor(html): evalModule(names, exports, prelude) brace-matches the named functions out of ledger.html, prepends a prelude of one-line consts, and imports the bundle as a data-URL ES module — the tests run the shipped source, not a copy.

SuiteCovers
test-syntaxEvery script block parses (vm.Script) — catches template-literal and escaping regressions whole-file.
test-newfeatures · features2-5Reconstruction edge cases, capital/XIRR, scorecards, CSV import, clusters, shock, goals, fee tiers, ISO weeks, variance, risk creep, demo fills.
test-api · test-serverThe server over real HTTP with a mocked exchange: auth scopes, 409 merges, refresh pipeline, capital, backups, metrics, engine-failure softness.
test-alertsalertsFrom thresholds, webhook body shaping, telegramReply wording.
test-gameLevels, XP, the shielded discipline streak, achievements' unlock dates, discipline saved, personal bests, the monthly report card (no dollar amounts), weekly challenge candidates and status, and source checks that every recent feature is gated by the coach-mode switch.
test-pulseThe Pulse view: readiness from the check-in, risk used against the day's cap and limit, size vs usual, process-vs-results trend stats, readiness vs discipline, the next step and coach line, the path switch (run against fake locations), coach layer forced on, the day's trade cap surviving a full-app save, and the /pulse, /pulse/, manifest, icon and app-shell-only service worker routes over real HTTP.
test-accountsPulse accounts: Ethereum signature recovery against ethers-made vectors, the EIP-4361 message format, wallet claims over real HTTP (wrong signer, reused, expired and cross-purpose nonces, the exact server-written text), the claim lock (impostors lose the address, nobody else can name it, a plain save can't move it), wallet sign-in issuing per-device keys, one-time device codes, signing out other devices, the encrypted vault's revisions and 409s and size limits, “only count claimed wallets”, release and removal; plus browser-side AES-GCM encryption round trips and the theme travelling with syncs.
test-autoPulse's automatic metrics: each of the six Discipline checks read from fills (revenge entry, trading on after two losses, sizing up after a loss, adding to a loser, overtrading against earlier days only, over-held losers), bonus XP that can only add, Form against your own baseline (including slow traders and stale history), Load against your usual day, one fixed loss rule shared with the server, and the in-depth stats breakdowns (equity and drawdown, streaks, the trade after a loss, sides, hours, size quarters, holding time, what slips cost).
test-socialsocial.js and Pulse's social client: stats clamping, share defaults, competition validation, feed events from stats diffs, return and drawdown from the portfolio P&L series (deposits excluded), weekly promotion, leaderboards with opt-outs, standings for all four competition types, and the member and admin API over real HTTP with a stubbed Hyperliquid (hashed keys, privacy, kudos, suspension, config, the lazy weekly rollover, and the admin API refusing without AUTH_TOKEN); plus unlock levels and the stats payload carrying no P&L.
test-passkeysPasskeys against a software authenticator built from node:crypto (real ES256 and Ed25519 keys, CBOR, signatures): CBOR decoding limits; registration and sign-in verifying; every tampering refused (challenge, origin, RP ID, user presence, ceremony type, signature, a counter going backwards); the routes over HTTP, including single-use challenges, unknown and doubly-linked passkeys, removal, and PUBLIC_ORIGIN pinning. The browser suite repeats the flow in Chrome with a virtual authenticator.
test-tax · test-playbooks · test-features6Tax presets (tax years, FX tables, FIFO, UK same-day/30-day/Section 104, Canadian ACB); playbook stats and wiring; screenshot mark-up, replay P&L by bar, and appearance.
test-wallet-approvalOwner wallet approval over real HTTP: off by default, existing wallets approved when it goes on, a new wallet not read on chain until approved (boards, verify state and return-competition entry say why), rejection dropping numbers at once and following the address to a new profile, bulk decisions, undo, input checks, owner-attached wallets counted as approved, invite joins recorded.
test-offsiteOff-site backups: AES-GCM round trips and tamper/wrong-key failures, the SigV4 signer against botocore vectors, config validation, the DATA_DIR bundle (skips, size cap, path-escape refusal), and the server wiring against a fake S3 that checks every signature, including retention and failure reporting.
test-adminThe owner's controls (social-config.js and the admin API over real HTTP): level curves and tables, XP and coach sanitizers, migration from earlier versions, admin-made members and their 7-day sign-in codes, XP grants, reward badges awarded by metric or by hand, leagues created, searched by name or number and joined several at once, opt-in global boards, league-only competitions, per-league rollover, coach allowances per member and per local day, and the admin panel's routine defaults matching the app's.
test-coach-chatThe AI coach chat with a stubbed Claude client: only the member's turn reaches the model, addresses scrubbed, trades and notes only when the member and owner allow it, the cached prompt and request shape, refusals not counted, daily limits, the owner's token, level gates and COACH_AI off.
test-fixesRegression tests for the third review round: day stepping across midnight DST starts (run under several TZ values), flip fills split between trades, the exchange's 10k-fill window flag, coin-name validation and escaped ids, process-score gating by rule and habit dates, per-wallet spot FIFO over real HTTP, and the model-gated coach letter request.
test-coachPlain-language findings (phrasebook, Welch confidence, ranking, no jargon outside the evidence line), habit tracking for every habit kind, the after-trade question, and the server's coach letter over real HTTP with a stubbed Claude client: facts allowlist, request shape, refusal handling, storage.
test-habitsRules from findings (live vs after-close, entry-state predicates on open trades, before/after follow-through test), live plan stamping and the held-through-stop check, check-in miner conditions, journal inbox and streak, the process score and its quadrants, replay extremes, and the server's end-of-day nudge.
test-replay-auto · test-walkforward-panelSource assertions for render-layer logic that can't be extracted.
test-projection · decay · edge-map · attribution · dexfilter · improvementsSeeded MC determinism, tz math, statistics against hand-computed and reference values.

CI (.github/workflows/test.yml) runs the full suite on Node 18 and 22 on every push and pull request. The suites are offline — the exchange is mocked — and finish in well under a minute.