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.
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.
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
accountGroupIdmatch 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. defaultForTypeis a plainVARCHAR(10). Auto-assignment matches the string exactly, soLiveorlivematches 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.
-
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.
-
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 nodefaultForType, so an operator has to put an account in it deliberately. -
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()); -
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.
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
FxTradingMarginCalltemplate, and at each forced close throughFxTradingStopOut. - 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
- Instruments, groups and sessions — symbol groups, the other half of the leverage calculation
- Running the desk — watching the accounts these tiers govern
- Settings and reference — every key and every table