Skip to content
QuantKit v0.1.0

Python library

A quant library that refuses to guess.

QuantKit computes and audits quantitative-finance quantities. Its two commitments are numerical correctness — every disputed convention is an explicit parameter with a documented default — and lookahead safety, built on information sets rather than data frames.

Tests
650
Failures
0
Modules
45
Formulas
65
Defects found
17

Lint, format and type checks clean · deterministic suite · no network, no wall clock

01What it is

and, just as importantly, what it is not

It is

  • A measurement library: returns, risk, covariance, factor models, portfolio construction, costs, backtesting
  • A set of lookahead-safe historical APIs
  • An audit tool — it will tell you when a signal reads the future
  • Explicit about every convention libraries disagree on

It is not

  • A trading bot or execution system
  • An alpha library or strategy collection
  • A brokerage or market-data integration
  • A price predictor of any kind

QuantKit ships no live market-data connection, by design. A library whose tests depend on a vendor API cannot be verified reproducibly. Its bundled datasets are simulated from a known factor model — they are validation fixtures, not evidence about any market.


02The lookahead problem

the flagship feature

A backtest that reads the future does not fail. It succeeds — spectacularly — and tells you nothing. The usual defence is discipline: remember to lag the signal, remember that the rolling mean at t already contains t. Discipline is exactly what fails at 2am.

QuantKit offers three defences instead, and is careful never to conflate them. They differ in kind, not just in strength.

LayerKind of protectionWhat it actually establishes
InformationSet Prevention by construction The strategy receives an object from which future rows are absent. Reading them is not forbidden — it is impossible.
Timing Runtime enforcement Observation, decision and execution are separate fields. Every recorded decision satisfies observation ≤ decision ≤ execution, checked at construction.
assert_causal Heuristic probe Recomputes a function without the future and reports whether the answer moved — on the inputs and cut points probed. A necessary-condition test.

assert_causal does not prove the absence of lookahead. It runs a function on truncated and perturbed inputs and reports whether the output moved. A leak on an unprobed row, an unexercised branch, or a code path the test data never reaches will pass. Only InformationSet is a guarantee about arbitrary strategy code.

What the audit does catch, reliably, is the entire family of bugs that affect every row — which is nearly all of them in practice:

# each of these has shipped in a real backtest
assert_causal(lambda p: p.rolling(20).mean(), prices)   # passes
assert_causal(lambda p: p.shift(-1), prices)            # LookaheadError
assert_causal(lambda p: p.rolling(21, center=True).mean(), prices)
assert_causal(lambda p: (p - p.mean()) / p.std(), prices)
assert_causal(lambda p: p[::-1].cummax()[::-1], prices)

Data with a reporting lag is declared, and the point-in-time cut respects it — a fundamental filed 45 days after its period end becomes visible 45 days late, not on the date the vendor stamped it:

run_backtest(prices, strategy, panels={
    "fundamentals": Panel("fundamentals", filings,
                            observation_lag=pd.Timedelta(days=45)),
})

Same-bar execution — filling at the close you decided from — is refused unless you pass allow_same_bar=True, so the assumption lands in your code and in the emitted timeline rather than in a footnote nobody reads.

03The execution lag is measured, not asserted

a behavioural check on the engine

With execution_lag=1, a decision made at bar t fills at the close of t+1 — so the first return the new book earns is bar t+2's. That is a testable claim. Give a strategy perfect foresight at each horizon and see where it pays.

Value of perfect foresight, by horizon

Final equity as a multiple of an honest equal-weight book. Log scale — the differences span two orders of magnitude.

1.0× (no edge) t+0 0.85× t+1 1.07× t+2 74.3× t+3 1.07× 10× 100× final equity ÷ honest equal-weight equity
Foresight is worth nothing at every horizon except exactly the one the timing model predicts. If the engine filled a bar early, t+1 would pay instead. This is a measurement of the lag rather than a claim about it — and it is asserted in the test suite.

04Conventions it refuses to guess

where two libraries disagree on the same data

Two implementations of “annualized volatility” can differ by 5% and neither is wrong — they made different, unstated choices. Every one of these is a named parameter in QuantKit, and every CLI command prints the convention it used alongside the number.

AmbiguityQuantKit defaultAlternative
Standard deviationddof=1 (sample, pandas)ddof=0 (NumPy)
Downside-deviation denominator"full" (÷ N)"downside"
VaR / ES sign"loss" (positive)"return"
Turnoverone-way (½Σ|Δw|)two-way
Scalar risk-free rateannual, geometric"simple"
Vector risk-free ratealready per-period
Spread costhalf the quoted spreadcross_full=True
Trading year252calendar_daily, or any number
Executionnext bar's closenext open · k bars · same bar

One documented claim turned out to be false, and saying so is the point of the exercise. “Arithmetic annualized return always exceeds the CAGR” is wrong. AM ≥ GM holds per period, always — but annualizing applies different transforms (linear scaling versus compounding), and over 252 periods the convexity of compounding can reverse the ordering. Both directions are now in the docs and in the tests.


05How it is validated

two kinds of evidence, deliberately kept apart

Independent validation compares a result against something QuantKit did not produce: a third-party library, a literal transcription of a published formula, or a closed-form solution derived analytically. That is the evidence the implementation is right.

Self-consistency validation checks that QuantKit agrees with itself — an identity holds, a fast kernel matches its own exact kernel, a round trip returns its input. These catch real defects, but cannot detect an error shared by both sides. The distinction is marked on every section of the validation document.

QuantKitChecked againstKindAgreement
ledoit_wolf_constant_correlationLiteral transcription of Ledoit & Wolf (2004), JPM 30(4)independent2.7e-20
ledoit_wolf_identitysklearn.covariance.ledoit_wolfindependent2.7e-20
oracle_approximating_shrinkageChen et al. (2010), eq. 23, literalindependent8.1e-20
ewma_covariancepandas.ewm(adjust=True).cov()independent5.4e-20
rolling_var / rolling_stdpandas.rolling()independent5.3e-17
min_variance (SLSQP)Closed form Σ⁻¹1 ⁄ 1ᵀΣ⁻¹1independent8.1e-07
factor_exposuresnumpy.linalg.lstsqindependent1e-10
risk_contributionEuler identity Σ CRC = σₚself-consistency2.8e-17
Backtest accountingIdentity E_post = E_pre − costself-consistencyexact

The literal transcriptions earned their keep

The reference implementations in the test suite are deliberately slow: explicit double loops, no vectorisation, no shared code with the library. That is what makes agreement meaningful — and it is what caught the worst bug in the project. The vectorised Ledoit–Wolf θ term used Σ x²ᵢ xⱼ where the derivation requires Σ x³ᵢ xⱼ. It produced entirely plausible shrinkage intensities — 0.91 instead of 0.75 — that no eyeball would ever have caught.

Seventeen real defects were found this way in total: seven while writing the tests, ten more in a dedicated adversarial audit before release. Every one shared a shape — a routine returning something that looked like a valid answer where it should have refused. A levered wipeout coming back sign-flipped, so a long position reported as a short. An R² of −15 for a constant series. A single NaN silently poisoning every subsequent row of a rolling window. Each has a regression test written as the smallest case that would have caught it.


06What is in it

eleven modules, one Python API

returns

Simple, log and cumulative returns; wealth index; CAGR; excess and active returns.

risk

Volatility, downside deviation, Sharpe, Sortino, Calmar, Omega, drawdowns, VaR (historical / Gaussian / Cornish–Fisher), expected shortfall.

rolling

Causal trailing volatility, beta, covariance, correlation, Sharpe, drawdown and VaR — with a fast prefix-sum kernel and an exact reference kernel.

covariance

Sample, EWMA (RiskMetrics), Ledoit–Wolf on two targets, OAS, PSD repair and conditioning diagnostics.

factors

OLS exposures with OLS or Newey–West HAC errors, rolling exposures, return attribution, systematic/specific risk decomposition.

portfolio

Weights, drift, Euler risk contributions, closed forms, and constrained SLSQP optimization including risk parity and the efficient frontier.

costs

Fixed, proportional, spread, per-asset linear and square-root market impact — composable with +. Turnover that accounts for drift.

backtest

Timing policy, share-level accounting, engine, and results carrying a full timing audit trail.

lookahead

Information sets, timing triples, lagging utilities and causality auditing.

validation

Structural and numerical checks plus a non-raising data-quality audit.

data

Four bundled deterministic datasets and validating CSV/Parquet readers.


07Using it

Python API is primary; the CLI is for quick audits

Python 3.10 or newer; numpy, pandas and scipy are the only runtime dependencies.

pip install -e .
import quantkit as qk

prices  = qk.data.load_sample_prices()
returns = qk.simple_returns(prices)

qk.sharpe_ratio(returns["ALFA"], risk_free=0.02, freq="daily")   # 1.019
qk.max_drawdown(returns["ALFA"])                                # -0.322
qk.value_at_risk(returns["ALFA"], 0.99)                          # 0.0366 — a loss

result = qk.run_backtest(prices, qk.backtest.EqualWeight(),
                         rebalance="monthly",
                         costs=qk.ProportionalCost(bps=5))
print(result.summary())
$ quantkit metrics   --dataset sample_prices --risk-free 0.02
$ quantkit risk      --dataset sample_prices --confidence 0.99
$ quantkit optimize  --dataset sample_prices --objective min-variance --long-only
$ quantkit backtest  --dataset sample_prices --strategy min-variance --cost-bps 5
$ quantkit lookahead --prices my_prices.csv --window 20 --exhaustive

optimize and lookahead exit non-zero when the optimizer fails to converge or a causality violation is found, so they compose into CI.


08Known limitations

stated plainly, not buried

A correctness claim is only worth what its caveats admit. These are on the front page of the repository too.

LimitationDetail
No live market dataBy design. Bundled datasets are simulated validation fixtures and say nothing about any market.
Causality auditing is a probeassert_causal samples. A leak on an unprobed row or an unexercised branch will pass.
Market impact is uncalibratedThe functional form is tested; the coefficient η is a scenario input. Published estimates span 0.3–1.0 — wider than the parameter itself.
Benchmarks are machine-specificMeasured on one Apple-silicon laptop with one NumPy/pandas/BLAS combination. No general speed claim is made. The rolling kernel measures 1.4–1.9× slower than pandas' compiled kernel, and that is reported rather than omitted.
Optimization inherits its inputsClosed forms invert the covariance and inherit its conditioning; group and leverage constraints use non-smooth formulations that report failure cleanly.
Backtest sizing overshootTargets are sized on pre-trade equity, so realised weights exceed target by E ⁄ (E − C). Chosen so every number stays reproducible by hand.
PSD repair is eigenvalue clippingOptimal only among matrices sharing the input's eigenvectors — not Higham's nearest-correlation algorithm.

The slower-than-pandas result is kept deliberately. pandas dispatches to a purpose-built compiled kernel; QuantKit computes prefix sums in NumPy because that implementation is transparent and checkable line by line against its own exact kernel. Both are O(n) — it is a constant factor, not a complexity difference. If rolling moments dominate your workload, use pandas; QuantKit agrees with it to 5.3e-17, which is exactly why substituting it is safe.