Skip to Content
EngineeringReconciliation

Reconciliation

The invariant: for a (user, subaccount) pair, the journal’s sums equal the subaccount’s balances at the exchange.

delta = deriveValue − ledgerValue

That is, what must be added to the journal for it to agree with the exchange.

Filtering by subaccount is mandatory. Without it the projector sums the accounts of all of the user’s subaccounts for that asset and compares the total against the balance of one exchange subaccount — a divergence invented from nothing, on every single run.

The currency set is the union of the journal’s keys and the exchange’s, with a missing side treated as '0'. That catches both “we have it, they do not” and the reverse.

Why quantities, not dollars

Reconciliation compares amount, not mark_value. Quantities come back as exact decimal strings; the dollar valuation is computed in float and printed with the whole mantissa. Comparing against that would find representation artefacts instead of accounting errors.

Epsilon

RECONCILE_EPSILON is required and has no default in the schema — unlike RECONCILE_MODE below, it is a plain z.string().regex(...).refine(...), so a missing variable fails startup rather than falling back to anything. The agreed value, 1e-12, lives in apps/api/.env.example. The schema requires < 1e-6.

Why an epsilon at all: not for our arithmetic — both sides are exact decimal strings — but as insurance against a float appearing on Derive’s side. Without it the first artefact would produce a stream of false adjustments, and their sum is an accounting-quality metric.

Why there is an upper bound. The regex alone accepts '1' and '100' just as happily. An operator typo — a lost 0. before the value — would pass validation, the service would start, and a threshold of one whole unit of the asset would silently disable detection of any divergence smaller than one unit. On ETH that is around $1,900, with nothing written to the journal and a permanently green status: 'match'.

The bound is duplicated inside the function in case it is ever called around the env schema.

Write order — the stop goes first

  1. stop_raisedthe run’s first durable fact
  2. a reconciliation_adjustment per divergence, plus its projection
  3. the result, returned to the caller

The spec lists these actions in a different order. That is the order of the logic, not the order of the writes.

The stop is written first on purpose. If its append fails, the run leaves no trace at all. If something after it fails, trading is already stopped and the adjustments are incomplete — a conservative direction to fail in.

The reverse order allowed exactly the second case with no record of it: if only the stop’s append failed, the retry would find the journal already equal to the exchange, report match, and never write the stop.

FX rates are resolved before the first write. Otherwise an exception on a non-first divergence would leave the recorded adjustments standing while stop_raised was not; worse, on retry the already-corrected accounts would match the exchange and drop off the list, while the unpriceable currency would throw again — in that bucket and in every bucket after. A run either writes nothing or writes everything.

The run signature in the stop’s idempotency key exists so that two runs inside the same minute with different contents each leave their own stop_raised instead of collapsing into one.

reason carries the account names, not just their count: it is the first artefact a human reads when working out what happened to an append-only journal.

The divergence FX rate

Taken from the markPrice of that very exchange collateral whose assetName matched the divergence’s currency — the exchange’s own mark price at the moment of reconciliation, not our valuation.

Substituting '1' for, say, ETH would record a divergence of 0.01 ETH ($19) as ~$0.01 — understated by a factor of thousands and permanently, because the journal is append-only.

If there is no such collateral: USDC'1' (“saying it explicitly is more honest than staying quiet”); anything else → UnpriceableDiscrepancyError. Writing a knowingly wrong number into an append-only journal is worse than dying mid-run: this condition deserves a human, not a quiet write.

The event checks itself

superRefine verifies assertKnownAccount and that delta === deriveValue − ledgerValue. delta is the only field a posting actually applies; the others would otherwise be decorative. An internally inconsistent event is rejected before append — after the write it can no longer be fixed.

How it is triggered

Two ticks, one lock (728_035_957_007), so they cannot overlap.

TickCadenceWhat it takes
Scheduledhourlyevery funded subaccount
After-operationevery minutesubaccounts with an event from SUBACCOUNT_EVENT_TYPES in the last 3 minutes

The second half of the requirement — “after every money operation” — is implemented as a query against the journal, not as calls from the five execution steps. The events already know what moved, and one query replaces five edits in money-moving code plus five new edges between modules. The cost is up to one tick of latency; the point of this half is that the cause of a divergence is still visible, and a minute does not change that.

The window is three times the period: a skipped tick would otherwise leave an operation unreconciled until the hourly run.

The in-flight gate

The exchange changes a balance before we record the confirmation. A run landing in that window would see a divergence that does not exist — and in enforce mode would write an adjustment, after which the normal event would arrive and decrease the account twice.

Busy means: a cycle on step funding, placing or settling; an active conversion circle; a release in flight.

Step open does not count as busy. The premium has been posted and nothing moves until expiry — and if it counted, reconciliation would not run for the entire life of a position.

Withdrawal requests are not in the gate either: their postings touch external and transit, while the reconciliation invariant covers only sub: accounts.

The default mode is observe

RECONCILE_MODE is off | observe | enforce, defaulting to observe — it compares and writes divergences to the log, writing nothing to the journal and stopping nobody.

The reason is that the noise floor is unknown. The exchange accrues interest continuously while the collector reads it on a tick, and whether accrued interest is included in the collateral’s amount has never been verified against a live response — there has never been an accrual on the account. At an epsilon of 1e-12 a constant drift would mean an adjustment on every run, and once written into an append-only journal it is indistinguishable from a real accounting error — at which point the sum of adjustments stops being a quality metric.

The order of operations is: observe until the noise floor is visible, then enforce.

What deliberately stays outside

The service does not write a second stop event. It writes stop_raised itself as the run’s first durable fact; the enforcement — setting subaccounts.status = 'stopped' — is done by the runner. Calling StopService.raise from there would write a second event about the same run.

Alerting

TelegramAlertSink sends to a chat when both TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set; otherwise everything falls back to logger.warn.

Two properties matter. It always calls the fallback first, with the full message, and only then sends the truncated version (Telegram’s limit is 4096 characters) — so the complete text exists somewhere even if the send fails. And it never throws: an unreachable alerting channel must not undo a stop.