Rebuilding an ALM Risk Pipeline: what DuckDB, dbt, Dagster, and Parquet bought me
I rebuilt a project I had shipped a few months ago: an Asset-Liability Management risk pipeline that computes IRRBB under BCBS 368, the EBA liquidity ratios (LCR, NSFR), and basic ICAAP-flavoured capital with stress testing. The original sat on Postgres + SQLAlchemy + Streamlit. The new one sits on DuckDB + dbt + Dagster + Streamlit.
The infrastructure as well as the modeling is more complex, and that’s what this post is about. The headline claim is that swapping the data plumbing took the project from “hard to demo, hard to extend, hard to host” to “171 invariant checks per build, sub-second cold start, scalable to production and zero dollars a month to keep online” while even adding more models.
v1: where it hurt
The goal of the first version was to demonstrate basic a database to dashboard pipeline:
- PostgreSQL as the warehouse, accessed through SQLAlchemy
declarative models in
models_sqlalchemy.py. - A hand-written
schema_postgres.sqlDDL file that had to be kept in sync with the ORM models. - A
generate_data_postgres.pyscript that opened a session andINSERT-ed rows. - A
compute.pythat ran SQLAlchemy queries, dropped the results into pandas, and did the maths. - A Streamlit app that opened a fresh database session per page.
The pain wasn’t that any one of those layers was wrong. It was that they were independently editable. Adding a single column to the cashflow table meant touching the DDL, the ORM models, the generator, the queries module, and probably at least one Streamlit page. Nothing verified the four were consistent. The schema lived in four places, so it lived nowhere.
Two follow-on costs:
- Numerical results were opaque.
compute.pydid the LCR ratio in Python, mid-function, after a couple of joins. If the LCR looked weird, there was nothing to inspect between “the data” and “the number”. - It was difficult to host. Streamlit Cloud is free; running a Postgres instance to back it is not.
v2: the new stack
The full v2 architecture is one DAG with five layers:
Parquet feeds (data/raw/)
│
▼
DuckDB warehouse (single file, ~28 MB)
│
├──▶ dbt staging ─▶ intermediate ─▶ marts ─┐
│ │
▼ ▼
Python risk engine Streamlit
(HW1F calibration, MC, EVE/NII, (reads marts only;
CPR, Black-76, ALMM) no engine work)
│
▼
Parquet outputs (data/risk_outputs/)
│
▼
Reloaded into DuckDB ──▶ dbt staging ──▶ marts ──▶ Streamlit
Every node, dbt model, risk-engine output table and raw Parquet is a Dagster asset. The whole DAG (~46 assets) renders in the Dagster UI.
Parquet: the schema-on-read floor
The synthetic generator now writes Parquet to data/raw/. The risk
engine reads Parquet, computes, and writes Parquet to
data/risk_outputs/. Everything in between is dbt or Dagster moving
those columns around.
Some advantages of Parquet:
- Schemas are derived from Pydantic. The
basel_common.typesmodule defines the row models in Pydantic v2. The generator writes columns in that order with those dtypes; dbt staging views read them back with the same names. There is no DDL file. The schema lives in one place. - Columnar reads are free. The Liquidity page reads three columns out of a 13-column cashflows table. It does so without DuckDB loading the other ten. The previous Postgres version selected the three but transferred all thirteen over the wire.
git diffworks on data. The committed warehouse is binary, but the source-of-truth synthetic feeds are versioned. Anyone who clones the repo gets the same numbers I see.
Pydantic: schema as a single source of truth
# basel_common/types.py
_BASE = ConfigDict(frozen=True, extra="forbid")
class CashflowRow(BaseModel):
model_config = _BASE
id: int
date: _date
direction: Direction # StrEnum: inflow | outflow
hqlatype: HQLAType # StrEnum: Level1 | Level2A | Level2B | None
amount: Decimal
asf_factor: Decimal = Field(default=Decimal("0"))
rsf_factor: Decimal = Field(default=Decimal("0"))
scenario_id: int | None = None
- One schema definition feeds ingestion, the risk engine, queries, and dbt source contracts. In the previous version (SQLAlchemy + Postgres), the same columns were declared in ~4 places that drifted apart.
extra="forbid"turns a typo in a generated column into an immediate validation error, not a silent NULL three layers downstream.StrEnummakes “is this HQLA Level 2A?” a closed set the type checker enforces. This prevents stray"level2a"/"Level_2A"variants.Decimal(not float) for regulatory amounts: LCR/NSFR ratios don’t inherit binary-float rounding noise.frozen=Truemakes rows hashable and immutable, making them safe to pass through the MC engine without defensive copies.
DuckDB: the warehouse that fits in a Git commit
DuckDB is a single-file analytical database. The whole warehouse.duckdb
is ~28 MB and lives at data/warehouse.duckdb, committed to the repo.
# basel_ingestion/load.py — Parquet -> warehouse, idempotent
con.execute(
f"CREATE OR REPLACE TABLE {table} AS "
f"SELECT * FROM read_parquet(?)", [str(pq)],
)
# basel_common/connection.py
return duckdb.connect(str(path), read_only=False) # writers
# ...the Streamlit app opens the same file read_only=True
Three concrete things this changed:
- No service to host. Streamlit Community Cloud clones the repo and opens the DuckDB file read-only. That’s the entire backend. The Postgres from before meant either a paid managed instance or a fragile self-hosted one.
read_parquet()is native and zero-copy — loading the feed is oneCREATE OR REPLACE TABLE, fully idempotent.- The “API” went away. In the previous version we might have considered putting a FastAPI layer between Streamlit and the database. With DuckDB embedded in the Streamlit process, the warehouse is the API. The dashboard reads marts directly, in SQL.
read_only=Truefrom the dashboard means concurrent viewers can’t corrupt or lock the file; the writers hold the only RW handle.- Same SQL dialect as the marts, i.e., no impedance mismatch between the engine’s ad-hoc queries and the dbt layer.
- Read-heavy analytical queries are an order of magnitude faster.
Even the dashboard’s heaviest possible queries, such as a 365-day rolling LCR over four
scenarios, joined to capital ratios, runs in single-digit milliseconds.
The same query against Postgres needed a careful index on
(scenario_id, as_of_date)to come within 10× of that.
The “but what about concurrent writes” objection isn’t an objection here. The pipeline is exactly one writer (Dagster, materialising assets) and many readers (Streamlit sessions). DuckDB handles that with a write-ahead log; the WAL is the only file I gitignore.
dbt: SQL with tests, lineage, and a contract
This might be the greatest improvement from before. dbt is “just” a templated-SQL transformation tool, but the things that follow from “transformations are now declared as code with declared expectations” are what make it worth introducing.
-- dbt_project/tests/assert_lcr_inflow_cap_respected.sql
-- EBA Delegated Act 2015/61 Art. 33: capped inflows <= 75% of outflows.
-- This test FAILS (returns rows) if any mart ever violates the cap.
SELECT m.scenario_id, m.capped_inflows, m.outflows, p.lcr_inflow_cap
FROM m
CROSS JOIN p
WHERE m.capped_inflows > m.outflows * p.lcr_inflow_cap + 1e-6
ref()builds the dependency graph automatically:staging -> intermediate -> martsrebuilds in the right order, and dbt knows what’s downstream of a changed source.- Staging models (
stg_cashflows) do the renaming/typing once (hqlatype AS hqla_type), so every mart speaks one vocabulary. - Singular tests are executable regulation: the LCR inflow cap and the
capital-stack ordering (CET1 <= Tier1 <= Total) are SQL assertions that fail
the build if the numbers stop being lawful. 157 tests gate every
dbt build. - Tests live next to the transformations, version-controlled, runnable in CI. Invariants can’t sit still in a forgotten notebook.
The pipeline has 6 staging views (1:1 typed views over the raw Parquet sources), 3 intermediate views (enrichment that’s used by more than one mart), and ~23 mart tables (the things Streamlit actually queries).
Here are a few great things about upgrading to dbt:
Tests on business invariants, not just data. The LCR mart enforces
that the EBA inflow cap is applied (capped_inflows ≤ 0.75 × outflows).
The capital-ratio mart enforces CET1 ≤ Tier1 ≤ Total Capital.
These are singular tests in dbt_project/tests/ which are
SQL queries that must return zero rows. If they don’t, the build
fails. There are 157 tests in total: uniqueness, not-null,
accepted-values, relationships, and the singular invariants above.
One place per concept. The 30-day rolling LCR is in
mart_lcr_daily.sql. The warm-up gate (NULL out the ratio until the
window is full, to avoid spikes from incomplete windows) is in that
file, in a WINDOW clause. The dashboard does not know any of this.
In v1 the equivalent rolling logic lived in pandas inside the page
code, and the warm-up artefact had to be solved separately on every
page that used the series.
Lineage I didn’t have to draw. dbt docs generate produces a
static site with the column-level lineage graph. The dashboard’s
“Model lineage” expander now embeds the model parameters (Hull-White
(a, σ), half-life, curve-fit residual, NMD overlay parameters)
straight from a mart_model_metadata table, so the rendered
LaTeX-equation panel and the numbers it cites can never disagree.
Refactors got cheap. Splitting mart_lcr into a static
per-scenario point ratio and a daily rolling variant was a dbt run
away. No application code changed.
The dbt docs site is auto-published to GitHub Pages by a workflow at
.github/workflows/dbt-docs.yml. Every push to main that touches
the dbt project rebuilds it. That’s the project’s reviewer surface:
anyone can see the model graph without cloning the repo.
Dagster: the DAG you can point at
Dagster is the orchestration layer. The reason it’s interesting here,
versus a cron-driven make-style runner, is that it’s asset-centric:
the unit of work is not “run this script” but “materialise this
asset”. You declare the dependencies between assets, and Dagster
decides when each needs to run.
# basel_dagster/assets/ingestion.py
@multi_asset(
outs={t: AssetOut(key=AssetKey(["raw", t]), group_name="ingestion")
for t in RAW_TABLES},
deps=[parquet_files],
)
def raw_tables(context):
counts = load_parquet_dir(DATA_RAW, DATA_WAREHOUSE)
for table in RAW_TABLES:
yield MaterializeResult(
asset_key=AssetKey(["raw", table]),
metadata={"row_count": MetadataValue.int(counts.get(table, 0))},
)
- The
raw/<table>asset keys match whatdagster-dbtresolvessource('raw', ...)to, so the Python ingestion assets and the dbt models stitch into one continuous graph with no glue code. The DAG spans generation -> load -> dbt marts -> risk engine. - Asset-centric (not task-centric): each node is a table/parquet, so the graph reads as data lineage, i.e., the story a reviewer wants.
MaterializeResultmetadata (row counts, file paths) is captured per run: built-in observability instead ofprintarchaeology.- A single
daily_full_refreshschedule re-materializes everything in dependency order; partial reruns are free.
In this project, every dbt model is a Dagster asset (via the
@dbt_assets decorator), and every risk-engine output Parquet is a
Dagster asset. So is each raw Parquet load. The full DAG:
data/raw/*.parquet ─┐
├──► raw_tables (multi-asset)
│ │
│ ▼
│ dbt staging / intermediate / marts
│ │
│ ▼
│ risk_engine_run (Python)
│ │
│ ▼
│ risk_outputs/*.parquet
│ │
│ ▼
└──► risk_outputs_tables (DuckDB reload)
│
▼
dbt staging / marts
│
▼
Streamlit
renders in the Dagster UI as a graph you can click through.
What this bought me:
- The “rerun what’s stale” problem solved. I no longer need to
remember that the risk engine has to run before the dbt marts that
consume its outputs. Dagster runs
risk_engine_run, then the multi-asset that reloads the Parquets into DuckDB, then the dbt models that source from them. One job, one materialise click. - Risk-engine outputs are tested. The risk engine writes 13
Parquet files. Each gets a dbt staging view with column types,
accepted values (e.g.
model_family ∈ {hull_white, vasicek}), and range checks (avg_cpr ∈ [0, 1]). That’s the same treatment raw data gets. The engine doesn’t get a free pass because it’s Python. - A typed boundary between Python and SQL. The risk engine doesn’t insert into DuckDB. It writes Parquet, Dagster reloads, dbt picks up as sources. The Parquet boundary is a serialisation checkpoint that forces the data shapes to be explicit.
Polars: fast, typed synthetic-data generation
This is an “extra step” that makes the project potentially more scalable for future improvements.
# basel_ingestion/generate.py
import polars as pl
def generate_callable_bonds(...) -> pl.DataFrame:
return pl.DataFrame(rows, schema_overrides={"call_date": pl.Date})
# basel_risk_engine/run.py — interop on the way out
pl.from_pandas(df).write_parquet(path)
- Explicit, strict dtypes at construction (
schema_overrides={"call_date": pl.Date}): the synthetic feed lands with the exact types the Pydantic models and DuckDB expect, noobject-column surprises. write_parquet()is fast and columnar by default: the feed DuckDB reads is produced without an unnecessary pandas round-trip.pl.from_pandas(...)gives zero-friction interop: the numeric engine stays in pandas/NumPy where natural and hands off to Polars at the parquet boundary.- Predictable memory and speed on the larger MC-path frames vs. pandas.
Concrete payoffs, with numbers
The point of changing infrastructure is to enable things that were hard before. Some specific things v2 made easy:
| Thing | Before | After |
|---|---|---|
| Schema source of truth | 4 files (DDL, ORM, generator, queries) | 1 file (basel_common.types Pydantic) |
| Tests on the data layer | 0 | 157 (dbt) |
| Tests on the risk engine | 0 | 74 (Hypothesis property tests) |
| Lineage graph | mental model | dbt docs (auto-published) |
| Hosting cost | ~$7/month (Postgres) | $0 (Streamlit Cloud + GitHub Pages) |
| Cold-start time | Postgres provisioning + data load | sub-second (read-only DuckDB file) |
| “Rerun what’s stale” workflow | manual ordering | dagster job execute full_refresh |
The two numbers I’m proudest of are:
- 74 + 157 = 231 invariant checks per build. Hypothesis property
tests on the risk engine (MC convergence to forward + convexity
adjustment, CPR-adjusted schedule conserves notional exactly,
Black-76 monotone in σ, supervisory outlier ratio =
|ΔEVE_worst| / Tier1) plus dbt tests on the data layer (LCR inflow cap, capital stack ordering, RWA range, uniqueness on(scenario_id, as_of_date)). Catches regressions I’d otherwise eyeball. - Curve-fit residual ~1e-16. Not a stack story, but enabled by it: switching from Vasicek to Hull-White 1F made the model arbitrage-free against today’s curve to machine precision. That fact is now surfaced on the dashboard’s lineage panel, sourced from a dbt mart, with the SDE rendered in LaTeX.
A list of possible future improvements
A few things are deliberately not in this version, despite being straightforward to add:
- No FastAPI. The warehouse is the API. Streamlit reads marts directly.
- No Postgres. DuckDB at this scale (~28 MB warehouse, ~50k rows in the biggest table) is faster for the read-heavy analytical workload and removes the hosting cost.
- No Airflow. Dagster’s asset-centric model fits the “one DAG, rebuild on demand, dev locally” workflow. Airflow’s task-centric model would have required materialisation state to be re-invented on top of it.
- No live Dagster instance. The orchestration story is the asset
graph, not a hosted scheduler. A screenshot of the graph and a Loom
of
dagster job executeis in the README; the live UI is a localdagster dev.
These could all be added later if a real production deployment ever called for them.
Closing
The rewrite reduced the surface area, not increased it. Streamlit stayed. The risk-model maths got more substantive (Hull-White 1F, CPR, Black-76, ALMM survival horizon) because the surrounding plumbing stopped being where the time went.
The shorter version of the post is: modern analytical stacks are better than the obvious “Postgres + ORM + script” default, even for small projects, because the things they let you stop doing (writing DDL, drawing lineage graphs by hand, paying for a database, juggling which script to run first) compound faster than you’d think.
The repo is at
thomasmartins/basel-risk-pipeline.
The live Streamlit dashboard is at
basel-risk-pipeline.streamlit.app.
The auto-published dbt docs are at
thomasmartins.github.io/basel-risk-pipeline.