Skip to content

Account isolation

Base-asset exclusivity (one base asset per Binance account)

A base asset is managed by at most one profile per account. An account is now a first-class row (accounts): one Binance API key pair, one binance_mode (the environment that key pair talks to), and one user-data stream. The operator owns N accounts under a single login; every profile references its parent via profiles.account_id, and all profiles under one account share that account's single key pair, wallet, and stream. The base asset is the unit of exclusivity because it IS the shared wallet line: two profiles drawing on one BTC balance — whether via BTCUSDT, BTCFDUSD, or any other BTC* pair — cannot size a sell or arm a protective stop independently. One profile's resting stop locks tokens the other counts as free, leading to under-sized stops, failed sells, and stops placed over a position the other profile opened. Keying on the base asset subsumes the per-symbol check: blocking the base also blocks every cross-quote pair over it.

Each bound row carries a denormalized base_asset column (profile_symbols), backfilled by stripping the profile's quote_asset suffix. The denormalization lets the guard stay a total pure-SQL backstop: the repo cannot read Binance exchangeInfo, so every bind seam resolves the base from exchangeInfo and passes it into upsert.

Enforcement is at the single bind seam — profileSymbols.upsert (packages/db/src/repo/profile-symbols.ts) — which runs three exclusivity checks, base-owned first (it wins precedence): findOwningSiblingByBase throws SymbolOwnershipConflictError when another profile on the same account already manages the base asset, then findSiblingQuotingBase throws SiblingQuoteConflictError for the quote-collision half below, then a self-check rejects when the binding profile's own quote_asset (uppercased) equals the candidate base — settling in the very asset it trades as a base — also throwing SymbolOwnershipConflictError. Every add path funnels through upsert: discovery rotation, manual add, orphan-order adoption, and config reset. The API maps both errors to 409 CONFLICT. Discovery additionally pre-filters sibling-owned candidates (discovery.cron.ts) so the rotation loop stays exception-free. This is an app-level guard, not a DB unique index: the worker is single-replica and discovery serialises per profile, so the only theoretical race is an operator-driven concurrent API write.

Quote-collision exclusivity (base equals a sibling's quote asset)

The shared wallet also couples in a second direction: a candidate whose base asset equals a sibling profile's quote asset. If a USDT profile buys BTCUSDT (base BTC) while a sibling profile settles in BTC (e.g. trades *BTC pairs), both draw on the one BTC balance line, so the sibling's sells and stops fight the same interference base-asset exclusivity guards against. Discovery auto-admission refuses such a candidate with the sibling-quotes-base disposition (its counterpart, base-owned-by-sibling, shows sibling-owns-base), surfaced in the Live universe so the operator sees why a matching coin was not added. Sibling quote assets are read account-scoped (profiles.listForAccount) once per profile per cron cycle. This half is also backstopped in upsert via findSiblingQuotingBase (comparing upper(quote_asset) against the candidate base, since a stored quote may be lower-case), throwing SiblingQuoteConflictError409 CONFLICT. So every bind seam — manual add, orphan adoption, config reset, and the redundant discovery re-add — is blocked, not just the discovery pre-filter. The discovery pre-filter remains as the exception-free fast path that shows the disposition; the DB backstop is what covers the manual and adoption routes the pre-filter never sees. The orphan-adoption route additionally pre-checks both halves before its order insert, so a conflict 409s without leaving a tracked order with no binding.

Editing a profile's own quote_asset is the same collision reached through a different door: pointing profile B's settlement at an asset a sibling already trades as a base (or that B itself trades) recreates the shared-wallet conflict after both are bound, and the symbol-bind guard never runs on a quote edit. So profiles.update runs the guard too, before it persists: when the new quote differs from the stored one it rejects a cross-profile clash (findOwningSiblingByBase) and a self-clash (profileManagesBase — B trading Q as a base while settling in Q), both throwing SymbolOwnershipConflictError409 CONFLICT. Only a genuine change is guarded, so re-setting the same quote never spuriously fails.

Two profiles under different accounts may hold the same base asset — a different account is a different key pair and wallet (and, since binance_mode lives on the account, possibly a different exchange environment), so there is no shared wallet line. The only residual is one profile binding two symbols over the SAME base (e.g. BTCUSDT and BTCFDUSD under one profile): the guard excludes self, so that intra-profile overlap is allowed and both rows track the one BTC balance — see reconcile-held-quantity.ts.

Destructive deletes (profile and account)

Deleting a profile is a soft disposal that archives the profile and cancels all resting orders, rather than a row delete.

DELETE /api/accounts/:accountId/profiles/:profileId now takes a disposition and answers 202:

  • no disposition + live exposure409 CONFLICT with { openOrderCount, openPositionCount }, so the UI can name what is open. (No exposure ⇒ no choice to make; it defaults to cancel-orders.)
  • ?disposition=cancel-orders → the worker cancels every resting order on Binance, then deletes. The coins return to the wallet as plain holdings.
  • ?disposition=handoff&toProfileId=<uuid> → the orders are still cancelled (a clientOrderId encodes the SOURCE profile, so the target's strategy could never recognise them — it would see a foreign resting SELL and refuse to arm its own stop), and the position — the avg_entry_prices row and the profile_symbols binding — is re-pointed to a target profile on the same account. The target is then reconfigured, which is what makes it tick the inherited symbol at all and seeds its strategy state from the moved cost basis, so it arms its own correctly-hashed stop on the next tick instead of believing itself flat and re-entering on top of the position.

The API has no Binance client, so it may only guard and enqueue; the dispose-profile worker job owns the teardown (disable+unsubscribe → cancel → handoff → re-verify DB and the exchange → wipe Redis → delete the row) and retries until the exchange is provably clear. See docs/architecture/worker-pipeline.md.

Deleting an account always 409s while any child profile holds live exposure, with no escape hatch: the cascade cannot cancel anything on Binance, so a forced account delete is exactly the abandonment above, one level up. Dispose of each profile first.

Orders are the exception, and they behave differently for the two deletes — because an order is ACCOUNT-domain, not profile-domain: its Binance id is unique per symbol not per account (so an account-wide sweep keys on symbol + orderId), the user-data stream that reconciles it is per account, and it keeps resting on the exchange whether or not the strategy that placed it still exists.

  • Deleting a profile DETACHES its orders (orders.profile_id → NULL, ON DELETE SET NULL). The order is still real money on Binance, so its ledger row must OUTLIVE the strategy — cascading it away would destroy the only record of a live exchange order. A detached row is reconciled by the detached-orders-reconcile cron (and, while the account still has an active profile, by the user-data stream via fillAdopter.reconcileDetachedFill), and closed when the exchange says the order has left the book.
  • Deleting an account CASCADES its orders (ON DELETE CASCADE). The key pair goes with the account, so nothing can ever query, cancel, or reconcile those orders again. There is no one left to keep the row for.

Exposure is counted so that a detached order still blocks the delete:

  • Profile: projections.countOpenExposure(ProfileScope).
  • Account: projections.countAccountOpenExposure(AccountScope) — counted off orders.account_id directly, not through a profiles join. A join would silently drop every DETACHED row, which is the exact exposure this guard exists to refuse: an account whose last profile was deleted while holding a resting order would otherwise report zero and be deletable. Positions still join through profiles (a position IS strategy state and dies with the profile). Both use the shared isHeldPosition predicate, so the two guards cannot drift.

orders_one_live_per_intent (profile_id, symbol, intent) WHERE closed_at IS NULL is unaffected by the nullable profile_id: Postgres treats NULLs as distinct in a unique index, so a detached row never blocks a new live slot.

toAccountScope: minting an AccountScope from a proven ProfileScope

Five orders functions moved to AccountScopefindByBinanceOrderId, closeByBinanceOrderId, markFilledByBinanceOrderId, stampRealizedPnl, reapWithReason — because all five seek by (account, binance_order_id) and a detached row is reachable ONLY that way. Narrowing them to the profile would make a detached-but-still-resting order unreconcilable.

Callers that already hold a ProfileScope (the worker's per-profile persistence bindings) must not re-run scopeAccount to reach them: the chain accounts.owner_id = operatorId AND profiles.account_id = accountId AND profiles.id = profileId was already proven, and the account half of it is a strict prefix. toAccountScope(ProfileScope): AccountScope (packages/db/src/repo/_scoped.ts) is the ONE sanctioned way to make that narrowing — it is not a cast, it is the statement that a proven profile chain contains a proven account chain. No other path may mint an AccountScope without scopeAccount.

The account delete enumerates its profiles before the cascade (which removes the rows), then, after the delete commits, wipes each profile's Redis keys and enqueues one unsubscribe-profile job per profile. That cleanup runs under Promise.allSettled and only logs on failure: the destructive write already succeeded, and a 500 would read as "the account is still there". A missed unsubscribe self-heals — the worker re-reads DB truth and a deleted row maps to teardown.

Per-symbol reserve (always-hold floor)

A reserve is a per-(profile, symbol) base-asset quantity the bot must never sell below — the operator's "always hold 50 ADA, keep trading on top". It is stored on profile_symbols.reserve_base_quantity (decimal-string, null = none) — account/capital data, not strategy config — so it is enforced strategy-agnostically and works for every strategy with no per-plugin code.

The bot trades only the surplus above the floor. Enforcement is a single pure helper, reserveAdjustedBalance(free, locked, reserve) (apps/worker/src/lib/reserve.ts), which drains the reserve from free first, then locked, applied at the only two places the worker reads the base-asset wallet balance:

  • Boot adoption (runHeldQuantityReconciliation): the wallet is reserve-adjusted before position adoption, so a fully-reserved holding reconciles to a flat position (the bot opens a fresh trade on top) and a partially-reserved holding adopts only the surplus. Without this, the boot reconciler would claim the whole wallet — including the reserve — as the bot's position, deadlocking it (a priced position it can neither trade on top of nor sell).
  • Per-tick sell sizing (buildTickInput): the bot-visible base balance is reduced by the reserve, so the pure strategy sees a wallet holding only the surplus and naturally never sizes a sell into the floor. The strategy never learns the reserve exists; golden replays are byte-identical.

The API (PUT /profiles/:id/symbols/:symbol/reserve) rejects a reserve larger than the live base-asset holding (422), read from the worker-maintained account-info Redis snapshot (with an avg_entry_prices ledger fallback) — never a live Binance call from the API process, preserving the keys-only-in-worker boundary. The reserve is a sell floor only; buys proceed per the strategy. Caveat: as with the wallet reconciler, one profile binding two symbols over the same base applies each symbol's own reserve to the shared balance — rare, and the floor only ever errs toward holding more, never less.