I built a synthetic neobank whose backend events misbehave on purpose — late, duplicated, reversed, schema-evolving — and a governed dbt warehouse that turns them into trusted Growth and referral-reward interfaces. The events are generated against a known-truth manifest, so the warehouse's correctness can be checked rather than asserted. On top of that, I ran an honest cost benchmark: does an incremental warehouse produce the same answer as a full rebuild, and what does each actually cost?
It is built on one idea: a check is only real if something fails when you take it away. Attestation is not enforcement — a log, an assertion or a green test is not a guarantee — so every detector here is tested against deliberate negative controls. Governance rules are proved against manifests built to be wrong. The causal estimators are proved immune to a poisoned answer key. Faults are applied only after valid generation, so known truth stays separable from the corruption. That is the part that transfers outside fintech, and it is worked through in Every check here is tested for detection.
Everything here is synthetic. No affiliation with Monzo or any bank; no real customer, internal, or proprietary data. Monzo's public engineering writing shaped which problems I chose, not how any of it is built. See Safety & ethics.
What ran:
- the four-layer warehouse runs locally on DuckDB and executed on BigQuery under a small, capped budget — one dated benchmark run, not a live service;
- full-refresh and incremental builds matched exactly at every governed interface, across all three phases;
- the cost result is measured and mixed, and I report it that way (below).
Most analytics demos start from clean data and treat growth as the only goal. A real neobank has neither luxury: events arrive late, duplicated, corrected and reversed, schemas change under you, and a bad growth decision can leave a vulnerable customer worse off. So the source here is built with the answer known in advance. Every duplicate, late arrival, reversal, malformed payload and missing posting is injected against a manifest. That is what makes the incremental-versus-full comparison and the reward reconciliation mean anything — there is a fixed truth to check against, not a plausible-looking output to trust.
The property this project is actually built around, stated plainly:
Attestation is not enforcement. A log, an assertion or a green test is not a guarantee. A check is only real if something fails when you take it away.
So every detector in this repository is itself tested against deliberate negative controls — inputs constructed to be wrong, which the detector must catch. Three worked examples, each reproducible:
The standards checker is tested for precision, not just detection.
tests/standards/ holds five deliberately-invalid manifest
fixtures, one per rule — missing owner, missing unique key, missing freshness
SLO, missing partition policy, and non-incremental materialisation without a
documented exemption. Each must produce exactly one violation, and it must be
the expected one, so a rule that fired on everything would fail the suite just
as surely as one that fired on nothing. A sixth, valid fixture must produce none,
which pins the false-positive side. The CLI's exit codes are checked in both
directions. Separately,
tests/standards/test_real_manifest.py
runs the same rules against the real dbt_neobank/target/manifest.json, so the
rules are proven to fire on fixtures and proven to hold on the actual build.
The causal estimators are tested for immunity to their own answer key.
The generator embeds the truth it is asking the analysis to recover, which is
exactly the setup where a project can fool itself.
tests/test_no_circularity.py poisons the input:
it injects true_d7_lift_pp = 99.0 into frames whose observed gap is about
+6pp, and asserts the estimate is byte-identical to the unpoisoned run — not
merely close. It repeats this for CUPED and for the fairness outcome_gap, and
adds a structural check that the release engine's input dataclass cannot carry a
generator-only field at all. An estimator that peeked would move; these cannot.
Corruption is applied after valid generation, so truth stays separable.
The pipeline is apply_faults(generate_valid_events(config), config) — faults
are a transformation over already-valid output, not something woven through
generation. The ordering is guaranteed by composition rather than by convention,
and the clean layer is exercised on its own in
tests/event_simulator/test_lifecycles.py.
That is what makes "the correct answer is known in advance" survive contact with
duplicates, late arrivals, reversals and schema drift.
None of this is specific to fintech. It is a way of building checks that holds wherever a check is supposed to mean something.
Synthetic service events (campaign, application/KYC, activation/funding,
(versioned envelope) referral/reward, experiment, customer-outcome)
|
v
Immutable delivery batches + quarantine (append-only; bad payloads kept as evidence)
|
v
Landing (lnd_*) -> Normalised (nrm_*) -> Logical / governed interfaces (lgl_*)
| | |
| SCD2 / current state v
| Presentation (prs_*)
| |
| +---------------+----------------+
| v v
| Streamlit / ML features /
| decision app scoring
v
Truth manifest -> correctness + reward-reconciliation oracle
BigQuery job metadata -> warehouse-health interface
Only normalised and logical models are governed interfaces; presentation models are replaceable. The existing analytics reach the interfaces through compatibility views, so nothing that already worked was thrown away. The full local and cloud picture is in docs/ARCHITECTURE.md.
Each result is checked by a test or a command in this repository — run
uv run python -m tools.ci.verify_pipeline to reproduce the local proof end to
end.
| Result | How it's checked |
|---|---|
| Standard profile: 568,789 deliveries in 356 batches, identical logical checksum across two runs | cli generate ×2 + cli compare (tests/event_simulator/test_reproducibility.py) |
| dbt build: 68 models, 217 data tests, 4 unit tests green | uv run dbt build --project-dir dbt_neobank --profiles-dir dbt_neobank --target dev (PASS=289, plus 4 exposures as NO-OP); also executed on BigQuery |
| Full versus incremental: exact match at all six governed interfaces (base/delta/repair) | blue/green harness tools/reconcile/compare_interfaces.py (tests/oracles/test_incremental_execution.py) |
| Cost, measured and mixed: incremental billed +1.95% bytes but used −62.7% compute; partitioning cut one query's scan 523.9× | BigQuery benchmark (below) |
| Late-event recovery: a held-back day missed by the 3-day lookback, recovered by a bounded backfill | tools/reconcile/backfill.py (tests/oracles/test_incremental_execution.py) |
| Reward reconciliation: debits equal credits, opening plus movements equals closing, every injected exception caught | tests/oracles/test_reward_reconciliation_execution.py + dbt_neobank/tests/logical/ |
| Full local suite: 400 pytest tests and 289 dbt-build results pass with no cloud account | uv run pytest · uv run dbt build --project-dir dbt_neobank --profiles-dir dbt_neobank --target dev |
Python 3.12+ and uv. The whole local platform
runs on DuckDB.
Generate the tiny profile twice and check it reproduces:
uv sync --group dev
uv run python -m src.event_simulator.cli generate --profile tiny --output data/generated/tiny-a
uv run python -m src.event_simulator.cli generate --profile tiny --output data/generated/tiny-b
uv run python -m src.event_simulator.cli compare --left data/generated/tiny-a --right data/generated/tiny-bRun the whole local proof — generation, ingestion, dbt build, standards, full-versus-incremental parity, tests:
uv run python -m tools.ci.verify_pipeline.github/workflows/ci.yml does the same proof, plus
two things worth calling out:
- Determinism is proven, not asserted — CI generates the tiny profile twice
into separate directories and fails unless
cli comparefinds the checksums identical. A generator that quietly drifted would break the build. - Both containers are built — the FastAPI image (
Dockerfile.api) and the Cloud Run jobs image (Dockerfile.jobs). The API image is additionally started and polled on/healthuntil it answers, so the build proves the service comes up, not just that it compiles. The jobs image is build-only.
Each interface answers one question and has one owner and one authoritative
grain (docs/metrics/metric-ownership.yml).
| Interface | Question | Owner |
|---|---|---|
growth_acquisition |
Where do applicants move or drop between application, approval and funded activation? | Growth |
referral_economics |
Do referrals bring in incremental activated customers at a reward cost worth paying? | Growth / Finance |
reward_reconciliation |
Which expected rewards are missing, duplicated, mismatched, stale or wrongly reversed? | Finance |
warehouse_health |
Which interfaces are stale, failing, expensive or slower than their baseline? | Platform |
Contracts live in contracts/interfaces/. To be exact
about what "contract" means here, because dbt uses the word for something
narrower: no model sets dbt's own contract: enforced: true. The contracts
in this project are YAML interface contracts and JSON-Schema event contracts,
and they are enforced by a checker rather than by dbt.
- Standards-as-code.
tools/standards/check_dbt_interfaces.pyreadsrules.ymland fails CI when a governednrm_/lgl_model omits an owner, purpose, grain, unique key, freshness SLO, classification, version, compatibility policy or declared exposures. It runs against the realdbt_neobank/target/manifest.json, not a copy (tests/standards/test_real_manifest.py). Five deliberately-invalid fixture manifests intests/standards/fixtures/prove each rule actually fails when violated, alongside a valid one that must pass — so the checker is tested for detection, not just for running clean. - Blue/green reconciliation.
tools/reconcile/compare_interfaces.pyasserts full-refresh and incremental builds produce identical output at all six governed interfaces, across every scenario, with no tolerance on keys or integer financial values. - History from occurrence time, never ingestion time. The SCD2-style models
nrm_account_history.sqlandnrm_referral_history.sqlrebuild state intervals from the event log ordered by business occurrence time with deterministic tie-breakers, so a late or replayed delivery cannot rewrite when something happened. - Declared consumers. Four dbt exposures (one typed
ml) tie the presentation layer to the things that read it, so breaking a consumer is visible in lineage. - Bounded BI re-derivation.
docs/metrics/metric-ownership.ymlgives every metric one authoritative owner and abi_mayclause saying what BI is permitted to re-derive (for example: aggregate, but do not reimplement qualification eligibility). Two metrics claiming the same name fail a test (tests/contracts/test_interface_contracts.py).
The comparison is set up to be hard to game. On an identical final event state, it pits rebuilding all history after a new-event batch against processing that batch plus the frozen lookback. The Base, Delta and Repair phases (90/9/1 by ingestion) were fixed before any output was looked at.
The result is mixed, and I report it as measured. On 569k deliveries, incremental billed 1.95% more bytes than a full rebuild while using 62.7% less compute. The byte figure is not a surprise once you see why: the raw event store is unpartitioned, so every strategy scans the whole landing view. The ablation shows where byte savings actually come from — the same seven-day reconciliation query scanned 523.9× fewer bytes on partitioned storage. Full refresh stays the simpler choice for the raw-scan parts; incremental's win here is compute, and partitioning is what buys bytes. None of it is extrapolated to production or Monzo scale.
The data-science methodology from earlier in the project's life is kept as a set of consumers of the governed interfaces, reached through compatibility views rather than rewritten:
- Experimentation — CUPED, SRM, heterogeneous effects, difference-in-
differences with clustered standard errors, parallel-trends, placebo-in-space
and synthetic control; the Welch/CUPED/SRM estimators run unchanged on the
governed
growth_acquisitiondata (a worked decision). - Responsible release-gate engine — evidence and customer-outcome guardrails
resolve to
ship / limited_rollout / experiment_only / needs_human_review / block, and a harm signal beats commercial uplift (framework). - Fairness, wellbeing, inclusion, protection modules and a fair-value pricing check, over synthetic proxies with executable use boundaries and RBAC (access control).
- Activation model + FastAPI service — calibrated scoring from the governed feature interface.
- Real-data cross-checks — the same estimators re-run on real public data, UCI Bank Marketing and Criteo Uplift, as method validation (UCI · Criteo).
- Marketing measurement — this project absorbed my separate marketing-effectiveness lab: the MMM-versus-experiment reconciliation and its parameter-recovery checks now live here (reconciliation · parameter recovery · benchmark note).
These surface in the Streamlit decision app.
If you read one supporting document, read docs/CREDIBILITY.md. It is where the project states which of its numbers can be trusted and on what grounds, and it is the honest answer to the fair question a reviewer should ask of any synthetic project: is this real signal, or a generator the author tuned?
Results split three ways, kept apart there:
- engineering truth — exact outcomes from the generator's manifest (event, duplicate, quarantine, ledger and exception counts);
- method validation — causal and statistical recovery against seeded truth and the two real-data adapters;
- illustrative magnitude — activation rates, £CLV and fairness-gap sizes are not evidence about real customers.
The data is engineered for coverage, not calibrated to any bank, and generation
and analysis are kept separate so the recovery is not circular (tested in
tests/test_no_circularity.py).
| Layer | Tools |
|---|---|
| Language / runtime | Python 3.12+, uv |
| Event simulator | seeded generators, virtual clock, JSON Schema contracts |
| Ingestion | append-only Parquet, checksum-gated batch registry, quarantine |
| Analytics engineering | dbt (four layers, incremental, unit tests) with YAML interface contracts checked against the built manifest, DuckDB local / BigQuery |
| Application | Streamlit decision app, FastAPI service |
| Experimentation | CUPED, SRM, DiD, synthetic control (scipy, statsmodels, linearmodels) |
| Modelling | scikit-learn, isotonic calibration, model card, batch scoring |
| Quality | pytest, ruff, GitHub Actions, standards-as-code |
contracts/ event/interface/scenario JSON-Schema + YAML contracts
src/
event_simulator/ deterministic generator (domains, scenarios, writers)
ingestion/ append-only loader + quarantine
experiments/ modelling/ wellbeing/ inclusion/ release_decisions/ protection/ downstream consumers
adapters/ real-data method-validation adapters
dbt_neobank/ four-layer warehouse (landing/normalised/logical/presentation/compatibility)
dataset/ synthetic event benchmark (tiny committed; standard built on demand)
tools/ standards checker, reconciliation harness, dataset builder, local verify runner
docs/ architecture, contracts, metrics, module docs
tests/ pytest suite
A note on the name. The published repository, the live dashboard and every link above use
responsible-neobank-growth. Some local checkouts and older internal references use the earlier directory nameneobank-product-analytics. Same project; the published name is the current one.
More: docs/ARCHITECTURE.md · docs/CREDIBILITY.md · docs/CLAIMS_AND_SCOPE.md · docs/GCP_WAREHOUSE.md · docs/CLOUD_RUN_DEPLOYMENT.md.
docs/CLAIMS_AND_SCOPE.md is the boundary of what this project claims: what the cloud work is and is not, which wording is banned, and which numbers may be quoted. If a statement here and a statement there disagree, that document wins.
This project is complete and frozen as of 2026-07-27. Four candidate next directions were assessed and declined on evidence; the reasoning is recorded in docs/FUTURES_DECLINED.md so it does not get re-litigated.
Synthetic data, and not a production banking, fraud, credit, eligibility or financial-advice system — and not affiliated with Monzo or any bank. The vulnerability, wellbeing and inclusion fields are synthetic proxies for testing product decisions; they must not be used to deny services, set prices unfairly, judge creditworthiness, or make punitive decisions. The customer-protection module is a supportive-intervention simulation, not a fraud engine.
MIT © 2026 Cheng-Yuan King. Independent synthetic reference project; if you cite it, cite the repository and commit. No affiliation with Monzo Bank Ltd is claimed or implied.
