API Reference¶
Everything the app is built from is importable, so a frame can be shaped, summarised or drawn without opening the GUI at all:
from jointview import demo_frame, line_chart, summary_markdown
frame = demo_frame()
chart = line_chart(frame, "world_equity", "tech_fund") # an Altair LayerChart
table = summary_markdown(frame, "world_equity", date_col="date") # a markdown table
Note what summary_markdown takes: the frame and the column name, not a bare
series, plus the column carrying the period. jQuantStats reads the annualisation factor
off the spacing of the observations, so the dates have to travel with the levels.
date_col defaults to "period" — the column aligned writes — so a frame
that has not been through aligned needs to name its own, as here. That name is
exported as jointview.PERIOD, so code reading an aligned frame back does not have to
spell the literal.
The five modules below are the whole of it. jointview.app is the notebook itself and
has no API — run it with jointview rather than importing it.
Data¶
Getting a frame in: reading a file, or generating one to look at.
jointview.data
¶
Getting a DataFrame into the app.
The app is handed a path on the command line; when there is none it falls back to
a generated frame so that marimo run app.py works out of the box.
demo_frame(rows=1500, seed=42)
¶
Daily NAVs for a handful of made-up funds, on deliberately different scales.
They share a market factor, so the lines rhyme without being copies, and they start anywhere from 1 to 1,450 — which is exactly the case that needs indexing before two of them can be read on one axis.
The dates come first, so the frame is ready for :func:jointview.plot.line_chart
as it stands, and seed makes it the same frame every time:
frame = demo_frame(rows=10) frame.columns[:2]['date', 'world_equity'] frame.height 10
Source code in src/jointview/data.py
load_frame(path)
¶
Read a DataFrame from path, or build the demo frame when it is None.
The suffix picks the reader, so no path means the generated frame rather than
an error — which is what makes marimo run app.py work with no arguments:
load_frame(None).columns[0] 'date'
Whatever the format, a column of dates arrives as dates: the text formats have no way to record that one ever was, so they are parsed back on the way in. A column that does not read as dates all the way down is left as the text it is.
A path that is not there is reported before a reader is chosen, so the message names the file rather than complaining about its extension:
load_frame("nowhere.parquet") Traceback (most recent call last): ... FileNotFoundError: no such file: nowhere.parquet
A directory is named as one. This is also where an empty Path lands, since
Path("") is Path(".") — the two are the same object by the time anything
here sees them, so the current directory is what actually arrived:
load_frame(".") Traceback (most recent call last): ... IsADirectoryError: not a file: .
Source code in src/jointview/data.py
Columns¶
Choosing the series, finding the x-axis, and cutting a chosen pair down to the rows the two share. Neither drawing nor summarising depends on the other; both are built on this.
jointview.columns
¶
Which columns the app can draw, and cutting a chosen pair down to its common sample.
This is the vocabulary both halves of the app are built on, and it belongs to neither
of them. :mod:jointview.plot draws what :func:aligned produces and
:mod:jointview.stats summarises it, so the column names and the shaping rules live
here rather than in either — two peers reaching into a third, instead of one reaching
into the other.
PERIOD is the clearest case. It is the name :func:aligned writes the x-axis under
and the name the statistics read it back out of: a data contract between the two, not
a fact about plotting.
aligned(frame, a, b)
¶
The two series on their common sample: period, a, b, in order.
Both the picture and the summary tables are built from this, so the numbers
beside the chart always describe the lines in it. Renaming also sidesteps
Vega-Lite's field-shorthand escaping and lets a and b be the same column.
The three names are fixed whatever the columns were called, the rows come out sorted by period, and a date where either series is missing is not part of the sample — here the frame arrives unsorted and with a gap on the 2nd:
import datetime as dt, polars as pl frame = pl.DataFrame( ... { ... "date": [dt.date(2024, 1, 3), dt.date(2024, 1, 1), dt.date(2024, 1, 2)], ... "x": [3.0, 1.0, None], ... "y": [30.0, 10.0, 20.0], ... } ... ) pair = aligned(frame, "x", "y") pair.columns ['period', 'a', 'b'] pair["a"].to_list() [1.0, 3.0]
Source code in src/jointview/columns.py
date_column(frame)
¶
The first temporal column, which becomes the x-axis. None means row number.
import datetime as dt, polars as pl date_column(pl.DataFrame({"when": [dt.date(2024, 1, 1)], "nav": [1.0]})) 'when' date_column(pl.DataFrame({"nav": [1.0]})) is None True
Source code in src/jointview/columns.py
default_pair(frame)
¶
Indices into :func:series_columns to open on — the first two series.
A frame with a single series opens on it twice, rather than refusing to draw.
import polars as pl default_pair(pl.DataFrame({"a": [1.0], "b": [2.0]})) (0, 1) default_pair(pl.DataFrame({"only": [1.0]})) (0, 0)
Source code in src/jointview/columns.py
series_columns(frame)
¶
The columns that can be drawn: every numeric one.
import datetime as dt, polars as pl frame = pl.DataFrame({"date": [dt.date(2024, 1, 1)], "nav": [1.0], "label": ["a"]}) series_columns(frame) ['nav']
Source code in src/jointview/columns.py
Charts¶
Turning an aligned pair into a picture.
jointview.plot
¶
Two price or NAV series drawn as two lines on one pair of axes.
The two columns are put on a shared y-axis rather than one axis each: two scales on
one plot invent a relationship that is not in the data. Where the levels are far
apart, rebase indexes both to the same starting value instead, which is the
honest way to compare a series priced at 12 with one priced at 4,000.
Choosing the columns and cutting them to their common sample happens before any of
this, in :mod:jointview.columns.
line_chart(frame, a, b, *, rebase=True, base=BASE, width='container', height=700, max_points=MAX_POINTS)
¶
Draw columns a and b of frame as two lines against time.
Four layers over one plotting area — the crosshair, the lines, the hover markers and the end labels — handed back as a plain Altair chart, so nothing here needs marimo to draw it:
import polars as pl frame = pl.DataFrame({"cash": [1.0, 1.01, 1.02], "balanced": [1450.0, 1479.0, 1465.0]}) chart = line_chart(frame, "cash", "balanced") type(chart).name 'LayerChart' len(chart.to_dict()["layer"]) 4
Width defaults to "container": the plot takes whatever the column around it
gives it, which is the point of a full-width app. Height stays a number, because
nothing in the page has a height for a chart to follow — 700 fills a laptop window
once the notebook margins are out of the way, without spilling off a short one.
Asking to rebase a pair that cannot be indexed draws the raw levels, and the
y-axis says level rather than claiming otherwise — see :func:_rebasable.
The title is read back out of the compiled spec, because that is the only place it
exists; layer 1 is :func:_lines, the series themselves:
chart.to_dict()["layer"][1]["encoding"]["y"]["title"] 'indexed to 100' pnl = pl.DataFrame({"strategy": [0.0, 5.0, 3.0], "benchmark": [0.0, 2.0, 4.0]}) line_chart(pnl, "strategy", "benchmark").to_dict()["layer"][1]["encoding"]["y"]["title"] 'level'
Source code in src/jointview/plot.py
line_frame(frame, a, b, *, rebase=True, base=BASE, max_points=MAX_POINTS)
¶
What the chart draws: one period column and one column per named series.
Wide rather than long, because the crosshair reads every series at the hovered period out of a single row.
import polars as pl frame = pl.DataFrame({"cash": [1.0, 1.01, 1.02], "balanced": [1450.0, 1479.0, 1465.0]}) drawn = line_frame(frame, "cash", "balanced") drawn.columns ['period', 'cash', 'balanced']
Rebasing is what lets those two share a y-axis at all: both leave the first
period at base, whatever they were priced at.
round(drawn["cash"][0], 6), round(drawn["balanced"][0], 6) (100.0, 100.0)
A column against itself is one line rather than two identical ones:
line_frame(frame, "cash", "cash").columns ['period', 'cash']
A series starting at zero cannot be indexed — a cumulative P&L curve starts there by construction — so the pair keeps its own levels instead:
pnl = pl.DataFrame({"strategy": [0.0, 5.0, 3.0], "benchmark": [0.0, 2.0, 4.0]}) line_frame(pnl, "strategy", "benchmark")["strategy"].to_list() [0.0, 5.0, 3.0]
Source code in src/jointview/plot.py
Statistics¶
The numbers under each dropdown, computed from the rows in the plot.
jointview.stats
¶
Summary statistics for a single price or NAV series, computed by jQuantStats.
Everything here takes a frame of levels — a net asset value, an index, a price — alongside the column that carries the period, and lets jQuantStats derive the returns and the statistics from them.
Passing the period column rather than a bare series is what buys the accuracy: the annualisation factor is read from the actual spacing of the observations, so a weekly or monthly series is annualised as one, instead of every series being assumed daily. A frame numbered by row rather than dated falls back to 252 periods a year, which is what a bare series had to assume in every case.
drawdown(frame, column, *, date_col=PERIOD)
¶
Distance below the running maximum, as a fraction — zero or negative.
jQuantStats reports the same quantity as a positive depth, so the sign is flipped here: a drawdown is a fall, and every other rate in this module carries its direction in its sign.
import polars as pl frame = pl.DataFrame({"period": [0, 1, 2], "nav": [100.0, 120.0, 60.0]}) round(drawdown(frame, "nav").min(), 4) -0.5
Source code in src/jointview/stats.py
metrics(frame, column, *, date_col=PERIOD, rf=0.0)
¶
The raw numbers behind the summary table, in natural units (0.07 is 7%).
A figure that cannot be formed — a Sharpe ratio for a flat series, a growth rate for a series that touches zero — comes back as NaN or infinity rather than raising, so one odd column never blanks the whole table.
Raises:
| Type | Description |
|---|---|
ValueError
|
if there are too few observations to derive anything. |
import polars as pl frame = pl.DataFrame({"period": [0, 1, 2, 3], "nav": [100.0, 110.0, 105.0, 120.0]}) numbers = metrics(frame, "nav") numbers["Observations"] 4.0 round(numbers["Total return"], 4) 0.2
Source code in src/jointview/stats.py
returns(frame, column, *, date_col=PERIOD)
¶
Simple period-over-period returns of a level series.
import polars as pl frame = pl.DataFrame({"period": [0, 1, 2], "nav": [100.0, 110.0, 99.0]}) [round(value, 4) for value in returns(frame, "nav")][0.1, -0.1]
Source code in src/jointview/stats.py
summary(frame, column, *, date_col=PERIOD, rf=0.0)
¶
The same numbers as :func:metrics, formatted for display.
import polars as pl frame = pl.DataFrame({"period": [0, 1, 2, 3], "nav": [100.0, 110.0, 105.0, 120.0]}) table = summary(frame, "nav") table.columns ['metric', 'value'] table.row(by_predicate=pl.col("metric") == "Total return")[1] '+20.00%'
Source code in src/jointview/stats.py
summary_markdown(frame, column, *, title=None, date_col=PERIOD, rf=0.0)
¶
A two-column markdown table, ready for mo.md.
import polars as pl frame = pl.DataFrame({"period": [0, 1, 2, 3], "nav": [100.0, 110.0, 105.0, 120.0]}) print(summary_markdown(frame, "nav", title="fund").splitlines()[0]) | fund | |
Source code in src/jointview/stats.py
Command line¶
What uvx jointview runs.
jointview.cli
¶
The jointview command: start marimo on the app that ships with this package.
main(argv=None)
¶
Run marimo run (or edit) on the packaged notebook, and return its exit code.