Ctrl K
ComunidadGuías…

tests/test_series.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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