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, mutation-tested 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 | ~10.6k LOC across ~34 files | ~12.3k LOC across ~12 files |
| Largest module | 1.4k LOC (_plots/_data.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, mutation, property, snapshot | Lighter (pytest + coverage, no enforced gate) |
| Plotting engine | Plotly-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:
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/ (Plotly figures, protocol-segregated)
├── _reports/ (Jinja2 HTML)
├── _utils/, _cache.py, exceptions.py, result.py
└── *_protocol.py (interface-segregation protocols)
Three patterns define its engineering:
- Mixin composition.
PortfolioandStatsare each assembled from focused mixins. Every mixin declares the attributes it expects inTYPE_CHECKINGblocks, so it type-checks standalone. - Protocol layering / interface segregation. Subpackages depend on minimal
*Likeprotocols (_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 bareValueErrors.
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__:
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), andpyrightis a dev dependency. But annotation coverage is partial — instats.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 stricttytype-checker in CI, and usesfrom __future__ import annotationswithTYPE_CHECKINGimport 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, mutation testing, 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 dedicatedtest_coverage_gate.py. - Mutation testing (
mutmut, gated in CI via.github/workflows/mutation.ymland amutation_gate.py) — verifies the tests actually catch injected bugs, not just execute lines. - 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. Data fetching and web/static-export deps are optional
extras (web, plot).
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), withseabornas a dependency. Awrappers.pyadds an optionalto_plotly(fig)converter (behind theplotlyextra) that re-wraps Matplotlib figures — so interactivity is bolted on, not native. Reports are assembled inreports.py. -
jquantstats builds Plotly figures natively (
_plots/) and renders self-contained interactive HTML through Jinja2 templates (_reports/, hence thejinja2runtime dependency). 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, mutation & 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.