Tax-aware backtesting for German investors. A Python framework where the German tax treatment of your gains is part of the simulation — and point-in-time signals are turned into concrete, plan-only broker order sheets for Trade Republic and Maxblue / Deutsche Bank.
Generic backtesters (backtrader, vectorbt, zipline) optimise gross or lightly-taxed returns.
For a German retail investor that overstates what actually reaches the depot. Kontor
models the things that decide your net outcome, per trade, so a net_liquidation result reflects
real after-tax money:
- German capital-gains tax logic — Abgeltungssteuer 26.375 % (25 % + 5.5 % Solidaritätszuschlag), Teilfreistellung (30 % partial exemption for equity funds), Freistellungsauftrag (Sparer-Pauschbetrag), and two loss pots per §20 Abs. 6 EStG (Aktienverlusttopf + allgemeiner Verlusttopf), on FIFO lot accounting.
- Live-order derivation — a point-in-time signal engine that emits broker-specific, plan-only order sheets for Trade Republic and Maxblue (dry-run / CSV plans; no live order submission by design).
- Survivorship-bias-free — strategies run on point-in-time index constituents, i.e. the universe as it actually was on each date.
- Data-integrity guards — a price is a (value, session date, source) triple, and the date has to be evidenced by the source. Stale prices don't raise errors; they produce confident wrong answers. See Data Integrity.
Scope & honesty: this is a research and decision-support tool, not investment advice. The tax model deliberately excludes Vorabpauschale, Kirchensteuer, and the derivatives loss pot (see
src/backtest/tax/de_tax_model.pyanddocs/german-tax-model.md). What the framework does not do is listed under Limitations — read it before you trust a number.
What's new: Kontor is developed in a private research repository and published here in curated batches. See the changelog for what each batch changed, in plain language.
# Clone and install
git clone https://github.com/Xindaan/kontor.git
cd kontor
poetry install # or: python -m pip install -e .Optional extras. The core install is deliberately lean; anything with a heavy or rarely-needed dependency lives in an optional group. Install what you need:
| Group | Command | Needed for |
|---|---|---|
web |
poetry install --with web |
the web UI (backtest web) — required for Option A below |
ml |
poetry install --with ml |
ML forecast tilt, regime classifier (lightgbm, xgboost, scikit-learn) |
sentiment |
poetry install --with sentiment |
--news-engine vader (nltk; also run python -m nltk.downloader vader_lexicon once) |
sentiment-finbert |
poetry install --with sentiment-finbert |
--news-engine finbert (pulls torch + transformers, a large download) |
Without its group, a command exits with an explicit install hint rather than a
stack trace — an Error: ... dependencies not installed message means you are
missing an extra, not that the install is broken.
If
pipisn't on your PATH, usepython -m pip ...instead.
Note: If you installed with Poetry (without
pip install -e .), run commands as
poetry run backtest ...instead ofbacktest ....
If
backtestis still “command not found”: it means the console script isn’t on yourPATH. Try one of these:
poetry run backtest web(if you used Poetry)python -m backtest.cli web(uses the module directly)- Ensure your Python
bindirectory is onPATH(wherepython -m pip install -e .put the script)
Option A: Web UI (Recommended)
poetry install --with web # once — the web deps are an optional group
poetry run backtest web
# Opens at http://localhost:8000Then select a strategy, set dates, and click "Run Backtest".
Option B: Command Line
# Run a simple strategy
poetry run backtest run strategies/dual_momentum.py --start 2015-01-01
# Compare multiple strategies
poetry run backtest compare strategies/dual_momentum.py strategies/classic_60_40.py
# View key metrics only
poetry run backtest metrics strategies/dual_momentum.pyProblem: Using today's S&P 500 stocks for a 2010 backtest is cheating - you're selecting winners with hindsight.
Solution: Use Point-in-Time (PIT) strategies:
# CORRECT: PIT strategies use historical constituent data
backtest run strategies/momentum_topn_pit_sp500.py --start 2010-01-01
# ALSO CORRECT: ETF-based strategies have no stock selection bias
backtest run strategies/sector_rotation_momentum.py --start 2010-01-01
backtest run strategies/dual_momentum.py --start 2010-01-01Generate PIT data first:
# Download historical S&P 500 constituents
python scripts/download_pit_data.py --index sp500
# Download NASDAQ 100 constituents (requires: pip install git+https://github.com/jmccarrell/n100tickers.git)
python scripts/download_pit_data.py --index nasdaq100Problem: Buy & Hold pays 0% realized tax (never sells), making it look better than it is.
Solution: Use --liquidate-at-end to simulate selling at the end:
# Fair comparison - all strategies show realistic tax impact
backtest compare strategies/*.py --liquidate-at-end
# Robustness testing with fair tax
backtest sweep strategies/*.py --window 10y --liquidate-at-endProblem: A strategy that worked 2010-2020 might fail 2015-2025.
Solution: Use rolling window analysis:
# Test with 10-year rolling windows
backtest sweep strategies/dual_momentum.py --window 10y
# Check consistency across many start dates
backtest sweep strategies/*.py --window 5y --start-grid monthlyProblem: Optimized parameters might be overfit to your test period.
Solution: Use walk-forward optimization:
backtest optimize strategies/dual_momentum.py \
--walk-forward \
--train-years 5 \
--test-years 1 \
--param lookback_days=126,189,252
# Good: Degradation < 25%
# Bad: Degradation > 50% (likely overfit)For stronger robustness checks, use nested walk-forward:
backtest optimize strategies/dual_momentum.py \
--walk-forward-nested \
--train-years 5 --test-years 1 \
--inner-train-years 3 --inner-test-years 1 \
--param lookback_days=126,189,252Nested mode reports IS/OOS degradation, parameter drift, and a PASS/FAIL robustness gate.
# Start the web interface
backtest web --port 8000The Web UI provides all backtesting features with an interactive interface:
| Page | Description |
|---|---|
| Run | Single strategy backtest with parameter customization |
| Compare | Side-by-side strategy comparison with charts |
| Sweep | Rolling window robustness analysis |
| Optimize | Parameter optimization with grid search or walk-forward |
| Batch Optimize | Optimize multiple strategies, ranked by metric |
| Signals | Live signals, order sizing, drift reconciliation, meta decision, bootstrap start choice |
| Manual Data | Manual data provenance registry and verification |
# Step 1: Optimize all strategies
backtest batch-optimize "strategies/[!_]*.py" --out results/opt
# Step 2: Compare with robustness testing
backtest sweep "strategies/[!_]*.py" \
--params-file results/opt/optimized_params.py \
--window 10y \
--liquidate-at-end
# Step 3: Review results
open results/sweep_*/report.html# Step 1: Backtest with optimal parameters
backtest run strategies/momentum_topn_pit_sp500.py \
--param top_n=10 --param lookback_days=189 \
--start 2010-01-01
# Step 2: Generate today's signals
backtest signals strategies/momentum_topn_pit_sp500.py \
--param top_n=10 --param lookback_days=189
# Step 3: Compare with your current portfolio
backtest signals strategies/momentum_topn_pit_sp500.py \
--portfolio portfolio.json \
--output signals.json
# Step 4: Register manual input provenance (e.g., SeekingAlpha export)
backtest data provenance add data/manual/seekingalpha/factors_2026-02-06.csv \
--dataset fundamentals_sp500 \
--seekingalpha \
--as-of-date 2026-02-06The CSV path in Step 4 is your own export — no such file ships with the repo.
Portfolio JSON format:
{
"positions": {"AAPL": 50, "MSFT": 30, "GOOGL": 20},
"cash": 5000.00,
"last_rebalance": "2025-12-31"
}You have to create this file yourself. It describes your holdings, so no portfolio JSON ships with the repository —
data/manual/contains onlyprovenance.json. Wherever the examples below say--portfolio data/manual/portfolio.json, substitute the path to the file you wrote. The same goes for the other example inputs (meta_candidates.json, the SeekingAlpha CSV,results/signals_report.json): they are placeholders for your own data or the output of an earlier step, not files you will find after cloning.
# Find best lookback period
backtest optimize strategies/dual_momentum.py \
--param lookback_days=126,189,252,315 \
--metric sharpe_ratio
# Multi-parameter optimization
backtest optimize strategies/volatility_targeting.py \
--param target_vol=0.10,0.12,0.15,0.20 \
--param lookback_days=20,40,60 \
--metric sharpe_ratio \
--output optimization.csv# Step 1: Choose a neutral start strategy
backtest meta-bootstrap \
--strategy-a strategies/levered_etf_momentum_sticky_adaptive_v2.py \
--strategy-b strategies/buy_and_hold.py \
--b-param allocation='{"SPY": 1.0}' \
--profile ausgewogen
# Step 2: Generate live signals with a challenger
# Candidates with params go into a JSON file, for example:
# [{"strategy":"strategies/buy_and_hold.py","params":{"allocation":{"SPY":1.0}}}]
backtest signals strategies/levered_etf_momentum_sticky_adaptive_v2.py \
--exposure-policy-enable \
--exposure-policy-profile us \
--meta-enable \
--meta-candidate strategies/buy_and_hold.py \
--meta-candidates-file meta_candidates.json \
--meta-evidence-required \
--meta-evidence-profile ausgewogen \
--meta-regime-mode strategy_fragility
# Step 3: Refresh / inspect the OOS evidence artifact
backtest meta-evidence \
--current-strategy strategies/levered_etf_momentum_sticky_adaptive_v2.py \
--target-strategy strategies/buy_and_hold.py \
--target-param allocation='{"SPY": 1.0}' \
--profile ausgewogenSignals now expose the same workflow in the UI:
- Meta Bootstrap (Startwahl) decides where to start.
- Meta Decision & Evidence decides whether a live switch is allowed.
- Exposure Policy keeps the strategy target intact but can de-lever 3x execution to a mapped 1x/Core fallback when fixed shock gates fire.
- The report shows gate-by-gate
switch_checksincluding aNeeds nexthint for blocked switches.
Each strategy carries a tier prefix in its name that shows its status at a glance. The sort order in the frontend follows these tiers (validated winners at the top, experimental at the bottom):
| Prefix | Meaning | Examples |
|---|---|---|
[Production] |
The framework's default reference strategy. | Levered ETF Momentum (Sticky Winner) |
[Pilot] |
Candidates with a serious promotion prospect. | Sticky Levered + Vol-Targeting, Cascade |
[Research] |
Legitimate alternatives, but not a default. | Sector-Aware VolTarget, Adaptive V2, Tax-Switch, Regime Vol Gate |
[Benchmark] |
Classic references — 60/40, All Weather, Dual Momentum, Trend Following, Risk Parity, Buy & Hold. | Buy & Hold, 60/40, All Weather |
[Experimental] |
Only with the greatest caution — extreme leverage / fragile data. | Levered 5x Momentum Guard |
Hidden from the frontend (CLI-only): the external-feature demos
(ml_forecast_tilt, sentiment_tilt, analyst_momentum_filter).
| Strategy | File | Description | Best For |
|---|---|---|---|
| Dual Momentum | dual_momentum.py |
Classic Antonacci dual momentum | Simplicity, low turnover |
| Sector Rotation | sector_rotation_momentum.py |
Top 3 S&P sectors by momentum | Sector exposure |
| PIT S&P 500 Momentum | momentum_topn_pit_sp500.py |
Top-N stocks with historical data | Fair stock momentum |
| PIT NASDAQ 100 | momentum_topn_pit_nasdaq100.py |
Top-N from NASDAQ 100 | Tech-focused momentum |
| PIT Russell 2000 | momentum_topn_pit_russell2000.py |
Small-cap momentum | Small-cap exposure |
| Factor VQM (PIT) | factor_value_quality_momentum.py |
Value + quality + momentum with PIT fundamentals | Factor investing |
| 60/40 Portfolio | classic_60_40.py |
Classic balanced allocation | Low volatility |
| Volatility Targeting | volatility_targeting.py |
Dynamic allocation by vol | Risk control |
ETF-Based (No PIT needed):
# Momentum / Trend
backtest run strategies/dual_momentum.py
backtest run strategies/sector_rotation_momentum.py
backtest run strategies/trend_following_sma.py
# Volatility Management
backtest run strategies/volatility_targeting.py
backtest run strategies/sticky_levered_vol_targeted.py
# Risk Parity / Balanced
backtest run strategies/inverse_vol_risk_parity.py
backtest run strategies/all_weather.py
backtest run strategies/classic_60_40.py
# Drawdown Protection (levered momentum with a trend and crash guard)
backtest run strategies/levered_momentum_crash_guard.pyStock-Picking (Requires PIT data):
# Generate PIT data first
python scripts/download_pit_data.py --index sp500
# Then run strategies
backtest run strategies/momentum_topn_pit_sp500.py --start 2004-01-01Note: the repo ships example CSVs in
data/fundamentals/(AAPL, MSFT) for the fundamentals loader. For real backtests, provide fundamentals data for every ticker in the universe.
The Signals UI is intentionally curated around the currently relevant live / leveraged strategies instead of showing every historical file.
| Strategy | File | Role |
|---|---|---|
| Buy & Hold | buy_and_hold.py |
Unlevered benchmark / robust core |
| 3x Buy & Hold | 3x_bh.py |
Levered benchmark |
| 60/40, All Weather, Dual Momentum, SMA Trend Filter, Volatility Targeting, Inverse-Vol Risk Parity, Sector Rotation, Momentum Top-N (PIT) | see strategies/ |
Classic reference strategies |
| Sticky Winner | levered_etf_momentum_sticky.py |
Core levered sticky baseline (TQQQ / UPRO / SOXL / TECL, safe: SPY) |
| Sticky Levered + Vol-Targeting | sticky_levered_vol_targeted.py |
Baseline + vol-targeting overlay — the strongest full-cycle result |
| Sticky Levered + Cascade | sticky_levered_cascade.py |
Multi-3x allocation (single_pick / cascade / inverse_vol) |
| Sticky Adaptive V2 | levered_etf_momentum_sticky_adaptive_v2.py |
Adaptive research variant |
| Crash Guard LBH Challenger | levered_momentum_crash_guard_lbh_challenger.py |
Alternative challenger with a different regime path |
| 5x Momentum Guard | levered_5x_momentum_guard.py |
Experimental real-ticker 5x family |
Notes:
levered_etf_momentum_sticky.pyis the Sticky/Core reference baseline. The leveraged sleeves default to the liquid US 3x ETFs, which carry the long history a meaningful backtest needs.- If you trade UCITS ETPs at a European broker instead, keep the US tickers as
the signal universe and map them to your broker's tradable line via
instrument_mapping(see Live Order Derivation). - Adaptive V2 remains available for research and explicit meta/evidence comparisons, but should not be treated as a default until it beats the Sticky/Core baseline on the same data, costs, and OOS evidence.
Robust Sticky/Core defaults from the 2026-04-13 rolling-window optimization:
- US Sticky/Core:
lookback_days=63,switch_buffer=0.04,min_hold_periods=2,momentum_floor=0.0,baseline_drawdown_floor=-1.0. - Trade Republic Sticky/Core:
lookback_days=63,switch_buffer=0.06,min_hold_periods=1,momentum_floor=-0.05,baseline_drawdown_floor=-1.0. - Maxblue Sticky/Core:
lookback_days=63,switch_buffer=0.06,min_hold_periods=1,momentum_floor=-0.05,baseline_drawdown_floor=-1.0. - These defaults come from a rolling-window robustness optimization (many start dates, degradation-checked), not a single-period fit.
Sticky Levered + Vol-Targeting is the first overlay that robustly beats the plain
Sticky-TR baseline: a weekly vol-target overlay scales the 3x position by
clip(0.40 / 20d-vol, 0, 1) and parks the remainder in SXR8.DE. Backtest
2016-2024 (full backtester incl. German tax): +7.8pp CAGR, +29 % Sharpe,
-11.7pp MaxDD vs plain Sticky Levered. It must be run weekly
(--rebalance-frequency weekly), otherwise the overlay does not engage.
Broker compatibility: the Trade Republic and Maxblue strategy variants encode
which instruments each broker can actually trade (e.g. Maxblue cannot trade
3SEM.L, so it routes via A2QC5J / VVSM.DE). See
docs/live-order-derivation.md.
# Default port 8000
backtest web
# Custom port
backtest web --port 8080
# Open browser automatically
backtest web --open| Page | URL | Description |
|---|---|---|
| Home | / |
Dashboard with quick links |
| Run | /run |
Single strategy backtest |
| Compare | /compare |
Multi-strategy comparison |
| Sweep | /sweep |
Rolling window robustness analysis |
| Optimize | /optimize |
Parameter grid search & walk-forward |
| Batch Optimize | /batch-optimize |
Multi-strategy optimization |
| Signals | /signals |
Live signals, portfolio drift, meta decision, evidence tuning, bootstrap start decision |
| Meta-Playbook | /playbook |
Strategy promotion governance: baseline vs. candidates, rolling 3Y/5Y, broker-mapping audit, past artifacts + markdown preview |
| Manual Data | /manual-data |
Manual data provenance registry / verification |
- Refined Financial Light Theme: Custom design system with Inter + JetBrains Mono fonts, consistent color palette, KPI cards with positive/negative indicators
- Dynamic Parameter Forms: Strategy parameters are auto-detected and rendered as form fields with collapsible advanced options
- Real-time Results: HTMX-powered updates without page reload, progress bars with ETA
- Interactive Charts: Plotly equity curves, heatmaps, and box plots with unified theme
- Strategy Comparison: Sortable table with best-value highlighting (green background on best metric per column)
- Tax Integration: Full German tax model support with all metric bases (gross, net realized, net liquidation)
- Live Trading Signals: Signal generation with BUY/SELL/HOLD badges, order proposals, and drift reconciliation
- Meta Bootstrap: Bilateral OOS evidence check to choose a neutral start strategy
- Meta Decision & Evidence: Evidence-gated strategy switching with regime-aware defaults
- Meta-Playbook (Strategy Promotion Governance): Reproducible audit artifact for quarterly reviews — baseline vs. candidates, rolling 3Y/5Y win-rates, broker-mapping check (TR/Maxblue), and an optional SOXL-proxy mode for long-history research. The artifact browser shows past reports with a Markdown preview.
- Switch Checks: Gate-by-gate visibility for score, confirmation, cadence, evidence, and conditioned evidence, including a “Needs next” hint for blocked switches
- Broker-Aware Strategy Set: Curated Signals dropdown with dedicated Trade Republic and Maxblue variants
- Manual Data Provenance: Register, inspect, and verify imported manual datasets (for example SeekingAlpha exports)
- Responsive Design: Works on desktop and tablet with scroll indicators on wide tables
The Web UI exposes a JSON API for programmatic access:
# List available strategies
curl http://localhost:8000/api/v1/strategies
# Run a backtest
curl -X POST http://localhost:8000/api/v1/run \
-H "Content-Type: application/json" \
-d '{
"strategy": "strategies/dual_momentum.py",
"start_date": "2015-01-01",
"initial_capital": 10000
}'
# Compare strategies
curl -X POST http://localhost:8000/api/v1/compare \
-H "Content-Type: application/json" \
-d '{
"strategies": [
{"strategy": "strategies/dual_momentum.py"},
{"strategy": "strategies/classic_60_40.py"}
],
"start_date": "2015-01-01"
}'
# Batch optimize
curl -X POST http://localhost:8000/api/v1/batch-optimize \
-H "Content-Type: application/json" \
-d '{
"strategies": ["dual_momentum.py", "volatility_targeting.py"],
"metric": "sharpe_ratio",
"start_date": "2015-01-01"
}'
# Live signals with meta decision
curl -X POST http://localhost:8000/api/v1/signals \
-H "Content-Type: application/json" \
-d '{
"strategy": "strategies/levered_etf_momentum_sticky_adaptive_v2.py",
"meta_decision": {
"enabled": true,
"candidates": [
{
"strategy": "strategies/buy_and_hold.py",
"params": {"allocation": {"SPY": 1.0}}
}
],
"evidence_required": true,
"evidence_profile": "ausgewogen",
"regime_mode": "strategy_fragility"
}
}'
# Explicit meta evidence refresh
curl -X POST http://localhost:8000/api/v1/meta-evidence/run \
-H "Content-Type: application/json" \
-d '{
"current_strategy": "strategies/levered_etf_momentum_sticky_adaptive_v2.py",
"target_strategy": "strategies/buy_and_hold.py",
"target_params": {"allocation": {"SPY": 1.0}},
"evidence_profile": "ausgewogen"
}'
# Bootstrap start decision
curl -X POST http://localhost:8000/api/v1/meta-bootstrap/run \
-H "Content-Type: application/json" \
-d '{
"strategy_a": "strategies/levered_etf_momentum_sticky_adaptive_v2.py",
"strategy_b": "strategies/buy_and_hold.py",
"strategy_b_params": {"allocation": {"SPY": 1.0}},
"evidence_profile": "ausgewogen"
}'Paths in the JSON API can be absolute or workspace-relative. In the Web UI, strategy selection is guided by dropdowns instead of requiring you to type these paths manually.
Full API Reference
| Endpoint | Method | Description |
|---|---|---|
/api/v1/strategies |
GET | List all available strategies |
/api/v1/strategies/{name}/schema |
GET | Get parameter schema for a strategy |
/api/v1/run |
POST | Run single backtest |
/api/v1/compare |
POST | Compare multiple strategies |
/api/v1/sweep |
POST | Run sweep analysis |
/api/v1/optimize |
POST | Run parameter optimization |
/api/v1/batch-optimize |
POST | Optimize multiple strategies |
/api/v1/signals |
POST | Generate live signals and optional meta decision |
/api/v1/meta-evidence/run |
POST | Build / refresh a switch evidence artifact |
/api/v1/meta-evidence/latest |
GET | Load the latest evidence artifact for a strategy pair |
/api/v1/meta-evidence/{artifact_id} |
GET | Load a specific evidence artifact |
/api/v1/meta-bootstrap/run |
POST | Bilateral bootstrap start decision between two strategies |
/api/v1/data/manual/provenance |
GET/POST | List or register manual provenance entries |
/api/v1/data/manual/provenance/{entry_id} |
GET | Load one provenance entry |
/api/v1/data/manual/provenance/verify |
GET | Verify registry entries against current files |
All commands below assume the backtest console script is installed (e.g. via pip install -e .).
If you use Poetry without installing, prefix commands with poetry run.
# Start web UI
backtest web [OPTIONS]
--port PORT Server port (default: 8000)
--host HOST Server host (default: 127.0.0.1)
--open Open browser automatically
# Run backtest
backtest run <strategy.py> [OPTIONS]
-s, --start DATE Start date (default: 2010-01-01)
-e, --end DATE End date (default: today)
-c, --capital FLOAT Initial capital (default: 10000)
-p, --param NAME=VALUE Override parameter (repeatable)
--cost-profile-file PATH JSON profile for per-ticker/asset-class costs
--execution-lag-days N Execution delay in trading days (0=same-day, 1=T+1)
--max-volume-participation X Max fraction of daily volume per trade (e.g., 0.1)
--min-daily-dollar-volume USD Skip trades below daily notional threshold
--no-tax Disable German tax model
--liquidate-at-end Include unrealized gains in tax
--drip Enable dividend reinvestment (DRIP)
--no-validate Suppress validation warnings
--skip-failed Skip tickers that fail to download (default: enabled)
--no-skip-failed Fail fast when ticker download fails
# Compare strategies
backtest compare <strategies...> [OPTIONS]
--cost-profile-file PATH JSON profile for per-ticker/asset-class costs
--execution-lag-days N Execution delay in trading days
--max-volume-participation X Max fraction of daily volume per trade
--liquidate-at-end Fair tax comparison
--allow-misaligned Allow different date ranges
--drip Enable dividend reinvestment
--no-validate Suppress validation warnings
# Robustness analysis
backtest sweep <strategies...> [OPTIONS]
--window LENGTH Window length: 3y, 5y, 10y (default: 10y)
--mode rolling|end-fixed Window mode (default: rolling)
--cost-profile-file PATH JSON profile for per-ticker/asset-class costs
--execution-lag-days N Execution delay in trading days
--max-volume-participation X Max fraction of daily volume per trade
--params-file PATH Apply optimized params JSON (from batch-optimize)
--jobs N Parallel execution
--drip Enable dividend reinvestment
--no-validate Suppress validation warnings
# Parameter optimization
backtest optimize <strategy.py> [OPTIONS]
-p, --param NAME=VAL1,VAL2 Parameter to optimize
-m, --metric METRIC Target metric (default: sharpe_ratio)
--cost-profile-file PATH JSON profile for per-ticker/asset-class costs
--execution-lag-days N Execution delay in trading days
--max-volume-participation X Max fraction of daily volume per trade
--walk-forward Enable walk-forward validation
--walk-forward-nested Enable nested walk-forward (inner + outer windows)
--train-years N Outer training years (default: 5)
--test-years N Outer test years (default: 1)
--inner-train-years N Inner training years for nested mode (default: 3)
--inner-test-years N Inner test years for nested mode (default: 1)
--drip Enable dividend reinvestment
# Live signals
backtest signals <strategy.py> [OPTIONS]
--portfolio PATH Compare with current holdings
--drift-tolerance FLOAT Weight drift tolerance (default: 0.005 = 0.5%)
-f, --format table|json|csv Output format
--exposure-policy-enable Enable optional 3x exposure controller
--exposure-policy-profile P trade_republic|tr|maxblue|us
--exposure-policy-file PATH JSON config for proxy/core mappings and shock thresholds
--meta-enable Enable evidence-gated meta decisioning
--meta-candidate PATH Add a challenger strategy (repeatable)
--meta-candidates-file PATH JSON list with candidates incl. params
--meta-evidence-required Hard gate: no switch without passing evidence
--meta-evidence-profile P defensiv|ausgewogen|aggressiv|custom
--meta-regime-mode MODE none|strategy_fragility
--meta-regime-profile P defensiv|ausgewogen|aggressiv|custom
--meta-confirm-points N Alpha confirmation count
--meta-switch-margin X Minimum score margin for alpha-driven switches
# Includes signal table + order proposals + drift reconciliation
# Meta evidence
backtest meta-evidence [OPTIONS]
--current-strategy PATH Current strategy
--target-strategy PATH Target strategy
--current-param NAME=VALUE Param for current strategy (repeatable)
--target-param NAME=VALUE Param for target strategy (repeatable)
--train-years N OOS train window
--test-years N OOS test window
--step-months N OOS step size
--profile P defensiv|ausgewogen|aggressiv|custom
# Meta bootstrap
backtest meta-bootstrap [OPTIONS]
--strategy-a PATH Strategy A
--strategy-b PATH Strategy B
--a-param NAME=VALUE Param for strategy A (repeatable)
--b-param NAME=VALUE Param for strategy B (repeatable)
--train-years N Bootstrap train window
--test-years N Bootstrap test window
--step-months N Bootstrap step size
--profile P defensiv|ausgewogen|aggressiv|custom
# Meta-Playbook (Strategy Promotion Governance)
# Reproducible audit artifact for quarterly promotion reviews:
# baseline vs. candidates, rolling 3Y/5Y, broker-mapping audit.
# Default: Sticky/Levered (baseline) vs. Vol-Targeting-Live + Cascade (candidates).
backtest meta-promotion [STRATEGIES...] [OPTIONS]
--baseline PATH Incumbent baseline (default: Sticky/Levered Live)
--soxl-proxy Long-history research mode: use SOXL as proxy
--start DATE Backtest start (default: 2016-01-01)
--end DATE Backtest end (default: today)
--capital FLOAT Initial capital EUR (default: 10000)
--costs FLOAT Transaction costs fraction (default: 0.001)
--metric-basis MODE gross|net_realized|net_liquidation (default: net_liquidation)
--no-tax Disable German tax model
--broker NAME Broker mapping audit (repeatable): trade_republic|maxblue
--output-dir PATH Output root (default: results/meta_promotion)
--align MODE intersection|ffill (default: ffill)
--skip-failed Skip tickers that fail to download
--no-validate Disable pre/post-run validation
# Artifacts land in results/meta_promotion/<YYYYMMDD>/<artifact_id>/{json,md}.
# Also available in the web UI under /playbook.
# Manual data provenance
backtest data provenance add <file> --dataset NAME --source NAME [OPTIONS]
backtest data provenance list [--dataset NAME] [--source NAME]
backtest data provenance show <entry_id>
backtest data provenance verify
# Optional: strict provenance gates for manual fundamentals
# (set as strategy params, works in CLI and WebUI strategy parameter form)
--param fundamentals_provenance_mode=strict
--param fundamentals_provenance_verify_hash=True
--param fundamentals_provenance_dataset=fundamentals_sp500
--param fundamentals_provenance_source=SeekingAlpha
# API keys: the vendor datasets read real environment variables. There is no
# .env loading (no python-dotenv), so export them in your shell — a .env file
# would be ignored:
# export FINNHUB_API_KEY=... # finnhub_analyst_*, finnhub_news
# export NEWSAPI_API_KEY=... # newsapi_news
# Everything marked mock_/synthetic_ below needs no key at all and is the way to
# try the pipeline without signing up anywhere.
# External features pipeline (analyst/news/ML)
backtest features pull --dataset mock_analyst --as-of 2026-05-01 [--tickers AAA,BBB]
backtest features list [--dataset NAME] [-f table|json]
backtest features verify [--dataset NAME] [-f table|json]
# Phase B analyst datasets (require explicit --tickers, except mock_analyst):
backtest features pull --dataset yahoo_analyst_current --as-of YYYY-MM-DD --tickers AAPL,MSFT
backtest features pull --dataset finnhub_analyst_current --as-of YYYY-MM-DD --tickers AAPL # FINNHUB_API_KEY
backtest features pull --dataset finnhub_analyst_actions --as-of YYYY-MM-DD --tickers AAPL # FINNHUB_API_KEY
backtest features pull --dataset synthetic_analyst_pit --as-of YYYY-MM-DD --tickers AAPL
# Phase C news datasets (Sentiment engine + intraday cutoff are PULL-TIME flags):
backtest features pull --dataset synthetic_news_pit --as-of YYYY-MM-DD --tickers AAPL,MSFT
backtest features pull --dataset yahoo_news --as-of TODAY --tickers AAPL # current-only
backtest features pull --dataset finnhub_news --as-of YYYY-MM-DD --tickers AAPL \
--news-engine vader --news-intraday-cutoff 17:00 # FINNHUB_API_KEY
backtest features pull --dataset newsapi_news --as-of YYYY-MM-DD --tickers AAPL \
--news-engine vader # NEWSAPI_API_KEY
# Activate external features on a run (default disabled, fully backward compatible)
backtest run strategies/analyst_momentum_filter.py \
--external-features-enable \
--external-features-dataset finnhub_analyst_actions \
--external-features-provenance-mode strict
# Same flag trio works on: run, compare, sweep, optimize, batch-optimize, signals
# Runtime news-score component (Phase C). News engine choice and the
# intraday cutoff live on `features pull`; runtime only consumes the
# stored scores:
backtest signals strategies/sentiment_tilt.py \
--external-features-enable --external-features-dataset synthetic_news_pit \
--news-dataset synthetic_news_pit --news-score-weight 0.2
# Phase D ML-Forecast: synthetic adapter works without a model bundle.
# Real adapters (`lightgbm_forecast`, `xgboost_forecast`,
# `ml_forecast_ensemble`) need a bundle trained via `backtest ml train`.
backtest features pull --dataset synthetic_ml_forecast --as-of YYYY-MM-DD \
--tickers AAPL,MSFT
backtest features pull --dataset lightgbm_forecast --as-of YYYY-MM-DD \
--tickers AAPL --ml-model-bundle data/external_features/ml/models/2024-12-31/lightgbm
# Train a bundle (requires `poetry install --with ml`):
backtest ml train --start 2018-01-01 --end 2024-12-31 \
--horizons 21,63,252 --models lightgbm --tickers AAPL,MSFT,GOOGL
# Use the ML forecast at runtime:
backtest signals strategies/ml_forecast_tilt.py \
--external-features-enable --external-features-dataset synthetic_ml_forecast \
--ml-dataset synthetic_ml_forecast --ml-score-weight 0.2
# Phase E1: cross-product consensus gate over analyst x news x ML.
# The switch gate keys on decision_bucket = current_regime_bucket.
backtest signals strategies/ml_forecast_tilt.py \
--external-features-enable --external-features-dataset synthetic_ml_forecast \
--ml-dataset synthetic_ml_forecast --ml-score-weight 0.2 \
--cross-product-require [--cross-product-threshold 0.5]
# Phase E4: Risk-Parity ERC im Strategy-Layer.
# strategies/ml_forecast_tilt.py akzeptiert weighting in {equal,
# inverse_vol, erc} plus optional target_sum, max_weight.
# Phase E2: execution-plan pipeline. PHASE E EMITS BROKER-READY
# ORDER PLANS, BUT NO REAL BROKER ORDERS. Real-money submission
# would be Phase F with its own safety spec.
backtest live plan --signals-report results/signals_report.json \
--broker dry_run --portfolio data/manual/portfolio.json
backtest live status --since 2026-06-01
backtest live reconcile --broker trade_republic_brief \
--portfolio data/manual/portfolio.json
# Verfuegbare Phase-E2-Broker (alle plan_only=True):
# dry_run, ibkr_basket_csv (TWS-CSV-Export ohne ib_insync),
# alpaca_paper_preview, trade_republic_brief, maxblue_briefClick to expand full CLI reference
backtest
├── web Start web interface
│ ├── --port PORT Server port (default: 8000)
│ ├── --host HOST Server host (default: 127.0.0.1)
│ └── --open Open browser automatically
│
├── run <strategy.py> Run single backtest
│ ├── -s, --start DATE Start date (default: 2010-01-01)
│ ├── -e, --end DATE End date (default: today)
│ ├── -c, --capital FLOAT Initial capital (default: 10000)
│ ├── -b, --benchmark NAME Benchmark name (default: S&P 500)
│ ├── --benchmark-ticker SYMBOL Override benchmark ticker
│ ├── --costs FLOAT Transaction costs (default: 0.001)
│ ├── --cost-profile-file PATH Per-ticker/asset-class cost profile JSON
│ ├── --execution-lag-days N Execution delay in trading days (0=same-day, 1=T+1)
│ ├── --t-plus-one Shortcut for --execution-lag-days 1
│ ├── --max-volume-participation X Max fraction of daily volume per trade
│ ├── --min-daily-dollar-volume USD Skip trades below daily notional threshold
│ ├── --liquidity-on-missing-volume M allow|skip
│ ├── --rebalance-frequency FREQ daily|weekly|monthly|quarterly|yearly
│ ├── --rebalance-min-deviation X No-trade band as a fraction (0.20 = 20pp; default: 0)
│ ├── --rebalance-weekday N Weekly anchor day, 0=Mon .. 4=Fri (default: last trading day)
│ ├── -p, --param NAME=VALUE Override strategy parameter (repeatable)
│ ├── -o, --output PATH Output file path
│ ├── -f, --format FORMAT html|json
│ ├── --no-tax Disable German tax model
│ ├── --tax-exemption FLOAT Freistellungsauftrag (default: 1000)
│ ├── --tax-treatment-file PATH Per-ticker tax treatment JSON (see German Tax Model)
│ ├── --metric-basis MODE gross|net_realized|net_liquidation
│ ├── --liquidate-at-end Alias for --metric-basis net_liquidation
│ ├── --drip Enable dividend reinvestment (DRIP)
│ ├── --no-validate Suppress validation warnings
│ ├── --skip-failed Skip failed ticker downloads (default: enabled)
│ ├── --no-skip-failed Fail fast when ticker download fails
│ └── --allow-universe-lookahead Allow non-PIT universe for historical backtests
│
├── compare <strategies...> Compare multiple strategies
│ ├── -s, --start DATE Start date
│ ├── -e, --end DATE End date
│ ├── -o, --output PATH Output file path
│ ├── --allow-misaligned Allow different date ranges
│ ├── --liquidate-at-end Fair tax comparison
│ ├── --cost-profile-file PATH Per-ticker/asset-class cost profile JSON
│ ├── --execution-lag-days N Execution delay in trading days
│ ├── --t-plus-one Shortcut for --execution-lag-days 1
│ ├── --max-volume-participation X Max fraction of daily volume per trade
│ ├── --min-daily-dollar-volume USD Skip trades below daily notional threshold
│ ├── --liquidity-on-missing-volume M allow|skip
│ ├── --drip Enable dividend reinvestment
│ ├── --no-validate Suppress validation warnings
│ └── --allow-universe-lookahead Allow non-PIT universe
│
├── sweep <strategies...> Robustness analysis (rolling windows)
│ ├── --mode rolling|end-fixed Window mode (default: rolling)
│ ├── --window LENGTH Window length: 3y, 5y, 10y, 15y
│ ├── --from DATE Minimum start date
│ ├── --to DATE Maximum start date
│ ├── --start-grid FREQ weekly|monthly|yearly (default: monthly)
│ ├── --params-file PATH Apply optimized params JSON (from batch-optimize)
│ ├── --cost-profile-file PATH Per-ticker/asset-class cost profile JSON
│ ├── --execution-lag-days N Execution delay in trading days
│ ├── --t-plus-one Shortcut for --execution-lag-days 1
│ ├── --max-volume-participation X Max fraction of daily volume per trade
│ ├── --min-daily-dollar-volume USD Skip trades below daily notional threshold
│ ├── --liquidity-on-missing-volume M allow|skip
│ ├── --jobs N Parallel jobs
│ ├── --liquidate-at-end Fair tax comparison
│ ├── --drip Enable dividend reinvestment
│ ├── --no-validate Suppress validation warnings
│ └── --out DIR Output directory
│
├── optimize <strategy.py> Parameter optimization
│ ├── -p, --param NAME=VAL1,VAL2,... Parameter to optimize (repeatable)
│ ├── -m, --metric METRIC Metric to optimize (default: sharpe_ratio)
│ ├── --minimize Minimize metric (e.g., max_drawdown)
│ ├── --cost-profile-file PATH Per-ticker/asset-class cost profile JSON
│ ├── --execution-lag-days N Execution delay in trading days
│ ├── --t-plus-one Shortcut for --execution-lag-days 1
│ ├── --max-volume-participation X Max fraction of daily volume per trade
│ ├── --min-daily-dollar-volume USD Skip trades below daily notional threshold
│ ├── --liquidity-on-missing-volume M allow|skip
│ ├── --walk-forward Enable walk-forward validation
│ ├── --walk-forward-nested Enable nested walk-forward validation
│ ├── --train-years N Training window years (default: 5)
│ ├── --test-years N Test window years (default: 1)
│ ├── --step-months N Outer window step in months (default: 12)
│ ├── --inner-train-years N Inner training years (nested mode, default: 3)
│ ├── --inner-test-years N Inner test years (nested mode, default: 1)
│ ├── --inner-step-months N Inner window step months (nested mode, default: 6)
│ ├── --inner-anchored Use anchored inner windows (nested mode)
│ ├── --drip Enable dividend reinvestment
│ ├── --no-validate Suppress validation warnings
│ └── -o, --output PATH Output CSV file
│
├── batch-optimize <strategies...> Optimize all strategies
│ ├── -m, --metric METRIC Metric to optimize
│ ├── --rebalance-frequency FREQS Frequencies to test
│ ├── --cost-profile-file PATH Per-ticker/asset-class cost profile JSON
│ ├── --execution-lag-days N Execution delay in trading days
│ ├── --t-plus-one Shortcut for --execution-lag-days 1
│ ├── --max-volume-participation X Max fraction of daily volume per trade
│ ├── --min-daily-dollar-volume USD Skip trades below daily notional threshold
│ ├── --liquidity-on-missing-volume M allow|skip
│ ├── --drip Enable dividend reinvestment
│ ├── --no-validate Suppress validation warnings
│ └── --out DIR Output directory
│
├── signals <strategy.py> Generate live trading signals
│ ├── -p, --param NAME=VALUE Strategy parameter
│ ├── --portfolio PATH Portfolio JSON for comparison
│ ├── -d, --date DATE Signal date (default: today)
│ ├── --drift-tolerance FLOAT Weight drift tolerance for reconciliation
│ ├── --meta-enable Enable evidence-gated meta switching
│ ├── --meta-candidate PATH Challenger strategy (repeatable)
│ ├── --meta-candidates-file PATH JSON list with candidates incl. params
│ ├── --meta-evidence-required Hard evidence gate
│ ├── --meta-regime-mode MODE none|strategy_fragility
│ ├── --meta-switch-margin FLOAT Minimum alpha score margin
│ └── -f, --format table|json|csv Output format
│
├── meta-evidence Build / refresh evidence artifact for a strategy pair
│ ├── --current-strategy PATH Current strategy path
│ ├── --target-strategy PATH Target strategy path
│ ├── --current-param NAME=VALUE Current strategy param (repeatable)
│ ├── --target-param NAME=VALUE Target strategy param (repeatable)
│ ├── --train-years N Train window in years
│ ├── --test-years N Test window in years
│ ├── --step-months N Step size in months
│ └── --profile PROFILE defensiv|ausgewogen|aggressiv|custom
│
├── meta-bootstrap Bilateral start decision between two strategies
│ ├── --strategy-a PATH Strategy A path
│ ├── --strategy-b PATH Strategy B path
│ ├── --a-param NAME=VALUE Param for strategy A (repeatable)
│ ├── --b-param NAME=VALUE Param for strategy B (repeatable)
│ ├── --train-years N Train window in years
│ ├── --test-years N Test window in years
│ ├── --step-months N Step size in months
│ └── --profile PROFILE defensiv|ausgewogen|aggressiv|custom
│
├── meta-promotion [STRATEGIES...] Strategy-Promotion-Governance-Report
│ ├── --baseline PATH Incumbent baseline (default: Sticky/Levered Live)
│ ├── --soxl-proxy Long-history research mode (SOXL as proxy)
│ ├── --start DATE Backtest start (default: 2016-01-01)
│ ├── --end DATE Backtest end (default: today)
│ ├── --capital FLOAT Initial capital EUR (default: 10000)
│ ├── --costs FLOAT Transaction costs (default: 0.001)
│ ├── --metric-basis MODE gross|net_realized|net_liquidation (default: net_liquidation)
│ ├── --no-tax Disable German tax model
│ ├── --broker NAME Broker mapping audit (repeatable): trade_republic|maxblue
│ ├── --output-dir PATH Output root (default: results/meta_promotion)
│ ├── --align MODE intersection|ffill (default: ffill)
│ ├── --skip-failed Skip tickers that fail to download
│ └── --no-validate Disable pre/post-run validation
│ Artefakte: results/meta_promotion/<YYYYMMDD>/<id>/{json,md}.
│ Parallel im Web-UI unter /playbook.
│
├── metrics <strategy.py> Quick metrics display
├── new <name> Generate strategy template
├── assets List available assets
├── data Data management
│ ├── download <tickers...> Download and cache data (one CSV per ticker, reused across date ranges)
│ ├── list List cached files
│ ├── clear Clear cache (all *.csv + coverage manifest)
│ └── provenance Manual data provenance registry
│ ├── add <file> Register source/import metadata
│ ├── list List provenance entries
│ ├── show <entry_id> Show a single entry
│ └── verify Verify file existence/checksums
│
└── features External features pipeline (analyst/news/ML)
├── pull Pull a snapshot from a registered adapter
│ ├── --dataset NAME Dataset id (e.g., mock_analyst)
│ ├── --as-of YYYY-MM-DD Snapshot as-of date
│ ├── --tickers AAA,BBB Required except for mock_analyst
│ ├── --root DIR Snapshot root (default: data/external_features/snapshots)
│ ├── --registry PATH Optional custom provenance registry
│ ├── --force Re-fetch even if cache exists
│ ├── --news-engine {mock,vader,finbert} Sentiment engine for news adapters
│ ├── --news-intraday-cutoff HH:MM UTC cutoff for headlines aggregated into the snapshot
│ ├── --ml-model-bundle DIR Phase D: override path to a specific ML bundle (default: latest with available_from<=as_of)
│ └── --ml-stacking-only Phase D: run only Stage-3 stacking head on cached OOF outputs
├── list List snapshot files and registry status
└── verify Validate snapshot schema and hashes
ml ML-Forecast training and bundle management (Phase D)
└── train Train a forecast bundle (walk-forward, label-purge OOF)
├── --start YYYY-MM-DD
├── --end YYYY-MM-DD
├── --horizons 21,63,252 Trading-day forward windows (Stage 3 stacks them)
├── --models lightgbm,xgboost Comma-separated families (requires --with ml extras)
├── --tickers AAA,BBB Required unless --universe-source is set (Codex D4)
├── --universe-source PATH PIT universe CSV (preferred — survivorship protection)
├── --output-dir DIR Bundle root (default: data/external_features/ml/models)
├── --inner-train-years N
├── --inner-test-months N
├── --grid-size N Hyperparameter grid size per inner fold
└── --seed N Manifest-pinned seed
Registered analyst datasets (Phase B / B+):
- mock_analyst synthetic test data; no --tickers required
- yahoo_analyst_current yfinance, current-only (hard-fail on hist as_of)
- finnhub_analyst_current Finnhub price_target + trends; FINNHUB_API_KEY required
- finnhub_analyst_actions Finnhub upgrade/downgrade events, PIT-safe aggregation
- finnhub_analyst_trends Finnhub recommendation_trends, PIT per period (~4M depth on free tier)
- synthetic_analyst_pit deterministic from prices; explicit --tickers required
Registered news datasets (Phase C):
- yahoo_news yfinance .news (current-only; hard-fail on hist as_of)
- finnhub_news Finnhub /company-news (~1y history; release_date=as_of)
- newsapi_news NewsAPI /everything (~1 month; 24h delay; 100 req/day)
- synthetic_news_pit deterministic from ticker+date; explicit --tickers required
Registered ml-forecast datasets (Phase D):
- lightgbm_forecast inference-only; resolves latest bundle with available_from <= as_of
- xgboost_forecast same; xgboost-trained Stage 1/2; ensemble pairs with lightgbm
- ml_forecast_ensemble stacked mean of lightgbm + xgboost bundles
- synthetic_ml_forecast deterministic from ticker+date; no model bundle required
External features flags available on run, compare, sweep, optimize, batch-optimize, signals:
--external-features-enable
--external-features-dataset NAME
--external-features-provenance-mode {off,warn,strict}
--external-features-root DIR
--news-dataset NAME Phase C: dataset id with news_sentiment_score
--news-score-weight FLOAT Phase C: blend news_score into meta-decision live_score
--ml-dataset NAME Phase D: dataset id with ml_forecast_score
--ml-score-weight FLOAT Phase D: blend ml_score into meta-decision live_score
--cross-product-require Phase E1: enforce cross-product consensus gate
--cross-product-threshold X Phase E1: override profile-default threshold
--external-features-registry PATH
Meta-decision analyst integration (Phase B):
REGIME_TOLERANCE_DEFAULTS["<profile>"]["analyst_score_weight"] = 0.0 # default off
run_meta_decision(... external_features_provider=..., analyst_score_weight=0.25,
analyst_datasets=("finnhub_analyst_actions",),
allow_synthetic_analyst_evidence=False)
-> analyst component only contributes when assess_analyst_evidence() PASSes;
FAIL/MISSING/STALE silently set effective_weight=0 and never abort the run.
Phase E commands:
backtest live plan --signals-report PATH --broker {dry_run,
ibkr_basket_csv, alpaca_paper_preview, trade_republic_brief,
maxblue_brief} [--portfolio PATH] [--new-run TOKEN]
backtest live status [--since YYYY-MM-DD] [--log-path PATH]
backtest live reconcile --broker NAME --portfolio PATH
Phase E vocabulary contract (Codex R2.1):
- NO `submit_order`/`placeOrder`/`cancel_order` calls in
src/backtest/live/ runtime code.
- NO `--live-execute` / `--execute` CLI flags. Phase F.
- All OrderPlanReceipts in Phase E have `plan_only=True`
(Codex R3.2).
Cross-Product-Evidence (Phase E1):
REGIME_TOLERANCE_DEFAULTS["<profile>"]["cross_product_consensus_threshold"]
= 0.7 (defensiv) / 0.5 (ausgewogen) / 0.3 (aggressiv)
run_meta_decision(... cross_product_require=True,
cross_product_threshold=None)
-> consensus[current_regime_bucket] >= threshold required;
live_scores unangetastet.
Meta-decision ML integration (Phase D):
REGIME_TOLERANCE_DEFAULTS["<profile>"]["ml_score_weight"] = 0.0 # default off
run_meta_decision(... external_features_provider=..., ml_score_weight=0.2,
ml_datasets=("lightgbm_forecast",),
allow_synthetic_ml_evidence=False,
ml_require_conditioned_evidence=False)
-> ML component only contributes when assess_ml_evidence() PASSes; snapshots
whose ml_available_from_ordinal > release_date.toordinal() are filtered
in the evidence engine (Codex D14/D18). Multi-Weight constraint:
w_analyst + w_news + w_ml must be < 1; ValueError otherwise.
backtest new "My Momentum"Creates strategies/my_momentum.py:
from datetime import date
import pandas as pd
from backtest.strategy import Strategy, Allocation
class MyMomentum(Strategy):
name = "My Momentum"
assets = ["SPY", "BND"] # Assets needed for this strategy
rebalance_frequency = "monthly"
def __init__(self, lookback: int = 252):
self.lookback = lookback
self.params = {"lookback": lookback}
def signal(self, current_date: date, data: pd.DataFrame) -> Allocation:
# Your strategy logic here
# data: DataFrame with price history, columns = tickers
spy_momentum = data["SPY"].iloc[-1] / data["SPY"].iloc[-self.lookback] - 1
if spy_momentum > 0:
return Allocation({"SPY": 1.0})
else:
return Allocation({"BND": 1.0})
# Required: instantiate for CLI
strategy = MyMomentum(lookback=252)from backtest.universe import CsvPITUniverseProvider
class MyPITStrategy(Strategy):
def __init__(self):
self._universe_provider = CsvPITUniverseProvider(
path="data/universes/sp500_constituents.csv",
date_col="as_of",
ticker_col="ticker",
)
def signal(self, current_date: date, data: pd.DataFrame) -> Allocation:
# Get universe AS OF the current backtest date (no lookahead)
snapshot = self._universe_provider.snapshot(current_date)
universe = snapshot.tickers # Only stocks that were in index at this date
# Your logic using the PIT universe...The framework implements German capital gains taxation (Abgeltungssteuer):
| Feature | Value |
|---|---|
| Tax Rate | 26.375% (25% + 5.5% Soli) |
| Teilfreistellung | 30% tax-free for equity funds |
| Freistellungsauftrag | 1,000 EUR (single) / 2,000 EUR (joint) |
| Cost Basis | FIFO (First In, First Out) — the only method implemented; cost_basis_method other than "FIFO" is rejected at construction rather than silently run as FIFO |
Two separate loss pots per §20 Abs. 6 EStG:
| Loss Pot | Applies To | Can Offset |
|---|---|---|
| Aktienverlusttopf | Losses from selling individual stocks | Only gains from individual stocks — and it is drawn first against them |
| Allgemeiner Verlusttopf | Losses from ETFs and funds (including equity ETFs, §20 Abs. 6 Satz 4 EStG), debt ETPs, bonds, certificates | Any capital income, including dividends |
The two axes that both hinge on the word "equity" are kept apart in one helper,
backtest.tax.tax_class(single_stock=..., equity_fund=...): an equity ETF gets the
30 % Teilfreistellung but its losses go to the general pot; an individual stock
goes to the equity pot without Teilfreistellung; a 3x debt ETP gets neither.
Tax is settled per event against a running calendar-year ledger, the way a
German broker does it: a later loss in the same year refunds tax withheld on an
earlier gain (a negative tax_due), and restores allowance that was already used.
Only losses that remain unabsorbed carry into the next year — there is no carryback.
Events must arrive in chronological order; editing an already settled year raises
instead of silently rewriting it.
Without further input every instrument is treated as an equity fund with 30 % Teilfreistellung — the legacy default, and too favorable for 3x leveraged ETPs, which are debt securities. Pass a JSON map to be exact:
# tax_treatment.json: ticker -> [instrument_class, is_equity_fund]
# {"TQQQ": ["general", false], "SPY": ["general", true], "AAPL": ["equity", false]}
backtest run strategies/sticky_levered_vol_targeted.py --tax-treatment-file tax_treatment.json["equity", true] is rejected: an individual stock is not a fund.
# Default: German tax enabled with 1000 EUR exemption
backtest run strategies/dual_momentum.py
# Joint filing (2000 EUR exemption)
backtest run strategies/dual_momentum.py --tax-exemption 2000
# Disable taxes
backtest run strategies/dual_momentum.py --no-tax
# Include unrealized gains (fair Buy&Hold comparison)
backtest run strategies/buy_and_hold.py --liquidate-at-endThe default backtest is deliberately frictionless-ish (a flat percentage cost, a
snap-to-target rebalance). That is fine for ranking strategies, but it overstates
what reaches a real German depot. These BacktestConfig options close the gap
between the simulation and how the book is actually traded and billed. They are
Python-level knobs (constructor parameters), and the three below are off by
default, so switching to this file's defaults leaves existing runs
bit-identical.
Note that "off by default" applies to these three only — it is not a claim that the backtest is frictionless out of the box.
slippage_pctdefaults to0.0005andslippage_bpsto5.0, so every run already carries some friction. The full field list is in Configuration Reference.
The transaction-cost model brackets the percentage tier with both a floor and a
ceiling (commission_max), which is how real retail schedules work. Two
German broker profiles are provided from the brokers' own published fee tables:
from backtest.costs import TransactionCostModel
db = TransactionCostModel.deutsche_bank_maxblue() # 0.25%, min €8.90, max €58.90
tr = TransactionCostModel.trade_republic() # flat €1.00 per order
db.calculate(1_000).commission # 8.90 (floored)
db.calculate(10_000).commission # 25.00 (in the tier)
db.calculate(100_000).commission # 58.90 (capped)Without the cap, a €100k order priced at 0.25% would charge €250 — over four
times what the depot is actually billed. commission_max=0 (the default) means
no cap.
rebalance_min_deviation makes the backtester trade only when the largest gap
between an actual and a target weight breaches the band — then it
rebalances fully. Without it, the engine snaps to target at every rebalance
date (down to a one-cent difference), simulating a weekly churn that a live rule
("only trade when drift breaches the gate") never produces.
BacktestConfig(rebalance_frequency="weekly", rebalance_min_deviation=0.20)On one levered sleeve, roughly 78% of trades and 23% of the tax paid at a
0.20 band came from that phantom snap-to-target — trades that never happen live.
0.0 (the default) keeps the old snap-to-target behaviour.
For weekly rebalancing, rebalance_weekday pins execution to a fixed weekday
(0=Mon … 4=Fri) instead of the ISO-week's last trading day (≈ Friday). If the
anchor day is a market holiday, the next open day in the same week is used.
BacktestConfig(rebalance_frequency="weekly", rebalance_weekday=0) # MondayThis is executional, not an edge: across one study the weekday moved CAGR by
~0.6 points but underwater duration by over 3 years, so pinning it to the day you
actually trade matters for tail-risk figures even when it barely moves the return.
None (the default) keeps the legacy last-trading-day behaviour.
Every field of BacktestConfig (src/backtest/backtester.py), with its default.
Most have a CLI equivalent; the ones that do not are set from Python. Anything not
listed here does not exist — if a knob you expect is missing, that is the answer.
Capital and benchmark
| Field | Default | Meaning |
|---|---|---|
initial_capital |
10_000.0 |
Starting capital |
currency |
"EUR" |
Portfolio currency; prices are converted into it |
benchmark |
"S&P 500" |
Comparison series; None disables it |
risk_free_rate |
0.02 |
Annual rate used for Sharpe/Sortino. Change it and every risk-adjusted metric moves |
cash_rate |
0.0 |
Interest earned on uninvested cash |
Costs and execution — see Execution & Cost Realism
| Field | Default | Meaning |
|---|---|---|
costs_pct |
0.001 |
Flat commission as a fraction of trade value (CLI: --costs) |
slippage_pct |
0.0005 |
Execution slippage. Not zero by default — every run carries it |
spread_pct |
0.0 |
Half bid-ask spread charged per trade |
cost_profile |
None |
Per-ticker cost overrides; falls back to the flat values above |
execution_lag_days |
0 |
Trading days between signal and execution (1 avoids same-close lookahead) |
Liquidity limits
| Field | Default | Meaning |
|---|---|---|
max_volume_participation |
None |
Cap an order at this fraction of the day's volume |
min_daily_dollar_volume |
0.0 |
Skip tickers whose daily turnover is below this |
liquidity_on_missing_volume |
"allow" |
What to do when volume data is absent: allow or skip |
Rebalancing
| Field | Default | Meaning |
|---|---|---|
rebalance_frequency |
None |
daily / weekly / monthly / quarterly / yearly; None uses the strategy's own |
rebalance_min_deviation |
0.0 |
No-trade band on |actual − target|; 0.0 snaps to target every date (CLI: --rebalance-min-deviation) |
rebalance_weekday |
None |
Weekly anchor day, 0=Mon … 4=Fri (CLI: --rebalance-weekday) |
German tax model — see German Tax Model
| Field | Default | Meaning |
|---|---|---|
tax_enabled |
True |
Master switch (CLI: --no-tax) |
tax_rate |
0.26375 |
Abgeltungssteuer incl. Solidaritätszuschlag |
tax_partial_exemption |
0.30 |
Teilfreistellung for equity funds |
tax_exemption_amount |
1000.0 |
Freistellungsauftrag (CLI: --tax-exemption) |
cost_basis_method |
"FIFO" |
Only "FIFO" is implemented; anything else raises |
daily_liquidation_tax |
True |
Value the daily curve net of the latent liquidation tax, not just realized tax |
equity_fund_map |
None |
Ticker → is-equity-fund; controls who gets the Teilfreistellung |
tax_treatment_map |
None |
Ticker → (instrument class, is-equity-fund) for finer control (CLI: --tax-treatment-file) |
actual_liquidation_at_end |
False |
Really sell everything on the last day (CLI: --liquidate-at-end) |
Metrics, validation, plumbing
| Field | Default | Meaning |
|---|---|---|
metric_basis |
"net_liquidation" |
gross, net_realized or net_liquidation |
allow_universe_lookahead |
False |
Permit non-point-in-time universes (survivorship bias) |
validate |
True |
Run pre/post validation checks |
strict_validation |
False |
Turn validation warnings into errors |
drip_enabled |
False |
Reinvest dividends (CLI: --drip) |
exposure_policy |
None |
Exposure-policy engine config |
risk_overlay |
None |
Risk-overlay engine config |
external_features_loader |
None |
Injects an external-features provider for the run |
Used by TransactionCostModel and the RunConfig path
(src/backtest/config/run_config.py):
| Field | Default | Meaning |
|---|---|---|
commission_pct |
0.001 |
Commission as a fraction of trade value |
commission_min |
0.0 |
Floor per order |
commission_max |
0.0 |
Ceiling per order; 0.0 means no cap |
spread_bps |
0.0 |
Full bid-ask spread in bps (half is applied per trade) |
slippage_bps |
5.0 |
Slippage in bps. Not zero by default |
The --drip flag enables automatic dividend reinvestment:
# Run with dividend reinvestment
backtest run strategies/buy_and_hold.py --drip
# Compare strategies with DRIP enabled
backtest compare strategies/*.py --drip --liquidate-at-endHow DRIP works:
- Dividends are loaded from Yahoo Finance when
--dripis enabled - When a dividend is paid, tax is deducted (Abgeltungssteuer)
- The net amount is automatically reinvested in the paying stock
- Fractional shares are supported
DRIP is available on all commands: run, compare, sweep, optimize, batch-optimize
Using today's index constituents for historical backtests introduces survivorship bias:
- Failed companies are excluded (they went bankrupt)
- Today's winners are selected based on hindsight
| Index | Source | Data Range | Command |
|---|---|---|---|
| S&P 500 | Wikipedia/GitHub | 1996 - present | python scripts/download_pit_data.py --index sp500 |
The bundled
data/universes/*_constituents.csvfiles are derived from Wikipedia index-change articles and carry a CC BY-SA attribution/share-alike obligation — seeNOTICE.md. Regenerating them with the command above is the cleaner route if you plan to redistribute. | NASDAQ 100 | n100tickers | 2015 - present |python scripts/download_pit_data.py --index nasdaq100|
# Using CLI (experimental N-PORT source)
poetry run backtest-constituents download SPY --start 2019-01-01 --source nport
poetry run backtest-constituents generate SPY --start 2019-01-01 --source nport
# If you're not using Poetry, set PYTHONPATH for src-layout projects:
PYTHONPATH=src python -m backtest.constituents download SPY --start 2019-01-01 --source nport
PYTHONPATH=src python -m backtest.constituents generate SPY --start 2019-01-01 --source nport
# Using scripts (recommended)
python scripts/download_pit_data.py --index sp500
python scripts/download_pit_data.py --index nasdaq100The framework blocks non-PIT universes for historical backtests:
# A strategy whose universe is today's index membership (not point-in-time)
# FAILS (protects you from survivorship bias):
backtest run strategies/<your_non_pit_strategy>.py --start 2013-01-01
# Error: Universe look-ahead detected!
# Options to proceed:
# 1. Use PIT strategy
backtest run strategies/momentum_topn_pit_sp500.py --start 2013-01-01
# 2. Explicit bypass (biased results)
backtest run strategies/<your_non_pit_strategy>.py --start 2013-01-01 --allow-universe-lookaheadA backtest — and a live signal far more so — is only as trustworthy as its price series. The failure mode that matters is not a crash. It is a stale price silently passing as today's close, which produces a confident, wrong answer instead of an error.
A price is a triple: value, session date, source. The session date must be evidenced by the
source — never inferred from today, from the requested date window, or from a page timestamp.
That makes one very common idiom unsafe: align="ffill" followed by iloc[-1]. Forward-fill
silently re-dates every stale value onto the newest index date, so the series can no longer tell you
that it is old. backtest.freshness therefore reads real prints per ticker without forward-fill,
and any verdict that cannot prove freshness reports "no data" instead of "hold".
The distinction is not academic: a stop-loss check run against a silently-stale quote reports a comfortable buffer on a position that has actually already breached its stop.
backtest.cache_integrity (check_cache, manifest_problems) validates the on-disk data/ cache
manifest offline, independent of loader behaviour. A manifest whose recorded end date runs ahead of
the data actually present can skew a live signal while every individual component still looks
healthy.
When dividends are loaded, PriceData.prices is unadjusted — split-adjusted, but not
dividend-adjusted. Using it alone materially understates distributing instruments.
total_return_prices() reinvests distributions at the ex-date and is the canonical path for
research returns:
data = loader.load(tickers, start, end, load_dividends=True)
tr = data.total_return_prices() # distributions reinvested at ex-date
px = data.prices # unadjusted: price-only, understates payersfrom backtest import DataLoader, Backtester, BacktestConfig
from strategies.dual_momentum import DualMomentum
# Load data
data = DataLoader.yahoo(
tickers=["SPY", "EFA", "BND"],
start="2010-01-01",
currency="EUR"
)
# Configure backtest
config = BacktestConfig(
initial_capital=10000,
costs_pct=0.001,
tax_enabled=True,
tax_exemption_amount=1000,
)
# Run backtest
strategy = DualMomentum()
result = Backtester(strategy, data, config).run()
# View results
print(result.summary())
result.to_html("report.html")from backtest.config.run_config import RunConfig, TaxConfig, CostConfig
config = RunConfig(
start_date="2010-01-01",
end_date="2024-01-01",
initial_capital=10_000,
rebalance_frequency="monthly",
costs=CostConfig.medium(), # .low(), .high(), .zero()
tax=TaxConfig.german_single(), # .german_joint(), .disabled()
)from datetime import date
from backtest.tax import GermanTaxModel
model = GermanTaxModel(exemption_amount=1000.0)
# Record purchase
model.record_purchase(
ticker="SPY",
shares=10,
price_per_share=100.0,
purchase_date=date(2024, 1, 1),
instrument_class="general", # ETF
)
# Apply sale
result = model.apply_sale(
ticker="SPY",
shares_sold=10,
sale_price=120.0,
sale_date=date(2024, 12, 1),
instrument_class="general",
)
print(f"Tax due: {result.tax_due:.2f}")Click to expand architecture details
kontor/
├── src/backtest/
│ ├── cli.py # CLI (argparse)
│ ├── strategy.py # Strategy ABC, Allocation
│ ├── backtester.py # Backtester, Portfolio, Trade
│ ├── data.py # DataLoader, CurrencyConverter
│ ├── metrics.py # MetricsCalculator
│ ├── sweep.py # Rolling window analysis
│ ├── universe.py # Universe providers + PIT guardrail
│ ├── tax/
│ │ └── de_tax_model.py # German tax model
│ ├── constituents/ # PIT constituent data generation
│ │ ├── cli.py # constituents CLI
│ │ ├── nport.py # N-PORT SEC filings parser
│ │ └── wikipedia.py # Wikipedia S&P 500 scraper
│ └── web/ # Web UI (FastAPI)
│ ├── app.py # FastAPI application
│ ├── routes/ # API and page routes
│ │ ├── api.py # REST API endpoints
│ │ └── pages.py # HTML page routes
│ ├── services/ # Business logic
│ │ ├── strategies.py # Strategy discovery
│ │ └── charts.py # Plotly chart generation
│ ├── models/ # Pydantic schemas
│ └── templates/ # Jinja2 HTML templates
│
├── strategies/ # Strategy implementations
├── scripts/ # Utility scripts
├── data/
│ ├── <TICKER>.csv # Price cache: one file per ticker (range-independent)
│ ├── _cache_manifest.json # Tracks the date range each cache file covers
│ └── universes/ # PIT constituent CSVs
└── tests/ # Unit tests
All strategies receive daily price data regardless of rebalance frequency:
Rebalance Frequency Data passed to signal() "126 days" means
─────────────────────────────────────────────────────────────────
daily Daily prices 126 trading days
weekly Daily prices 126 trading days
monthly Daily prices 126 trading days
The backtester uses SELL → Tax → BUY execution:
- SELL trades executed first
- Tax calculated and deducted from cash
- BUY trades executed with remaining cash
| Category | Metrics |
|---|---|
| Returns | Total Return, CAGR |
| Risk | Volatility, Max Drawdown, Drawdown Duration |
| Risk-Adjusted | Sharpe, Sortino, Calmar Ratio |
| Trading | Number of Trades, Turnover, Total Costs |
| Tax | Total Tax, Effective Rate, Tax Drag |
The framework includes comprehensive validation:
from backtest import BacktestConfig
# Strict mode: raise on validation errors
config = BacktestConfig(
validate=True,
strict_validation=True,
)Validation checks:
- No negative/NaN prices
- No extreme returns (>50% daily)
- Tax invariants (gross >= net_realized >= net_liquidation)
- Config sanity (positive capital, reasonable costs)
poetry run pytest # All tests
poetry run pytest --cov=backtest # With coverage
poetry run pytest tests/test_tax.py -v # Specific test
poetry run pytest tests/test_invariants.py # Tax invariantsThe individual points are stated where they belong throughout this file; they are collected here so you do not have to find them one at a time.
The tax model is a good model, not your tax return. It covers Abgeltungssteuer, Solidaritätszuschlag, Teilfreistellung, Freistellungsauftrag and the two loss pots on FIFO lots. It excludes Vorabpauschale, Kirchensteuer and the derivatives loss pot (§20 Abs. 6 Satz 5 EStG), and only FIFO cost basis is implemented. It is not tax advice and it will not match a Steuerbescheid to the cent.
A backtest is not a forecast. Every figure quoted in this file comes from one study over one window with one parameter set. Change the period, the weekday, the starting year, and the numbers move — sometimes by more than the effect being measured (that is the point of the rebalance weekday example). Treat quoted improvements as "this is what the mechanism did there", never as an expected return. The Best Practices section exists because the default failure mode of a backtesting framework is a convincing overfit.
Costs and execution are modelled, not observed. Commission schedules, spread and slippage are parameters you supply. Real fills differ; illiquid instruments differ a lot. The liquidity caps help, they do not make the result real.
Prices come from free public sources. Yahoo Finance data carries survivorship and revision quirks; the Data Integrity section describes the guards, which detect problems rather than eliminate them. Fundamental data is sparse and, in this repository, largely synthetic sample data.
EUR runs start where the exchange rate starts. USD assets are converted with
the latest EURUSD observation on or before each date — never with a later one.
Yahoo's EURUSD series begins on 2003-12-01, so an EUR run on USD assets that
starts earlier stops with FX: no prior observation instead of pricing the early
years with a rate from the future.
Nothing here places an order. The live path derives plan-only order sheets; no broker integration submits anything, by design.
No performance claim is being made. This repository ships a framework and a strategy library, not a track record. Strategies included as examples — including the levered-momentum ones — can and do have deep drawdowns; run them over 2000-2003 and 2007-2009 before drawing conclusions.
MIT — see LICENSE.
The MIT licence covers the code. Some bundled datasets come from third parties
under their own terms (the point-in-time index constituents are derived from
Wikipedia, CC BY-SA; the total-return test fixtures were captured from Yahoo
Finance). See NOTICE.md for the per-dataset breakdown before reusing any
of the data.
Kontor is a published mirror of a private research repository, not a repo that is developed here. It is maintained as a single squashed commit that is rewritten on every batch, so a pull request cannot be merged in the usual way and would be overwritten by the next publication.
Issues and discussion are very welcome — bug reports, questions about the tax model, and pointers to something that is plainly wrong all land where they should. If you want to build on the code, fork it; that works exactly as you would expect.