// case studies
Selected Work
Four projects spanning tail-risk forecasting, causal inference, prediction intervals, and regulatory reporting measurement.
P01
GARCH-EVT Risk Engine — Scheduled Azure Deployment
Azure SQLTerraform
GitHub Actions OIDCPandera
archSciPy
Runs unattended every day to answer one question for anyone exposed to Finnish electricity prices —
how bad could a bad day get — and halts rather than publishing when the input series has a gap it
cannot defensibly fill or the volatility model fails to converge.
The first out-of-sample backtest rejected 99% coverage — 8 breaches against 2.44 expected,
Kupiec p = 0.0047 — and the forecast was withheld. On the current pinned range: 14 breaches
against 14.05 expected at 95% over 281 days (Kupiec p = 0.99), with the 99% level reported
alongside its power caveat rather than as confirmation.
- That fix changed four things at once (threshold selector, rolling window, exceedance floor, evaluation period). A later ablation cleared the selector and the floor; window length and evaluation period remain confounded, and cannot be separated because the 2023 range no longer exists upstream. The original single-cause attribution was wrong, and its replacement is narrower rather than conclusive. Both the failure and the correction are in the devlog.
- Rebuilt a local research pipeline as a passwordless, zero-secret Azure deployment: Terraform-provisioned Azure SQL Serverless, Blob-backed remote state, GitHub Actions authenticated via OIDC federated credentials.
- Built an idempotent ingestion layer (staging table + T-SQL
MERGE) with Pandera contracts enforcing a gapless, timezone-aware hourly index, and exponential backoff to survive Azure SQL Serverless cold starts (error 40613).
- Fitted a rolling GJR-GARCH(1,1,1) + Peaks-Over-Threshold EVT engine with daily Kupiec POF and Christoffersen conditional-coverage tests, plus a raw Acerbi–Székely ES statistic that is reported rather than thresholded (it follows no standard distribution); the pipeline halts on unfillable data gaps or convergence failures rather than silently dropping days.
- Benchmarked against filtered historical simulation, a GARCH-t quantile, and an unconditional empirical quantile over an identical 281-day scheme. No arm separates on coverage — including the unfiltered one — so the honest reading is that the sample lacks the power to rank them; GARCH-EVT differs only in running 10–15% more conservative at 99%.
- Fixed three Azure/ODBC/SQLAlchemy production issues: OIDC subject-claim mismatch, driver auth keyword collision, SQL type inference on tz-aware timestamps.
PythonDoubleML
LightGBMStreamlit
uvPytestMyPy
Tells a pricing team how much of their brand's share a price change would actually move,
separating real customer response from the brand loyalty and competitor discounting that make
raw sales data misleading — and lets them test a scenario in a dashboard before committing.
A 10-cent increase in sale price lowers the probability of choosing the brand by
~4.6 percentage points (θ̂₀ = −0.460, p < 0.001), holding loyalty and
competitor price and promotion fixed. Estimated on a linear probability scale, so this is a
local slope, not a calibrated probability model.
- Read as a local slope, not a dollar-scale prediction: the treatment (sale price) spans $1.39–$2.09, so a full $1.00 move is outside the support of the data.
- Omitted-variable-bias sensitivity puts the robustness value at 12.3%: at worst-case alignment, an unobserved confounder would need to explain ~12.3% of residual variance in both treatment and outcome to null the point estimate — 8.1% to null significance. Not yet benchmarked against the observed covariates, so it bounds fragility without establishing that no such confounder exists.
- Observations cluster by store and week but the model was fitted with i.i.d. standard errors, so the p-value is understated; the point estimate is the defensible quantity.
- Estimated the effect with a partially linear DML model on the partialling-out score — LightGBM nuisance learners, 5 folds × 5 repetitions.
PriceCH and SpecialCH are excluded together: sale price is list price minus discount, and the discount is near-zero absent a promotion, so conditioning on both leaves almost no residual treatment variation. The cost is that list-price level goes uncontrolled — a stated trade-off, not a free lunch.
- Validated the estimator against a synthetic design with known θ₀ and a second with an analytically derived omitted-variable bias, both asserted in CI alongside Ruff, MyPy (strict), and a locked dependency graph.
- Built a typed
src/ layout with a Streamlit simulator that reads the pipeline artifact rather than refitting.
P03
Adaptive Conformal Forecasting
Distribution-free prediction intervals for volatile time series
View repo
PythonMAPIE
DockerPlotly
uvPytest
Turns a single-number electricity demand forecast into a 90% prediction interval — the band the
outcome falls inside nine hours in ten, not a best case or a worst case — that planners can size
capacity and hedging decisions against. The band recalibrates itself against realized coverage as
the series shifts regime, instead of holding a width that stopped being correct.
On 1,500 held-out hours at a 90% target, the adaptive layer covered 89.9% with
intervals 7.5% narrower than a static conformal baseline that over-covered at 92.7%.
- Reported with its resolution: at n = 1,500 the binomial SE on coverage is ≈0.8 pp before correcting for serially dependent misses, so 89.9% is indistinguishable from the 90% target — the separation from 92.7% is the informative comparison, not the last decimal.
- The γ sweep behind that default was run on the same held-out window the numbers are reported on, so the width reduction is optimistically biased; a three-way split is the first structural fix. γ = 0.005 gave a larger reduction (10.9%) than the chosen 0.01, so the default is a judgment call on adaptation speed rather than an optimum the sweep establishes.
- Above γ ≈ 0.03 the update overshoots after miscoverage events — coverage still tracks 90%, but mean width inflates sharply (47% wider than static at γ = 0.05).
- Applied EnbPI with block-bootstrap residuals (MAPIE) in place of split conformal — a weaker assumption, not no assumption: coverage becomes approximate and asymptotic under stationarity and mixing conditions rather than finite-sample under exchangeability.
- Fixed an ACI update that ran once per 168-hour chunk on the chunk's aggregate error rate instead of per timestep — roughly two orders of magnitude coarser than the Gibbs & Candès recursion. The α path is now per-step; the emitted band stays piecewise-constant over the batching interval, so the displayed interval is a downsampled view of it.
- Containerized the stack with Docker and wrote one end-to-end validation script covering install, tests, lint, image build, and container health check.
P04
ESEF Machine-Readability Benchmark
PythonSQLite
XBRL / iXBRLCluster bootstrap
uvPytestMyPy (strict)
EU-listed companies are required to file their annual accounts in a machine-readable format, so
software should be able to find the headline figures — revenue, profit, assets, equity, liabilities,
cash — without guessing. This measures how often it actually can, on every Finnish issuer that filed.
93.9% of 882 issuer-field cells carry the ESEF core standard element (95% cluster-bootstrap
CI 91.8%–95.7%, 147 issuer clusters, B = 10,000). The gap is not spread evenly: liabilities is
tagged in 79.6% of filings and revenue in 89.8%, while the other four sit above 98%.
- The dominant cause is legitimate presentation, not defective tagging: 30 of 147 issuers report equity and liabilities split into non-current and current components, with no bare total line to tag. That is a valid IAS 1 statement and a machine-readability gap at the same time, which is the finding.
- Excluded cells stay in the denominator. Conditioning on classifiability would have defined away part of the thing being measured; the exclusions are published by reason code so a reader can apply their own test.
- The pre-registered reason for clustering on the issuer was falsified by the data and is published as falsified: shared templates and tagging vendors predict an intraclass correlation near 1, and the measured value is 0.1099. The clustering is still correct; the argument for it was not.
- Stopped the planned LLM extraction arm on a measurement, before a single document was sent: the deepest lossless redaction still left a mean 1,331,978-token document against a 1,050,000-token context window, at $51.66 projected for this frame. Recorded VOID rather than FAIL — a gate whose inputs were never observed has no verdict — and the scope change is a dated amendment, not a rewritten spec.
- Removed a verification script that ran the scripts writing its own inputs and then asserted PASS on them, and traced a one-character constant corruption that disabled the entire redactor while
mypy --strict reported success. Three reasoned diagnoses were wrong; printing the bytes at the failing offset settled it.