Candles, tickers and the trade tape
Where every number on an Ecosystem chart comes from — the closed interval list, the candles table, the four WebSocket routes, the trade tape's TTL, and the repair script for a broken candle series.
Ecosystem has no price feed. Nothing polls Binance, nothing imports history, and no seeding job invents a chart for a market you just created. Every candle, every ticker and every print on the trade tape is produced by this install's own matching engine out of orders that actually filled, and stored in ScyllaDB.
That single fact answers most "the chart is broken" reports before you open anything: a market with no trades has no market data, and it is supposed to look that way.
What produces what
| Surface | Built from | Stored in | Key |
|---|---|---|---|
| Candles | Real fills | trading.candles |
(symbol, interval, "createdAt") |
| Ticker | The engine's in-memory 1d candle, against yesterday's 1d close |
derived, not stored | — |
| Aggregated order book | Resting orders, not fills | trading.orderbook |
((symbol, side), price) |
| Trade tape | Real fills, plus AI market-maker prints | trading.trades |
((symbol), "createdAt", id) |
The keyspace name comes from SCYLLA_KEYSPACE (default trading).
Two consequences worth holding on to. The ticker is derived from the 1d
candle, so a market whose last fill was months ago still reports that fill as
last — the ticker is not a liveness signal. And getTickers omits any symbol
whose last price is zero, so a market that has never traded is simply absent
from the all-markets ticker rather than present with zeros.
The interval list is closed
Thirteen intervals exist, and they are hard-coded in
ecosystem/utils/candles.ts. There is no admin screen, no setting, and no way
to add a fourteenth without a code change.
1m |
3m |
5m |
15m |
30m |
1h |
2h |
4h |
6h |
12h |
1d |
3d |
1w |
interval is part of the candles primary key, so an interval outside this list
is not "no data" — it is a partition that cannot exist. The chart endpoint
rejects it rather than returning an empty array:
{ "message": "Unsupported interval. Use one of: 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 12h, 1d, 3d, 1w." }If a customer reports a blank chart on a timeframe your UI offers, check that
the timeframe is on this list first. A 45m or 8h button wired up in a theme
returns 400 on every request.
Every fill writes all thirteen intervals at once. The engine keeps the current candle per symbol per interval in memory, updates close, volume, high and low in place while the boundary holds, and inserts a new row when the boundary rolls over — opening it at the previous candle's close so the series stays continuous across quiet periods.
The chart endpoint
All four query parameters are required: symbol (BTC/USDT), from and
to (millisecond timestamps, from at or before to), and interval. Missing
any of them, or a non-numeric range, is a 400 naming the problem.
What comes back is an array of [time, open, high, low, close, volume] arrays,
after two transformations you should know about because they make an empty
market look less empty than it is:
- Gaps are filled. Where the stored series jumps more than one interval, the response is padded with flat zero-volume candles at the previous close, up to 500 of them per gap. So a flat line with no volume is a period with no trades, not a period with a stable price.
- A range with nothing in it falls back to lookback. If the requested window holds no candles at all, the endpoint finds the most recent candle before the window and projects flat candles forward from it, again capped at 500. Only a symbol and interval with no stored history anywhere returns an empty array.
The four WebSocket routes
Live market data does not come from the chart endpoint. Four sockets serve it,
all under /api:
| Route | Auth | Carries |
|---|---|---|
/api/ecosystem/market |
none | Per-symbol ticker, trades, orderbook and ohlcv streams |
/api/ecosystem/ticker |
none | The all-markets ticker map |
/api/ecosystem/order |
required | A user's own order updates |
/api/ecosystem/deposit |
required | Deposit detection for the address on screen |
A client subscribes on /api/ecosystem/market with a type and a symbol, and
the server polls and broadcasts on a 2-second interval per symbol for as
long as anyone is subscribed. OHLCV subscriptions carry their interval and
order-book subscriptions their limit, so panels at different depths coexist.
Before subscribing, the handler looks up ecosystem_market by currency and pair
with status: true. A symbol that does not exist, is misspelled, or has been
disabled produces a server log line and no data — no error is sent back to
the browser. "The chart is empty on this one pair" is very often a disabled
market.
They need the proxy configured for upgrades
These are the same /api sockets the core install guides cover, and they fail
the same way: a proxy that drops the upgrade does not return an error, it returns
a chart that never populates.
- On nginx,
location /api/needsproxy_http_version 1.1plus theUpgradeandConnectionheaders, and aproxy_read_timeoutcomfortably above the 30-second heartbeat. See Nginx. - On Apache, the WebSocket rewrite rule must come before the catch-all
ProxyPass. See Apache.
The market and ticker services read NEXT_PUBLIC_WEBSOCKET_URL. Leave it unset
unless you are deliberately terminating sockets somewhere other than the site
origin — and remember NEXT_PUBLIC_* values are baked into the browser bundle at
build time, so changing one needs a frontend rebuild, not a restart.
The trade tape
trading.trades is a display record, not the settlement record. It exists to
answer "the last N prints on this market" as one cheap partition read, and the
route that serves it takes the rows straight out of clustering order.
- Each row carries
isAiTrade. AI market-maker prints live in the same table as customer fills and are flagged rather than kept apart, so a tape that looks busy on a market nobody trades is the market maker. - A real fill writes two rows, one per side, at the same timestamp. AI prints write one — they are a synthetic print with no counterparty.
- Rows expire.
ECO_TRADE_TAPE_TTL_DAYSsets the TTL, defaulting to 30 days; set it to0to keep the tape forever.
The permanent record of every fill is the trades JSON column on the orders
themselves, which never expires, and the price history is in candles. Nothing
auditable lives only in this table. If Scylla falls behind on writes the tape's
buffered writer sheds its oldest rows and logs a warning saying exactly that —
treat it as a cluster-performance signal, not a data-loss one.
Repairing a broken candle series
Two things go wrong with stored candles: more than one row inside the same interval boundary (which draws as overlapping bars), and an open price that does not match the previous candle's close (which draws as a gap or a broken wick).
pnpm fix:eco-candles fixes both. For every symbol and every one of the thirteen
intervals it groups candles by their normalised boundary, merges duplicates into
the earliest row — widest high, lowest low, latest close, summed volume — deletes
the extras, then walks the series forward rewriting each open to the previous
close and widening high/low to span it.
Unlike the other repair scripts, fix-ecosystem-candles.mjs takes no arguments
and no flags. Running it writes immediately, and it runs against both the
ecosystem keyspace and the futures keyspace (SCYLLA_KEYSPACE and
SCYLLA_FUTURES_KEYSPACE) in one pass. Snapshot Scylla first if the series
matters.
pnpm fix:eco-candlesWhat it will not touch: orders, the aggregated order book, the open-orders
index, the trade tape, and anything at all in MySQL. It only rewrites candles.
So it can repair how a chart draws; it cannot repair a book that disagrees with
its orders — that is pnpm rebuild:eco-orderbook, covered in
Operations.
Restart the backend afterwards. The engine holds the current candle per symbol per interval in process memory, and a repaired table underneath a running engine diverges again on the next fill.
Restoring from mismatched backups
The one failure mode that produces plausible-looking wrong market data is a restore where MySQL and ScyllaDB come from different moments.
Wallet balances, inOrder amounts, transactions and market definitions are in
MySQL. Orders, candles, the order book and the tape are in ScyllaDB, and the
platform ships no backup path for it at all. Restore a Scylla snapshot taken
before your MySQL dump and you get charts that stop at the snapshot, resting
orders that no longer match the funds held against them, and a trade tape that
ends before the transactions in MySQL say trading did.
Take the two together, keep the pairs together, and restore them as a set. See Backups for the snapshot commands and the verification steps.
Related
- Tokens and markets — creating the pair that produces this data
- Operations — the engine's placement and the other repair scripts
- Troubleshooting — symptom-first diagnosis
- Environment reference —
SCYLLA_*and the rest