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.

8 min readUpdated 6 August 2026instruments, catalog, sql, contract-spec, delisting

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:

Paginated catalog browser, sorted A→Z by base currency
Symbol-group options for the instrument edit form. The literal value 'none' means no group
Edits one instrument — status, group, swaps, provider symbols, contract spec, display flags
Moves one instrument through its lifecycle — the table's bulk actions
Seeds the curated catalog and links provider symbols

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/PAIREUR/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_providertwelvedata, 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

Moves an instrument through its lifecycle

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.

  1. 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.

  2. 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.

  3. Move it to DELISTED. This is one-way. Nothing transitions out of DELISTED, so the only route back is a SQL UPDATE of the status column.

  4. 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, INACTIVE and 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 ACTIVE instrument stays ACTIVE; a DELISTED one stays DELISTED.
  • It never deletes another provider's mapping. It merges its finding into providerSymbols under 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