Adding a symbol the seed catalog does not have
There is no create-instrument and no delete-instrument endpoint — how to insert an fx_instrument row by SQL, what the contract spec must contain, and how to retire a market through CLOSE_ONLY to DELISTED.
The Import Instrument Catalog action seeds a fixed, curated list: 7 FX
majors, 21 FX crosses, gold, silver, WTI, Brent, 50 US large caps, 4 index ETFs
and 2 crypto CFDs — 88 instruments, all created INACTIVE. Every desk
eventually wants one it does not contain (USD/SEK, a European ADR, a second oil
contract) or wants a symbol gone for good.
Neither is a button. This page is how you do both.
The instrument admin surface is five routes
That is the whole of it:
An instrument cannot be created or deleted through the API or the admin
panel. The screen at Admin → Forex Trading → Market Data → Instruments is
built with canCreate={false} and no delete action, and no route exists behind
either. create.forex_trading.instrument is real, but it gates the import
action, not a create form.
So a symbol the import did not seed requires a direct INSERT into
fx_instrument, and retiring one is a lifecycle move to DELISTED rather than
a deletion.
Compare this with symbol groups (full create/edit/delete) and session calendars (full create/edit/delete). Instruments and account tiers are the two tables the admin panel cannot create rows in — see Account tiers for the other one.
Inserting a symbol by hand
assetClass, status, providerSymbols and metadata are all validated by
the Sequelize model, and none of that runs on SQL. providerSymbols and
metadata are TEXT columns holding JSON — the model stringifies on write and
parses on read, so what you insert must already be valid JSON or every read of
that row throws.
The columns
fx_instrument is UNIQUE on (currency, pair)
(fxInstrumentCurrencyPairKey), and the symbol a customer sees is written
CURRENCY/PAIR — EUR/USD, XAU/USD for gold, AAPL/USD for an Apple CFD.
There is no separate symbol column; the display symbol is composed.
| Column | Type | Must be |
|---|---|---|
id |
CHAR(36) |
A version-4 UUID you generate |
currency |
VARCHAR(191) |
The base leg — SEK for USD/SEK is not it; base is USD |
pair |
VARCHAR(191) |
The quote leg |
assetClass |
VARCHAR(20) |
Exactly one of FOREX, STOCK, COMMODITY, INDEX, CRYPTO |
groupId |
CHAR(36) NULL |
An fx_symbol_group.id — see the warning below |
status |
VARCHAR(20) |
INACTIVE. Start there and promote it through the panel |
providerSymbols |
TEXT NULL |
A JSON object, {"twelvedata":"USD/SEK"} — never an array |
swapLong / swapShort |
DOUBLE NOT NULL |
Overnight swap in points. Negative charges, positive credits |
metadata |
TEXT NULL |
The contract spec JSON — next section |
isTrending / isHot |
BOOLEAN NULL |
See "display flags are not decoration" below |
createdAt / updatedAt |
DATETIME |
Both required |
groupId is nullable and nothing refuses a null. An instrument with no symbol
group quotes at zero spread markup, charges zero commission, has no
leverage cap of its own, follows no session calendar (so it is treated as
24/7), and is skipped entirely by the swap cron — the rollover loop
continues on any instrument without a group. That is a market you carry the
risk on and earn nothing from, open at 3am on a Sunday. Always set a group.
The contract spec
metadata is what turns an order size into money, and the model enforces four
things on every write through the ORM: it must be a JSON object, precision
must be an object, and contractSize, pipSize and pointSize must each be a
number. The rest is unvalidated but read by the engine.
| Field | Read by | Meaning |
|---|---|---|
contractSize |
margin, P&L, swap, lot maths | Units in one standard lot |
pipSize |
display and spread markup | The pip in price terms |
pointSize |
stops distance, swap, point value | The point in price terms |
digits |
the terminal | Price precision shown |
precision.price / precision.amount |
the terminal | Decimal places for the price and the size input |
limits.amount.min / .max / .step |
order validation | Refused below min, above max, or off the step, both at placement and at fill |
limits.cost.min / .max |
order validation | Notional (amount × price) bounds in the quote currency. 0 is inert — the seed ships {min:0,max:0} |
stopsLevel |
order and position validation | Minimum distance in points a stop loss, take profit or pending level may sit from the market |
delayed |
the order path | true refuses fills unless fxTradingDelayedStocksTradable is on |
The values the seeder actually uses, which are the ones to copy from:
| Class | contractSize |
pipSize |
pointSize |
digits |
limits.amount min / max / step |
stopsLevel |
delayed |
|---|---|---|---|---|---|---|---|
| FX, non-JPY quote | 100,000 | 0.0001 | 0.00001 | 5 | 1,000 / 50,000,000 / 1,000 | 20 | false |
| FX, JPY quote | 100,000 | 0.01 | 0.001 | 3 | 1,000 / 50,000,000 / 1,000 | 20 | false |
Gold (XAU) |
100 | 0.1 | 0.01 | 2 | 1 / 10,000 / 1 | 30 | false |
Silver (XAG) |
5,000 | 0.01 | 0.001 | 3 | 50 / 500,000 / 50 | 30 | false |
Energy (other COMMODITY) |
1,000 | 0.01 | 0.01 | 2 | 10 / 100,000 / 10 | 10 | false |
STOCK and INDEX |
1 | 0.01 | 0.01 | 2 | 1 / 100,000 / 1 | 5 | true |
CRYPTO |
1 | 0.01 | 0.01 | 2 | 0.001 / 1,000 / 0.001 | 50 | false |
Sizes everywhere in this addon are base units, not lots. A minimum of 1,000 on an FX pair with a contract size of 100,000 is 0.01 lots.
There is no pipValue column and no pointValue column, and adding one would
be wrong. Both are derived at use time from pointSize × contractSize in the
quote currency and converted to the account currency at the current rate.
Storing them would make every stored value wrong the moment the market moved.
A worked insert
node -e "console.log(require('crypto').randomUUID())"INSERT INTO `fx_instrument`
(`id`, `currency`, `pair`, `assetClass`, `groupId`, `status`,
`providerSymbols`, `swapLong`, `swapShort`, `metadata`,
`isTrending`, `isHot`, `createdAt`, `updatedAt`)
SELECT
'PASTE-YOUR-V4-UUID', 'USD', 'SEK', 'FOREX', g.id, 'INACTIVE',
'{"twelvedata":"USD/SEK"}', 0, 0,
'{"precision":{"price":5,"amount":0},"digits":5,
"limits":{"amount":{"min":1000,"max":50000000,"step":1000},
"cost":{"min":0,"max":0}},
"contractSize":100000,"pipSize":0.0001,"pointSize":0.00001,
"stopsLevel":20,"delayed":false}',
0, 0, NOW(), NOW()
FROM `fx_symbol_group` g
WHERE g.`name` = 'FX Crosses';The providerSymbols key must be the provider's name as stored in
fx_provider — twelvedata, finnhub, tradermade or polygon — and the
value must be the vendor's own code for that instrument. Get it wrong and the
instrument activates and then streams nothing.
Every trading path reads a shared instrument map with a 30-second TTL. The admin endpoints bust it explicitly; a SQL write cannot. Your new row is invisible for up to 30 seconds — and on a multi-process deployment, for up to 30 seconds per process. Do not conclude the insert failed. See Deployment topology.
What the edit form can change afterwards
Once the row exists, PUT /api/admin/forex-trading/instrument/{id} accepts
exactly eight fields — status, groupId, swapLong, swapShort,
providerSymbols, metadata, isTrending and isHot — and refuses the
request with "No editable fields provided" if none of them are present.
currency, pair and assetClass are not editable by any route. A symbol
inserted with the wrong base, quote or asset class has to be fixed with SQL.
The edit form on Market Data → Instruments exposes four of those groups:
Lifecycle (status), Dealing configuration (groupId, swapLong,
swapShort), Display flags (isTrending, isHot) and Contract specification
(metadata, as a JSON textarea — a JSON string is accepted and parsed, and an
empty textarea is treated as "no change" rather than as null).
providerSymbols is not on the form. The endpoint accepts it, but the only
ways to set a mapping from the panel are re-running the import or an API call of
your own; otherwise it is SQL. Whenever the endpoint does receive
providerSymbols it refreshes the adapters' symbol maps immediately.
Status moves made through the edit form run the same transition table and the same guards as the bulk status endpoint — the form re-submits the unchanged status on every save, which is treated as a no-op rather than an error.
Activation still needs a mapping for the active provider
Promoting INACTIVE → ACTIVE is refused unless providerSymbols contains a key
for the provider whose status is currently true. The refusal names what you
do have:
- With no mapping at all: "Instrument has no provider symbol mapping — run 'Import Instrument Catalog' … (or set providerSymbols manually) before activating".
- With a mapping for some other vendor: "Instrument has no Twelve Data symbol mapping (only: finnhub)".
- With no provider active at all: "No active market-data provider".
That check exists precisely because of the hand-inserted case. A row with no
mapping activates cleanly and then quotes nothing — it appears in the markets
rail, never reaches QUOTING, and refuses every open and close with a stale or
missing quote. The guard turns a silent dead market into a 400 you can read.
Retiring an instrument
There is no delete. The terminal state is DELISTED, and it is reachable from
CLOSE_ONLY only:
INACTIVE -> ACTIVE
ACTIVE -> CLOSE_ONLY | HALTED | INACTIVE
CLOSE_ONLY -> ACTIVE | HALTED | DELISTED
HALTED -> ACTIVE | CLOSE_ONLY
DELISTED -> (nothing)Both INACTIVE and DELISTED are additionally refused while any position on
that instrument is OPEN; the error names the count.
-
Move it to
CLOSE_ONLY. Customers can still exit; nobody can open a new position or increase margin on it. Use the Close Only bulk action or the edit form. -
Watch the open-position count fall to zero. Admin → Forex Trading → Trading → Positions, filtered to that instrument. Force-closing from Positions is available if you need to finish it, and stamps the close
ADMIN. -
Move it to
DELISTED. This is one-way. Nothing transitions out ofDELISTED, so the only route back is a SQLUPDATEof thestatuscolumn. -
Do not delete the row. The deals ledger and every closed position reference
instrumentId. Removing the row orphans that history — and the next Import Instrument Catalog run will recreate any seeded symbol you deleted anyway,INACTIVEand unmapped.
HALTED is the right state for something temporary — a corporate action, a
provider outage, a news event — because it returns to ACTIVE or CLOSE_ONLY
in one move.
Re-running the import is safe
POST /api/admin/forex-trading/instrument/import is idempotent by design:
- Calendars, symbol groups and instruments are created with
findOrCreate. An existing row is never modified — your leverage, markup, swap and metadata edits survive. - It never changes a status. An
ACTIVEinstrument staysACTIVE; aDELISTEDone staysDELISTED. - It never deletes another provider's mapping. It merges its finding into
providerSymbolsunder the active provider's key and leaves the rest. - It never deletes anything.
What it does do is link the active provider's codes onto matching rows, keyed
by asset class and pair together. Matching on the pair alone once bound the
WTI crude contract to a same-named US penny stock and quoted crude at $3.66
forever, so a class mismatch now leaves the row unmatched instead. INDEX rows
are the one exception: they are ETF proxies and legitimately resolve against the
provider's STOCK universe. A small alias table also covers the OANDA-derived
codes for crude — WTI/USD tries WTICO/USD, USOIL/USD and WTICOUSD;
BRENT/USD tries BCO/USD, UKOIL/USD and BCOUSD.
Read two fields in the response:
| Field | Meaning |
|---|---|
symbolsUnmatched |
Catalog instruments this provider could not serve. Mostly harmless for rows you never enabled |
strandedActive |
Instruments already ACTIVE, CLOSE_ONLY or HALTED that this provider cannot quote |
strandedActive is the one that matters. Those are live markets — possibly
holding open client positions — that will go silent, because the import will not
change their status and will not remove the old provider's mapping. It is the
signature of a provider switch, and the install checklist makes an empty
strandedActive a gate on going live.
Your hand-inserted symbols are simply not in the seed list, so the import will
report them under symbolsUnmatched unless the provider happens to publish them
under exactly the same assetClass|CUR/PAIR key — and it will still link the
provider symbol if it does.
Display flags are not decoration
isTrending and isHot render badges on the markets rail, and they do one more
thing: the tick engine subscribes to every ACTIVE instrument carrying either
flag at boot, whether or not anybody holds a position in it.
Everything else joins the stream on demand — when a client opens that symbol, when a position or resting order exists on it, or when the swap cron pins a missing USD-hub conversion leg. So the flags are how you keep a symbol's chart warm and its quote fresh from process start, and they cost provider bandwidth on a metered plan and a WebSocket slot on a capped one (Finnhub's free stream is 50 symbols). Flag the handful you show on the landing rail, not the whole catalog.
Next
- Instruments, groups and sessions — the lifecycle, symbol-group economics and session calendars in full
- Market data providers — which vendors serve which asset classes, and what a provider switch costs
- Account tiers — the other table with no create endpoint
- Troubleshooting — the activation refusals, by message