tests/test_series.py
Rama main · réplica de lectura del repositorio original.
"""The chart series: `portfolio_series`, `grouped_series`, `instrument_series`.
All three are walks over the same event stream (trades + dividends, valued
against stepped daily prices), so the tests here lean on one identity:
gain = value + cumulative proceeds + cumulative CASH income - invested
Everything else in this file — flow vs flow_in, what counts as `invested`, how
a non-AUD holding is converted — is a consequence of that identity holding at
*every* point in the series, not just at the last one. A series that is only
right on the final day still draws a wrong chart.
Every expected number below is derived by hand in the comment beside it. Two
constants are hand-listed from the reference fixture rather than read back out
of it (`SELL_PROCEEDS`, `CASH_DIVIDENDS`) precisely so the invariant check is
independent of the code under test: reconstructing cumulative proceeds from the
series' own `flow`/`flow_in` outputs would make the assertion circular, and in
particular would NOT notice a reinvested distribution being counted as income.
"""
from __future__ import annotations
import datetime as dt
from decimal import Decimal
import pytest
from freezegun import freeze_time
from sqlalchemy import select
import fixture_portfolio as ref
from app import queries
from app.models import Instrument
from factories import add_dividend, add_drp, add_fx_series, add_prices, add_trade, make_instrument
pytestmark = pytest.mark.usefixtures("pf")
@pytest.fixture
def portfolio(pf):
ref.build_reference(pf)
return pf
# --------------------------------------------------------------------------- #
# The reference portfolio's realised cash flows, hand-listed.
#
# Local to this file on purpose: the invariant test must know the answer
# without asking the series for it.
# --------------------------------------------------------------------------- #
# BETAX sell 150 x 8.00 - 10.00 brokerage = 1190.00
# ZULU sell 200 x 9.00 - 10.00 brokerage = 1790.00
SELL_PROCEEDS = [
(dt.date(2024, 6, 10), Decimal("1190.00")),
(dt.date(2024, 6, 20), Decimal("1790.00")),
]
# ALPHA's 2022 distribution was taken as cash, so it IS income. ALPHA's 2021
# distribution was reinvested and is deliberately absent: those 5 units are
# already in `value`, and counting the $50 here as well double-counts it.
CASH_DIVIDENDS = [(dt.date(2022, 7, 1), Decimal("60.00"))]
def _cumulative_at(rows: list[tuple[dt.date, Decimal]], day: dt.date) -> float:
"""Sum of the hand-listed amounts dated on or before `day`."""
return float(sum((amount for when, amount in rows if when <= day), Decimal(0)))
# --------------------------------------------------------------------------- #
# portfolio_series — the core invariant
# --------------------------------------------------------------------------- #
@freeze_time(ref.TODAY)
def test_gain_identity_holds_at_every_point_in_the_series(portfolio):
"""gain == value + proceeds + cash income - invested, on every single day.
Checked at all ~130 points rather than only the last, because the charts
draw the whole line: a mid-series drift (an event applied on the wrong day,
a cumulative accumulator reset) is invisible in an end-of-series assertion.
"""
series = queries.portfolio_series(portfolio)
assert series["dates"], "the reference portfolio must produce a series"
for i, iso in enumerate(series["dates"]):
day = dt.date.fromisoformat(iso)
expected = (
series["value"][i]
+ _cumulative_at(SELL_PROCEEDS, day)
+ _cumulative_at(CASH_DIVIDENDS, day)
- series["invested"][i]
)
assert series["gain"][i] == pytest.approx(expected, abs=0.01), f"gain at {iso}"
@freeze_time(ref.TODAY)
def test_series_matches_hand_computed_points_across_its_life(portfolio):
"""Four dates worked out by hand, each chosen for what it proves.
Spot values rather than the whole line: the invariant above proves the
parts are consistent with each other, these prove the parts are right.
"""
series = queries.portfolio_series(portfolio)
at = {iso: i for i, iso in enumerate(series["dates"])}
def point(iso: str) -> tuple[float, float, float]:
i = at[iso]
return series["invested"][i], series["value"][i], series["gain"][i]
# 2021-02-01 — ALPHA alone, one month after the only buy so far.
# invested 100 x 10.00 + 10.00 brokerage = 1010.00
# value 100 units x 10.00 = 1000.00
# gain 1000.00 - 1010.00 = -10.00 (the brokerage)
assert point("2021-02-01") == (1010.00, 1000.00, -10.00)
# 2021-08-01 — after the DRP. The 5 reinvested units add VALUE and nothing
# else: no invested (they cost no money in), no income (they are units).
# value 105 x 10.00 = 1050.00, gain 1050.00 - 1010.00 = 40.00
assert point("2021-08-01") == (1010.00, 1050.00, 40.00)
# 2022-08-01 — OMEGA bought (USD), and ALPHA's cash distribution received.
# invested 1010.00 + (10 x 100.00 + 5.00) USD x 1.50 fx = 1010.00 + 1507.50
# = 2517.50
# value ALPHA 105 x 10.00 = 1050.00
# + OMEGA 10 x 100.00 USD x 1.50 fx = 1500.00 = 2550.00
# gain 2550.00 + 0 proceeds + 60.00 cash - 2517.50 = 92.50
assert point("2022-08-01") == (2517.50, 2550.00, 92.50)
# 2024-07-01 — both sells done, every position at its post-sale balance.
# invested ALPHA 1010.00 + BETAX 1120.00 + GAMMA 2010.00
# + OMEGA 1507.50 + ZULU 2010.00 = 7657.50
# value ALPHA 105 x 12.00 = 1260.00
# + BETAX 50 x 8.00 = 400.00
# + GAMMA 100 x 20.00 = 2000.00
# + OMEGA 10 x 100.00 x 1.50 = 1500.00 = 5160.00
# gain 5160.00 + 2980.00 proceeds + 60.00 cash - 7657.50 = 542.50
assert point("2024-07-01") == (7657.50, 5160.00, 542.50)
@freeze_time(ref.TODAY)
def test_series_starts_at_the_first_trade_not_the_first_price(portfolio):
"""ALPHA is priced from 2021-01-01 but was bought on the 4th; a chart that
opened three days early would draw a flat zero run-in."""
series = queries.portfolio_series(portfolio)
# The first price date on or after the 2021-01-04 buy is the next monthly
# calendar point, 2021-02-01.
assert series["dates"][0] == "2021-02-01"
assert series["dates"][-1] == ref.TODAY.isoformat()
@freeze_time(ref.TODAY)
def test_invested_never_decreases_and_ends_at_the_hand_computed_total(portfolio):
"""`invested` is all-time outlay ("Total Spent"), so it only ever ratchets
up — a sale returns money but does not un-spend it."""
series = queries.portfolio_series(portfolio)
assert all(b >= a for a, b in zip(series["invested"], series["invested"][1:]))
assert Decimal(str(series["invested"][-1])) == ref.SERIES_INVESTED # 7657.50
# --------------------------------------------------------------------------- #
# The DRP double-count regression
# --------------------------------------------------------------------------- #
@freeze_time("2026-08-02")
def test_reinvested_dividends_are_not_counted_as_income(pf):
"""A reinvested distribution is units, not income.
The double-count adds the DRP's cash to `divs` as well as its units to
`value`, inflating gain by the distribution amount (~$9k on a DRP-heavy
portfolio). The smallest shape that catches it: one buy, one reinvested
distribution, nothing else — so gain must be exactly value - invested.
"""
nova = make_instrument(pf, "NOVA", asset_class="etf", name="Nova Index Fund", drp=True)
add_prices(pf, nova, [("2026-01-01", "10.00"), ("2026-04-01", "10.00"),
("2026-07-01", "12.00")])
add_trade(pf, nova, "2026-01-01", "buy", 100, "10.00", brokerage="10.00")
# $50 of distribution taken as 5 more units at $10 — no cash ever arrives.
add_drp(pf, nova, "2026-04-01", "50.00", 5, "10.00")
series = queries.portfolio_series(pf)
# invested = 100 x 10.00 + 10.00 brokerage = 1010.00
# value = (100 + 5 DRP) units x 12.00 = 1260.00
# gain = 1260.00 - 1010.00 = 250.00
# Counting the $50 as income as well would read 300.00.
assert series["invested"][-1] == 1010.00
assert series["value"][-1] == 1260.00
assert series["gain"][-1] == 250.00
assert series["gain"][-1] == series["value"][-1] - series["invested"][-1]
# And it must never show up as income on any day, including the DRP's own.
assert series["cash_div"] == [0.0, 0.0, 0.0]
@freeze_time("2026-08-02")
def test_cash_distribution_on_the_same_shape_does_count_as_income(pf):
"""The mirror of the regression above: take the same $50 as cash and it
must land in income, otherwise "don't double count" would have been
implemented as "don't count at all"."""
nova = make_instrument(pf, "NOVA", asset_class="etf", name="Nova Index Fund")
add_prices(pf, nova, [("2026-01-01", "10.00"), ("2026-04-01", "10.00"),
("2026-07-01", "12.00")])
add_trade(pf, nova, "2026-01-01", "buy", 100, "10.00", brokerage="10.00")
add_dividend(pf, nova, "2026-04-01", "50.00")
series = queries.portfolio_series(pf)
# value 100 x 12.00 = 1200.00, invested 1010.00, cash income 50.00
# gain = 1200.00 + 50.00 - 1010.00 = 240.00
assert series["value"][-1] == 1200.00
assert series["gain"][-1] == 240.00
assert series["cash_div"] == [0.0, 50.0, 0.0] # only on the payment date
# --------------------------------------------------------------------------- #
# flow (net) vs flow_in (gross)
# --------------------------------------------------------------------------- #
@freeze_time("2026-08-02")
def test_flow_is_net_of_sale_proceeds_while_flow_in_is_gross_buys(pf):
"""The two flows exist because they answer different questions: a
time-weighted return must remove the NET external flow, but "money you put
in" means GROSS buys — netting sales off that shrinks the denominator and
flatters the percentage."""
acme = make_instrument(pf, "ACME", asset_class="share", name="Acme Industries")
add_prices(pf, acme, [("2026-01-01", "10.00"), ("2026-03-01", "12.00"),
("2026-05-01", "12.00")])
add_trade(pf, acme, "2026-01-01", "buy", 100, "10.00", brokerage="10.00")
add_trade(pf, acme, "2026-03-01", "sell", 40, "12.00", brokerage="10.00")
series = queries.portfolio_series(pf)
# Day 1: a 1010.00 buy and no sale, so the two agree.
# 100 x 10.00 + 10.00 brokerage = 1010.00
assert series["flow_in"][0] == 1010.00
assert series["flow"][0] == 1010.00
# Day 2: no buy, one sale of 40 x 12.00 - 10.00 brokerage = 470.00 out.
# gross buys 0.00; net flow 0.00 - 470.00 = -470.00
assert series["flow_in"][1] == 0.00
assert series["flow"][1] == -470.00
# Over the whole series the difference is exactly the proceeds.
assert sum(series["flow_in"]) - sum(series["flow"]) == pytest.approx(470.00)
# flow_in is the day-by-day decomposition of `invested`.
assert sum(series["flow_in"]) == pytest.approx(series["invested"][-1])
# --------------------------------------------------------------------------- #
# What `invested` counts
# --------------------------------------------------------------------------- #
@freeze_time("2026-08-02")
def test_invested_counts_buys_only_not_drp_units_or_sales(pf):
"""DRP units cost nothing (no money entered the portfolio) and a sale
returns money rather than spending it, so neither moves `invested`."""
widget = make_instrument(pf, "WIDGET", asset_class="share", name="Widget Co")
add_prices(pf, widget, [("2026-01-01", "10.00"), ("2026-02-01", "10.00"),
("2026-03-01", "12.00")])
add_trade(pf, widget, "2026-01-01", "buy", 100, "10.00", brokerage="10.00")
add_trade(pf, widget, "2026-02-01", "drp", 5, "10.00")
add_trade(pf, widget, "2026-03-01", "sell", 20, "12.00", brokerage="10.00")
series = queries.portfolio_series(pf)
# The single buy: 100 x 10.00 + 10.00 brokerage = 1010.00, flat thereafter.
assert series["invested"] == [1010.00, 1010.00, 1010.00]
# Units still move: 100 -> 105 (DRP) -> 85 (sale).
# 100 x 10.00 = 1000.00 | 105 x 10.00 = 1050.00 | 85 x 12.00 = 1020.00
assert series["value"] == [1000.00, 1050.00, 1020.00]
# Final gain: 1020.00 value + (20 x 12.00 - 10.00) = 230.00 proceeds
# - 1010.00 invested = 240.00
assert series["gain"][-1] == 240.00
# --------------------------------------------------------------------------- #
# Non-AUD holdings
# --------------------------------------------------------------------------- #
@freeze_time("2026-08-02")
def test_foreign_cost_uses_each_trades_own_fx_while_value_uses_the_stored_rate(pf):
"""Cost is fixed in AUD the day it is paid; value floats with the rate.
Two buys at deliberately different rates, then a third rate for valuation,
so no single-rate shortcut can produce these numbers by accident.
"""
vertex = make_instrument(pf, "VERTEX", exchange="NASDAQ", asset_class="share",
currency="USD", name="Vertex Systems")
add_prices(pf, vertex, [("2026-01-01", "100.00"), ("2026-03-01", "100.00"),
("2026-05-01", "110.00")])
add_fx_series(pf, "USDAUD", [("2026-01-01", "1.50"), ("2026-03-01", "2.00"),
("2026-05-01", "1.80")])
add_trade(pf, vertex, "2026-01-01", "buy", 10, "100.00", brokerage="5.00",
fx_rate="1.50")
add_trade(pf, vertex, "2026-03-01", "buy", 10, "100.00", brokerage="5.00",
fx_rate="2.00")
series = queries.portfolio_series(pf)
# invested, each parcel at the rate it was actually paid at:
# (10 x 100.00 + 5.00) USD x 1.50 = 1507.50
# + (10 x 100.00 + 5.00) USD x 2.00 = 2010.00 -> 3517.50
assert series["invested"] == [1507.50, 3517.50, 3517.50]
# value, at the rate stored for that day:
# 10 x 100.00 x 1.50 = 1500.00
# 20 x 100.00 x 2.00 = 4000.00
# 20 x 110.00 x 1.80 = 3960.00
assert series["value"] == [1500.00, 4000.00, 3960.00]
# gain = 3960.00 - 3517.50 = 442.50. Revaluing the COST at 1.80 would give
# 20 x 100.50 x 1.80 = 3618.00 invested and a 342.00 gain instead.
assert series["gain"][-1] == 442.50
@freeze_time("2026-08-02")
def test_foreign_value_before_the_fx_history_starts_is_not_valued_at_one_to_one(pf):
"""A portfolio seeded from a workbook can predate the FX feed's backfill.
Nothing has moved on day one — same price as the purchase, and 1.50 is the
only rate the app has ever held for this pair — so the chart must not open
with a loss manufactured out of a missing market-data row.
`(rate or Decimal(1))` is the tempting shape and it is wrong: it values the
holding unconverted while its cost is booked at the trade's real rate.
`FxBook` falls back to the earliest rate it has, never to 1 —
decisions.md #5.
"""
vertex = make_instrument(pf, "VERTEX", exchange="NASDAQ", asset_class="share",
currency="USD", name="Vertex Systems")
add_prices(pf, vertex, [("2026-01-01", "100.00"), ("2026-03-01", "100.00")])
# The daily FX series only reaches back to March; the trade is from January.
add_fx_series(pf, "USDAUD", [("2026-03-01", "1.50")])
add_trade(pf, vertex, "2026-01-01", "buy", 10, "100.00", fx_rate="1.50")
series = queries.portfolio_series(pf)
# invested = 10 x 100.00 USD x 1.50 = 1500.00 on both days.
assert series["invested"] == [1500.00, 1500.00]
# value on day one should be 10 x 100.00 USD x 1.50 = 1500.00 (the earliest
# rate known for the pair), so gain is 0.00. It currently reads 1000.00 —
# the USD figure unconverted — and a -500.00 gain.
assert series["value"][0] == 1500.00
assert series["gain"][0] == 0.00
# --------------------------------------------------------------------------- #
# grouped_series
# --------------------------------------------------------------------------- #
@freeze_time(ref.TODAY)
def test_grouped_by_asset_class_sums_to_the_ungrouped_total(portfolio):
"""Splitting a chart must not create or destroy money."""
total = queries.portfolio_series(portfolio)
grouped = queries.grouped_series(portfolio, "asset_class")
assert grouped["dates"] == total["dates"]
assert sorted(grouped["groups"]) == ["etf", "share"]
etf, share = grouped["groups"]["etf"], grouped["groups"]["share"]
# etf = ALPHA only: 105 x 15.00 = 1575.00
# share = BETAX 50 x 9.00 = 450.00
# + GAMMA 100 x 5.00 = 500.00
# + OMEGA 10 x 120.00 USD x 1.60 fx = 1920.00
# + ZULU (sold out, 0 units) = 0.00 = 2870.00
assert etf["value"][-1] == 1575.00
assert share["value"][-1] == 2870.00
assert etf["value"][-1] + share["value"][-1] == total["value"][-1] # 4445.00
# invested: etf 1010.00; share 1120.00 + 2010.00 + 1507.50 + 2010.00
# = 6647.50
assert etf["invested"][-1] == 1010.00
assert share["invested"][-1] == 6647.50
assert etf["invested"][-1] + share["invested"][-1] == total["invested"][-1] # 7657.50
# gain: etf 1575.00 + 0 proceeds + 60.00 cash - 1010.00 = 625.00
# share 2870.00 + 2980.00 proceeds + 0 cash - 6647.50 = -797.50
assert etf["gain"][-1] == 625.00
assert share["gain"][-1] == -797.50
assert etf["gain"][-1] + share["gain"][-1] == pytest.approx(total["gain"][-1]) # -172.50
@freeze_time(ref.TODAY)
def test_grouped_by_ticker_sums_to_the_ungrouped_total(portfolio):
"""The finest split there is — every holding on its own line."""
total = queries.portfolio_series(portfolio)
grouped = queries.grouped_series(portfolio, "ticker")
# ZULU is fully sold but keeps a line: it has invested history to draw.
assert sorted(grouped["groups"]) == ["ALPHA", "BETAX", "GAMMA", "OMEGA", "ZULU"]
per_ticker = {k: v["value"][-1] for k, v in grouped["groups"].items()}
# 105 x 15.00 | 50 x 9.00 | 100 x 5.00 | 10 x 120.00 x 1.60 | 0 units
assert per_ticker == {"ALPHA": 1575.00, "BETAX": 450.00, "GAMMA": 500.00,
"OMEGA": 1920.00, "ZULU": 0.00}
assert sum(per_ticker.values()) == total["value"][-1] # 4445.00
invested = {k: v["invested"][-1] for k, v in grouped["groups"].items()}
assert invested == {"ALPHA": 1010.00, "BETAX": 1120.00, "GAMMA": 2010.00,
"OMEGA": 1507.50, "ZULU": 2010.00}
assert sum(invested.values()) == total["invested"][-1] # 7657.50
@freeze_time(ref.TODAY)
def test_grouped_gain_identity_holds_per_group_at_every_point(portfolio):
"""The same identity as the portfolio series, but ALPHA on its own: value
plus its cash distribution less its outlay, on every day."""
grouped = queries.grouped_series(portfolio, "ticker")
alpha = grouped["groups"]["ALPHA"]
for i, iso in enumerate(grouped["dates"]):
day = dt.date.fromisoformat(iso)
# ALPHA never sold, so proceeds are zero throughout; its only cash
# income is the 60.00 taken on 2022-07-01 (the 2021 one was reinvested).
cash = 60.00 if day >= dt.date(2022, 7, 1) else 0.00
expected = alpha["value"][i] + cash - alpha["invested"][i]
assert alpha["gain"][i] == pytest.approx(expected, abs=0.01), f"ALPHA gain at {iso}"
@freeze_time("2026-08-02")
def test_groups_with_no_history_are_dropped(pf):
"""An instrument in the catalogue that this portfolio never bought would
otherwise draw a flat zero line and a legend entry for nothing."""
acme = make_instrument(pf, "ACME", asset_class="share", name="Acme Industries")
orbit = make_instrument(pf, "ORBIT", asset_class="etf", name="Orbit Index Fund")
add_prices(pf, acme, [("2026-01-01", "10.00"), ("2026-03-01", "12.00")])
add_prices(pf, orbit, [("2026-01-01", "20.00"), ("2026-03-01", "25.00")])
add_trade(pf, acme, "2026-01-01", "buy", 100, "10.00", brokerage="10.00")
by_ticker = queries.grouped_series(pf, "ticker")
by_class = queries.grouped_series(pf, "asset_class")
# ORBIT is priced but never traded here: no invested, no units, no line.
assert sorted(by_ticker["groups"]) == ["ACME"]
assert sorted(by_class["groups"]) == ["share"]
# ACME's own numbers are untouched by the empty neighbour:
# invested 100 x 10.00 + 10.00 = 1010.00, value 100 x 12.00 = 1200.00
assert by_ticker["groups"]["ACME"]["invested"][-1] == 1010.00
assert by_ticker["groups"]["ACME"]["value"][-1] == 1200.00
@freeze_time(ref.TODAY)
def test_filters_restrict_which_instruments_contribute(portfolio):
"""The chart builder's filter shelf: an asset-class filter must remove the
other holdings from the totals, not merely from the legend."""
etf_only = queries.grouped_series(portfolio, "ticker", {"asset_class": ["etf"]})
assert sorted(etf_only["groups"]) == ["ALPHA"]
# ALPHA alone: 105 x 15.00 = 1575.00 value, 100 x 10.00 + 10.00 invested.
assert etf_only["groups"]["ALPHA"]["value"][-1] == 1575.00
assert etf_only["groups"]["ALPHA"]["invested"][-1] == 1010.00
@freeze_time(ref.TODAY)
def test_a_filtered_series_starts_at_the_filtered_holdings_first_trade(portfolio):
"""Filtering down to GAMMA must also shorten the date axis — carrying the
portfolio's 2021 start would draw five years of flat nothing."""
gamma_only = queries.grouped_series(portfolio, "asset_class", {"ticker": ["GAMMA"]})
assert sorted(gamma_only["groups"]) == ["share"]
assert gamma_only["dates"][0] == "2024-03-01" # GAMMA's buy date
# 100 x 20.00 + 10.00 = 2010.00 invested, 100 x 5.00 = 500.00 value today,
# so gain = 500.00 - 2010.00 = -1510.00
assert gamma_only["groups"]["share"]["invested"][-1] == 2010.00
assert gamma_only["groups"]["share"]["value"][-1] == 500.00
assert gamma_only["groups"]["share"]["gain"][-1] == -1510.00
@freeze_time(ref.TODAY)
def test_a_filter_matching_nothing_yields_an_empty_series(portfolio):
"""A filter combination with no instruments behind it is a legitimate state
of the chart builder, not an error."""
none = queries.grouped_series(portfolio, "ticker", {"asset_class": ["crypto"]})
assert none == {"dates": [], "groups": {}, "excluded": []}
# --------------------------------------------------------------------------- #
# Empty portfolios
# --------------------------------------------------------------------------- #
@freeze_time("2026-08-02")
def test_empty_portfolio_returns_empty_series_rather_than_raising(pf):
"""A brand-new account loads the charts page before entering anything."""
series = queries.portfolio_series(pf)
assert series == {"dates": [], "invested": [], "value": [], "gain": [],
"gain_pct": [], "flow": [], "flow_in": [], "cash_div": [],
"unpriced": [], "excluded": [], "excluded_since": {}}
assert queries.grouped_series(pf, "ticker") == {"dates": [], "groups": {}, "excluded": []}
@freeze_time("2026-08-02")
def test_catalogue_with_prices_but_no_trades_returns_an_empty_series(pf):
"""Market data is shared, so a fresh portfolio can see priced instruments
it has never held. That is still an empty series, not a zero-value line."""
orbit = make_instrument(pf, "ORBIT", asset_class="etf", name="Orbit Index Fund")
add_prices(pf, orbit, [("2026-01-01", "20.00"), ("2026-03-01", "25.00")])
assert queries.portfolio_series(pf)["dates"] == []
assert queries.grouped_series(pf, "asset_class") == {"dates": [], "groups": {}, "excluded": []}
# --------------------------------------------------------------------------- #
# instrument_series
# --------------------------------------------------------------------------- #
@freeze_time("2026-08-02")
def test_instrument_series_tracks_cumulative_invested_and_unit_value(pf):
"""One holding's own line: invested ratchets on buys only, value follows
the unit balance times that day's close."""
delta = make_instrument(pf, "DELTAQ", asset_class="share", name="Delta Quarry")
add_prices(pf, delta, [("2025-12-01", "9.00"), ("2026-01-01", "10.00"),
("2026-02-01", "11.00"), ("2026-03-01", "12.00"),
("2026-04-01", "12.00")])
add_trade(pf, delta, "2026-01-01", "buy", 100, "10.00", brokerage="10.00")
add_trade(pf, delta, "2026-02-01", "drp", 5, "11.00")
add_trade(pf, delta, "2026-03-01", "sell", 25, "12.00", brokerage="10.00")
series = queries.instrument_series(pf, delta)
# The 2025-12-01 close predates the first trade and is dropped.
assert series["dates"] == ["2026-01-01", "2026-02-01", "2026-03-01", "2026-04-01"]
# invested: 100 x 10.00 + 10.00 brokerage = 1010.00, then flat — the DRP
# units cost nothing and the sale is not a negative purchase.
assert series["invested"] == [1010.00, 1010.00, 1010.00, 1010.00]
# value: 100 x 10.00 | 105 x 11.00 | 80 x 12.00 | 80 x 12.00
assert series["value"] == [1000.00, 1155.00, 960.00, 960.00]
@freeze_time("2026-08-02")
def test_instrument_series_is_in_native_currency_not_converted(pf):
"""The per-holding chart is drawn in the instrument's own currency, so a
stored FX rate must not touch it — mixing the two would silently rescale
every USD holding's line."""
vertex = make_instrument(pf, "VERTEX", exchange="NASDAQ", asset_class="share",
currency="USD", name="Vertex Systems")
add_prices(pf, vertex, [("2026-01-01", "100.00"), ("2026-03-01", "110.00")])
add_fx_series(pf, "USDAUD", [("2026-01-01", "1.50"), ("2026-03-01", "1.80")])
add_trade(pf, vertex, "2026-01-01", "buy", 10, "100.00", brokerage="5.00",
fx_rate="1.50")
series = queries.instrument_series(pf, vertex)
# USD throughout: 10 x 100.00 + 5.00 = 1005.00 invested (not x1.50),
# value 10 x 100.00 = 1000.00 then 10 x 110.00 = 1100.00 (not x1.80).
assert series["invested"] == [1005.00, 1005.00]
assert series["value"] == [1000.00, 1100.00]
@freeze_time("2026-08-02")
def test_instrument_series_is_empty_for_an_untraded_instrument(pf):
"""Opening the ledger for something in the catalogue this portfolio has
never bought."""
orbit = make_instrument(pf, "ORBIT", asset_class="etf", name="Orbit Index Fund")
add_prices(pf, orbit, [("2026-01-01", "20.00"), ("2026-03-01", "25.00")])
assert queries.instrument_series(pf, orbit) == {"dates": [], "invested": [], "value": []}
@freeze_time(ref.TODAY)
def test_instrument_series_value_reaches_zero_when_a_position_is_closed(portfolio):
"""ZULU was sold out entirely: its line must go to zero and stay there
while `invested` keeps the outlay on the record."""
zulu = portfolio.scalars(select(Instrument).where(Instrument.ticker == "ZULU")).one()
series = queries.instrument_series(portfolio, zulu)
at = {iso: i for i, iso in enumerate(series["dates"])}
# Bought 200 @ 10.00 + 10.00 brokerage on 2023-02-01 -> 2010.00 outlay,
# 200 x 10.00 = 2000.00 of value the day it was bought.
assert series["invested"][at["2023-02-01"]] == 2010.00
assert series["value"][at["2023-02-01"]] == 2000.00
# Sold out on 2024-06-20; the next price point holds no units.
assert series["value"][at["2024-07-01"]] == 0.00
assert series["invested"][at["2024-07-01"]] == 2010.00 # outlay stays on record
assert series["value"][-1] == 0.00