I put a one year bitcoin chart on a page last week and labelled it with the price of its last point. That number was out by nearly ten per cent, and the response had been telling me so the whole time.
Here is the endpoint I was using. It is a free crypto price API, three requests an hour per endpoint, key in a header.
const res = await fetch(
"https://www.thecoinanalysis.com/api/public/v1/prices/BTC/history?days=365&maxPoints=100",
{ headers: { "x-api-key": KEY } }
);
const { data } = await res.json();
const series = data.BTC.history; // [{ ts, price, pctChange }, ...]
days is how far back to go, maxPoints is how many points you want out. Reasonable knobs. What I did not think through is what has to happen between them.
The tail is missing
Asking for a year at 100 points, today, the series runs from 20 January to 13 September. Today is 22 September. The last point on my chart was nine days old.
series.at(-1); // { ts: "2026-09-13T21:00:00.000Z", price: 77313 }
data.BTC.currentPrice; // 85869
Eight and a half thousand dollars between the point I was labelling and the price a user could go and check. Just under ten per cent.
Ask for 500 points instead and the same call ends on 20 September, two days back rather than nine. So the gap is not a bug to route around, it is a function of how coarse a series you asked for. The coarser the buckets, the further from the present the last full bucket sits.
The gaps are not even either
I assumed a downsampled series was regularly spaced, because that is what the word suggests and what a naive implementation does. It is not, at least not here:
| maxPoints | points | median gap | largest gap |
|---|---|---|---|
| 100 | 100 | 11.0 h | 261 h |
| 500 | 500 | 2.25 h | 72 h |
Eleven days between two adjacent points at the coarse setting. Anything I compute from neighbouring points, a slope, a 24 hour change, a rate of anything, is computed over an interval I did not choose and cannot see without checking the timestamps myself.
And a year is not always a year
The same response starts on 20 January 2026 whichever resolution I ask for. That is about 245 days, not 365. The series starts where the data starts, and days is a ceiling rather than a promise. Worth an assertion if your axis labels are generated from the request rather than the response:
const span = (new Date(series.at(-1).ts) - new Date(series[0].ts)) / 864e5;
// 236 days at maxPoints=100, not 365
What I do now
Three rules, and none of them are specific to this API. Any endpoint that hands you a series plus a summary is doing the same thing.
Take headline numbers from the summary, not from the series. The response carries currentPrice beside the history precisely because the two answer different questions. The series answers "what shape was this", the summary answers "where is it now".
Never compute a rate from adjacent points without reading the timestamps. If you want a 24 hour change, find the point nearest 24 hours back and use its actual distance, or take the change from a field that was computed upstream on the full data.
Draw the gap instead of hiding it. If the last point is materially older than the bucket size, I plot the live price as a separate marker rather than extending the line to it. A line that runs to the right edge is a claim that you have data at the right edge.
The wider lesson is the one I keep relearning: the shape you receive is not the data, it is a rendering of the data chosen by a parameter you passed. Details of this one are at https://www.thecoinanalysis.com/developers, but the failure is portable.
Top comments (0)