Getting Started¶
jQuantStats provides two entry points depending on what data you have:
| You have… | Use… | Why |
|---|---|---|
| Prices and positions | Portfolio |
Unlocks execution-delay analysis, turnover analytics, and cost modelling |
| Returns (or prices only) | Data |
Lighter-weight path; easiest migration from QuantStats |
flowchart TD
Q1{What data do you have?}
Q1 -- "prices + positions" --> PF["Portfolio.from_cash_position(...)"]
Q1 -- "returns only" --> DR["Data.from_returns(...)"]
Q1 -- "prices only" --> DP["Data.from_prices(...)"]
PF --> PA["stats · plots · report<br/>+ turnover · costs · lag() · attribution"]
DR --> DA["stats · plots · report"]
DP --> DA
Both routes expose the same stats, plots, and report API.
First five minutes¶
A complete, runnable example — returns in, Sharpe out:
import polars as pl
from jquantstats import Data
returns = pl.DataFrame(
{
"Date": ["2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"],
"Fund": [0.011, -0.004, 0.007, 0.002],
}
).with_columns(pl.col("Date").str.to_date())
data = Data.from_returns(returns)
print(data.stats.sharpe()) # {'Fund': ...} — annualised
data.plots.snapshot().show() # interactive Plotly chart
Swap the inline frame for your own returns (pandas works too — any
narwhals-compatible frame is accepted) and you have a full tear sheet via
data.reports.full(). The rest of this page covers the two entry points in
depth; runnable notebooks live under Examples.
Installation¶
Python version
Python 3.11+ is required.
Optional extras:
pip install jquantstats[mpl] # matplotlib rendering backend
pip install jquantstats[plot] # static chart export via kaleido
pip install jquantstats[web] # FastAPI web server
Route A: Portfolio¶
Use Portfolio when you have prices and positions. This unlocks
position-level analytics — turnover, cost modelling, execution-delay analysis —
that are impossible from returns alone.
Internally, Portfolio derives everything from the three raw inputs:
flowchart LR
P[prices] --> PR["profits<br/>(per-asset daily P&L)"]
CP[positions] --> PR
PR --> PT["profit<br/>(aggregate daily P&L)"]
PT --> NAV["nav_accumulated /<br/>nav_compounded"]
AUM[aum] --> NAV
PT --> RET["returns<br/>(profit / AUM)"]
AUM --> RET
RET --> OUT["stats · plots · report"]
CP --> TO[turnover] --> COST["cost_adjusted_returns ·<br/>trading_cost_impact"]
COST --> AD["as_data(frame)"] --> OUT
stats, plots and report read the gross returns series. To run the same
metrics over a derived one — cost-adjusted, fee-deducted, or hand-built — pass
the frame through pf.as_data(frame):
net = pf.deduct_management_fee(annual_fee=0.0085, base=pf.cost_adjusted_returns(cost_bps=5))
pf.as_data(net).stats.sharpe()
Prefer it to building the Data yourself: pf.returns and the cost frames also
carry profit and NAV_accumulated columns, and Data.from_returns would score
those as two additional assets. as_data keeps only returns.
Build a Portfolio¶
from datetime import date
import polars as pl
from jquantstats import Portfolio
prices = pl.DataFrame({
"date": [date(2020, 1, 2), date(2020, 1, 3), date(2020, 1, 6)],
"AAPL": [75.09, 74.36, 75.80],
"MSFT": [160.62, 158.96, 159.03],
})
# Dollar amount held per asset each day
positions = pl.DataFrame({
"date": [date(2020, 1, 2), date(2020, 1, 3), date(2020, 1, 6)],
"AAPL": [500_000.0, 500_000.0, 600_000.0],
"MSFT": [300_000.0, 300_000.0, 300_000.0],
})
pf = Portfolio.from_cash_position(
prices=prices,
cash_position=positions,
aum=1_000_000,
)
Core series¶
pf.returns # daily portfolio returns → pl.Series
pf.nav_compounded # compounded NAV curve → pl.Series
pf.drawdown # drawdown from HWM → pl.Series
Stats¶
pf.stats.sharpe() # (1)
pf.stats.max_drawdown()
pf.stats.volatility()
pf.stats.summary() # full metrics table → pl.DataFrame
- Returns a
dictkeyed by column name, e.g.{'AAPL': 1.34, 'MSFT': 0.91, 'portfolio': 1.21}
Plots¶
fig = pf.plots.snapshot() # NAV + drawdown dashboard
fig = pf.plots.rolling_sharpe_plot(window=60)
fig.show() # opens in browser / notebook
Report¶
Route B: Data¶
Use Data when you already have a return series (or just prices without
positions). This is the lighter-weight path and accepts pandas DataFrames too.
from datetime import date
import polars as pl
from jquantstats import Data
returns = pl.DataFrame({
"Date": [date(2020, 1, 2), date(2020, 1, 3), date(2020, 1, 6)],
"Strategy": [0.012, -0.009, 0.005],
"Benchmark": [0.004, -0.003, 0.002],
})
data = Data.from_returns(
returns=returns,
benchmark="Benchmark", # column name to use as benchmark
rf=0.0, # risk-free rate (float or time-varying DataFrame)
)
The date column¶
The date column is identified by type, not by name: the first temporal
column of each input frame is used, whether it is called Date, timestamp,
or anything else. Returns, benchmark and risk-free frames are resolved
independently, so they need not agree on a label.
Whatever it was called on the way in, the object exposes it as date — the
same name the Portfolio internals use, so one reader works against both
routes:
data.index["date"] # the date series
data.date_col # ['date'] — the supported way to read the name
pf.data.date_col # ['date'] — a Portfolio's Data agrees
Pass date_col="…" to nominate a column explicitly. That is required for a
date-free (integer-indexed) frame, since there is no temporal column to find;
such an index keeps its own name rather than being relabelled date.
Stats¶
data.stats.sharpe()
data.stats.sortino()
data.stats.cagr()
data.stats.annual_breakdown() # pl.DataFrame: year | return | sharpe | ...
Plots¶
fig = data.plots.snapshot()
fig = data.plots.monthly_heatmap()
fig = data.plots.histogram()
fig.show()
Report¶
Choosing a plotting backend¶
Charts render with Plotly by default. Install the mpl extra and you can ask
for matplotlib instead, which is markedly cheaper when a script builds many
charts at once — the reason the option exists.
Three ways to select it, most specific first:
import jquantstats as jqs
# 1. one call
fig = data.plots.drawdown(backend="matplotlib")
# 2. a block — restored on exit, even if the body raises
with jqs.plot_backend("matplotlib"):
fig = data.plots.drawdown()
# 3. the process-wide default
jqs.set_plot_backend("matplotlib")
fig = data.plots.drawdown()
Both backends draw from the same numbers, so the two figures plot identical
series. What differs is what a figure is: plotly.graph_objects.Figure one
way, matplotlib.figure.Figure the other.
The matplotlib backend is static
Hover tooltips, the date range-selector buttons and legend click-to-toggle are interactive features with no matplotlib equivalent, so they are simply absent. Everything that determines what is plotted — series, colours, axis scales and tick formats — is reproduced.
HTML reports (portfolio.report.to_html(), data.reports.full()) always
render Plotly regardless of the selected backend, since they embed Plotly JSON
and link plotly.js.
Execution-Delay Analysis¶
Portfolio route only
Execution-delay analysis requires the Portfolio entry point.
A return series does not carry enough information to reconstruct
what happens under different execution assumptions.
Simulate what happens if signals are executed one or more days late:
pf_t0 = pf # ideal T+0
pf_t1 = pf.lag(1) # T+1 fill — signal today, fills tomorrow
pf_t2 = pf.lag(2) # T+2 fill
print(pf_t0.stats.sharpe()) # {"portfolio": 1.34}
print(pf_t1.stats.sharpe()) # {"portfolio": 1.28}
print(pf_t2.stats.sharpe()) # {"portfolio": 1.19}
# Visualise the full lead/lag sweep as a single chart
fig = pf.plots.lead_lag_ir_plot(start=-5, end=10)
fig.show()
pf.lag(n) returns a new Portfolio with positions shifted by n periods.
All downstream accessors — .stats, .plots, .report — recompute on the
shifted positions, so a single call gives you the full analytics picture
under a different execution assumption.
Transaction Costs¶
Portfolio route only
Cost modelling requires the Portfolio entry point.
See Cost Models for the full reference. Quick example:
from jquantstats import CostModel, Portfolio
pf = Portfolio.from_cash_position(
prices=prices,
cash_position=positions,
aum=1_000_000,
cost_model=CostModel.turnover_bps(5.0), # 5 bps one-way cost on AUM turnover
)
# Sweep Sharpe ratio across 0 → 20 bps — how robust is the strategy?
impact = pf.trading_cost_impact(max_bps=20)
print(impact)
NaN / null handling¶
Polars vs pandas null semantics
Unlike pandas, Polars propagates null by default — if any value in a column
is null, most statistics return null instead of a numeric result.
Use the null_strategy parameter to control this behaviour:
# Mirrors pandas / QuantStats — silently drop rows with nulls
data = Data.from_returns(returns=returns_pl, null_strategy="drop")
# Forward-fill nulls before computing statistics
data = Data.from_returns(returns=returns_pl, null_strategy="forward_fill")
# Raise an error if any null is found (useful during development)
data = Data.from_returns(returns=returns_pl, null_strategy="raise")
The default (null_strategy=None) passes nulls through unchanged.
Next steps¶
- Cost Models — model transaction costs
- Migration from QuantStats — complete API mapping
- API Reference — full method signatures