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.

6 min readUpdated 6 August 2026candles, chart, websocket, ticker, trades, scylladb

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

Historical candles for one symbol and interval

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/ needs proxy_http_version 1.1 plus the Upgrade and Connection headers, and a proxy_read_timeout comfortably 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_DAYS sets the TTL, defaulting to 30 days; set it to 0 to keep the tape forever.
Days a row survives in the display trade tape. 0 disables expiry. The permanent record of a fill is not here.

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-candles

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