Context
A price chart needs (timeframe, granularity, price_type) → points[]. The existing surfaces do not fit: /v1/history is raw trades, /v1/history/since-inception is one unbounded series with no timeframe, and /v1/ohlc, /v1/vwap, /v1/twap are single-bar aggregates.
Decision
Add GET /v1/chart as its own endpoint; do not extend since-inception, whose unbounded window is its contract and whose latency and cap profile differ from a rolling-window chart.
GET /v1/chart?asset=<id>"e=<id>&timeframe=<tf>&granularity=<g>&price_type=<pt>
quote default USD
timeframe 1h | 24h | 1w | 1mo | 1y | all default 24h
granularity 1m | 15m | 1h | 4h | 1d | 1w | 1mo default per timeframe
price_type vwap | twap | market_cap default vwapThe response mirrors /v1/history/since-inception: data carries asset_id, quote, timeframe, granularity, price_type and points[] of {t, p, v_usd}, plus flags.
| Timeframe | Default granularity | Approx points |
|---|---|---|
1h | 1m | 60 |
24h | 15m | 96 |
1w | 1h | 168 |
1mo | 4h | 180 |
1y | 1d | 365 |
all | 1d | variable |
The table is a default, not a constraint; an explicit granularity overrides it.
- `vwap` is served from the
prices_<gran>continuous aggregates. - `twap` is served from the
twap_1handtwap_1daggregates (migration 0081) for every non-fiat base. The requestedgranularityis snapped to the grain a TWAP aggregate backs (1d,1w,1moto1d; anything finer to1h), and the response's owngranularityreports the grain actually served. A fiat:fiat pair is served fromfx_quotesfor everyprice_type, because the daily reference rate is the series. A request is never silently answered with a differentprice_type. - Closed buckets only (ADR-0015):
HistoryPointsInRangeappliesbucket + interval <= now(). The in-progress bucket is absent; sub-bucket freshness comes from/v1/priceor/v1/oracle/latest. - Cap:
historyMaxPoints = 50_000, as forsince-inception. When the requested(timeframe, granularity)grid exceeds the cap, the series is served at the finest granularity whose grid fits, and the response'sgranularityreports it (today only1y+1m, served at15m).timeframe=allis never coarsened, because its point count is a property of the data. When the cap does cut a series (timeframe=all), it keeps the earliest buckets and drops the most recent. - `flags.truncated` is the RETENTION signal, computed from
points[0]against the window start; it is not a statement about the cap. - Retention of `1m`: migration 0156 attaches a 90-day retention policy to
prices_1monly, shipped disabled and armed by a deliberate operator act. Coarser rungs keep full history and raw trades are retained forever, so a dropped range is recomputable with a forcedrefresh_continuous_aggregate. The response cap and stored history are independent bounds.
Invariant
/v1/chartserves only closed buckets, never the in-progress one. Enforced by theHistoryPointsInRangeclosed-bucket filter.- A response always names the
granularityandprice_typeactually served, andtwapnever falls back to VWAP. Enforced byTestChart_TWAP_ServesTimeWeightedSeries,TestChart_TWAP_GranularitySnappingandTestChart_TWAP_StablecoinFallbackininternal/api/v1/chart_test.go. - A bounded-timeframe request over the point cap is coarsened to a granularity that fits, not cut; the response names the granularity served.
Consequences
- One endpoint, one storage method (
HistoryPointsInRangeonHistoryReader) and one OpenAPI operation; no change to/v1/history/since-inception. - Clients that need the full series (regulators, CSV export, audit) and clients that need a rolling chart evolve independently.
- A consumer can never mistake a snapped or coarsened series for the one it asked for, at the cost of reading
granularityfrom the response.
Evidence
internal/api/v1/chart.go,internal/api/v1/chart_test.go;historyMaxPointsininternal/api/v1/history.go.- Migrations 0081 (TWAP aggregates) and 0156 (
prices_1mretention).