Skip to content

jquantstats vs. QuantStats — Engineering Comparison

This document compares the two libraries by their underlying engineering: data model, architecture, API design, type safety, testing rigor, and dependency footprint. It is a companion to difference.md, which compares them by features.

All figures were measured against the versions installed in this repo's environment: jquantstats 0.9.6 and QuantStats 0.0.81 (both pure-Python, neither ships a compiled extension).

TL;DR — QuantStats is a procedural, pandas-centric library: a handful of large modules of free functions that operate on (and monkey-patch onto) pandas Series. jquantstats is an object-oriented, Polars-native library: many small modules composed via mixins and protocols into frozen, immutable, memoised objects, behind a strict-typed, 100%-covered quality gate. QuantStats optimises for familiarity and reach; jquantstats optimises for correctness, type-safety, and maintainability.


At a glance

Dimension jquantstats 0.9.6 QuantStats 0.0.81
Data engine Polars (+ narwhals boundary layer) pandas + numpy
Paradigm OO: frozen dataclasses, mixins, protocols Procedural: module-level functions
Public API Explicit Portfolio/Data objects + accessors Free functions + extend_pandas() monkey-patch
State model Immutable (frozen, slotted) + memoised Stateless functions over mutable frames
Source size ~16.0k LOC across ~77 files ~12.3k LOC across ~12 files
Largest module 583 LOC (portfolio.py) 3.3k LOC (stats.py)
Runtime deps 6 (no pandas) 8 (incl. pandas, matplotlib, yfinance)
Type coverage Full annotations, strict (ty), py.typed Partial (~50% in stats.py), py.typed
Docstring coverage 100% enforced (interrogate) Good, not gated
Tests shipped 15k LOC test suite in repo Not shipped in wheel
Quality gates 100% line+branch cov, property, snapshot Lighter (pytest + coverage, no enforced gate)
Plotting engine Plotly and matplotlib, both native Matplotlib core + optional to_plotly() converter
Python 3.11+ 3.10+

1. Data engine & numeric core

QuantStats is built directly on pandas + numpy. Every metric is a function that takes a pd.Series/pd.DataFrame and returns a scalar or Series:

def sharpe(returns, rf=0.0, periods=252, annualize=True, smart=False) -> float | _pd.Series:
    ...

It carries explicit compatibility shims — _compat.py (430 LOC) and _numpy_compat.py (288 LOC) — to absorb breaking changes across pandas/numpy versions. This is the cost of binding tightly to a fast-moving numeric stack.

jquantstats is Polars-native and computes via Polars/narwhals expressions rather than pandas operations. narwhals (import narwhals as nw) acts as a boundary adapter, so the library accepts pandas, Polars, or other frames at its edges while keeping a zero-pandas runtime internally. There is no per-version compatibility shim layer because Polars' API is more stable and the narwhals abstraction isolates engine differences.

A concrete consequence is null semantics: jquantstats distinguishes Polars null from IEEE-754 NaN and forces an explicit choice at ingestion (null_strategy={"raise","drop","forward_fill"}), whereas QuantStats inherits pandas' NaN-conflates-everything conventions.


2. Architecture & code organization

This is the sharpest engineering divide.

QuantStats — flat & procedural

A handful of large, monolithic modules:

stats.py            3307 LOC   (85 free functions, 0 classes)
reports.py          2515 LOC
_plotting/core.py   2137 LOC
_plotting/wrappers  2114 LOC
utils.py            1002 LOC

There are no classes in stats.py — it is a flat namespace of functions. This is easy to read, easy to grep, and easy to contribute a one-off metric to. The flip side is large files, implicit coupling through shared helpers, and behaviour distributed across long modules.

jquantstats — composed & layered

Many small, single-responsibility modules assembled by composition:

src/jquantstats/
├── portfolio.py            Portfolio = 4 mixins
│   ├── _portfolio_nav.py          (NAV/returns chain)
│   ├── _portfolio_attribution.py  (tilt/timing)
│   ├── _portfolio_turnover.py     (turnover)
│   └── _portfolio_cost.py         (cost models)
├── data.py                 Data (returns route)
├── _stats/   (5 mixins: _basic, _performance, _reporting, _rolling, _montecarlo)
├── _plots/   (backend-agnostic specs + Plotly/matplotlib renderers)
├── _reports/ (Jinja2 HTML)
├── _utils/, _cache.py, exceptions.py, result.py
└── *_protocol.py  (interface-segregation protocols)

Three patterns define its engineering:

  • Mixin composition. Portfolio and Stats are each assembled from focused mixins. Every mixin declares the attributes it expects in TYPE_CHECKING blocks, so it type-checks standalone.
  • Protocol layering / interface segregation. Subpackages depend on minimal *Like protocols (_protocol.py, _plots/_protocol.py, …) rather than on the concrete classes, which avoids circular imports without runtime glue.
  • Domain exceptions. A dedicated exceptions.py (347 LOC) with named errors (MissingDateColumnError, NullsInReturnsError, BenchmarkAlignmentWarning, …) instead of bare ValueErrors.

The trade-off: jquantstats has more files and more indirection. There is more to learn before you can locate a calculation, but each unit is small, isolated, and independently testable.


3. API design & state model

QuantStats exposes free functions and, via extend_pandas(), monkey-patches ~100+ methods onto pandas itself:

qs.extend_pandas()
returns.sharpe()          # method now lives on pd.Series globally
qs.stats.sharpe(returns)  # or call the function directly

This is maximally discoverable for pandas users but mutates a third-party class process-wide — a global side effect that can surprise other code in the same interpreter. Functions are stateless: they recompute from the input frame on every call, and the input frame is mutable.

jquantstats uses explicit objects with namespaced accessors and no global mutation:

pf = Portfolio.from_cash_position(prices=..., cash_position=..., aum=...)
pf.stats.sharpe()    # accessor namespaces: .stats / .plots / .report / .utils

The core objects are frozen, slotted dataclasses — immutable after construction. Because frozen+slotted means neither functools.cached_property (needs __dict__) nor attribute assignment works, the library ships a bespoke cached_in_slot decorator (_cache.py) that memoises derived values into explicitly-declared slot fields via object.__setattr__:

@property
@cached_in_slot("_profits_cache")
def profits(self) -> pl.DataFrame: ...

So expensive derived quantities (NAV, drawdown, returns) are computed once and cached, while the object stays immutable. The docstring is candid that this cache is not thread-safe but is correct under races (every thread computes the same deterministic value).

Net: QuantStats trades immutability for the convenience of patched pandas; jquantstats trades convenience for immutability, memoisation, and zero global state.


4. Type safety

Both ship a py.typed marker, but the depth differs.

  • QuantStats has improved markedly: functions now carry signatures and rich docstrings (e.g. sharpe(...) -> float | _pd.Series), and pyright is a dev dependency. But annotation coverage is partial — in stats.py, 37 of 85 functions have return annotations and ~133 of 256 parameters are typed. Typing is aspirational rather than enforced.

  • jquantstats annotates fully (modern str | None, Self, Literal, ParamSpec), runs the strict ty type-checker in CI, and uses from __future__ import annotations with TYPE_CHECKING import blocks to keep runtime imports lean and break import cycles. Typing is a gate, not a goal.

Docstrings: jquantstats enforces 100% docstring coverage via interrogate; QuantStats has good docstrings but no coverage gate.


5. Testing & quality gates

This is where the engineering philosophies diverge most.

QuantStats does not ship its test suite in the wheel. Its dev tooling is conventional: pytest, pytest-cov, ruff, pyright. There is no evidence of an enforced coverage threshold or property-based testing.

jquantstats treats verification as a first-class deliverable:

  • ~15k LOC of tests (larger than the ~10.6k LOC of source).
  • 100% line and branch coverage, enforced (fail_under=100) with a dedicated test_coverage_gate.py.
  • Property-based testing (hypothesis) for numerical invariants.
  • Snapshot/regression testing (syrupy).
  • Performance benchmarks (pytest-benchmark, with a PR-benchmark workflow).
  • Validation against QuantStats itself — QuantStats is a test dependency so jquantstats' metrics are checked against the reference implementation.

The repo also carries a richer CI surface (CodeQL, release automation, weekly runs, a Makefile) reflecting a heavier engineering process.


6. Dependency footprint

jquantstats runtime (6): jinja2, narwhals, numpy, plotly, polars, scipy. No pandas — and no matplotlib, which is an optional extra rather than a runtime dependency, so the second plotting backend costs nothing unless you ask for it. The remaining optional extras are mpl (matplotlib), plot (static image export via kaleido) and web (the FastAPI service).

QuantStats runtime (8): matplotlib, numpy, pandas, python-dateutil, scipy, seaborn, tabulate, yfinance. The base install therefore pulls in the full plotting stack and a network/market-data client (yfinance) whether or not you use them.

Engineering implication: jquantstats keeps the base surface small and pushes optional capability behind extras; QuantStats bundles batteries (plotting + data) into the default install, which is more convenient but a larger and more opinionated dependency graph.


7. Plotting & reporting internals

  • QuantStats renders with Matplotlib at its core (_plotting/core.py), with seaborn as a dependency. A wrappers.py adds an optional to_plotly(fig) converter (behind the plotly extra) that re-wraps Matplotlib figures — so interactivity is bolted on, not native. Reports are assembled in reports.py.

  • jquantstats builds both natively. Every chart is described once as a backend-agnostic spec (_plots/_specs/), which one of two renderers (_plots/_render/) turns into a Plotly or a matplotlib figure — so the arithmetic behind a chart is written once and neither backend is a wrapper over the other. Plotly is the default; set_plot_backend("matplotlib") switches. Reports render self-contained interactive HTML through Jinja2 templates (_reports/, hence the jinja2 runtime dependency), and stay Plotly-only. Plot and report layers are protocol-segregated from the data layer.


8. Maturity & versioning

A fair point in QuantStats' favour: its 0.0.x version string understates a mature, widely-deployed codebase with years of real-world edge-case hardening, a large user base, and extensive third-party documentation. jquantstats (0.9.6) is younger; its engineering rigor is high but its battle-testing in the wild is necessarily shallower.


Summary

If you value… Lean toward
Familiar pandas idioms, monkey-patched convenience QuantStats
Batteries-included install (plots + data fetch) QuantStats
Maximum community/edge-case maturity QuantStats
Immutability, memoisation, zero global state jquantstats
Strict typing + enforced docstrings jquantstats
100% coverage & property testing jquantstats
Polars-native performance, lean base deps jquantstats
Small, single-responsibility, testable modules jquantstats

Both are pure-Python portfolio-analytics libraries with overlapping metric sets. The engineering difference is one of philosophy: QuantStats is a pragmatic, procedural toolkit that meets pandas users where they are; jquantstats is a rigorously-engineered, type-safe, immutable, Polars-native rebuild that trades some approachability for correctness guarantees and long-term maintainability.


Measurements taken from the installed packages and this repository (pyproject.toml, src/jquantstats/, tests/, .github/workflows/) and from the QuantStats source on GitHub.