"""Read-model for the UI: everything is computed from trades + prices. The dataset is tiny (hundreds of rows), so these pull the rows and compute in Python rather than pushing aggregation into SQL — clearer to evolve while the model is still growing (FY reports, CGT parcels, charts all come later). """ from __future__ import annotations import bisect import datetime as dt import json import threading from collections import Counter, OrderedDict from dataclasses import dataclass, field from decimal import Decimal from urllib.parse import urlencode from sqlalchemy import func, select from sqlalchemy.orm import Session, selectinload from . import clock, money from .models import ( CorporateAction, Dividend, FxRate, HoldingPref, Instrument, InvestmentPlan, InvestmentPlanEntry, Price, Trade, trade_order, ) ZERO = Decimal(0) ONE = Decimal(1) # What every `*_aud` figure is expressed in. Named rather than spelled "AUD" at # each comparison so the T28b work has one place to look — `portfolio. # reporting_currency` already exists in the schema and this is what it will # feed. Until then the app is AUD-only and says so here rather than in a dozen # string literals. REPORTING_CURRENCY = "AUD" # A quantity column's last place: a split is rounded to it, once per holding. UNIT = Decimal("0.00000001") def timeline(trades, actions=()) -> list: """Trades and corporate actions in the order they happened. An action takes effect from the start of its day, before that day's trades (#146).""" return sorted([*trades, *actions], key=moment) def moment(row) -> tuple: """Where a trade or an action sits in its holding's timeline.""" if isinstance(row, CorporateAction): return (row.date, 0, dt.time.min, 0, row.id or 0) return (row.date, 1, *trade_order(row)[1:]) def action_label(action: CorporateAction) -> str: """What a corporate action is called in a ledger or an export row.""" if action.kind == "split": word = "split" if action.ratio_new > action.ratio_old else "consolidation" return f"{word} {action.ratio_new} for {action.ratio_old}" if action.kind == "capital_return": return "capital return" return f"cost base {'+' if action.amount > 0 else '−'}{abs(action.amount).normalize():f}" def split_units(units: Decimal, action: CorporateAction) -> Decimal: """`units` after a split, rounded once to the quantity column's last place: a holding's parcels then share exactly this between them.""" return (units * action.ratio_new / action.ratio_old).quantize(UNIT) def units_through(rows) -> Decimal: """Units held after `rows`, a `timeline`: trades add and take away, a split scales what is held.""" units = ZERO for row in rows: if isinstance(row, CorporateAction): if row.kind == "split": units = split_units(units, row) else: units += row.quantity if row.type in ("buy", "drp") else -row.quantity return units def later_splits(rows) -> dict[int, Decimal]: """For each trade in `rows`, a `timeline`, what the splits after it multiply its units by: how many units it is now. Keyed by `id(trade)`.""" factor, out = Decimal(1), {} for row in reversed(rows): if isinstance(row, CorporateAction): if row.kind == "split": factor = factor * row.ratio_new / row.ratio_old else: out[id(row)] = factor return out def prefs_by_instrument(session: Session) -> dict[int, HoldingPref]: """This user's per-holding settings (DRP participation + private note), keyed by instrument id. Tenancy filtering happens in tenancy.py.""" return {p.instrument_id: p for p in session.scalars(select(HoldingPref)).all()} @dataclass class Holding: instrument: Instrument units: Decimal avg_price: Decimal | None # native ccy, over buys + DRP (matches the sheet) # What the units still held cost, with brokerage, the oldest sold first: # the gain below is UNREALISED, on those units (decisions.md #54). None # for a ledger with a sale of more than was held, which has no FIFO cost. cost: Decimal | None # native ccy dividends_cash: Decimal # native ccy price: Decimal | None # latest close, native ccy price_date: dt.date | None value: Decimal | None # native ccy gain: Decimal | None # value - cost gain_pct: Decimal | None # Every buy with its brokerage, sold or not: what a closed position cost. invested: Decimal = ZERO # native ccy # AUD view: cost at trade-date fx, value at latest fx. None = fx missing. cost_aud: Decimal | None = None value_aud: Decimal | None = None gain_aud: Decimal | None = None dividends_aud: Decimal | None = None # The per-unit pair, converted. These exist because a USD holding's `Avg # price` and `Price` answer in dollars nobody here paid or received, and # the portfolio's own totals are in AUD — so the two columns that say what # ONE unit cost and is worth were the only ones with no reporting-currency # counterpart. See app/columns.py for the columns themselves. avg_price_aud: Decimal | None = None price_aud: Decimal | None = None # What the share registry is still holding: the DRP residual left by the # most recent distribution. **A balance, not income** — the whole # distribution is already assessable through `cash_amount`, so this must # never be added to a dividend total. None means no distribution has ever # recorded one, which is a different fact from a residual of zero. drp_residual: Decimal | None = None # Daily move: latest close vs the one before (sheet's "% Increase Today"). day_change_aud: Decimal | None = None day_pct: Decimal | None = None # This user's own view of the holding (holding_pref) — never shared. note: str | None = None drp: bool = False # A sale of more than was held, which leaves no FIFO cost (fyreport.Oversold). oversold: bool = False # A figure here converted at a rate more than a week from its date (#124). far: bool = False def _latest_price(session: Session, instrument_id: int) -> tuple[Decimal, dt.date] | None: row = session.execute( select(Price.close, Price.date) .where(Price.instrument_id == instrument_id) .order_by(Price.date.desc()) .limit(1) ).first() return (row.close, row.date) if row else None def close_on_or_before( session: Session, instrument_id: int, on: dt.date ) -> tuple[Decimal, dt.date] | None: """The close for `on`, or the last one before it, with the date it is for. Backs the trade form's price prefill. Returns the date too, so the form can say "the 18 Sep close" rather than implying the market traded on a Saturday. Reaches BACKWARDS only, unlike `FxBook.rate` — decisions.md #124. """ row = session.execute( select(Price.close, Price.date) .where(Price.instrument_id == instrument_id, Price.date <= on) .order_by(Price.date.desc()) .limit(1) ).first() return (row.close, row.date) if row else None def fx_on_or_before( session: Session, currency: str, on: dt.date ) -> tuple[Decimal, dt.date] | None: """The rate for `on`, or the last one before it within FX_REACH_DAYS. None for AUD, and None when the stored series has nothing that close. Same query the price feed's repair pass uses, and the same backwards-only rule as `close_on_or_before`: an empty rate is one the feed can still fix, a wrong one is not — decisions.md #124. AUD returns None rather than 1, because the form hides the field entirely for the reporting currency and a 1 in it only invites being edited. """ if currency == REPORTING_CURRENCY: return None row = session.execute( select(FxRate.rate, FxRate.date) .where(FxRate.pair == f"{currency}{REPORTING_CURRENCY}", FxRate.date <= on, FxRate.date >= on - dt.timedelta(days=FX_REACH_DAYS)) .order_by(FxRate.date.desc()) .limit(1) ).first() return (row.rate, row.date) if row else None def _last_two_closes(session: Session, instrument_id: int) -> list[Decimal]: return list( session.execute( select(Price.close) .where(Price.instrument_id == instrument_id) .order_by(Price.date.desc()) .limit(2) ).scalars() ) def latest_fx(session: Session, currency: str) -> Decimal | None: """AUD per 1 unit of `currency` (latest stored close); 1 for AUD.""" if currency == "AUD": return ONE return session.execute( select(FxRate.rate) .where(FxRate.pair == f"{currency}AUD") .order_by(FxRate.date.desc()) .limit(1) ).scalar() def in_aud(amount: Decimal, currency: str, stored_rate: Decimal | None) -> Decimal | None: """`amount` converted to AUD, or None when the rate is genuinely unknown. A same-currency row needs no stored rate; a foreign one with no rate is still withheld. Both halves matter — decisions.md #5 and #6. """ if currency == REPORTING_CURRENCY: return amount if stored_rate is None: return None return amount * stored_rate def build_holding( session: Session, inst: Instrument, pref: HoldingPref | None = None, fx_book: FxBook | None = None, ) -> Holding: from . import fyreport fx_book = fx_book or FxBook(session) buys = [t for t in inst.trades if t.type == "buy"] accumulating = [t for t in inst.trades if t.type in ("buy", "drp")] rows = timeline(inst.trades, inst.actions) units = units_through(rows) invested = sum((t.quantity * t.unit_price + t.brokerage for t in buys), ZERO) # Over the units each purchase is now, after any split since it. now = later_splits(rows) acc_units = sum((t.quantity * now[id(t)] for t in accumulating), ZERO) avg = ( (sum((t.quantity * t.unit_price for t in accumulating), ZERO) / acc_units) if acc_units else None ) dividends_cash = sum((d.cash_amount for d in inst.dividends), ZERO) # The cost of the units still held, from the same FIFO walk as the CGT # figures, each parcel at its own trade's rate or else the stored series. oversold = False try: held = fyreport.walk(inst, fx_book).held except fyreport.OversoldError: held, oversold = [], True cost = None if oversold else sum((p.native_held for p in held), ZERO) cost_aud = None if oversold or any(p.cost_held is None for p in held) else sum( (p.cost_held for p in held), ZERO) latest = _latest_price(session, inst.id) price, price_date = latest if latest else (None, None) value = gain = gain_pct = None if price is not None and units: value = units * price if cost is not None: gain = value - cost gain_pct = gain / cost if cost else None # Value at the latest rate, named if that is more than a week old. fx_now = fx_book.rate(inst.currency, clock.today(), inst.ticker if price is not None and units else None) value_aud = value * fx_now if (value is not None and fx_now is not None) else None gain_aud = ( value_aud - cost_aud if (value_aud is not None and cost_aud is not None) else None ) dividends_aud = ZERO for d in inst.dividends: part = in_aud(d.cash_amount, inst.currency, fx_book.of(d, inst.currency, inst.ticker)) if part is None: dividends_aud = None break dividends_aud += part # The average paid per unit, each trade converted at its OWN rate and on # the same basis as the native `avg_price` above. Deliberately not # `cost_aud / units`, which is a third number — decisions.md #50. avg_price_aud = None if acc_units: paid_aud = ZERO for t in accumulating: part = in_aud(t.quantity * t.unit_price, inst.currency, fx_book.of(t, inst.currency, inst.ticker)) if part is None: paid_aud = None break paid_aud += part if paid_aud is not None: avg_price_aud = paid_aud / acc_units price_aud = price * fx_now if (price is not None and fx_now is not None) else None # The LATEST residual, not a sum. Every dividend stores the balance the # registry holds *after* it, so adding them together would count the same # few cents once per distribution. last_dividend = max(inst.dividends, key=lambda d: (d.date, d.id), default=None) drp_residual = last_dividend.residual_carried if last_dividend else None day_change_aud = day_pct = None if units: closes = _last_two_closes(session, inst.id) if len(closes) == 2 and closes[1]: day_pct = (closes[0] - closes[1]) / closes[1] if fx_now is not None: day_change_aud = (closes[0] - closes[1]) * units * fx_now return Holding( instrument=inst, units=units, avg_price=avg, cost=cost, dividends_cash=dividends_cash, price=price, price_date=price_date, value=value, gain=gain, gain_pct=gain_pct, invested=invested, cost_aud=cost_aud, value_aud=value_aud, gain_aud=gain_aud, dividends_aud=dividends_aud, avg_price_aud=avg_price_aud, price_aud=price_aud, drp_residual=drp_residual, day_change_aud=day_change_aud, day_pct=day_pct, note=pref.note if pref else None, drp=pref.drp if pref else inst.drp, oversold=oversold, far=inst.ticker in fx_book.far, ) def all_holdings(session: Session) -> list[Holding]: """A holding for each instrument this portfolio has, sold or not. The catalogue is shared (decisions.md #125): built from all of it, another portfolio's instruments cost queries here and read as history.""" instruments = ( session.scalars( select(Instrument) .where(Instrument.id.in_(portfolio_instrument_ids(session))) .options(selectinload(Instrument.trades), selectinload(Instrument.dividends), selectinload(Instrument.actions)) .order_by(Instrument.asset_class, Instrument.ticker) ) .unique() .all() ) prefs = prefs_by_instrument(session) fx_book = FxBook(session) return [build_holding(session, inst, prefs.get(inst.id), fx_book) for inst in instruments] @dataclass class RealisedPosition: instrument: Instrument proceeds: Decimal | None # native ccy; None when sale not yet recorded outlay: Decimal note: str | None = None # the holder's own note (sale details etc.) def split_positions( holdings: list[Holding], ) -> tuple[list[Holding], list[RealisedPosition]]: """Open positions vs closed/sold ones (zero units or marked inactive).""" open_, closed = [], [] for h in holdings: if h.units > 0 and h.instrument.active: open_.append(h) elif h.instrument.trades: # ignore instruments with no history (e.g. ZULU) sells = [t for t in h.instrument.trades if t.type == "sell"] proceeds = ( sum((t.quantity * t.unit_price - t.brokerage for t in sells), ZERO) if sells else None ) closed.append( RealisedPosition( instrument=h.instrument, proceeds=proceeds, outlay=h.invested, note=h.note, ) ) return open_, closed @dataclass class DashboardTotals: """AUD totals over open positions whose AUD value is computable. `cost` is what the units held cost and `gain` is unrealised (decisions.md #54).""" cost: Decimal value: Decimal gain: Decimal gain_pct: Decimal | None dividends: Decimal excluded: list[str] # tickers without a usable AUD valuation yet # Separate from `excluded`: a holding can be valued perfectly and still # have one unconvertible distribution. Reported so the dividend total # cannot shrink without saying so. dividends_excluded: list[str] = field(default_factory=list) day_change: Decimal = ZERO # AUD move since the previous close day_pct: Decimal | None = None # Left out for a sale of more than was held, which leaves no FIFO cost. oversold: list[str] = field(default_factory=list) # Converted at a rate more than a week from its date (#124). far: list[str] = field(default_factory=list) def totals(open_positions: list[Holding]) -> DashboardTotals: cost = value = dividends = ZERO excluded: list[str] = [] oversold: list[str] = [] dividends_excluded: list[str] = [] for h in open_positions: if h.cost_aud is not None and h.value_aud is not None: cost += h.cost_aud value += h.value_aud elif h.oversold: oversold.append(h.instrument.ticker) else: # No stored fx/price yet (feed hasn't run since this was added). excluded.append(h.instrument.ticker) if h.dividends_aud is not None: dividends += h.dividends_aud elif h.instrument.dividends: dividends_excluded.append(h.instrument.ticker) gain = value - cost day_change = sum( (h.day_change_aud for h in open_positions if h.day_change_aud is not None), ZERO ) yesterday = value - day_change return DashboardTotals( cost=cost, value=value, gain=gain, gain_pct=(gain / cost) if cost else None, dividends=dividends, excluded=excluded, dividends_excluded=dividends_excluded, day_change=day_change, day_pct=(day_change / yesterday) if yesterday else None, oversold=oversold, far=[h.instrument.ticker for h in open_positions if h.far], ) @dataclass(frozen=True) class Breach: """Where a timeline first goes negative, and what tipped it over.""" trade: Trade # the sell that can't be covered balance: Decimal # the (negative) running balance right after it held_before: Decimal # units held immediately before it is_candidate: bool # is the offender the row being added/edited? def _walk( trades: list[Trade], candidate: Trade | None, exclude_ids: frozenset[int], actions=(), ) -> tuple[Decimal, Breach | None]: rows = timeline( [ *(t for t in trades if t.id is None or t.id not in exclude_ids), *([candidate] if candidate is not None else []), ], actions, ) units = ZERO for t in rows: if isinstance(t, CorporateAction): if t.kind == "split": units = split_units(units, t) continue before = units units += t.quantity if t.type in ("buy", "drp") else -t.quantity if units < 0: return units, Breach( trade=t, balance=units, held_before=before, is_candidate=t is candidate, ) return units, None def balance_after( trades: list[Trade], candidate: Trade | None = None, *, exclude_ids: frozenset[int] | set[int] = frozenset(), actions=(), ) -> Decimal | None: """Running unit balance walked in timeline order, or None if it ever goes negative. Guards manual sell entry: `fyreport.instrument_disposals` raises when a sell exceeds the parcels held, which leaves the holding out of the FY report's capital gains and refuses the CGT exports (`fyreport.Oversold`). Checking the *running* balance (not just the total) also catches a backdated sell that only balances because of units bought later. `actions` are the holding's corporate actions: a split changes what is held from its day. `exclude_ids` drops saved rows from the walk, which is how an edit is checked: the original comes out, the candidate goes in, and the whole timeline is re-walked. Validating only the edited row would miss the case that matters — cutting an old buy's quantity strands a sell years later. """ units, breach = _walk(trades, candidate, frozenset(exclude_ids), actions) return None if breach is not None else units def balance_breach( trades: list[Trade], candidate: Trade | None = None, *, exclude_ids: frozenset[int] | set[int] = frozenset(), actions=(), ) -> Breach | None: """The first point the timeline goes negative, or None if it never does. Same walk as `balance_after`, but it keeps the detail needed to say *which* trade breaks and by how much — "that sell would leave a negative balance" is true but unhelpful when the offender is a row from three years ago. """ return _walk(trades, candidate, frozenset(exclude_ids), actions)[1] def _units(value: Decimal) -> str: """Units as someone wrote them: 100, not 100.00000000.""" return f"{value.normalize():f}" def breach_message(ticker: str, breach: Breach) -> str: """Plain-English refusal naming the trade that has to change first.""" when = breach.trade.date.isoformat() kind = "sell" if breach.trade.type == "sell" else breach.trade.type if breach.is_candidate: return ( f"That {kind} of {_units(breach.trade.quantity)} units is more than " f"the {_units(breach.held_before)} units of {ticker} held on {when} " "— check the date and units against the ledger." ) return ( f"That change would leave {ticker} at {_units(breach.balance)} units " f"from {when}: the {kind} of {_units(breach.trade.quantity)} units on " f"{when} would then be more than the {_units(breach.held_before)} units " f"held. Edit or delete that {kind} first." ) @dataclass class LedgerEvent: date: dt.date kind: str # buy / sell / drp / dividend quantity: Decimal | None unit_price: Decimal | None brokerage: Decimal | None cash: Decimal | None note: str | None # Per-parcel performance at the latest price, on the units of it still # held (buy/drp rows only) — the sheet's "% / $ Inc for this trade" rows. gain: Decimal | None = None gain_pct: Decimal | None = None # Which row the edit/delete buttons act on. A DRP row shows the trade but # points at its DIVIDEND: the pair is one statement event, and editing half # of it would leave the units and the cash disagreeing. row_kind: str | None = None # "trade" | "dividend" | "action" row_id: int | None = None time: dt.time | None = None label: str | None = None # what the row says, where it is not `kind` def ledger( session: Session, ticker: str, exchange: str | None = None ) -> tuple[Instrument, Holding, list[LedgerEvent]] | None: # This portfolio's, not the catalogue's: a ticker is unique per exchange, # so the catalogue can hold another portfolio's under the same one. Held # here on two exchanges, the exchange says which; without it, the older. found = held_as(session, ticker, exchange) if not found: return None inst = session.scalars( select(Instrument).where(Instrument.id == found[0].id) .options(selectinload(Instrument.trades), selectinload(Instrument.dividends), selectinload(Instrument.actions)) ).one() from . import fyreport pref = prefs_by_instrument(session).get(inst.id) latest = _latest_price(session, inst.id) latest_close = latest[0] if latest else None fx_book = FxBook(session) try: held = {p.trade_id: p for p in fyreport.walk(inst, fx_book).held} except fyreport.OversoldError: held = {} def _parcel_gain(t: Trade) -> tuple[Decimal | None, Decimal | None]: """What the parcel's units still held have made at the latest close, against what they cost: unrealised, as the holding's gain is (#54). A parcel sold in full has none.""" parcel = held.get(t.id) if latest_close is None or parcel is None: return None, None cost = parcel.native_held gain = parcel.remaining * latest_close - cost return gain, (gain / cost if cost else None) # A DRP trade is edited through the dividend that reinvested into it. dividend_of_trade = { d.reinvest_trade_id: d.id for d in inst.dividends if d.reinvest_trade_id } events = [ LedgerEvent( date=t.date, kind=t.type, quantity=t.quantity, unit_price=t.unit_price, brokerage=t.brokerage, cash=None, note=t.note, gain=_parcel_gain(t)[0], gain_pct=_parcel_gain(t)[1], row_kind="dividend" if t.id in dividend_of_trade else "trade", row_id=dividend_of_trade.get(t.id, t.id), time=t.time, ) for t in inst.trades ] + [ LedgerEvent( date=d.date, kind="dividend", quantity=None, unit_price=None, brokerage=None, cash=d.cash_amount, note=d.note, row_kind="dividend", row_id=d.id, ) for d in inst.dividends if d.reinvest_trade_id is None # DRP cash shows through its trade row ] + [ LedgerEvent( date=a.date, kind=a.kind, label=action_label(a), quantity=None, unit_price=None, brokerage=None, cash=a.amount if a.kind == "capital_return" else None, note=a.note, row_kind="action", row_id=a.id, ) for a in inst.actions ] events.sort(key=lambda e: (e.date, e.kind), reverse=True) return inst, build_holding(session, inst, pref, fx_book), events # ---------- chart series (computed from trades + daily prices/fx) ---------- def _fx_series(session: Session) -> dict[str, list[tuple[dt.date, Decimal]]]: rows = session.execute( select(FxRate.pair, FxRate.date, FxRate.rate).order_by(FxRate.date) ).all() out: dict[str, list[tuple[dt.date, Decimal]]] = {} for pair, date, rate in rows: out.setdefault(pair, []).append((date, rate)) return out class _Stepper: """Walk a date-sorted series, returning the latest value on-or-before d. Forward-only: `at()` must be called with non-decreasing dates. That suits the daily walks it is used for and keeps them linear. Anything that needs random access wants `FxBook` instead. """ def __init__(self, rows: list[tuple[dt.date, Decimal]]): self.rows = rows self.i = 0 self.current: Decimal | None = None def at(self, d: dt.date) -> Decimal | None: while self.i < len(self.rows) and self.rows[self.i][0] <= d: self.current = self.rows[self.i][1] self.i += 1 return self.current # How far from its date a rate may be and still be taken as that date's # without saying so: a weekend, a public holiday or two (decisions.md #124). FX_REACH_DAYS = 7 class FxBook: """Every stored exchange rate, and an honest answer for any date. For a pair with rows: the nearest rate on or before the date, and failing that the earliest there is — a rate a few days out is a rounding error, where 1:1 would be a fabrication (decisions.md #5). For a pair with none, `rate()` returns None and the caller leaves the AUD figure out. `known()` decides that once per instrument rather than per day. Random-access by `bisect`, not a cursor: trade and price dates interleave, and a cursor returns a stale rate the first time it looks backwards. """ def __init__(self, session: Session): self._rows = _fx_series(session) self._dates = {pair: [d for d, _ in rows] for pair, rows in self._rows.items()} # Holdings a figure was converted for at a rate more than # FX_REACH_DAYS from its date, for the page to name. self.far: set[str] = set() # The other market data a CGT walk from 1 July 2027 needs, read the # first time one asks (decisions.md #150). self._session = session self._cpi = None self._closes: dict[dt.date, dict[int, Decimal]] = {} @property def cpi(self): """The CPI series, for indexing a cost base. Settable: the sale simulator puts in one that estimates a quarter not yet out.""" if self._cpi is None: from . import cpi self._cpi = cpi.CpiBook(self._session) return self._cpi @cpi.setter def cpi(self, book) -> None: self._cpi = book def close_before(self, instrument_id: int, day: dt.date) -> Decimal | None: """The last close before `day` within FX_REACH_DAYS of it: a market value "just before" a date is not a close from months earlier.""" if day not in self._closes: rows = self._session.execute( select(Price.instrument_id, Price.date, Price.close) .where(Price.date < day, Price.date >= day - dt.timedelta(days=FX_REACH_DAYS)) .order_by(Price.date) ).all() self._closes[day] = {instrument_id: close for instrument_id, _d, close in rows} return self._closes[day].get(instrument_id) def known(self, currency: str) -> bool: """Whether an AUD figure can be produced for this currency at all.""" return currency == "AUD" or bool(self._rows.get(f"{currency}AUD")) def rate(self, currency: str, on: dt.date, ticker: str | None = None) -> Decimal | None: """The rate for `on`. With `ticker`, the holding it converts a figure for, which is noted in `far` when the rate is from more than a week away.""" if currency == "AUD": return ONE pair = f"{currency}AUD" rows = self._rows.get(pair) if not rows: return None i = bisect.bisect_right(self._dates[pair], on) # i == 0 means every stored rate is later than `on`: use the earliest. found, rate = rows[i - 1] if i else rows[0] if ticker is not None and abs((found - on).days) > FX_REACH_DAYS: self.far.add(ticker) return rate def of(self, obj, currency: str, ticker: str | None = None) -> Decimal | None: """The rate to book a trade or dividend at. Its own captured `fx_rate` wins — that is the rate that actually applied on the day, and the ATO wants the transaction-date rate. Only when it was never recorded (an import, or a trade entered before the feed had that pair) does this fall back to the stored series. """ if obj.fx_rate is not None: return obj.fx_rate return self.rate(currency, obj.date, ticker) # The daily series walks every instrument across every priced day (~1s on a # six-year portfolio) and is asked for several times per page. Cache it per # portfolio against a fingerprint of the inputs, so a new trade, a new price or # a new dividend invalidates it and nothing else has to remember to. _series_cache: dict[int, tuple[tuple, dict]] = {} def _table_fingerprint(session: Session, model) -> tuple: """Enough of a table's shape to notice any change to it. Count alone was correct only while rows were append-only. Since trades and dividends became editable and deletable it takes three numbers: the count (adds and deletes), the max id (a delete-then-add that leaves the count unchanged) and the max `updated_at` (an edit, which changes neither). """ return tuple( session.execute( select(func.count(model.id), func.max(model.id), func.max(model.updated_at)) ).one() ) def _series_fingerprint(session: Session) -> tuple: from .models import Dividend, HoldingPref return ( # Count AND max date, for the reason the FX line below already gives: # a BACKFILL writes rows behind a date some other holding has already # reached, so the maximum does not move. That is not hypothetical — it # is what happens every time an instrument is added to a portfolio that # is already priced to today, and it served a chart showing a −83% # cliff against a +214% Gain tile until the count went in. And the # latest `updated_at`: a second quote on a day, or the settled close # replacing a provisional one, rewrites a row in place, which moves # neither (decisions.md #91, carried over). session.execute(select(func.count(), func.max(Price.date), func.max(Price.updated_at)) .select_from(Price)).one(), # FX gets its own terms rather than riding on the price date. A holding # excluded for want of a rate comes back the moment one is stored, and # a feed run that fetches only FX — or backfills an old pair — moves no # price date at all, so the charts would keep serving the gap. session.execute(select(func.count(), func.max(FxRate.date), func.max(FxRate.updated_at)) .select_from(FxRate)).one(), *_table_fingerprint(session, Trade), *_table_fingerprint(session, Dividend), *_table_fingerprint(session, CorporateAction), # A class or currency corrected, and a holding's reinvest flag or note. *_table_fingerprint(session, Instrument), *_table_fingerprint(session, HoldingPref), ) _holdings_cache: dict[int, tuple[tuple, list]] = {} def cached_holdings(session: Session) -> list[Holding]: """`all_holdings` behind the same fingerprint cache as the daily series. The charts page runs one spec per chart and several are per-holding; each was rebuilding every position from its trades. """ from .tenancy import current_portfolio_id key = current_portfolio_id(session) fingerprint = _series_fingerprint(session) hit = _holdings_cache.get(key) if hit is not None and hit[0] == fingerprint: return list(hit[1]) # a copy: a caller sorting its own must not reorder ours holdings = all_holdings(session) _holdings_cache[key] = (fingerprint, holdings) return list(holdings) # Least recently used goes first. The key includes the filters, and the chart # builder's preview asks for a new set at every change, each a whole series. GROUPED_CACHE_SIZE = 8 _grouped_cache: OrderedDict[tuple, tuple[tuple, dict]] = OrderedDict() _grouped_lock = threading.Lock() def cached_grouped_series(session: Session, by: str, filters: dict | None = None) -> dict: """`grouped_series` behind the same fingerprint cache, keyed also on the split and filter so different charts don't evict each other.""" from .tenancy import current_portfolio_id key = (current_portfolio_id(session), by, json.dumps(filters or {}, sort_keys=True)) fingerprint = _series_fingerprint(session) with _grouped_lock: hit = _grouped_cache.get(key) if hit is not None and hit[0] == fingerprint: _grouped_cache.move_to_end(key) return hit[1] series = grouped_series(session, by, filters) with _grouped_lock: _grouped_cache[key] = (fingerprint, series) _grouped_cache.move_to_end(key) while len(_grouped_cache) > GROUPED_CACHE_SIZE: _grouped_cache.popitem(last=False) return series def cached_portfolio_series(session: Session) -> dict: """`portfolio_series` with a fingerprint cache. Use this from request paths.""" from .tenancy import current_portfolio_id key = current_portfolio_id(session) fingerprint = _series_fingerprint(session) hit = _series_cache.get(key) if hit is not None and hit[0] == fingerprint: return hit[1] series = portfolio_series(session) _series_cache[key] = (fingerprint, series) return series def _instrument_group(inst, by: str) -> str: return { "ticker": inst.ticker, "asset_class": inst.asset_class, "currency": inst.currency, "exchange": inst.exchange, }[by] def _matches(inst, filters: dict | None) -> bool: """Whether an instrument survives the builder's filter shelf.""" if not filters: return True for key, allowed in filters.items(): if not allowed: continue if _instrument_group(inst, key) not in allowed: return False return True def grouped_series(session: Session, by: str, filters: dict | None = None) -> dict: """The daily series split by a holding attribute — value per ticker, per asset class, and so on. Same walk as `portfolio_series`, but every accumulator is kept per group so a chart can compare holdings over time rather than only in total. Groups with no history at all are dropped: an empty line in a legend is noise. """ # This portfolio's instruments, not the catalogue's, as in portfolio_series. instruments = ( session.scalars( select(Instrument) .where(Instrument.id.in_(portfolio_instrument_ids(session))) .options(selectinload(Instrument.trades), selectinload(Instrument.dividends), selectinload(Instrument.actions)) ) .unique() .all() ) instruments = [i for i in instruments if _matches(i, filters)] price_rows = session.execute( select(Price.instrument_id, Price.date, Price.close).order_by(Price.date) ).all() wanted = {i.id for i in instruments} prices: dict[int, list[tuple[dt.date, Decimal]]] = {} date_set: set[dt.date] = set() for iid, date, close in price_rows: if iid not in wanted: continue prices.setdefault(iid, []).append((date, close)) date_set.add(date) fxbook = FxBook(session) # Same rule as portfolio_series: no stored rate for the pair, no honest AUD # figure, so the instrument is named rather than guessed at. excluded = sorted( {inst.ticker for inst in instruments if not fxbook.known(inst.currency)} ) # By instrument, not ticker: the same code on another exchange, in a # currency that has a rate, is not the one that cannot be converted. instruments = [inst for inst in instruments if fxbook.known(inst.currency)] events = [] for inst in instruments: for t in inst.trades: events.append((t.date, "t", inst, t)) for d in inst.dividends: if d.reinvest_trade_id is None: # reinvested cash is already units events.append((d.date, "d", inst, d)) for a in inst.actions: events.append((a.date, "a", inst, a)) if not events: return {"dates": [], "groups": {}, "excluded": excluded} # An action takes effect from the start of its day (#146). events.sort(key=lambda e: (e[0], e[1] != "a")) first = events[0][0] dates = sorted(d for d in date_set if d >= first) steppers = {iid: _Stepper(rows) for iid, rows in prices.items()} labels = sorted({_instrument_group(i, by) for i in instruments}) def blank() -> dict[str, list]: return {k: [] for k in ("value", "invested", "gain", "gain_pct", "flow_in", "cash_div")} out = {label: blank() for label in labels} # Per instrument, as in `portfolio_series`: a holding that cannot be valued # on a day leaves that day's line whole, cost and proceeds and income, # rather than charting as a loss of everything it cost. units: dict[int, Decimal] = {} spent: dict[int, Decimal] = {} returned: dict[int, Decimal] = {} # sale proceeds and cash income ei = 0 for d in dates: day_flow = {label: ZERO for label in labels} day_div = {label: ZERO for label in labels} while ei < len(events) and events[ei][0] <= d: _, kind, inst, obj = events[ei] label = _instrument_group(inst, by) fxr = fxbook.of(obj, inst.currency) # never None: unknown pairs are excluded if kind == "t": if obj.type in ("buy", "drp"): units[inst.id] = units.get(inst.id, ZERO) + obj.quantity if obj.type == "buy": spend = (obj.quantity * obj.unit_price + obj.brokerage) * fxr spent[inst.id] = spent.get(inst.id, ZERO) + spend day_flow[label] += spend else: units[inst.id] = units.get(inst.id, ZERO) - obj.quantity returned[inst.id] = returned.get(inst.id, ZERO) + ( obj.quantity * obj.unit_price - obj.brokerage) * fxr elif kind == "a": if obj.kind == "split": units[inst.id] = split_units(units.get(inst.id, ZERO), obj) elif obj.kind == "capital_return": # money back, as a sale's is returned[inst.id] = returned.get(inst.id, ZERO) + obj.amount * fxr else: cash = obj.cash_amount * fxr returned[inst.id] = returned.get(inst.id, ZERO) + cash day_div[label] += cash ei += 1 value = {label: ZERO for label in labels} invested = {label: ZERO for label in labels} back = {label: ZERO for label in labels} for inst in instruments: u = units.get(inst.id, ZERO) close = steppers[inst.id].at(d) if inst.id in steppers else None if u and close is None: continue label = _instrument_group(inst, by) invested[label] += spent.get(inst.id, ZERO) back[label] += returned.get(inst.id, ZERO) if u: value[label] += u * close * fxbook.rate(inst.currency, d) for label in labels: gain = value[label] + back[label] - invested[label] g = out[label] g["value"].append(round(float(value[label]), 2)) g["invested"].append(round(float(invested[label]), 2)) g["gain"].append(round(float(gain), 2)) g["gain_pct"].append( round(float(gain / invested[label]), 4) if invested[label] else 0.0 ) g["flow_in"].append(round(float(day_flow[label]), 2)) g["cash_div"].append(round(float(day_div[label]), 2)) # Income alone is history too: dropping its line left the split short of # the total by exactly that income. live = {k: v for k, v in out.items() if any(any(series) for series in v.values())} return {"dates": [d.isoformat() for d in dates], "groups": live, "excluded": excluded} def portfolio_series(session: Session) -> dict: """Daily AUD series since the first trade: invested, value, net gain. invested = cumulative buy cost (incl. brokerage) at trade-date fx — the sheet's "Total Spent". net gain = value + realised proceeds + CASH dividends - invested. Only *cash* dividends count: a reinvested one already shows up as units in `value`, so adding its cash too would count it twice ( worth ~9k on a DRP-heavy portfolio). Also returns the per-day external flow and cash income the time-weighted return needs: `flow` is money in (buys) less money out (sale proceeds), `cash_div` is income that left the portfolio rather than buying more units. """ # This portfolio's instruments and their prices, not the catalogue's # (#139): what another portfolio holds is not this one's to chart. owned = portfolio_instrument_ids(session) instruments = session.scalars( select(Instrument).where(Instrument.id.in_(owned)) .options(selectinload(Instrument.trades), selectinload(Instrument.dividends), selectinload(Instrument.actions)) ).unique().all() price_rows = session.execute( select(Price.instrument_id, Price.date, Price.close) .where(Price.instrument_id.in_(owned)).order_by(Price.date) ).all() prices: dict[int, list[tuple[dt.date, Decimal]]] = {} date_set: set[dt.date] = set() for iid, date, close in price_rows: prices.setdefault(iid, []).append((date, close)) date_set.add(date) fxbook = FxBook(session) # An instrument whose currency has no stored rate at all cannot be put into # an AUD total honestly, so it is left out of every figure here and named in # `excluded` for the page to say so. Its own trades might carry rates, but # its market VALUE could not be converted, and a series mixing converted # cost with unconverted value is worse than one that admits the gap. excluded = sorted( {inst.ticker for inst in instruments if not fxbook.known(inst.currency)} ) # By instrument, not ticker, as in grouped_series. priced = [inst for inst in instruments if fxbook.known(inst.currency)] # When each of those was first traded or paid, so a year's figures name # only what they would otherwise have held. excluded_since = { inst.ticker: min(row.date for row in [*inst.trades, *inst.dividends]).isoformat() for inst in instruments if not fxbook.known(inst.currency) and (inst.trades or inst.dividends) } events = [] # (date, kind, inst, trade/dividend/action) for inst in priced: for t in inst.trades: events.append((t.date, "t", inst, t)) for d in inst.dividends: events.append((d.date, "d", inst, d)) for a in inst.actions: events.append((a.date, "a", inst, a)) if not events: return {"dates": [], "invested": [], "value": [], "gain": [], "gain_pct": [], "flow": [], "flow_in": [], "cash_div": [], "unpriced": [], "excluded": excluded, "excluded_since": excluded_since} # An action takes effect from the start of its day (#146). events.sort(key=lambda e: (e[0], e[1] != "a")) first = events[0][0] dates = sorted(d for d in date_set if d >= first) steppers = {iid: _Stepper(rows) for iid, rows in prices.items()} units: dict[int, Decimal] = {} invested = proceeds = divs = Decimal(0) # The same three running totals, kept PER INSTRUMENT as well, so a holding # that cannot be valued on a given day can be removed from that day whole # rather than only from `value` — see the loop below. spent: dict[int, Decimal] = {} taken: dict[int, Decimal] = {} earned: dict[int, Decimal] = {} ei = 0 out_dates, out_invested, out_value, out_gain, out_gain_pct = [], [], [], [], [] out_flow, out_flow_in, out_cash_div, out_unpriced = [], [], [], [] prev_invested = prev_proceeds = Decimal(0) for d in dates: day_cash_div = Decimal(0) while ei < len(events) and events[ei][0] <= d: _, kind, inst, obj = events[ei] # Never None here: `priced` excluded the unknown pairs already. fxr = fxbook.of(obj, inst.currency) if kind == "t": if obj.type in ("buy", "drp"): units[inst.id] = units.get(inst.id, ZERO) + obj.quantity if obj.type == "buy": cost = (obj.quantity * obj.unit_price + obj.brokerage) * fxr invested += cost spent[inst.id] = spent.get(inst.id, ZERO) + cost else: units[inst.id] = units.get(inst.id, ZERO) - obj.quantity got = (obj.quantity * obj.unit_price - obj.brokerage) * fxr proceeds += got taken[inst.id] = taken.get(inst.id, ZERO) + got elif kind == "a": if obj.kind == "split": units[inst.id] = split_units(units.get(inst.id, ZERO), obj) elif obj.kind == "capital_return": # money back, as a sale's is got = obj.amount * fxr proceeds += got taken[inst.id] = taken.get(inst.id, ZERO) + got else: # Reinvested distributions are already in `value` as units. if obj.reinvest_trade_id is None: cash = obj.cash_amount * fxr divs += cash day_cash_div += cash earned[inst.id] = earned.get(inst.id, ZERO) + cash ei += 1 value = Decimal(0) # A holding whose stored prices start AFTER it was bought cannot be # valued on this day. Withholding its value is right — decisions.md #5, # never fabricate a number — but leaving its cost in the denominator is # not, and it took roughly the whole position off the percentage: a # live portfolio charted a −83% cliff against a +214% Gain tile. # # So it leaves the day ENTIRELY: cost, proceeds and cash income # together, the same way an instrument with no FX rate at all leaves # every figure. Removing only the cost would be worse than the bug for # a part-sold holding, whose proceeds would then stand against nothing. # # `if not u` comes first on purpose. A closed position has no units to # price and contributes `proceeds`, so it is never "unvalued" and its # realised result is never rewritten. drop_cost = drop_proceeds = drop_divs = Decimal(0) unpriced: list[str] = [] for inst in priced: u = units.get(inst.id, ZERO) if not u: continue close = steppers[inst.id].at(d) if inst.id in steppers else None if close is None: drop_cost += spent.get(inst.id, ZERO) drop_proceeds += taken.get(inst.id, ZERO) drop_divs += earned.get(inst.id, ZERO) unpriced.append(inst.ticker) continue value += u * close * fxbook.rate(inst.currency, d) invested_now = invested - drop_cost gain = (value + (proceeds - drop_proceeds) + (divs - drop_divs) - invested_now) # Two flows, deliberately: NET (buys less sale proceeds) is what a # time-weighted return must remove, but GROSS buys are what "money you # put in" means to a person — netting sales off it shrinks the # denominator and flatters the percentage. out_flow.append(round(float((invested - prev_invested) - (proceeds - prev_proceeds)), 2)) out_flow_in.append(round(float(invested - prev_invested), 2)) out_cash_div.append(round(float(day_cash_div), 2)) out_unpriced.append(tuple(sorted(unpriced))) prev_invested, prev_proceeds = invested, proceeds out_dates.append(d.isoformat()) out_invested.append(round(float(invested_now), 2)) out_value.append(round(float(value), 2)) out_gain.append(round(float(gain), 2)) # Return on what went in — the honest denominator for "how am I doing", # and what the sheet's percentage columns meant. out_gain_pct.append( round(float(gain / invested_now), 4) if invested_now else 0.0 ) return { "dates": out_dates, "invested": out_invested, "value": out_value, "gain": out_gain, "gain_pct": out_gain_pct, "flow": out_flow, "flow_in": out_flow_in, "cash_div": out_cash_div, # Held that day but with no price stored by then, so left out of it. "unpriced": out_unpriced, # Tickers left out of every number above, for the page to disclose. "excluded": excluded, "excluded_since": excluded_since, } CHART_YEAR_RANGES = (20, 10, 5, 3, 1) # Windows the period summary offers, longest last so the table reads short→long. PERIODS = ( ("1 day", dt.timedelta(days=1)), ("1 month", dt.timedelta(days=30)), ("6 months", dt.timedelta(days=182)), ("1 year", dt.timedelta(days=365)), ("3 years", dt.timedelta(days=365 * 3)), ("5 years", dt.timedelta(days=365 * 5)), ("10 years", dt.timedelta(days=365 * 10)), ("20 years", dt.timedelta(days=365 * 20)), ) def period_summary(session: Session, series: dict | None = None) -> list[dict]: """How the portfolio actually performed over each window. Two returns, because they answer different questions (decisions.md #95): * `on_money_in` — growth over what was at work. The familiar figure. * `twr` — time-weighted, contributions removed: how the investments performed. `twr_annual` restates long windows per year. `growth` is what the market and income actually made — value change less what you put in, plus cash income. """ P = series if series is not None else portfolio_series(session) dates = P["dates"] if not dates: return [] today = clock.today() last = len(dates) - 1 first_date = dt.date.fromisoformat(dates[0]) def summarise(label: str, start_index: int, window_days: int | None = None) -> dict: # Chain daily returns from the day AFTER the opening balance. factor = 1.0 for i in range(start_index + 1, last + 1): opening = P["value"][i - 1] if opening <= 0: continue r = (P["value"][i] + P["cash_div"][i] - opening - P["flow"][i]) / opening factor *= 1.0 + r net_flow = sum(P["flow"][start_index + 1 : last + 1]) contributed = sum(P["flow_in"][start_index + 1 : last + 1]) income = sum(P["cash_div"][start_index + 1 : last + 1]) growth = P["value"][last] - P["value"][start_index] - net_flow + income # Capital that was at work: the opening balance plus every dollar put # in. Using the NET flow here understated it by the sale proceeds, which # is what made the all-time row disagree with the cumulative gain % on # the "Overall performance by year" chart. money_in = P["value"][start_index] + contributed days = ( dt.date.fromisoformat(dates[last]) - dt.date.fromisoformat(dates[start_index]) ).days # Annualise on the window ASKED FOR, not the span it snapped to — # decisions.md #35. The arithmetic still uses the real span. asked = days if window_days is None else window_days annual = None if asked >= 365 and days > 0 and factor > 0: annual = round(factor ** (365.0 / days) - 1.0, 4) return { "label": label, "from": dates[start_index], "value_then": P["value"][start_index], "contributed": round(contributed, 2), "growth": round(growth, 2), "on_money_in": round(growth / money_in, 4) if money_in else 0.0, "twr": round(factor - 1.0, 4), "twr_annual": annual, } rows = [] for label, delta in PERIODS: cutoff = today - delta if cutoff < first_date: continue # the record doesn't reach back that far start = next((i for i, d in enumerate(dates) if d >= cutoff.isoformat()), None) if start is None or start >= last: continue rows.append(summarise(label, start, window_days=delta.days)) rows.append(summarise("All time", 0)) return rows def chart_ranges(first_date: dt.date | None) -> list[dict]: """Range buttons the history can actually fill. A "10y" button over six years of trades would just redraw the same chart with a misleading label, so a window only appears once the record reaches back that far. """ ranges = [{"key": "all", "label": "All"}] if first_date is not None: span = (clock.today() - first_date).days # Longest first, so the bar reads All → 20y → … → 1y → This FY. for years in sorted(CHART_YEAR_RANGES, reverse=True): if span >= years * 365: ranges.append({"key": f"y{years}", "label": f"{years}y"}) ranges.append({"key": "fy", "label": "This FY"}) return ranges def _fy_end(d: dt.date) -> dt.date: return dt.date(d.year if d.month <= 6 else d.year + 1, 6, 30) def annual_series(session: Session, series: dict | None = None) -> dict: """Per-AU-FY aggregates from the daily series (current FY = to date). Takes the series when the caller already has it — recomputing it here cost ~750ms on every charts page load. """ daily = series if series is not None else portfolio_series(session) if not daily["dates"]: return {"labels": [], "invested_yr": [], "invested_cum": [], "value_eofy": [], "value_gain_cum": [], "gain_cum": [], "gain_pct": [], "gain_yr": [], "yr_pct": [], "left_out": []} idx_by_fy: dict[dt.date, int] = {} for i, ds in enumerate(daily["dates"]): idx_by_fy[_fy_end(dt.date.fromisoformat(ds))] = i # last index in each FY labels, invested_yr, invested_cum, value_eofy = [], [], [], [] value_gain_cum, gain_cum, gain_pct, gain_yr, yr_pct = [], [], [], [], [] left_out: list[list[tuple[str, str]]] = [] since = daily["excluded_since"] prev_invested = prev_gain = 0.0 for fy in sorted(idx_by_fy): i = idx_by_fy[fy] inv, val, gain = daily["invested"][i], daily["value"][i], daily["gain"][i] # What the year's last day leaves out, and why (decisions.md #5). left_out.append( [(t, "no exchange rate") for t in sorted(since) if since[t] <= daily["dates"][i]] + [(t, "no price by then") for t in daily["unpriced"][i]]) labels.append(f"FY{str(fy.year - 1)[-2:]}/{str(fy.year)[-2:]}") invested_yr.append(round(inv - prev_invested, 2)) invested_cum.append(inv) value_eofy.append(val) value_gain_cum.append(round(val - inv, 2)) gain_cum.append(gain) gain_pct.append(round(gain / inv, 4) if inv else 0) gain_yr.append(round(gain - prev_gain, 2)) yr_pct.append(round((gain - prev_gain) / inv, 4) if inv else 0) prev_invested, prev_gain = inv, gain return {"labels": labels, "invested_yr": invested_yr, "invested_cum": invested_cum, "value_eofy": value_eofy, "value_gain_cum": value_gain_cum, "gain_cum": gain_cum, "gain_pct": gain_pct, "gain_yr": gain_yr, "yr_pct": yr_pct, "left_out": left_out} def instrument_series(session: Session, inst: Instrument) -> dict: """Daily holding value + cumulative invested for one instrument (native ccy).""" price_rows = session.execute( select(Price.date, Price.close).where(Price.instrument_id == inst.id).order_by(Price.date) ).all() rows = timeline(inst.trades, inst.actions) if not inst.trades or not price_rows: return {"dates": [], "invested": [], "value": []} first = min(t.date for t in inst.trades) units = invested = Decimal(0) ti = 0 dates, inv_out, val_out = [], [], [] for date, close in price_rows: if date < first: continue while ti < len(rows) and rows[ti].date <= date: t = rows[ti] if isinstance(t, CorporateAction): if t.kind == "split": units = split_units(units, t) elif t.type in ("buy", "drp"): units += t.quantity if t.type == "buy": invested += t.quantity * t.unit_price + t.brokerage else: units -= t.quantity ti += 1 dates.append(date.isoformat()) inv_out.append(round(float(invested), 2)) val_out.append(round(float(units * close), 2)) return {"dates": dates, "invested": inv_out, "value": val_out} def portfolio_instrument_ids(session: Session) -> set[int]: """The instruments this portfolio has: a holding setting, a trade, a dividend, a corporate action, or a place in its plan. The catalogue is shared (decisions.md #125); what a portfolio is shown of it, and picks from, is its own.""" plans = select(InvestmentPlan.id) return (set(session.scalars(select(HoldingPref.instrument_id))) | set(session.scalars(select(Trade.instrument_id).distinct())) | set(session.scalars(select(Dividend.instrument_id).distinct())) | set(session.scalars(select(CorporateAction.instrument_id).distinct())) | set(session.scalars(select(InvestmentPlanEntry.instrument_id) .where(InvestmentPlanEntry.plan_id.in_( list(session.scalars(plans))))))) def held_as(session: Session, ticker: str, exchange: str | None = None) -> list[Instrument]: """This portfolio's instruments a ticker can mean, oldest first: one, none, or the same code on more than one exchange. `ticker` may carry the exchange itself, as TICKER:EXCHANGE — the form a list of tickers names it in.""" code, _, named = ticker.strip().upper().partition(":") exchange = (exchange or named).strip().upper() ids = portfolio_instrument_ids(session) if not code or not ids: return [] stmt = select(Instrument).where(Instrument.ticker == code, Instrument.id.in_(ids)) if exchange: stmt = stmt.where(Instrument.exchange == exchange) return list(session.scalars(stmt.order_by(Instrument.id))) def held_twice(found: list[Instrument]) -> str: """Why a ticker alone is not enough here, and how to say which.""" code = found[0].ticker return (f"{code} is held on {' and '.join(i.exchange for i in found)} — " f"name the exchange, as {code}:{found[0].exchange}.") def ambiguous_tickers(session: Session) -> set[str]: """Tickers this portfolio holds on more than one exchange.""" ids = portfolio_instrument_ids(session) if not ids: return set() counts = Counter(session.scalars(select(Instrument.ticker).where(Instrument.id.in_(ids)))) return {ticker for ticker, n in counts.items() if n > 1} def listing_ref(ticker: str, exchange: str, ambiguous: set[str]) -> str: """How a list of tickers names a holding: its ticker, or TICKER:EXCHANGE when that ticker is held on more than one exchange.""" return f"{ticker}:{exchange}" if ticker in ambiguous else ticker def holding_path(ticker: str, exchange: str | None, ambiguous: set[str], **params) -> str: """The holding page's address. The exchange goes in only when the ticker is held on more than one, so every other address is what it always was.""" if exchange and ticker in ambiguous: params = {"exchange": exchange, **params} query = urlencode({k: v for k, v in params.items() if v}, safe="/") return f"/holding/{ticker}" + (f"?{query}" if query else "") def portfolio_instruments(session: Session) -> list[Instrument]: """This portfolio's active instruments, in the order every list uses.""" ids = portfolio_instrument_ids(session) if not ids: return [] return list(session.scalars( select(Instrument).where(Instrument.id.in_(ids), Instrument.active.is_(True)) .order_by(Instrument.asset_class, Instrument.ticker))) def last_brokerage(session: Session, *, foreign: bool = False) -> Decimal | None: """What this portfolio paid its broker last for a trade of this kind — in the reporting currency, or (`foreign`) in any other: the brokerage on the most recent buy or sell by the trade's own date. None before the first trade, so a form offers nothing rather than one broker's fee. A reinvested distribution costs nothing and says nothing about the broker.""" kind = (Instrument.currency != money.REPORTING) if foreign \ else (Instrument.currency == money.REPORTING) return session.scalar( select(Trade.brokerage).join(Instrument, Trade.instrument_id == Instrument.id) .where(Trade.type.in_(("buy", "sell")), kind) .order_by(Trade.date.desc(), Trade.id.desc()).limit(1)) def latest_prices(session: Session, instrument_ids: list[int]) -> dict[int, Decimal]: """The most recent stored close for each instrument, by id. One query with a window function rather than one per instrument: the holdings page asks for every instrument it lists, and a loop there would be a query per row. Instruments with no stored price are simply absent, which the template renders as an em dash — "no price yet" and "price of zero" are different facts. """ if not instrument_ids: return {} ranked = ( select( Price.instrument_id, Price.close, func.row_number() .over(partition_by=Price.instrument_id, order_by=Price.date.desc()) .label("rank"), ) .where(Price.instrument_id.in_(instrument_ids)) .subquery() ) return { row.instrument_id: row.close for row in session.execute(select(ranked).where(ranked.c.rank == 1)) }