snapdata

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

CalendarTimezoneUsed by
IN-EQUITYAsia/KolkataNifty 50, Nifty Bank, Sensex, Indian Crude Basket
IN-BULLIONAsia/KolkataDerived INR gold and silver
US-EQUITYAmerica/New_YorkBrent, WTI
GLOBAL-FXUTCAll 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.json

IN-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: null means the value was observed on that date. A non-null filled_from is always strictly earlier than date.

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
StatusMeaningvalue
finalSettled. Will not change.a number
provisionalCorrect as far as we know, still inside the 48h settling window.a number
estimatedDerived from a lower-resolution or carried input.a number
missingNo value exists for this date.null
not_backfilledA 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 closedmissing, with is_trading_day: false
  • market open, no printmissing, with is_trading_day: true
  • we haven't imported it yetnot_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.

AgeCache-ControlMeaning
Inside 48hmax-age=300, s-maxage=600Values may still be corrected.
Past 48hmax-age=31536000, immutableThese 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.

CronUTCIST (Asia/Kolkata)
0 */3 * * * (IST)18:30, 21:30, 00:30, 03:30, 06:30, 09:30, 12:30, 15:3000: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.

CronUTCIST (Asia/Kolkata)Refreshes
0 3 1 * * (IST)21:30 on the last day of the previous month03:00 on the 1stTickers
0 4 1 * * (IST)22:30 on the last day of the previous month04:00 on the 1stGeo

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:

datedayvalueis_trading_daystatusfilled_from
2026-08-14Fri15280.30truefinalnull
2026-08-15Sat, Independence Daynullfalsemissingnull
2026-08-16Sunnullfalsemissingnull
2026-08-17Mon15311.05truefinalnull

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 date as a date, never a timestamp.
  • Never assume value is a number. Check for null first.
  • If you see a repeated value, check filled_from before assuming it is a bug, and if filled_from is null, 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 missing from not_backfilled before concluding a series has a gap.
  • Poll latest.* with If-None-Match. After the first fetch it costs nothing.
  • Pin dated URLs freely once they are 48 hours old. That is a promise.

On this page