Ctrl K
ComunidadGuías…

app/columns.py

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

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