Account tiers — margin call, stop out and leverage caps

Creating and maintaining fx_account_group rows by SQL, because no admin screen or write endpoint exists — with worked ESMA and offshore tiers and the traps in auto-assignment.

8 min readUpdated 6 August 2026account-groups, margin-call, stop-out, leverage, sql

An account tier is a row in fx_account_group. It decides, for every account assigned to it, when the customer gets a margin call, when the engine starts force-closing their positions, whether a negative balance is written off, and how much leverage they may reach.

Three other pages in this documentation tell you to create one, and the production checklist makes "at least one account group exists, with a defaultForType for LIVE" a gate on going live. This page is how you actually do it, because the answer is not what you expect.

Nothing in /admin/forex-trading creates, edits or deletes an fx_account_group row, and no POST, PUT or DELETE route exists anywhere in the backend for that model. Symbol groups have full CRUD; account groups do not. Plenty of routes read the table — the two option pickers below, the admin account list, the account and routing-rule updates that check the id you send against it, and both customer account paths resolving defaultForType — but not one of them writes a row.

Tiers are created and maintained with SQL against the fx_account_group table. That is the supported path today, and everything below assumes you have a MySQL client open on the platform database.

What the admin panel does give you

Two read-only option endpoints, and one place to use them.

Value/label pairs for the account form's tier picker
Value/label pairs for the routing-rule tier dimension

Both do a plain findAll of id and name ordered by name, and both prepend a sentinel entry — No group on the account form, Any account group (wildcard) on the routing-rule form. Neither can create a row.

So once a tier exists in the table it is immediately pickable in two places:

  • Admin → Forex Trading → Trading → Accounts, edit an account, the Risk controls group, the Account group field.
  • Admin → Forex Trading → Execution → Routing Rules, as the accountGroupId match dimension on an A-book rule.

The six columns

Column Type Default What it does
name VARCHAR(191), UNIQUE none The tier's label, e.g. ESMA Retail. This is the only unique key besides the primary key
marginCallLevel DOUBLE NOT NULL 100 Margin level % below which the customer is notified, blocked from margin-increasing orders and blocked from withdrawing
stopOutLevel DOUBLE NOT NULL 50 Margin level % below which the risk engine force-closes positions, largest loser first
negativeBalanceProtection BOOLEAN NULL true Zero a negative balance after full liquidation, booking the deficit as an NBP_CORRECTION deal against your P&L
maxLeverage INT NOT NULL 100 The tier's leverage cap
defaultForType VARCHAR(10) NULL NULL DEMO or LIVE — auto-assign this tier to newly created accounts of that type

Plus id (CHAR(36), the primary key) and the createdAt / updatedAt timestamps, all three of which your INSERT has to supply.

The defaults in that table are model defaults, applied by the ORM. A raw SQL INSERT that omits a column gets the column's DDL default, which for a table created by model sync is the same value — but be explicit anyway and write every column.

Margin level is equity / usedMargin × 100. At 100% the customer's equity exactly covers their requirement.

The model refuses a negative marginCallLevel or stopOutLevel, a maxLeverage below 1, and a defaultForType that is not exactly DEMO or LIVE. None of that runs on a raw INSERT. In particular:

  • Nothing enforces stopOutLevel < marginCallLevel. Set them the other way round and liquidation fires at or before the warning does, so the customer gets no window in which to act.
  • defaultForType is a plain VARCHAR(10). Auto-assignment matches the string exactly, so Live or live matches nothing and the column silently does nothing at all.

An account with no tier

groupId on fx_account is nullable, and nothing seeds a tier. An account whose groupId is NULL — or which points at a row you have since deleted — falls back to:

Value
Margin call level 100
Stop out level 50
Negative balance protection On
Tier leverage cap None — the cap drops out of the leverage calculation entirely

That is a workable ESMA-ish default and it is why an install with no tiers still behaves sanely. It is a fallback, not a decision: nobody chose those numbers for your book, and the missing leverage cap means the account's own leverage value and the symbol group's cap are the only two limits in play.

Creating a tier

fx_account.groupId is validated by the ORM as a UUID version 4. MySQL's UUID() function returns a version-1 (time-based) UUID, which fails that check. A tier created with UUID() appears in the picker and then breaks every write of it: assigning it from the admin form is refused, and if it carries a defaultForType, customer account creation itself fails because the account row cannot be written with that groupId.

  1. Generate one v4 UUID per tier on the app server.

    node -e "console.log(require('crypto').randomUUID())"

    Run it once for each row you are about to create and keep the values.

  2. Insert the tiers. Two worked examples — a European retail tier that will be the default for new live accounts, and an offshore tier you assign by hand.

    INSERT INTO `fx_account_group`
      (`id`, `name`, `marginCallLevel`, `stopOutLevel`,
       `negativeBalanceProtection`, `maxLeverage`, `defaultForType`,
       `createdAt`, `updatedAt`)
    VALUES
      ('PASTE-YOUR-FIRST-V4-UUID', 'ESMA Retail',
       100, 50, 1, 30, 'LIVE', NOW(), NOW()),
      ('PASTE-YOUR-SECOND-V4-UUID', 'Offshore Pro',
       100, 20, 0, 500, NULL, NOW(), NOW());

    ESMA Retail — margin call at 100%, stop out at 50%, negative-balance protection on, 30x cap. Offshore Pro — margin call at 100%, stop out at 20%, negative-balance protection off, 500x cap, and no defaultForType, so an operator has to put an account in it deliberately.

  3. Insert a demo tier if you want demo accounts to land somewhere on purpose rather than on the fallback.

    INSERT INTO `fx_account_group`
      (`id`, `name`, `marginCallLevel`, `stopOutLevel`,
       `negativeBalanceProtection`, `maxLeverage`, `defaultForType`,
       `createdAt`, `updatedAt`)
    VALUES
      ('PASTE-YOUR-THIRD-V4-UUID', 'Demo',
       100, 50, 1, 100, 'DEMO', NOW(), NOW());
  4. Check the picker. Open Admin → Forex Trading → Trading → Accounts, edit any account, and confirm the new names appear in Account group. No restart is needed — every read of a tier is a fresh query, and there is no settings cache in front of this table.

negativeBalanceProtection is a TINYINT(1): write 1 for on and 0 for off. NULL also means on — the risk engine only skips the write-off when the column is explicitly false, so leaving it null does not disable protection.

Auto-assignment, and the two-defaults trap

When a customer creates an account, the backend resolves the tier with:

const group = await models.fxAccountGroup.findOne({
  where: { defaultForType: type },
});

There is no ORDER BY, and name is the only unique column in the table — nothing stops two rows both carrying defaultForType = 'LIVE'. When that happens MySQL is free to return either one, and which tier a new customer lands in becomes arbitrary and can differ between two accounts created a minute apart.

The auto-provisioned demo account created on a customer's first visit to the terminal resolves the same way, against defaultForType = 'DEMO'.

Keep at most one row per type, and check it after every edit:

SELECT `defaultForType`, COUNT(*)
FROM `fx_account_group`
WHERE `defaultForType` IS NOT NULL
GROUP BY `defaultForType`;

Assigning a tier to an existing account

This part is available from the admin panel, and it is the only tier operation that is.

Updates one account — kill-switch, leverage, tier, swap-free flag, withdraw limits

Admin → Forex Trading → Trading → Accounts, the row's edit action, Risk controls → Account group. The endpoint accepts groupId and verifies the row exists before writing; an id that matches nothing is refused with "groupId does not reference an account group".

To take an account out of every tier, pick the No group entry. The literal string "none" is the sentinel the endpoint converts to NULL (an empty string does the same). That account then falls back to 100 / 50 with no tier leverage cap.

Balance never changes here — that is the separate reason-coded adjustment action, which writes an ADJUSTMENT deal. See Running the desk.

Bulk moves are not offered in the UI. Reassigning many accounts at once is SQL:

UPDATE `fx_account`
SET `groupId` = 'THE-TIER-V4-UUID', `updatedAt` = NOW()
WHERE `id` = 'THE-ACCOUNT-UUID';

Effective leverage is the minimum of three numbers

effectiveLeverage = min(
  fx_account.leverage,           the per-account knob, 1..3000
  fx_symbol_group.leverage,      the instrument's group cap
  fx_account_group.maxLeverage   the tier cap
)

Values that are absent, non-numeric or below 1 are ignored rather than treated as zero, so an account with no tier is simply capped by the other two. If none of the three is usable the engine falls back to 1.

This is why a tier edit is not a small change: used margin on positions that are already open is recomputed from the current effective leverage on every sweep. The usedMargin column on fx_position is a bookkeeping snapshot taken at open and is explicitly not what the account's margin requirement is calculated from.

Drop a tier from 500x to 30x and every open position in that tier immediately requires roughly sixteen times the margin it did a moment earlier. Margin level collapses by the same factor, and the next risk sweep — which runs on the next tick for any account exposed to that instrument, and at most once per second per account — will fire margin calls and then stop-outs.

There is no preview, no dry run and no re-confirmation step. Move leverage caps down out of trading hours, or move the affected accounts to a new tier one at a time instead of editing the tier they are in.

Changing a live tier

Every field is read fresh from the database on each sweep; nothing is cached in the engine.

Change When it takes effect
stopOutLevel The next risk sweep for each account, including accounts already holding positions. No re-confirmation, no notice to the customer
marginCallLevel The next sweep. Also gates withdrawals and margin-increasing orders from that moment
maxLeverage The next sweep, through recomputed used margin — see the warning above
negativeBalanceProtection The next time an account in the tier is fully liquidated with a negative balance
defaultForType The next account created of that type. Existing accounts are never re-assigned

Raising stopOutLevel (say 20 → 50) liquidates earlier and reduces the deficit you can be left with. Lowering it lets customers run closer to zero and puts more of the tail risk on you, which is exactly what negative-balance protection then pays for.

Renaming and deleting

name is unique, so a rename is a plain UPDATE that fails loudly on a collision. Nothing else references a tier by name.

Deleting is SQL too, and there is no guard of any kind — no route refuses it, nothing checks whether accounts or routing rules still point at the row:

SELECT COUNT(*) FROM `fx_account` WHERE `groupId` = 'THE-TIER-UUID';
SELECT COUNT(*) FROM `fx_routing_rule` WHERE `accountGroupId` = 'THE-TIER-UUID';

The association declares ON DELETE SET NULL, so accounts in a deleted tier lose their groupId and drop to the 100 / 50 fallback with no leverage cap — which for an offshore tier is a tightening and for a 30x ESMA tier is a loosening. Reassign the accounts first, then delete the empty row.

An A-book routing rule whose accountGroupId points at a deleted tier stops matching anything, silently. Check the second query above before deleting, and re-point the rule.

Where the levels surface

  • The customer is notified at the margin-call level through the FxTradingMarginCall template, and at each forced close through FxTradingStopOut.
  • The account's expanded view in Trading → Accounts shows the tier name, its margin-call level and its stop-out level alongside the live margin level.
  • Admin → Forex Trading (the risk desk) lists accounts near stop-out, which is measured against each account's own tier.

Next