PhD Researcher · Quant Enthusiast · Probabilist

Daan Zunnenberg_

Mathematics researcher with a builder's streak for quant trading, HFT & market-making.

Lost in abstract probability, inspired by quantitative markets. Beyond the desk →

Working Together

Here for a collaboration or a career opportunity? Choose the path that fits.

Selected Projects

Functional Volatility Surface Modelling

  • Extending the functional GARCH framework to a generalized autoregressive score (GAS) model to estimate and capture time-varying intraday volatility surfaces.
  • Designed efficient estimation procedures using B-splines, applying numba JIT compilation to enable scalable modelling of volatility surfaces from granular intraday return data.
Python · SAS · FunctionalScale on GitHub

Overview

Treats continuous intraday log-return paths as functional data objects \(y_t(u)\) over the trading day \(u \in [0,1]\). The Functional GARCH(p,q) model generalizes classical volatility dynamics to an infinite-dimensional Hilbert space, capturing how shocks at any point of the day impact the entire upcoming volatility surface.

Model Definition

The functional scale model decomposes returns using a time-varying conditional variance curve \(\sigma_t^2(u)\), so we look at \[y_t(u) = \sigma_t(u)\eta_t(u)\] driven by the recursion \[\sigma_t^2 = \delta + \sum_{i=1}^{q}\alpha_i\left(y_{t-i}^2\right) + \sum_{j=1}^{p}\beta_j\left(\sigma_{t-j}^2\right)\]

where \(\delta\) is a strictly positive baseline intercept curve, and \(\alpha_i, \beta_j\) are non-negative integral kernel operators mapping past squared return curves and past volatility curves into today's volatility surface.

Functional GAS Extension

The functional scale model can be extended to a functional Generalized Autoregressive Score (GAS) model by replacing the autoregressive dependence on past squared return curves with a score-driven update. The returns are defined as \[y_t(u) = \sigma_t(u)\eta_t(u),\] where \(\sigma_t^2(u)\) denotes the time-varying conditional variance curve. The functional GAS dynamics are specified as \[\sigma_{t+1}^2 = \delta + B\sigma_t^2 + A\int \phi_K(s)y_t^2(s)\,ds,\] where \(\delta\) is a strictly positive baseline intercept curve, \(B\) is a linear persistence operator acting on the previous volatility curve, and \(A\) maps the observed squared return curve into the updated volatility curve. The basis projection term \[\int \phi_K(s)y_t^2(s)\,ds\] represents the score-driven innovation component obtained from the functional representation of the return process.

Equivalently, the recursion can be expressed using integral kernel operators as \[\sigma_{t+1}^2(u) = \delta(u) + \int B(u,v)\sigma_t^2(v)\,dv + \int A(u,s)\phi_K(s)y_t^2(s)\,ds.\] The functional GAS model therefore updates the conditional variance curve using information contained in the most recent squared return curve, while the operator \(B\) captures the persistence of past volatility curves. Compared with the functional scale model, \[\sigma_t^2 = \delta + \sum_{i=1}^{q}\alpha_i(y_{t-i}^{2}) + \sum_{j=1}^{p}\beta_j(\sigma_{t-j}^{2}),\] the GAS formulation replaces the ARCH-type feedback operators \(\alpha_i(y_{t-i}^{2})\) with a score-driven update based on the functional projection of the return innovations.

Estimation

Because standard likelihood functions cannot be directly evaluated for continuous curves, the model is estimated using Functional Quasi-Maximum Likelihood Estimation (QMLE).

Setup

git clone https://github.com/DaanZunnenberg/FunctionalScale.git
cd FunctionalScale
pip install -e .            # editable install, pulls in numpy/scipy/numba/pandas/matplotlib/tqdm
pip install -e ".[dev]"      # + pytest, jupyter (optional, for tests/notebooks)

Usage: functional GARCH

import numpy as np
from funcgarch import fit, garch_filter

# mY: (N, T) matrix of intraday return curves, N grid points per day, T days
mY = np.load("returns.npy")
N, T = mY.shape
M = 4  # number of Bernstein basis functions

result = fit(mY, n_grid=N, M=M)          # QMLE-style estimation (scipy.optimize)
vtheta_hat = result.x

sigma2 = garch_filter(mY, n_grid=N, vtheta=vtheta_hat, M=M)  # (N, T) fitted variance surface

Usage: functional GAS-GARCH

from scipy.optimize import minimize
from funcgarch import gas_garch_estimator

# vtheta = [nu, ou_scale, omega (M,), vec(B) (M*M,), vec(A) (M*M,)]
result = minimize(
    gas_garch_estimator, x0=vtheta_init, args=(mY, N, M),
    method="SLSQP",
)

Because the score-driven update adapts its B-spline coefficients every day rather than fitting one static operator to the whole sample, the GAS-GARCH fit tracks the true surface considerably more tightly than the plain functional GARCH fit above:

True versus GAS-GARCH-estimated volatility surface, side by side

The first comparison figure shows the estimated GAS-GARCH volatility surface against the true volatility surface to assess the model’s ability to recover the underlying dynamics.

Placing the two estimators' fitted surfaces directly next to each other, rather than each against the true surface separately, makes the score-driven adaptation's smoothing effect easier to see:

Functional GARCH-estimated versus GAS-GARCH-estimated volatility surface, side by side

The second comparison figure compares the functional GARCH and GAS-GARCH volatility surfaces, demonstrating the increased flexibility of the GAS-GARCH specification relative to the traditional functional GARCH model.

Data Flow

wrds/*.sas                    scripts/taq_cleaner.py           funcgarch/*.py
┌─────────────────┐           ┌────────────────────┐           ┌──────────────────────┐
│ WRDS TAQ pull   │  raw CSV  │ DataCleaner.clean()│  mY (N,T) │ fit() / garch_filter │
│ (data_fetcher,  │ ────────► │  - align to grid   │ ────────► │ gas_garch_estimator  │
│  taq_cleaner,   │           │  - compute returns │           │ func_garch_estimator │
│  nbbo/dynamic_  │           │  - reshape to      │           │                      │
│  taq_minute,    │           │    (N, T) matrix   │           │  -> vtheta_hat,      │
│  export)        │           │                    │           │     sigma2 surface   │
└─────────────────┘           └────────────────────┘           └──────────────────────┘

Requires Python ≥ 3.9. Full theory, references, and repository layout in the README.

Functional Stationarity Test

A nonparametric stationarity test for multidimensional diffusions, comparing two smoothers whose convergence rates diverge under nonstationarity. Built for my MSc thesis, released as an open-source library.

Overview

We consider a d-dimensional Itô diffusion process \((X_t)_{t \ge 0}\) defined by \[dX_t = b(X_t)\,dt + \sigma(X_t)\,dW_t,\] where \(X_t \in \mathbb{R}^d\), \(b: \mathbb{R}^d \to \mathbb{R}^d\) is the drift function, \(\sigma: \mathbb{R}^d \to \mathbb{R}^{d \times m}\) is the diffusion coefficient, and \(W_t\) is an \(m\)-dimensional Brownian motion. The instantaneous covariance matrix is given by \[a(x) = \sigma(x)\sigma(x)^\top.\]

Throughout, we assume that the diffusion satisfies the uniform ellipticity condition, meaning that there exists a constant \(\lambda > 0\) such that \[\xi^\top a(x) \xi \ge \lambda \|\xi\|^2, \qquad \forall x \in \mathbb{R}^d,\ \xi \in \mathbb{R}^d.\] This assumption ensures that the diffusion is non-degenerate in every direction. As a consequence, the transition probabilities of the process assign positive probability to every non-empty open subset of the state space. Hence, the process is open-set irreducible. Moreover, under standard regularity conditions, the diffusion is aperiodic.

If the process is additionally positive Harris recurrent, then it admits a unique invariant probability distribution and satisfies the usual ergodic properties of Markov processes. In particular, long-run averages of functions of the process converge to their corresponding expectations under the invariant distribution, ensuring stable long-run behaviour.

The proposed stationarity test is based on the relationship between stationarity and the growth behaviour of the occupation measure, \[\mu_t(A) = \int_0^t \mathbf{1}_A(X_s)\,ds,\] which measures the amount of time the diffusion spends in a measurable set \(A\). For a stationary and ergodic diffusion, the occupation measure grows linearly with time, with the growth rate determined by the invariant distribution. Therefore, under the diffusion assumptions above, stationarity is equivalent to linear divergence of the occupation measure.

The test exploits this equivalence by comparing two consistent estimators of the diffusion matrix. The first estimator is constructed in the time domain, while the second estimator is constructed in the state domain using the occupation measure. Under stationarity, the linear growth of the occupation measure guarantees compatible asymptotic behaviour of both estimators. Under nonstationarity, this linear divergence property fails, leading to a divergence between the two estimators.

The limiting distribution used for the test statistic is obtained from the extreme value theory of Pickands and Berman for running maxima of stationary Gaussian sequences. Under the stationary regime, the standardized difference between the estimators admits a Gaussian approximation, and the corresponding running maximum converges to a Gumbel-type limit distribution. The critical values for the test are therefore obtained from this Pickands–Berman extreme value distribution.

Quick Start

import numpy as np
from mht.models.processes import BivariateOUProcess
from mht.testing.kernel_test import KernelTest, Kernel, TestPlotter

# Simulate a bivariate OU process
ou_config = {
    'T': 365, 'dt': 1/20,
    'sigma1': np.sqrt(2), 'sigma2': np.sqrt(2),
    'theta1': 0.2, 'theta2': 0.2,
    'rho': 0.75,
}
process = BivariateOUProcess(**ou_config)
process.simulate(seed=1)
X, T, n = process.config()

# Estimate and test
test = KernelTest(data=X, kernel_params={'bandwidth': np.sqrt(3) * 9 / ((n ** (1/6)) * np.log(n)), 'n': n, 'T': T, 'kernel': Kernel.BaseKernel}, time_params={'bandwidth': 200 * T / n, 'n': n, 'T': T})
test.time_domain_smoother(lamb=0.99)
test.state_domain_smoother(dist=True)
test.gauss()
bound, scalar_gauss = test.transform_1D_gauss()

Requires Python ≥ 3.10. Full setup, repository layout, and batch KPSS/Leybourne–McCabe comparisons on the Experience & Projects page.

Tukey Depth Under Mixing

Research code for studying Tukey depth under dependence: how depth-based statistical methods behave when observations come from dependent, mixing time series rather than independent samples.

Overview

This repository contains research code for studying Tukey depth under dependence. The objective is to investigate how depth-based statistical methods behave when observations are generated from dependent time series rather than independent samples. The code provides tools for simulating dependent, mixing time series processes and estimating Tukey's halfspace depth together with the corresponding minimal direction.

The package includes simulation routines for generating synthetic bivariate time series with different dependence structures and controllable mixing behaviour. It also provides implementations for estimating Tukey depth and the direction that determines the limiting halfspace empirically from simulated samples. In addition, the code includes closed-form benchmark cases for Gaussian models and stationary vector autoregressive processes, allowing the empirical estimates to be compared against theoretical values.

The main application is to study the convergence behaviour of depth estimates as the sample size increases under different dependence regimes.

Usage

The repository includes Python examples for simulating dependent time series models and estimating Tukey depth and its minimal direction.

from Core.MixingModels import MixingLinearModel
from Core.Depth import Estimator, GaussianDepth

process = MixingLinearModel(mixing_rate=1.5)
X = process.simulate(n=500)

result = Estimator(X, X0, method="deg")   # or "point_wise"
depth, direction = result.depth, result.direction

See the notebooks in Core/, linked from the Experience & Projects page, for the full VAR(1) and mixing-process walkthroughs.

All projects →

Experience

QuantFi · Quantitative Developer

  • Ran algorithmic market-making in crypto derivatives: volatility/skew estimation, order-flow modelling, queue-aware execution.
  • Built a smart order router with dynamic liquidity allocation across fragmented order books, cutting slippage and transaction costs by 5.8% on average.
Python · asyncio · numba · ccxt

VU Econometrics and Data Science · Research Assistant

  • Built likelihood-based estimation for functional location-scale models, contributing to Lin & Lucas's work on robust observation-driven dynamics.
  • Projected the conditional-variance operator onto a Bernstein-polynomial basis, turning an infinite-dimensional, positivity-constrained QMLE problem into one numerical optimization can actually solve.
Python

QuantFi · Operational Trader

  • Managed live risk on production market-making algorithms through high-volatility sessions.
  • Built the desk's real-time position/order terminal, with Tardis.dev-backed fill reconstruction for post-session audits.
Python · ccxt · Tardis.dev

Full resume →

A Small Confession About the Banner

That flickering panel behind my name up top isn't decoration. It's a real, currently-live order book, quietly doing its job in the background while you read a page about probability theory. Old habits: even here, on a page about myself, I couldn't resist leaving a market open in the corner. If it looks unusually calm right now, that's not a design choice, that's just what the market is doing at this exact moment, for better or worse.