The pipeline, in four stages
Four chained jobs run in sequence, each one triggered by the success (or failure) of the one before it:- Fund universe: refresh the list of tracked funds and ETFs.
- Price history: fetch daily pricing and land it in Snowflake.
- Returns computation: turn fresh prices into trailing and calendar year returns.
- Cross-job checks: reconciliation tests that need output from both stage 2 and stage 3.
Stage 1: fund universe, no S3 required
The list of active US funds and ETFs comes from our pricing provider’s exchange symbol list. Because this is small reference data (thousands of rows, not millions), it’s loaded straight into a Snowflake raw table with no intermediate storage. It’s then merged into a fund reference table using a soft-delete pattern: a fund that drops out of the source list gets markedis_inactive rather than removed, so its historical returns stay queryable
and the dataset never introduces survivorship bias.
Stage 2: price history, staged in S3
This is where most of the volume lives, and where S3 comes in. End-of-day pricing for every active fund is fetched, written to local parquet files, and uploaded to a dated S3 folder before anything touches Snowflake:fetch_mode is either full or bulk (incremental), and each run’s
folder is scoped to both the date and the mode. That means a full run and a
bulk run for the same day always land in separate folders and load
independently, so the two can never be combined into a single COPY INTO
by mistake.
The two modes also fetch differently:
- Full mode writes one parquet file per ticker, fetched in parallel across a small worker pool.
- Bulk mode writes one parquet file per date, fetched sequentially, covering only recent price changes.
Stage 3: S3 into Snowflake
Once a run’s files are in S3, aCOPY INTO pulls them into a raw Snowflake
table from an external stage pointed at that day’s folder:
match_by_column_name lets the parquet schema map directly onto the raw
table’s columns without an explicit column list. The raw table itself is
truncated and fully reloaded on every run. It’s a landing zone, not a
system of record, with all the incremental logic and deduplication handled
downstream in the transformation layer instead.
Stage 4: from raw rows to curated views
From there, a chain of dbt models turns raw price rows into the tables you actually query:- Staging models rename and lightly cast the raw columns: no logic, just a clean handoff.
- Intermediate models merge new rows in incrementally (keyed on fund and date) and apply data-quality soft-deletes, described below, so flagged rows are excluded from downstream calculations rather than silently kept or hard-deleted.
- Return calculation views use an as-of join to find each fund’s nearest prior trading day for every lookback period, rather than requiring an exact date match, since not every fund trades on every date.
- Final models pivot that into one row per fund per as-of date with one column per return period, and are exposed as secure views: the actual objects the Fund Returns Snowflake listing is built on.
Guardrails along the way
A pipeline that reloads and recomputes data on a schedule needs automated checks, not manual review, to catch problems before they reach a curated view. The checks that run on every build include:- Positive price validation: a closing or adjusted-closing price must be strictly positive, checked before it can propagate into any return figure.
- Grain uniqueness: exactly one row per fund per date at the raw layer, and per fund per as-of-date (or calendar year) downstream, so a double-loaded batch can never silently duplicate rows into a return calculation.
- Capped and zeroed-value checks: adjusted-close values that are zeroed out or pinned at an implausible ceiling (both signs of an upstream provider issue rather than a real price) are flagged and excluded.
- Isolated first-price check: a fund’s very first price record is flagged if it sits more than a few days away from the next one, since a stray stub record like that would otherwise corrupt an inception-to-date return.
- As-of ordering check: the trading day a return is based on must fall on or before the as-of date it’s reported against, guarding against the as-of join matching in the wrong direction.
- Return sanity bands: a return can never mathematically fall below -100%, and any 1-day or 1-week return beyond ±50% is flagged for review rather than rejected outright, since a small number of cases (leveraged or inverse funds, genuine crash days) are legitimate.
- Cross-model reconciliation: a fund’s year-to-date trailing return is independently recomputed from the calendar-year model and checked against the trailing-returns model, so the two paths have to agree.
- Price coverage check: every fund with a fresh price on the latest date must have a corresponding row in the final return tables for that date, so a fund can’t quietly fall out of the returns views while still showing up in price history.
Why this shape
Staging in S3 before loading, rather than writing straight to Snowflake, buys a few things: raw files are cheap to keep around for reprocessing or audit, aCOPY INTO from object storage is fast and simple compared to
row-by-row inserts, and partitioning by date and fetch mode gives every run
a natural, isolated boundary. Pairing that with soft-deletes and automated
checks downstream, rather than trying to get every upstream value perfect,
keeps a single bad batch from silently corrupting a return figure.
F.A.Q.
Why stage data in S3 instead of writing directly to Snowflake?
Why stage data in S3 instead of writing directly to Snowflake?
Raw files in S3 are cheap to keep around for reprocessing or audit, a
COPY INTO from object storage is fast and simple compared to row-by-row inserts, and partitioning by date and fetch mode gives every run a clean, isolated folder to write to and load from.How often is Fund Returns data refreshed?
How often is Fund Returns data refreshed?
An incremental price update runs five days a week (Sunday, Monday, Tuesday, Thursday, Friday), and a full reload of every fund, with the return tables fully rebuilt, runs twice a week (Wednesday and Saturday).
What happens if a run fails partway through?
What happens if a run fails partway through?
Uploads to S3 are idempotent, so a retried run skips files it already uploaded rather than duplicating them. The Snowflake
COPY INTO step uses on_error = abort_statement, so a load either lands cleanly or fails outright; it never partially commits a run’s bad or incomplete data into the raw table.Why do some funds get a full price history reload instead of an incremental update?
Why do some funds get a full price history reload instead of an incremental update?
Our pricing provider backward-adjusts a fund’s entire adjusted-close history whenever a dividend or split posts. An incremental update alone would leave the older, now-stale adjusted prices in place, so any fund with a new corporate action is force-refetched in full instead.