One deterministic row per timestamp
DISTINCT ON the timestamp picks one row, consistently. A series is never an interleaved mix, and limit counts rows, not duplicates.
- No silent range shrink
GET /v1/ohlc returns up to 1,000 completed candles a call, and every one of
the 42 published indicator series can be pulled as a time series beside them on the same
timestamps. The values were computed once — so a replay reads the same numbers
a live run would have seen.
{ "success": true, "data": { "symbol": "EURUSD", "timeframe": "H1", "candles": [ // "candles" - there is no "bars" key { "time": "2026-09-18T06:00:00Z", "open": 1.08210, "high": 1.08245, "low": 1.08192, "close": 1.08231, "volume": 8432 }, { "time": "2026-09-18T07:00:00Z", … }, … oldest first, 100 of them ], "count": 100, "retention": "14 days" // the LIVE figure, from the server } }
The ceilings are the part worth reading before you write the fetch loop: a candle request can span the whole retention window, an indicator-history request can only span half of it, and a tick request can span one hour. Each limit is enforced with a named 400 that tells you the exact number.
| Surface | Max window | Rows | Weight | Scope | Plan | Returns |
|---|---|---|---|---|---|---|
| GET /v1/ohlc | Full retention | limit 1 to 1000, default 100 | 2× | historical | Any paid plan | Candles: time, open, high, low, close, volume — plus count and the live retention string. |
| GET /v1/indicator/history | Half the retention | limit 1–5,000, capped per timeframe | 5× | historical | Starter, Pro, Enterprise, PAYG | One series as a time series: series[] of {time, value}, plus from, to, count and max_window_hours. |
| GET /v1/multi (with from/to) | Half the retention | ≤10 symbols, 5,000 rows total | 2× per call | premium | Starter, Pro, Enterprise, PAYG | Several series across several symbols at one timeframe. An unknown series name is a 404, not an empty column. |
| GET /v1/ticks | 1 hour per request | Hard cap of 50,000 rows | 3× | historical | Pro and Enterprise only | Raw ticks: time, bid, ask, flags. from and to are both required; 24-hour retention. |
| GET /v1/spread | 1h, 24h, 7d or 30d | Aggregates, not rows | 1× | none | Every plan | Current, average, minimum, maximum and standard deviation of the spread, for cost modelling. |
/v1/indicators is not a history endpoint. It returns the current cached
snapshot for one symbol and timeframe and has no time-range parameters at all. For a
series over time use /v1/indicator/history, or /v1/multi with
from and to when you want several series across up to ten
symbols in one request.
The resolver that answers "how far back does this go" takes a timeframe and nothing else — there is no plan argument anywhere in it, so a pay-as-you-go account and an Enterprise account read the identical window. The right-hand columns are the separate, tighter ceiling that applies to the historical indicator endpoints: half the retention, and the candle count that fits inside it.
| Timeframe | Retention | ≈ candles retained | Indicator-history window | Max candles per history call |
|---|---|---|---|---|
| M1 — 1 Minute | 1 d | 1,440 | 12 h | 720 |
| M5 — 5 Minutes | 2 d | 576 | 1 d | 288 |
| M15 — 15 Minutes | 4 d | 384 | 2 d | 192 |
| M30 — 30 Minutes | 7 d | 336 | 3.5 d | 168 |
| H1 — 1 Hour | 14 d | 336 | 7 d | 168 |
| H4 — 4 Hours | 30 d | 180 | 15 d | 90 |
| D1 — Daily | 45 d | 45 | 22.5 d | 22 |
Raw ticks sit on the M1 schedule: 24 hours. They are cleaned up with the same retention key as M1 data, so tick-level work is always a rolling window. Combined with the one-hour range cap and the 50,000-row ceiling, a full day of ticks for one symbol is 24 requests at 3× each.
One of them is required and one of them behaves in a way that quietly breaks naive pagination. Invalid values are rejected with a named error code and the acceptable set in the detail rather than being coerced into something that silently changes your result.
| Parameter | Type | Required | Default | Behaviour |
|---|---|---|---|---|
| symbol | string | required | — | Canonical symbol; the response echoes the canonical name. |
| timeframe | string | optional | H1 | M1, M5, M15, M30, H1, H4, D1. Uppercased for you. Anything else is a 400 with INVALID_TIMEFRAME and the valid set in the detail. |
| from | string | optional | retention boundary | ISO 8601 start. Omit it and the window opens at the earliest retained candle. Earlier than that is a 400 with OUTSIDE_RETENTION, which names earliest_available so you can retry correctly. |
| to | string | optional | now | ISO 8601 end. This is the parameter you move when paging backwards, because there is no offset. |
| limit | integer | optional | 100 | Candles to return, 1 to 1000 inclusive, validated rather than clamped. The query sorts newest-first before applying it, so limit returns the NEWEST n inside the window — then hands them back oldest-first. |
There is no offset. The query sorts newest-first, applies
limit, then reverses — so limit selects the newest candles in the
window and returns them oldest-first. Page by moving to back to the timestamp
of the first candle you received. On /v1/indicator/history the ordering is the
other way round: rows are read oldest-first, so limit there truncates the
end of the window, not the beginning.
Indicator history is 5× per call per symbol, so the expensive mistake is re-requesting the same series for every parameter you want to try. Fetch each series once, cache it on disk, and run the sweep against the cached copy.
to backwards; there is no offset. time, not by index. OUTSIDE_RETENTION as "end of history". # newest 100 H1 candles curl -H "X-API-Key: YOUR_API_KEY" \ "https://tickatlas.com/v1/ohlc?symbol=EURUSD&timeframe=H1&limit=100" # one indicator as a SERIES - note the window ceiling curl -H "X-API-Key: YOUR_API_KEY" \ "https://tickatlas.com/v1/indicator/history?symbol=EURUSD&indicator=RSI_14&timeframe=H1"
# page BACKWARDS. There is no offset on /v1/ohlc - the # query takes the newest rows inside [from, to], so you walk "to". URL = "https://tickatlas.com/v1/ohlc" H = { "X-API-Key": KEY } def pull_all(symbol, timeframe="H1"): out, to = [], None while True: p = { "symbol": symbol, "timeframe": timeframe, "limit": 1000 } if to: p["to"] = to r = requests.get(URL, headers=H, params=p) if r.status_code == 400: # OUTSIDE_RETENTION names earliest_available - we are done break d = r.json()["data"] if not d["candles"]: break out = d["candles"] + out # response is oldest-first to = d["candles"][0]["time"] # step the window back if d["count"] < 1000: break return out # d["retention"] is the real depth # Descriptive crossover measurement - counts state changes between # two series and records the realised difference between them. # It is arithmetic over past data, not a rule to trade. sma20 = series("SMA_20") # /v1/indicator/history -> data.series sma50 = series("SMA_50") # align on "time" - do NOT assume equal lengths pairs = { p["time"]: p["value"] for p in sma50 } joined = [(p["time"], p["value"], pairs[p["time"]]) for p in sma20 if p["time"] in pairs] crossings = [] for (t0, a0, b0), (t1, a1, b1) in zip(joined, joined[1:]): if (a0 - b0) * (a1 - b1) < 0: crossings.append({ "time": t1, "state": "fast_above" if a1 > b1 else "fast_below" }) print(len(crossings), "state changes over", len(joined), "aligned bars")
time.Candles, indicator series and ticks all carry a UTC timestamp aligned to the timeframe that produced them, which makes assembling a feature matrix a dictionary join rather than an interpolation problem. Join on the key rather than by position: an indicator row is skipped when its value is null, so two series for the same window can differ in length.
M1, M5, M15, M30, H1, H4, D1, defaulting to H1. Higher timeframes keep more
calendar time and fewer rows; lower timeframes keep more rows over less time. Nothing
above D1 is accepted by the historical endpoints — W1 exists only inside
/v1/heatmap, and MN1 is accepted nowhere.
A backtest is only worth running if the data it reads is the data the live system would have read. These are the four guarantees that make that true here — and the one place the platform deliberately does not promise.
DISTINCT ON the timestamp picks one row, consistently. A series is never an interleaved mix, and limit counts rows, not duplicates.
Every timestamp is UTC and every candle starts on its timeframe boundary — H1 on the hour, H4 at 00:00, 04:00, 08:00 and so on. No local time, no daylight-saving drift inside the data.
Indicators are computed once and stored; the live and historical endpoints read the same rows. A backtest and a live run do not diverge because of a second implementation.
OUTSIDE_RETENTION returns earliest_available; RANGE_TOO_LARGE returns max_window_hours and max_candles. You never have to guess a limit from a page.
Prices, features, execution costs and the calendar of scheduled events, all on the same symbol names and the same key. Weights are the real usage multipliers.
/v1/spread works on every plan and /v1/ohlc on every paid plan, including
pay-as-you-go, and historical indicator series work from pay-as-you-go up. Tick data is Pro and
Enterprise only — that is the one gate worth planning a research budget
around.
Every research workflow built on this data ends up in the same shape: one bounded pull into local storage, then as many passes over the cache as the question needs.
Pull the candles once, pull each indicator series once, align on the time key and step the result. The indicator values were computed once, at the candle close, so a replay reads the same numbers a live run would have seen.
The 42 published series include several periods of the same family — SMA at five lengths, EMA at three, ATR at two, CCI at two — so comparing period choices is a second series request rather than a second implementation.
A time-aligned join of candles and indicator series is a feature matrix. Because the values are pre-computed and identical across every consumer, a training set built today matches what the live API will serve tomorrow.
How deep the window really is, why the indicator endpoints stop at half of it, how to page without an offset, and what the data is and is not checked against.
Retention is set per timeframe and never per plan — settings.get_retention_hours(timeframe) takes no plan argument, so Enterprise and pay-as-you-go read exactly the same window. As deployed that is 45 days on D1, 30 on H4, 14 on H1, 7 on M30, 4 on M15, 2 on M5 and 24 hours on M1, with ticks on the same 24-hour schedule as M1. Treat those as the documented values and the API as the authority: every /v1/ohlc response repeats the live figure back in data.retention, and an out-of-range request returns 400 OUTSIDE_RETENTION carrying earliest_available.
Because the query window on the historical indicator endpoints is capped at half the retention period for that timeframe — not the whole of it. /v1/ohlc will serve the full retention in one call; /v1/indicator/history and /v1/multi with from/to will not. The 400 carries max_window_hours and max_candles for the timeframe you asked about, so the retry is mechanical: split the range into windows of that size and walk them.
Correct, and this is the part that surprises people. The query sorts by candle time descending, applies limit, then reverses — so limit returns the NEWEST n candles inside [from, to] and hands them back oldest-first. To walk backwards, set `to` to the time of the first candle you received and repeat until the response is short or you hit OUTSIDE_RETENTION. Incrementing an imaginary offset will silently return the same page forever.
No. /v1/ohlc returns candles and nothing else; /v1/indicators returns the CURRENT cached snapshot for one symbol and timeframe with no time-range parameters at all. The historical series come from /v1/indicator/history, one series per call, or /v1/multi with from and to for several series across up to ten symbols. They share the same candle timestamps, so a join on time is exact — but align on the key rather than assuming the two arrays are the same length, because a series row is skipped when its column is null.
Pro and Enterprise only. The route carries an explicit allowlist and returns 403 PLAN_UPGRADE_REQUIRED for every other plan, including pay-as-you-go and Starter. Both from and to are required, the range is capped at one hour per request, the result is hard-limited to 50,000 rows inside the query, and tick retention is 24 hours — so tick-level work is a rolling-window exercise, not a deep-history one.
time, open, high, low, close and volume — volume as our data carries it for that candle. There is no bid, ask or spread on a candle. Spread statistics come from /v1/spread, which aggregates current, average, minimum, maximum and standard deviation over 1h, 24h, 7d or 30d, and that is the endpoint to use when you want realistic cost modelling in a backtest.
No. Candle and tick queries are DISTINCT ON the timestamp, so one deterministic row wins per timestamp. A series is never an interleaved mix, and limit counts rows, not duplicates.
Every incoming record is checked on ingest against that symbol’s own last cached mid and skipped if the move exceeds the threshold for its asset class — 5% for FX majors, 15% for gold, 25% for oil, 10% for indices, 50% for the large crypto pairs — plus a separate rejection for a quoted spread above 10% of mid. Separately, a background scan walks stored candles every two minutes, learns each symbol’s own trading hours from its history rather than assuming a schedule, and queues a backfill for anything missing. Both are outlier and gap handling on our data, and neither promises a completion time.
The set of tracked symbols follows our data and changes when it does, so it is not a fixed universe and a backtest run today may cover instruments a backtest run last month did not. FX pairs are not delisted the way equities are, which removes the classic form of the problem, but the honest answer is that coverage is operational rather than curated. GET /v1/symbols returns the live list, filterable by category and paginated up to 500 rows a page.
Candles are 2× per call and carry up to 1,000 rows, so a deep pull is cheap per row. Indicator history is 5× per call and per symbol, which is where a careless sweep gets expensive: fetch each series once, cache it locally, and iterate your parameters against the cached copy rather than re-requesting. Ticks are 3× for at most one hour of data. All timestamps are UTC and candle boundaries align to the timeframe, so a locally cached series stays joinable indefinitely.
The same rows the live endpoints read, on the same timestamps, with every limit reported by the API instead of guessed from a page. Start on your $2.50 of credit, no card.