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.htmlholds the markup, styles and fonts. It loads vendored Chart.js and the app's seventeen parts fromapp/as classic scripts, in order (core.js,engine.js, …boot.jslast), 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://, withapp/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.jsuses only Node built-ins (http,fs,zlib,node:vm, globalfetch).npm installinstalls 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.jsreturnsledger.htmlwith everyapp/part inlined in load order) and evaluate them — the server into anode:vmcontext, 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
| Path | Role |
|---|---|
| ledger.html | The 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.js | Companion server: persistence, /api/v1 analytics, scheduled refresh, alerts, digests, Telegram bot, backups, metrics (≈1,800 lines). |
| help.html · tech.html | This 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.json | Start script (node server.js) and host config. No dependencies. |
| README.md · README-deploy.md | Full feature docs and deployment guide. |
| AUDIT.md · AUDIT-2.md · AUDIT-3.md | Records of the three audit/build cycles, each marked addressed. |
| .github/workflows/test.yml | CI: the full suite on Node 18 and 22 for every push and PR. |
03
Client architecture
Boot sequence
initServerSync()probes/api/health. If a companion server answers, its snapshot is applied before any local reads, so the synced state wins.- Journal, settings, and saved MAE/MFE measurements hydrate from the active store (server snapshot, linked file, or IndexedDB/localStorage).
- 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
| Global | Holds |
|---|---|
| allTrades | Every reconstructed trade (perp + spot), open and closed, all wallets pooled. |
| journal | One object for every journal level, keyed by trade id, day:YYYY-MM-DD, or week:GGGG-Www. |
| settings | Wallets, view, tz, break-even band, R basis, rules, goals, theme. |
| openPositions · spotHoldings · accountValue | Latest exchange snapshot for the risk panel, tripwire, and sizer. |
| _excM | Per-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. attributeFundingassigns funding rows to whichever trade held the position when the payment occurred.- A trade whose opening fills predate the available history is flagged
partialHistoryinstead 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, Daruma and the tax export see one kind of trade.
- Lighter. Read from the browser (Lighter allows cross-origin calls).
accountsByL1Addressfinds the sub-accounts, andtradespages 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, sostartPositionandclosedPnlare exact. The fee isusd_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 owntotal_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'sSETTLEMENTrows. Binance USD-M: the income history gives funding and the symbols traded, thenuserTradesper symbol in 7-day windows over 89 days.realizedPnlis 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.
venueBootTradesrebuilds from the caches alone, the same waybootFromCachedoes 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/isBEclassify 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_LIBnames the top-level functions to lift; their source text is concatenated with_WORKER_PRELUDE(the handful of consts they close over) and a_WORKER_DISPATCHmessage 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/mfePctper 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.
- Client edits mark dirty state: per-journal-id edit counters (
_dirtyJ) and a settings baseline_lastSyncedScaptured at the last successful sync. - Writes are debounced ~800 ms into
PUT /api/data {rev, snapshot}. The server accepts only ifrevmatches its current revision; otherwise it answers 409 with the newer state. - 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
_lastSyncedSbaseline (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. - 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 shape | Entry |
|---|---|
| <wallet>:<coin>:<openTime> | Per-trade: notes, tags[], setup, rating, mistakes[], risk, plan {entry, stop, target}, attachments flag. |
| day:YYYY-MM-DD | Day journal: bias, plan, maxLoss (arms the tripwire), review, adherence. |
| week:GGGG-Www | Weekly 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 onemarket.jsonpositions snapshot; trades are memoized against a cache signature and rebuilt only when inputs change. POST /api/v1/refreshmirrors the client'sloadAll: 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 unlessforce), 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 forAUTH_LOCK_MIN(15) minutes — a 429 withRetry-After, right token included, so parallel guessing gets nowhere.READ_TOKEN— exactlyGET /api/v1/*; it can never touch/api/data, attachments, snapshots, backups, or refresh.CORS_ORIGIN— one exact origin, off by default. Responses setnosniffandno-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, exportedalertsFrom(state, cfg)derives alert lines with dedupe keys (per position, per day, cooldown-scoped). Sent-state persists toalert-state.jsonso 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 astatfsofDATA_DIRintohealth:refresh/health:disklines, deduped through the same persisted sent-state with a 24 h cooldown; clearing a senthealth:refreshposts one recovery line. - Delivery. Webhook and Telegram are peer channels behind one
deliver(); success on either counts. Digests recordwebhookSentand 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 puretelegramReply(cmd, state)router overbuildBotState()— engine analytics reduced to plain data, so the wording is unit-testable without a bot or network. - Backups.
POST /api/backupshape-checks (app:'ledger'), writes gzip atomically, prunes to the newest 10. - Exchange relay.
cex-relay.jsforwards browser-signed Bybit and Binance requests:GETonly, 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, orm:<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. WithCEX_RELAY_URLthe server passes requests to a second copy runningCEX_RELAY_ONLY=1, which checksX-Relay-Secretin constant time and answers nothing else. - Off-site.
offsite.js(zero dependencies) encrypts each object with AES-256-GCM. The key comes fromOFFSITE_KEYvia 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 aDATA_DIRbundle once perOFFSITE_EVERY_H(the cadence is saved inoffsite-state.json, so redeploys don't re-upload). Bundle format: gzip of repeated{p,n,m}JSON header lines, each followed bynraw bytes. That avoids tar's path-length limits, since attachment keys run to 200 characters. Pruning lists the prefix and deletes the oldest pastOFFSITE_KEEP. Restore refuses any path outside its target directory. - Metrics.
GET /api/v1/metricsflattens the headline numbers;?format=promemitsledger_*gauges, numeric fields only.
13
API reference
GET /api/v1 returns a machine-readable index of everything, including auth modes and filter docs. Summary:
| Endpoint | Auth | Returns |
|---|---|---|
| GET /api/health | none | {ok, auth, appSyncCapable} — the client's server-detection probe. |
| GET/PUT /api/data | full | The synced snapshot; PUT is revision-checked (409 on conflict). |
| GET /api/snapshots[/date] | full | Rotating daily snapshots (14 kept). |
| GET/PUT/DELETE /api/att/:key | full | Journal image attachments. |
| POST /api/backup · GET /api/backups[/:name] | full | Server-held full backups (gzip, newest 10). |
| POST /api/v1/refresh | full | Pull 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 · metrics | read | The analytics surface — same engine functions the app runs. |
| DELETE /api/v1/cache/:addr | full | Evict one wallet's server caches. |
| GET /help · GET /docs | none | This 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
- Extractable functions stay top-level. Anything in
ENGINE_FNSor_WORKER_LIB, and anything the tests extract, must remain a top-levelfunction 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.jsonstays empty of deps. - The worker is source text. A function used in the worker must not close over module state beyond what
_WORKER_PRELUDEcarries. - 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 withresetDerivedState().
15
Testing
npm test runs 33 suites (~620 tests) via tests/run-all.mjs, including size budgets per screen: the journal and Daruma pages with their scripts, and the font files (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, Daruma 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.
| Suite | Covers |
|---|---|
| test-syntax | Every script block parses (vm.Script) — catches template-literal and escaping regressions whole-file. |
| test-newfeatures · features2-5 | Reconstruction edge cases, capital/XIRR, scorecards, CSV import, clusters, shock, goals, fee tiers, ISO weeks, variance, risk creep, demo fills. |
| test-api · test-server | The server over real HTTP with a mocked exchange: auth scopes, 409 merges, refresh pipeline, capital, backups, metrics, engine-failure softness. |
| test-alerts | alertsFrom thresholds, webhook body shaping, telegramReply wording. |
| test-game | Levels, 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-pulse | The Daruma 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 /daruma, /daruma/, manifest, icon and app-shell-only service worker routes over real HTTP. |
| test-accounts | Daruma 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-auto | Daruma'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-social | social.js and Daruma'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-passkeys | Passkeys 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-features6 | Tax 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-approval | Owner 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-offsite | Off-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-admin | The 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-chat | The 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-fixes | Regression 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-coach | Plain-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-habits | Rules 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-panel | Source assertions for render-layer logic that can't be extracted. |
| test-projection · decay · edge-map · attribution · dexfilter · improvements | Seeded 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.