Recipe: add a chart field or measure¶
Charts are built from a vocabulary, not from bespoke code per chart. A saved chart is a spec — a grain, an x axis, some measures, a type — and the builder page offers whatever the vocabulary declares. Adding a measure makes it available to every chart, the builder UI, and the table view, at once.
Start with the test
Write the assertion in tests/test_charts.py for the number your field
should produce, from a fixture you can compute by hand, and watch it fail.
Doing it in this order is what stops a chart field from being "whatever the
code returned". Details in Testing it below and in
Testing.
The pieces¶
| File | Holds |
|---|---|
app/fields.py |
The vocabulary: grains, and the fields each one offers |
app/charts_build.py |
Running a spec against the data |
app/chart_templates.py |
The built-in charts, expressed as specs |
Grains are the shapes data can come in: daily (the time series),
holdings (one row per instrument), periods (the performance table). A field
belongs to a grain because it only means something at that shape — "day change"
is a holdings idea, not a daily-series one.
Adding a measure to an existing grain¶
In fields.py, add it to that grain's field list:
Field(
key="h_yield_on_cost",
label="Yield on cost",
blurb="Distributions received, over what you paid. Shown per holding.",
kind="percent", # money | percent | number | text
value=lambda h: (h.dividends_cash / h.cost) if h.cost else None,
),
keyis stored in saved chart specs. Once shipped it is permanent — changing it breaks everyone's saved charts. Prefix by grain (h_for holdings) so two grains can both have a "value".blurbis shown in the builder. Say what it means, not what it is called.kinddrives formatting, axis choice and whether it can be stacked.valuereturnsNonewhen the measure does not apply. Never zero — a zero yield and an unknowable yield are different facts, and only one of them should be plotted.
That is usually the whole change. The builder, the renderer and the table view all read the vocabulary.
Things that will catch you¶
Ratios cannot be stacked. Stacking two percentages produces a number that
means nothing. If your measure is a ratio, make sure kind="percent" — the
renderer uses that to refuse.
One axis, always. This project does not do dual-axis charts. If a measure cannot share a scale with the others, it belongs on its own chart. (There is a known open defect where a split positions chart stacks a ratio measure — do not copy that pattern.)
Money must be blurrable. Values rendered into the table view go through the
m class so the idle overlay can obscure them. If you add a renderer path, keep
that.
FX honesty applies here too. If your measure converts to AUD, go through
queries.FxBook. An instrument whose currency has no stored rate is excluded
and named in the series' excluded list, not silently valued at 1:1.
Testing¶
Test the value function directly against the reference fixture, where the expected number can be worked out by hand:
def test_yield_on_cost_is_distributions_over_outlay(portfolio):
holdings = {h.instrument.ticker: h for h in queries.all_holdings(portfolio)}
field = fields.by_key("h_yield_on_cost")
# ALPHA: 60.00 distributions on 1010.00 outlay = 5.94%
assert field.value(holdings["ALPHA"]) == pytest.approx(Decimal("0.0594"), abs=1e-4)
Then the None case, and one builder test that the field appears for its grain
and not for the others.