Binary options: switching it on and the global limits
The two settings rows that turn binary trading on, the nine-tab settings console behind them, the per-user order limits, and what else has to be true before /binary works.
Binary options is part of the core product, not an addon — there is no extension row for it and nothing to license. It ships switched on at the settings level and switched off in practice, because it has no markets until you create some.
Two admin screens own it:
| Screen | Path | Page permission |
|---|---|---|
| Binary Markets | /admin/finance/binary/market |
access.binary.market |
| Binary Settings | /admin/finance/binary/settings |
access.binary.settings |
Both sit under Admin → Finance → Binary Options. Customer orders are listed
separately under Order Management → Binary Orders
(/admin/finance/order/binary, permission access.binary.order).
Payouts, order types and duration ladders have their own page: Binary order types, durations and payout percentages.
The two switches, and where they live
binaryStatus and binaryPracticeStatus are two ordinary flat rows in the
settings table. They are not part of the settings JSON blob, and this is the
only screen in the whole product that writes them — they do not appear on
Admin → System → Platform Settings.
| Key | What it gates |
|---|---|
binaryStatus |
The master switch. Off, the /binary menu entry disappears, the landing page tile disappears, the footer link disappears, and the order route answers 403 "Binary trading is currently disabled". It is also what the backend checks at boot before arming expiry timers for pending orders. |
binaryPracticeStatus |
Demo/practice trading. Off, a demo order answers 403 "Binary practice mode is currently disabled" while live trading carries on. |
Both are written by the two switches at the top of /admin/finance/binary/settings.
Because binaryStatus is a plain settings row, it is also the row the platform's
route catalogue uses to decide whether /binary is reachable at all — the same
mechanism the support assistant uses when deciding whether to send a customer
there.
The JSON blob below carries its own global.enabled and global.practiceEnabled
fields, and the order service checks global.enabled as a second gate (answering
400 rather than 403 when it is false). The master switch on screen writes only
binaryStatus; the save route falls back to global.enabled only when the
request carries no binaryStatus at all. Both ship true, and all three presets
set them true, so in practice binaryStatus is the switch — but if you ever
edit the blob directly, make the two agree.
On boot, and only on the main thread, the backend sweeps every already-expired
binary order and installs an in-process expiry timer for the rest — but it does
that only when binaryStatus is already true. Switching the master switch
on at runtime does not retroactively arm those timers. Restart the backend after
turning binary trading on for the first time.
Everything else is one JSON row
The remaining configuration — order types, barriers, durations, cancellation
rules, risk limits, chart choice — is stored as a single binarySettings row
holding one JSON document. There is no binaryMaxDailyOrders key, no
binaryChartType key, and nothing you can find by searching the settings table
for a field name you saw on screen.
Binary settings are cached per process, and the settings table has its own Redis
layer in front of it. A hand-written UPDATE on the row updates neither. The
save button on the settings screen invalidates the binary cache across every
worker and then clears the global settings cache — without that announcement,
other workers keep paying out on the old profit percentages until their own TTL
lapses. Change it from the screen.
The settings console
/admin/finance/binary/settings is one page with nine groups:
| Tab | What it configures |
|---|---|
| Global Settings | Master switches and the per-user trading limits below. |
| Chart & Display | display.chartType. |
| Order Types | The five order types, whether each is enabled, its base payout, and demo/live availability. |
| Barrier Levels | Barrier and strike ladders with their own payout percentages. |
| Durations | The expiry ladder and per-duration payout adjustments. |
| Cancellation | Whether customers may close early, how close to expiry, and the penalty. |
| Risk Management | riskManagement.dailyLossLimit and riskManagement.winRateAlert. |
| Payout Optimizer | A calculator. It computes break-even win rates and platform edge from what you have configured and can apply a suggestion. It saves nothing on its own. |
| Presets | Three shipped templates — conservative, balanced, aggressive — each returned with its own validation result. |
Reading and writing are different permissions. access.binary.settings gates
the GET and the presets list; edit.binary.settings gates the PUT. A role with
only the first sees the whole console populated and gets a permission error when
it presses Save. Grant both, or neither. The co-located frontend contract for
this screen records access.binary.settings.
isDefault: true means nobody has ever saved
The GET returns isDefault: true when no binarySettings row has ever been
written. The console shows a banner for it, and it is worth taking seriously: the
install is running live on DEFAULT_BINARY_SETTINGS — RISE/FALL only, 72%
payout, a three-second cooldown, and every other order type off. Nothing is
broken, but nothing has been decided either. Open the console and press Save once
so the row exists and you know what it contains.
The validation object
Both GET and PUT return a validation object with valid, errors and
warnings. Errors block the save — the PUT answers 400 and writes nothing.
Warnings do not; they are advice, each carrying a level (info, warning,
danger), a category, a message, a suggestion and often the exact field.
Errors are structural: fewer than one concurrent order allowed, a payout outside 0–1000%, an enabled order type with no barrier or strike levels, a negative barrier distance, no durations at all, no enabled duration, a duration under one minute.
Warnings are commercial. The ones worth acting on:
| Level | Warning |
|---|---|
danger |
No durations are enabled — trading will not work. |
danger |
Higher/Lower's widest enabled barrier is over 2% away, which allows near-guaranteed wins. |
warning |
The same barrier over 1% away — probably too easy. |
warning |
Average payout across enabled types is over 90% and may not be sustainable. |
warning |
Turbo's maxDuration is longer than 5 minutes. |
info |
No daily loss limit is configured (riskManagement.dailyLossLimit is 0, the shipped default, which means disabled). |
info |
Every enabled order type carries the same payout, so nothing differentiates them by difficulty. |
The global limits
All five live under global and all five are shipped defaults you should review
before you take live money.
| Key | Default | What it does |
|---|---|---|
global.maxConcurrentOrders |
10 |
Most open (PENDING) live orders one user may hold at once. Exceeding it answers 429. 0 disables the limit. |
global.maxDailyOrders |
100 |
Most live orders one user may place in a rolling 24-hour window. 429 when exceeded. 0 disables it. |
global.cooldownSeconds |
3 |
Minimum gap between one user's consecutive live orders. 429 with the remaining seconds. 0 disables it. |
global.orderExpirationBuffer |
30 |
Seconds before expiry within which a new order is refused, with a 400 naming the buffer and the time remaining. |
global.cancelExpirationBuffer |
60 |
Published to the trading page so the client can hide the cancel button near expiry. |
The three shipped presets move all five together, which is the fastest way to see what a coherent set looks like:
| Conservative | Balanced (the default) | Aggressive | |
|---|---|---|---|
maxConcurrentOrders |
5 | 10 | 20 |
maxDailyOrders |
50 | 100 | 200 |
cooldownSeconds |
5 | 3 | 0 |
orderExpirationBuffer |
45 | 30 | 15 |
cancelExpirationBuffer |
90 | 60 | 30 |
A preset rewrites the whole settings document, including every payout and the entire duration ladder — not just this table. Read the list of changes the Presets tab shows you before you apply one.
maxConcurrentOrders, maxDailyOrders and cooldownSeconds are skipped for
demo orders, and skipped for copy-trading replication orders (which are placed by
the server at the leader's cadence and bounded by the follower's allocation
instead). A user cannot exhaust their daily limit on practice trades, and they
cannot use practice trades to work around a cooldown either.
global.cancelExpirationBuffer is returned by the public binary settings
endpoint the trading page reads, but the server-side refusal to cancel comes from
the Cancellation tab's per-type minTimeBeforeExpirySeconds, not from this
value. Set both, and set them consistently — otherwise the button disappears at
one moment and the server refuses at another.
Chart & Display
display.chartType has exactly two values.
| Value | Requires | Effect |
|---|---|---|
CHART_ENGINE |
The Chart Engine addon, licensed | The default. Positions are drawn directly on the chart with P/L zones, entry markers and countdown timers, so the trading page hides its separate positions sidebar. |
TRADINGVIEW |
Nothing | The TradingView chart, with the positions sidebar shown beside it. |
If the Chart Engine licence comes back revoked or expired from the six-hourly
licence heartbeat, the platform rewrites display.chartType inside the
binarySettings row to TRADINGVIEW — because the alternative is a chart area
that renders nothing. If your binary chart changed on its own, check the Chart
Engine licence first. See Licences and activation.
What else has to be true before /binary works
binaryStatus is necessary and not sufficient. The customer-facing page requires
all of:
- A signed-in user.
/binaryis not public. binaryStatus= true.- The KYC feature
binary_trading, when KYC feature enforcement is on. The order route asserts it — but only for real-money orders. A demo order never touches a funded wallet, and practice mode is the funnel that brings unverified users to verification, so gating it would close the door before they reach it. The check is keyed on the values that actually persist as demo (true,1,"true","1"), so anything ambiguous is treated as real money and gated. - At least one active binary market.
If customers report the menu entry missing, check 2. If they report the page
loading and every real-money order refused, check 3 against the verification
level builder at /admin/crm/kyc/level and the Enforce KYC Feature Access
switch on Platform settings.
Binary markets
/admin/finance/binary/market, permission access.binary.market for the page.
A binary market is a currency/pair with its own limits and a price source. The row carries:
| Column | Meaning |
|---|---|
currency |
Base symbol — BTC, ETH. |
pair |
Quote symbol — USDT, USD. |
minAmount · maxAmount |
Stake bounds for this market. |
source |
EXCHANGE or ECOSYSTEM — which price feed backs it. |
isTrending · isHot |
Display badges on the customer's market list. |
status |
Whether the market is live. Toggled inline from the list. |
Available is the picker: it returns the exchange and ecosystem markets you
could add, and flags for each whether it already has an AI market maker or a
Binary AI Engine attached. Import is the bulk action: it takes every active
spot exchange market, skips anything already present as a binary market, skips
swap and futures rows (a pair like USDT:USDT), and creates the rest in one
transaction, reporting how many it imported and how many it skipped.
EXCHANGE reads the centralised exchange provider's price. ECOSYSTEM reads
your own ecosystem market — which may be driven by an AI market maker. Those are
different price series, and entry price, settlement price and the Binary AI
Engine must all agree on which one to read. It defaults to EXCHANGE so existing
rows keep their behaviour. pnpm --filter backend verify:binary audits this
across your real data and can backfill unambiguous rows; see
Repair scripts.
One trap on the settings screen itself
If the GET fails — a network blip, an expired session, a permission problem — the console does not stay empty. It falls back to a client-side copy of the defaults, sets both switches to on, and shows an error toast. That fallback copy is not identical to the server's defaults (its cooldown is 0 rather than 3, and its base payouts are higher), so pressing Save on a screen that failed to load writes a configuration you did not choose.
If you see "Failed to load settings", reload the page. Do not save through it.