Dates & trading days
Why date is not a timestamp, why we emit rows for days the market was shut, and how aggregates settle. The page that makes the rest of the data trustworthy.
This is the page to read before you write any code against snapdata. Everything else is an endpoint list; this is the contract.
Most free feeds in this space get one of these rules wrong, and the failure is silent, you get a number, it looks plausible, and it is wrong in a way that only shows up in a backtest six months later.
1. date is a calendar date, never a timestamp
Every observation carries a date in YYYY-MM-DD, in the market's own local
calendar.
{ "date": "2026-08-11", "instrument_id": "NIFTY50.INR.IDX", "value": 24318.55 }An Indian close and a US close on the same date are different UTC instants.
That is correct and deliberate. 2026-08-11 for Nifty 50 means the session that
ended at 15:30 IST; 2026-08-11 for Brent means the US business day. They are
roughly ten hours apart and we do not pretend otherwise.
Do not parse date as a timestamp. new Date("2026-08-11") in JavaScript gives
you midnight UTC, which is 05:30 on the 11th in Kolkata and 20:00 on the
10th in New York. If you then format it for display in a US timezone you
will show your users the wrong day. Treat date as an opaque string, or parse
it with an explicit date-only type.
Everything that is a timestamp (generated_at, retrieved_at) is RFC 3339,
UTC, Z-suffixed, and never ambiguous:
{ "generated_at": "2026-08-11T13:02:11Z" }2. Every dataset declares a calendar
| Calendar | Timezone | Used by |
|---|---|---|
IN-EQUITY | Asia/Kolkata | Nifty 50, Nifty Bank, Sensex, Indian Crude Basket |
IN-BULLION | Asia/Kolkata | Derived INR gold and silver |
US-EQUITY | America/New_York | Brent, WTI |
GLOBAL-FX | UTC | All FX pairs, USD metal spot |
The calendar is in the envelope, and the full trading-day list is published:
GET /api/v1/calendar/IN-BULLION/2026.jsonIN-BULLION is not the same set as IN-EQUITY. The physical bullion market
does not close for Maharashtra Day or Ambedkar Jayanti; the exchanges do. If
you assume one calendar covers all of India, you will mark real bullion sessions
as holidays.
Each calendar file carries the source of its holiday table. The Indian tables for 2026 and 2027 are best-effort projections until reconciled against the NSE circular, movable festival dates are set by circular, not by formula, and we would rather say so than let you assume otherwise.
3. We never fabricate a value on a non-trading day
This is the rule the whole project is organised around.
When a market is closed, we still emit the row:
{
"date": "2026-08-15",
"instrument_id": "XAU.24K.INR.G",
"value": null,
"status": "missing",
"is_trading_day": false,
"filled_from": null
}Not a 404. Not a repeat of Friday's number. Not a silently absent index entry.
Why the row exists at all. Absence is ambiguous to an agent. If
/api/v1/gold/in/2026-08-15.json 404'd, a consumer could not distinguish "the
market was shut" from "your request was malformed" from "the pipeline broke".
An explicit row answers the question.
Why the value is null. Every free feed in this space repeats the previous close across weekends with no flag. A bot polling one of them on a Sunday gets Friday's number presented as Sunday's, and a naïve volatility calculation silently under-reports because two-sevenths of its observations are duplicates.
4. filled_from, the carry-forward flag
Sometimes a value genuinely is carried forward. When it is, we say so:
{
"date": "2026-08-11",
"instrument_id": "XAU.USD.OZT",
"value": 4131.50,
"status": "estimated",
"filled_from": "2026-08-01"
}This says: the number is real, it came from 2026-08-01, and it is standing in
for 2026-08-11. In this case it is the World Bank's monthly figure for August,
there is no licensable daily USD gold source, so the honest thing is to serve
the monthly number and label it.
The invariant you can rely on:
filled_from: nullmeans the value was observed on that date. A non-nullfilled_fromis always strictly earlier thandate.
If you only want directly observed values, filter on filled_from === null. If
you want a continuous series, use everything and treat filled_from rows as
lower-confidence.
5. The status enum
final | provisional | estimated | missing | not_backfilled| Status | Meaning | value |
|---|---|---|
final | Settled. Will not change. | a number |
provisional | Correct as far as we know, still inside the 48h settling window. | a number |
estimated | Derived from a lower-resolution or carried input. | a number |
missing | No value exists for this date. | null |
not_backfilled | A value exists upstream; we have not imported it. | null |
not_backfilled is the one that earns its keep. Three states that consumers
routinely conflate:
- market closed →
missing, withis_trading_day: false - market open, no print →
missing, withis_trading_day: true - we haven't imported it yet →
not_backfilled
Asking /api/v1/equity-indices/in/2015-06-01.json gives you not_backfilled, not
missing, because Nifty 50 certainly had a value that day, NSE's terms simply
do not permit us to have it. /api/v1/coverage.json carries the reason for every
such limit.
Derived values inherit their weakest input. If the USD gold spot is
estimated and USD/INR is final, an INR value built from both is
estimated. A derived number is only as trustworthy as the worst thing it went
through, which is also why rupee gold is relayed from IBJA rather than
computed from a monthly USD average.
6. How dated files settle
A dated file is provisional for 48 hours after its market's close, then
becomes immutable.
| Age | Cache-Control | Meaning |
|---|---|---|
| Inside 48h | max-age=300, s-maxage=600 | Values may still be corrected. |
| Past 48h | max-age=31536000, immutable | These bytes are permanent. |
immutable is a one-way door. Once we serve it, browsers and CDNs may cache
those bytes for a year and no purge reaches them. So we do not say it until we
mean it.
Corrections made after the window closes increment revision, append to
/api/v1/revisions.json, and trigger a targeted purge.
A revision above 0 is your signal that a number you may have cached changed.
7. Aggregates settle behind their slowest constituent
/api/v1/all/2026-08-11.json mixes an Indian close that has already happened with a
US close that has not.
We do not force-align them. Each observation keeps its own date and
status; the US rows sit at missing until their close, then backfill. The
file as a whole becomes immutable only after the last constituent calendar's
close, plus 48 hours.
IN-EQUITY closes 10:00 UTC ─┐
IN-BULLION closes 18:00 UTC ├─ aggregate settles at
US-EQUITY closes 21:00 UTC │ 22:00 UTC + 48h
GLOBAL-FX closes 22:00 UTC ─┘The alternative (freezing the file when the Indian leg lands) would mark a
file immutable while four of its rows were still going to change. That is
worse than being slow.
8. What latest actually means
latest is the most recent date carrying real values, which is not always
today.
At 09:00 IST today's row exists and is legitimately empty; the market has not
closed. A consumer polling /api/v1/gold/in/latest.json then wants yesterday's
close, not a wall of nulls. So each date is scored: a directly observed value
counts double a carried one, and the highest score wins, most recent breaking
ties.
This is also what happens during an outage. If NSE fails for three days,
latest.json keeps returning the last good session. That is deliberate: the
edge serves last-known-good under stale-if-error for a week rather than
throwing 5xx at your bot.
Serving stale data is fine. Claiming to be healthy while doing it is not.
Always check /api/v1/meta.json if freshness matters to
you. It reports per-source staleness honestly and will say degraded or down
while latest.json is still happily returning cached numbers.
9. When new data actually lands
Not everything moves at the same speed, so not everything is on the same cron. Ingest runs in cadence tiers, and a dataset belongs on the slowest tier that still catches its changes.
Rolling — every three hours
Prices and air quality. Anything with a market close or an hourly reading behind it.
| Cron | UTC | IST (Asia/Kolkata) |
|---|---|---|
0 */3 * * * (IST) | 18:30, 21:30, 00:30, 03:30, 06:30, 09:30, 12:30, 15:30 | 00:00, 03:00, 06:00, 09:00, 12:00, 15:00, 18:00, 21:00 |
Pinned to Asia/Kolkata and landing on Indian midnight and every third hour
after, so it sits just past the local market close rather than on an arbitrary
UTC boundary.
There is exactly one publisher, and that is deliberate. A second schedule writing the same files from different code means there is no single answer to "what produced this number" — which is why a Worker cron and a hosted-CI fallback were both removed. It also could not have worked: three of the sources geo-gate on an Indian IP, so a hosted runner could never have fetched gold, silver or the Indian indices at all.
Monthly — the 1st
Reference data. Things that change when an institution files paperwork, not when a market moves.
| Cron | UTC | IST (Asia/Kolkata) | Refreshes |
|---|---|---|---|
0 3 1 * * (IST) | 21:30 on the last day of the previous month | 03:00 on the 1st | Tickers |
0 4 1 * * (IST) | 22:30 on the last day of the previous month | 04:00 on the 1st | Geo |
03:00 and 04:00 IST sit between two rolling runs, so nothing on this tier contends with a price run or with the other job on its own tier. The reason the tier exists at all is concrete: the US listed universe changes a handful of times a month, and rebuilding its 10,404 objects every three hours was ten minutes of work per run to discover nothing had changed.
Geo is the only scheduled job that writes D1 as well as R2, and the order inside it is load-then-publish — so the published files can never name a release the database does not actually hold.
There is no weekly tier
Nothing currently needs one. It is named here so that "weekly" is a decision someone makes rather than a gap they fill by defaulting to the rolling schedule — which is how a monthly dataset ends up rebuilt 240 times a month.
A schedule is a promise, not an observation. A cron keeps firing long after
the runs behind it have stopped producing anything. Do not derive freshness from
this table, read generated_at on the payload you were served, or
/api/v1/meta.json, which reports the last
successful run rather than the last attempt.
Running more often than the rolling tier gains you nothing: the response is
CDN-cached, dated files older than 48 hours are immutable, and most upstreams
publish once a day. For a daily series, once or twice a day is the honest
polling rate.
A worked example
Friday 2026-08-14 through Monday 2026-08-17, /api/v1/gold/in/series/30d.json:
| date | day | value | is_trading_day | status | filled_from |
|---|---|---|---|---|---|
| 2026-08-14 | Fri | 15280.30 | true | final | null |
| 2026-08-15 | Sat, Independence Day | null | false | missing | null |
| 2026-08-16 | Sun | null | false | missing | null |
| 2026-08-17 | Mon | 15311.05 | true | final | null |
Monday's prev_close is Friday's 15280.30, not Sunday's null. Trading-day
arithmetic skips closed days; it does not treat them as zeros.
Checklist for your integration
- Treat
dateas a date, never a timestamp. - Never assume
valueis a number. Check fornullfirst. - If you see a repeated value, check
filled_frombefore assuming it is a bug, and iffilled_fromisnull, it genuinely is one. Please open an issue. - Use
is_trading_day, not "did the API return a number", to decide whether a market was open. - Distinguish
missingfromnot_backfilledbefore concluding a series has a gap. - Poll
latest.*withIf-None-Match. After the first fetch it costs nothing. - Pin dated URLs freely once they are 48 hours old. That is a promise.