Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,21 @@ updates:
schedule:
interval: monthly
open-pull-requests-limit: 5
cooldown:
default-days: 7

- package-ecosystem: github-actions
directory: "/"
schedule:
interval: monthly
open-pull-requests-limit: 5
cooldown:
default-days: 7

- package-ecosystem: docker
directory: "/"
schedule:
interval: monthly
open-pull-requests-limit: 5
cooldown:
default-days: 7
58 changes: 52 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,65 @@ on:
branches: [main]

jobs:
test-fast:
# Fast feedback: unit tests and light orchestration tests only (`slow`
# deselected). Runs in parallel with `test` below rather than blocking
# on it, so a regression in the common-case code paths surfaces well
# before the slow, walk-forward-heavy suite finishes. No coverage gate
# here -- `test` already enforces it over the full suite; gating on
# this narrower subset alone would risk a spurious failure merely
# because the slow tests are the ones that exercise most of
# `quantlab.validation`.
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
version: "0.12.3"

- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}

- name: Install dependencies (locked)
run: >-
uv sync --locked --python ${{ matrix.python-version }}
--extra dev --extra dashboard --extra yahoo --extra docs
--extra notebooks

- name: Lint (ruff)
run: |
uv run ruff check src tests scripts
uv run ruff format --check src tests scripts

- name: Type-check (mypy)
run: uv run mypy src tests scripts

- name: Test (fast offline suite)
run: uv run pytest -m "not network and not slow"

test:
# Exercise both supported Python versions on Linux and Windows.
# The authoritative, coverage-gated run: the full offline suite,
# `slow` included (real multi-fold walk-forward selection,
# multi-invocation CLI scenarios, ...). Exercise both supported Python
# versions on Linux and Windows.
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Install uv
uses: astral-sh/setup-uv@v10.0.0
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
version: "0.12.3"

Expand All @@ -43,7 +89,7 @@ jobs:
- name: Type-check (mypy)
run: uv run mypy src tests scripts

- name: Test (offline suite, with coverage)
- name: Test (full offline suite, with coverage)
run: >-
uv run pytest -m "not network" --cov=quantlab
--cov-report=term-missing --cov-report=xml --cov-fail-under=82
Expand All @@ -59,10 +105,10 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Install uv
uses: astral-sh/setup-uv@v10.0.0
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
version: "0.12.3"

Expand Down
15 changes: 9 additions & 6 deletions .github/workflows/semgrep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ on:
branches:
- main
- master
paths:
- .github/workflows/semgrep.yml
schedule:
# random HH:MM to avoid a load spike on GitHub Actions at 00:00
- cron: 34 7 * * *
Expand All @@ -17,10 +15,15 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v6
- run: semgrep ci
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
# Standalone mode (no Semgrep AppSec Platform connection, no
# SEMGREP_APP_TOKEN): `semgrep ci` requires that token to actually be
# configured as a repo secret, and fails outright for a fork PR or a
# Dependabot PR either way (GitHub withholds secrets from both for
# security). `p/ci` is the same ruleset pre-commit's own semgrep hook
# already runs locally (see .pre-commit-config.yaml), so CI enforces
# exactly what a contributor's pre-commit run already checked.
- run: semgrep scan --config p/ci --error --skip-unknown-extensions .
20 changes: 19 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ repos:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
# mkdocs.yml uses MkDocs' own YAML tags (e.g. !!python/name:... for
# markdown extension config) that a plain, generic YAML loader
# can't construct -- check-yaml's default safe_load rejects it even
# though the file is valid MkDocs config; `mkdocs build --strict`
# already validates it with the loader that actually understands
# those tags.
exclude: ^mkdocs\.yml$
- id: check-toml
- id: check-added-large-files
args: ["--maxkb=1024"]
Expand All @@ -15,12 +22,23 @@ repos:

- repo: https://github.com/astral-sh/ruff-pre-commit
# Keep aligned with the Ruff version pinned in uv.lock and used by CI.
rev: v0.16.1
rev: v0.16.2
hooks:
- id: ruff
args: ["--fix"]
- id: ruff-format

- repo: https://github.com/semgrep/pre-commit
rev: v1.173.0
hooks:
- id: semgrep
# p/ci is the Semgrep-recommended starting ruleset for pre-commit/CI:
# broader packs like p/security-audit surface more findings but also
# more noise. --error makes matches fail the hook instead of only
# warning; --skip-unknown-extensions leaves non-code files (YAML
# configs, notebooks) alone.
args: ["--config", "p/ci", "--error", "--skip-unknown-extensions"]

- repo: local
hooks:
- id: mypy
Expand Down
58 changes: 50 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ sensitivity and bootstrap.
> results.

The goal is **not** to claim a profitable strategy. It is to demonstrate a
rigorous, reproducible research process with no information leakage.
rigorous, reproducible research process, designed to prevent common
look-ahead leakage through delayed execution. Custom strategies remain
responsible for causal feature and signal construction.

---

Expand All @@ -27,9 +29,12 @@ rigorous, reproducible research process with no information leakage.
interface.
- **Realistic costs** — explicit commission, spread and (constant or
volume-based) slippage; every result reports **gross vs net**.
- **Look-ahead-safe engine** — signals are strictly shifted before returns; the
separation between *signal at t*, *position at t+1* and *realised return* is
enforced and unit-tested.
- **Delayed-execution barrier** — signals are strictly shifted before returns;
the separation between *signal at t*, *position at t+1* and *realised
return* is enforced and unit-tested. This prevents the common look-ahead
leak of acting on a signal the same period it was formed — a custom
strategy that reads future rows directly remains responsible for its own
causal construction.
- **Risk analytics** — Sharpe, Sortino, Calmar, max drawdown, VaR/CVaR,
exposures, benchmark alpha/beta, and more, implemented from first principles.
- **Walk-forward validation** — expanding/rolling windows, parameter selection
Expand Down Expand Up @@ -141,14 +146,39 @@ already covers the requested period.
## Command-line interface

```bash
quantlab download --config configs/momentum_sp500.yaml
quantlab backtest --config configs/momentum_sp500.yaml
quantlab walk-forward --config configs/momentum_sp500.yaml
quantlab report --experiment cross_sectional_momentum_etfs
quantlab download --config configs/momentum_sp500.yaml
quantlab backtest --config configs/momentum_sp500.yaml
quantlab walk-forward --config configs/momentum_sp500.yaml
quantlab stress-test --config configs/momentum_sp500.yaml
quantlab bootstrap --config configs/momentum_sp500.yaml
quantlab permutation-test --config configs/momentum_sp500.yaml
quantlab sensitivity --config configs/momentum_sp500.yaml
quantlab robustness --config configs/momentum_sp500.yaml
quantlab report --experiment cross_sectional_momentum_etfs
quantlab dashboard
quantlab --help
```

`stress-test`/`bootstrap`/`permutation-test`/`sensitivity` each run one
robustness technique (with a matching `--n-iterations`/`--block-size`/
`--param-x` etc. override); `robustness` runs every technique enabled under
a config's `robustness:` block in one pass. All five branch on
`validation.method`: with `walk_forward`, each starts from the same
walk-forward-stitched out-of-sample result rather than a single backtest, so
the evidence never silently comes from a different validation method than
the one configured. `stress-test` and `sensitivity` re-run the whole
walk-forward selection process per scenario/candidate, since each one
represents a different cost/methodology assumption or parameter to
re-optimise under. `bootstrap` and `permutation-test` do not: they resample
or permute the walk-forward's already-realised out-of-sample return series
statistically, without re-running the selection process itself.

`walk-forward`, `stress-test`, `sensitivity` and `robustness` show a live
progress bar with an ETA in the terminal, and checkpoint their progress to
disk as they go — an interruption (Ctrl+C, a crash, closing the terminal)
resumes automatically on the next matching run instead of starting over.
Pass `--fresh` to discard a checkpoint and start clean.

Each backtest, walk-forward or report run writes a structured artefact
folder under the generated-reports directory. In a source checkout this is
`reports/generated/<experiment>/`; after a regular package installation it is
Expand Down Expand Up @@ -183,6 +213,18 @@ Regenerate them (after `quantlab download` for each config) with
streamlit run src/quantlab/dashboard/app.py
```

A **Backtest** / **Walk-forward** mode switch sits above the sidebar.
Walk-forward mode runs the same train/validation/test parameter selection as
`quantlab walk-forward`, with its own sidebar (windows, expanding mode,
optimization metric, parameter-grid picker) and Results/Trades/Robustness/
Report tabs built from the stitched out-of-sample result, driven by a live
progress bar with an ETA while a run is in flight. Both modes' Robustness
tab includes stress tests, block bootstrap, a Monte Carlo permutation test
and a 2-parameter sensitivity heatmap,
individually or via "Run all robustness tests" — in Walk-forward mode,
stress tests and sensitivity re-run the whole selection process per
scenario/cell rather than a single backtest.

![QuantLab dashboard results](reports/figures/dashboard_results.png)

<details>
Expand Down
29 changes: 25 additions & 4 deletions configs/btc_trend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
experiment_name: btc_trend_following

data:
source: binance
symbols:
- BTCUSDT
instruments:
- symbol: BTCUSDT
source: binance
calendar: "24/7"
start_date: "2018-01-01"
end_date: "2025-12-31"
frequency: "1d"
Expand Down Expand Up @@ -34,7 +35,10 @@ execution:

backtest:
initial_capital: 100000
benchmark_symbol: BTCUSDT
benchmark:
symbol: BTCUSDT
source: binance
calendar: "24/7"
risk_free_rate: 0.0
periods_per_year: 365 # crypto trades every calendar day

Expand All @@ -45,3 +49,20 @@ validation:

reproducibility:
random_seed: 42

# `quantlab stress-test|bootstrap|permutation-test|sensitivity --config
# configs/btc_trend.yaml` each run one technique regardless of `enabled`
# below (only the `robustness` orchestrator reads it). validation.method is
# holdout above, so every technique here evaluates a single plain backtest.
robustness:
stress_test:
enabled: true
bootstrap:
enabled: true
permutation_test:
enabled: true
sensitivity:
enabled: true
parameters:
fast_window: [10, 20, 40]
slow_window: [50, 100, 200]
31 changes: 25 additions & 6 deletions configs/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
experiment_name: default_buy_and_hold

data:
source: yahoo
symbols:
- SPY
- QQQ
instruments:
- symbol: SPY
source: yahoo
calendar: XNYS
- symbol: QQQ
source: yahoo
calendar: XNYS
start_date: "2015-01-01"
end_date: "2024-12-31"
frequency: "1d"
Expand All @@ -31,8 +34,12 @@ backtest:
initial_capital: 100000
# symbol | equal_weight | first_asset | cash
benchmark_kind: symbol
# Used only by the symbol benchmark.
benchmark_symbol: SPY
# Used only by the symbol benchmark. Matches the SPY instrument above
# exactly (same source/calendar), so its already-loaded data is reused.
benchmark:
symbol: SPY
source: yahoo
calendar: XNYS
risk_free_rate: 0.0
periods_per_year: 252

Expand All @@ -43,3 +50,15 @@ validation:

reproducibility:
random_seed: 42

# `quantlab stress-test|bootstrap|permutation-test --config
# configs/default.yaml` each run regardless of `enabled` below (only the
# `robustness` orchestrator reads it). No `sensitivity` block: buy_and_hold
# has no strategy parameters to sweep.
robustness:
stress_test:
enabled: true
bootstrap:
enabled: true
permutation_test:
enabled: true
Loading
Loading