app/columns.py
Rama main · réplica de lectura del repositorio original.
"""What the holdings table can show, declared once.
A column is one entry — name, accessor, renderer — rather than a `<th>` and a
`<td>` in a template that have to agree, so adding one is a single change and
the header cannot drift from the cell.
Choices are per USER, not per portfolio: two people sharing a portfolio each
get their own, stored like theme and accent. `DEFAULTS` is exactly what the page
showed before the chooser existed, so nobody's dashboard changes until they
touch it.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from . import money
from .models import ASSET_CLASSES
# Groups exist so the chooser is scannable rather than a wall of twenty
# checkboxes. They carry no behaviour.
GROUP_POSITION = "Position"
GROUP_VALUE = "Value"
GROUP_PERFORMANCE = "Performance"
GROUP_INCOME = "Income"
@dataclass(frozen=True)
class Column:
key: str
label: str
group: str
value: Callable # Holding -> anything the renderer understands
render: str = "money" # money | qty | pct | text | ticker | gain
numeric: bool = True # right-aligned, tabular figures
# How to ORDER by this column when that differs from what it displays.
# Only `ticker` and `drp` need it — decisions.md #52. See `sort_key()`.
sort: Callable | None = None
# "reporting" (converted), "native" (varies by row) or None (not money).
# The header renders the code from this rather than baking it into `label`,
# so a per-portfolio reporting currency stays a one-place change.
currency: str | None = None
# A header that cycles through more than one sort key, with the mark each
# one shows. Gain and Today display an amount AND a percentage in one cell,
# and both are worth ordering by — "what made me the most" and "what grew
# fastest" are different questions. See `sorting.Sort.cycle`.
sort_pair: tuple[str, ...] = ()
sort_marks: tuple[str, ...] = ()
# A column nobody can turn off. `ticker` is the row's identity and the link
# to its ledger — a table without it is a list of anonymous numbers.
locked: bool = False
help: str = ""
def heading(self, native: str | None = None, *, mark: bool = True) -> str:
"""The label with its currency — for the table header AND the chooser.
The currency lives here rather than in every cell: a symbol repeated
down two hundred right-aligned cells is noise and fights the alignment
that makes a numeric column scannable.
**`mark=False` when the whole table is one currency**, which is almost
every portfolio — seven headers each saying "(AUD)" is the same noise
moved up a row. The chooser always marks, because `cost` and
`cost_reporting` share the label "Cost" and are told apart only by
currency; two identical checkboxes would be a coin toss.
`native` is the one currency the holdings trade in, or None when they
trade in several — in which case the column cannot name one, and says so.
"""
if not mark or self.currency is None:
return self.label
if self.currency == "reporting":
return f"{self.label} ({money.REPORTING})"
return f"{self.label} ({native})" if native else f"{self.label} (native)"
@property
def is_money(self) -> bool:
return self.currency is not None
def one_currency_everywhere(native: str | None) -> bool:
"""Whether every money figure in the table is in the same currency.
True when the holdings all trade in the reporting currency: native and
reporting columns then show the same thing, so nothing needs distinguishing
and the table can name its currency once.
"""
return native is not None and native == money.REPORTING
COLUMNS: list[Column] = [
Column("ticker", "Ticker", GROUP_POSITION, lambda h: h, render="ticker",
numeric=False, locked=True, sort=lambda h: h.instrument.ticker,
help="The holding, linking to its full ledger."),
Column("asset_class", "Class", GROUP_POSITION,
lambda h: ASSET_CLASSES.get(h.instrument.asset_class, h.instrument.asset_class),
render="text", numeric=False,
help="ETF, share or crypto."),
Column("name", "Name", GROUP_POSITION, lambda h: h.instrument.name or "",
render="text", numeric=False,
help="The full name. Off by default — it is wide."),
Column("exchange", "Exchange", GROUP_POSITION, lambda h: h.instrument.exchange,
render="text", numeric=False),
Column("units", "Units", GROUP_POSITION, lambda h: h.units, render="qty"),
Column("avg_price", "Avg price", GROUP_VALUE, lambda h: h.avg_price, currency="native",
help="What you paid per unit on average, in the instrument's own currency."),
Column("cost", "Cost base", GROUP_VALUE, lambda h: h.cost, currency="native",
help="What the units held cost, with brokerage, the oldest counted as "
"sold first. In the instrument's own currency."),
Column("price", "Price", GROUP_VALUE, lambda h: h.price, currency="native",
help="Latest stored close, in the instrument's own currency."),
# The reporting-currency set, declared in the same order as the native one
# above so the chooser reads as two parallel lists rather than a pile. Only
# the chooser's order comes from here — what the table renders is the order
# the person picked, and `DEFAULTS` names its own.
Column("avg_price_reporting", "Avg price", GROUP_VALUE, lambda h: h.avg_price_aud,
currency="reporting",
help="What you paid per unit on average, each purchase converted at "
"its own exchange rate."),
Column("cost_reporting", "Cost base", GROUP_VALUE, lambda h: h.cost_aud,
currency="reporting",
help="What the units held cost, each trade at its own exchange rate."),
Column("price_reporting", "Price", GROUP_VALUE, lambda h: h.price_aud,
currency="reporting",
help="The latest stored close at the latest exchange rate."),
Column("value_reporting", "Value", GROUP_VALUE, lambda h: h.value_aud, currency="reporting",
help="Today's value at the latest exchange rate."),
Column("gain_reporting", "Unrealised gain", GROUP_PERFORMANCE, lambda h: h.gain_aud,
currency="reporting", render="gain",
sort_pair=("gain_reporting", "gain_pct"), sort_marks=("$", "%"),
help="Value less cost base, with the percentage underneath. Clicking "
"cycles the sort: amount up, % up, amount down, % down."),
Column("gain_pct", "Unrealised gain %", GROUP_PERFORMANCE, lambda h: h.gain_pct,
render="pct"),
Column("day_change", "Today", GROUP_PERFORMANCE, lambda h: h.day_change_aud, currency="reporting",
render="gain",
sort_pair=("day_change", "day_pct"), sort_marks=("$", "%"),
help="Movement since the previous close. Clicking cycles the sort: "
"amount up, % up, amount down, % down."),
Column("day_pct", "Today %", GROUP_PERFORMANCE, lambda h: h.day_pct, render="pct"),
Column("dividends", "Dividends", GROUP_INCOME, lambda h: h.dividends_cash, currency="native",
help="Cash distributions received, in the instrument's own currency."),
Column("dividends_reporting", "Dividends", GROUP_INCOME, lambda h: h.dividends_aud,
currency="reporting"),
Column("drp", "DRP", GROUP_INCOME, lambda h: "yes" if h.drp else "",
render="text", numeric=False, sort=lambda h: bool(h.drp),
help="Whether distributions are reinvested."),
# The registry allots whole units only, so the remainder is money it holds
# until the next distribution — on no statement total and in no other
# column. A BALANCE, never income: decisions.md #8.
Column("residual", "DRP residual", GROUP_INCOME, lambda h: h.drp_residual,
currency="native",
help="Cash the registry is holding from your last distribution — too "
"little to buy a whole unit, and carried into the next one. A "
"balance, not income; blank means none was ever recorded."),
]
BY_KEY = {c.key: c for c in COLUMNS}
# Exactly what the dashboard showed before it was configurable. Changing this
# list changes what every user who has never opened the chooser sees, so treat
# it as a decision rather than a default.
DEFAULTS = [
"ticker", "asset_class", "units", "avg_price", "cost",
"price", "value_reporting", "gain_reporting", "day_change", "dividends",
]
def sort_key(column: Column) -> Callable:
"""How to order by this column — its own `sort` if it declared one,
otherwise the value it displays."""
return column.sort or column.value
def sortable_keys() -> set[str]:
"""The allow-list `sorting.read()` checks a query parameter against."""
return {c.key for c in COLUMNS}
def chosen(preference: list[str] | None) -> list[Column]:
"""The columns to render, **in the order they were chosen**.
Unknown keys are dropped and duplicates collapse; a locked column keeps
whatever position it was given. Why each of those: decisions.md #62.
"""
keys = list(preference) if preference else list(DEFAULTS)
seen: set[str] = set()
ordered = [k for k in keys
if k in BY_KEY and not (k in seen or seen.add(k))]
missing_locked = [c.key for c in COLUMNS if c.locked and c.key not in seen]
return [BY_KEY[k] for k in missing_locked + ordered]
def move(preference: list[str], key: str, direction: str) -> list[str]:
"""`preference` with `key` swapped one place up or down.
A no-op at either end, for a key that is not there, or for a direction that
is not one of the two — all three are the stale-form case (two tabs, one of
which has already saved something else), and a quiet no-op is a better
answer there than a 500 or a reordering of whatever happened to be nearby.
"""
if direction not in ("up", "down") or key not in preference:
return list(preference)
index = preference.index(key)
target = index - 1 if direction == "up" else index + 1
if not 0 <= target < len(preference):
return list(preference)
out = list(preference)
out[index], out[target] = out[target], out[index]
return out
def groups() -> list[tuple[str, list[Column]]]:
"""Columns grouped for the chooser, in declaration order within a group."""
by_group: dict[str, list[Column]] = {}
for column in COLUMNS:
by_group.setdefault(column.group, []).append(column)
return list(by_group.items())
# Each native-currency money column and its reporting-currency twin: same label,
# same group, the other currency.
TWINS = {
native.key: reporting.key
for native in COLUMNS if native.currency == "native"
for reporting in COLUMNS
if reporting.currency == "reporting" and reporting.label == native.label
and reporting.group == native.group
}
def chooser_groups(one_currency: bool, chosen: set[str]) -> list[tuple[str, list[Column]]]:
"""The chooser's groups. With one currency everywhere a column and its
twin show the same numbers, so only one of each pair is offered: the one
the layout already names, else the native one. Offering both was two
identical boxes, each saying "(AUD)"."""
if not one_currency:
return groups()
dropped = {
native if (reporting in chosen and native not in chosen) else reporting
for native, reporting in TWINS.items()
}
return [(group, [c for c in items if c.key not in dropped])
for group, items in groups()]
def clean(keys: list[str]) -> list[str]:
"""A submitted selection, filtered to what exists and always answerable.
An empty selection would leave a table of nothing, so it falls back to the
defaults rather than rendering a page that looks broken.
**Order is preserved**, because it carries meaning. Sorting here
would undo every reorder on the next save.
"""
valid = [k for k in keys if k in BY_KEY]
if not [k for k in valid if not BY_KEY[k].locked]:
return list(DEFAULTS)
return valid