"""Moving a holding's rows from one portfolio to another — issue #36. The move itself is one column: `portfolio_id = target`. `Instrument` is a shared catalogue, unique on `(exchange, ticker)` and deliberately not portfolio-scoped, so nothing is copied and no instrument is created in the destination. The reporter expected that to be the hard part; it is not. What is hard is deciding what travels together, and there are exactly three rules. Two are enforced because breaking them leaves the data inconsistent, and the third is left to a person because the data does not contain the answer. **Balance (enforced, and it pulls rows in).** Take a buy out of a portfolio and a sell left behind may exceed what is still held. `queries.balance_breach` reports only the FIRST such point, so the set of rows that must follow is a fixpoint: pull the offender in, walk the timeline again, repeat. **Pairs (enforced, and invisible).** A reinvested distribution is one event across two tables, joined by `Dividend.reinvest_trade_id`, and every reader assumes the halves share a portfolio. So `movable_rows` offers a pair as ONE row — the `drp` trade, which carries the units — and there is no way to tick half of it. `_couple` still applies the rule in both directions server-side, because the posted ids are user input and a hand-made request must not be able to split what the dialog cannot. **Cash (offered, never assumed).** A distribution does not touch the unit balance, so nothing forces it to move. Which DRP buy "belongs" to which portfolio is likewise not derivable — a DRP arose from units held at a record date, and once those units are being split the attribution is a judgement. The dialog offers them and a person decides. The destination is validated but never auto-corrected. A sell moved somewhere holding nothing goes negative there, and the fix is to tick more buys — which is a choice, so `target_problem` says what is wrong and stops. """ from __future__ import annotations import datetime as dt from dataclasses import dataclass, field from decimal import Decimal from sqlalchemy import select from sqlalchemy.orm import Session from . import queries, tenancy from .models import ( MARKET_OPEN, WRITE_ROLES, CorporateAction, Dividend, Instrument, Trade, trade_order, ) def _actions(session: Session, instrument_id: int) -> list[CorporateAction]: """The bound portfolio's corporate actions on the holding. They stay where they were recorded: each side's balance is walked with its own (#146).""" return list(session.scalars( select(CorporateAction).where(CorporateAction.instrument_id == instrument_id))) @dataclass(frozen=True) class Movable: """One row the dialog offers, trade or distribution alike. `kind` is what it is called on screen (buy / sell / drp / dividend) and `is_trade` says which table `row_id` names — the two are not the same question, because a DRP is a trade whose partner is a dividend. """ kind: str row_id: int is_trade: bool date: dt.date time: dt.time | None units: Decimal | None # None for cash: there are no units to show value: Decimal # trade value, or the distribution's cash note: str | None pairs_with: int | None = None # the other half of a reinvested pair @property def token(self) -> str: """What the tickbox is worth: `trade:12` or `dividend:5`. One checkbox name across both tables, because on screen they are one list. Built here rather than in the template so the form and `_parse_rows` cannot disagree about the spelling. """ return f"{'trade' if self.is_trade else 'dividend'}:{self.row_id}" @dataclass class Selection: """What will actually move, and what the person did not tick themselves. `added` exists so the dialog can pre-tick the rest and say it did, rather than either refusing with a puzzle or moving rows nobody chose. It holds both kinds; the caller counts only the trades, because a coupled distribution has no tickbox of its own to explain. """ trades: set[int] = field(default_factory=set) dividends: set[int] = field(default_factory=set) added: set[tuple[str, int]] = field(default_factory=set) def movable_rows(session: Session, instrument_id: int) -> list[Movable]: """Everything this portfolio holds for one instrument, in timeline order. Trades and distributions in one list because they are one decision on screen. Only the current portfolio's rows: the tenancy filter does that, and nothing here works around it. """ trades = list(session.scalars( select(Trade).where(Trade.instrument_id == instrument_id) )) dividends = list(session.scalars( select(Dividend).where(Dividend.instrument_id == instrument_id) )) reinvested = {d.reinvest_trade_id: d.id for d in dividends if d.reinvest_trade_id} rows = [ Movable( kind=t.type, row_id=t.id, is_trade=True, date=t.date, # MARKET_OPEN is the "no time given" sentinel, not ten o'clock — # `_parse_time` stores it whenever the box is unticked. Reported as # unset so the dialog shows a time only where one was chosen. time=None if t.time == MARKET_OPEN else t.time, units=t.quantity, # Brokerage excluded: this is what the units were worth, which is # what tells two trades apart. The cost base is the report's job. value=(t.quantity * t.unit_price), note=t.note, pairs_with=reinvested.get(t.id), ) for t in trades ] + [ Movable( kind="dividend", row_id=d.id, is_trade=False, date=d.date, time=None, units=None, value=d.cash_amount, note=d.note, ) # A REINVESTED distribution gets no row of its own. It is half of the # DRP trade above, which carries the units and points back at it, so # two rows would be two tickboxes for one indivisible thing. The # holding ledger shows the pair the same way, for the same reason. for d in dividends if d.reinvest_trade_id is None ] rows.sort(key=lambda r: (r.date, 0 if r.is_trade else 1, r.row_id)) return rows def _couple(session: Session, instrument_id: int, chosen: Selection) -> None: """Bring the other half of every reinvested pair in, both directions. Read from the table rather than from `movable_rows`, which offers a pair as a single row and so cannot express half of one. The dividend-to-trade direction is therefore unreachable from the dialog and kept anyway: the posted ids are user input, and a request naming a reinvested dividend on its own must not be able to split the pair. """ pairs = session.execute( select(Dividend.reinvest_trade_id, Dividend.id).where( Dividend.instrument_id == instrument_id, Dividend.reinvest_trade_id.is_not(None), ) ).all() for trade_id, dividend_id in pairs: if trade_id in chosen.trades and dividend_id not in chosen.dividends: chosen.dividends.add(dividend_id) chosen.added.add(("dividend", dividend_id)) elif dividend_id in chosen.dividends and trade_id not in chosen.trades: chosen.trades.add(trade_id) chosen.added.add(("trade", trade_id)) def expand( session: Session, instrument_id: int, *, trades: set[int], dividends: set[int], pull_in: bool = True, ) -> Selection: """Everything that must move, given what was ticked. `pull_in` is the difference between filling in a form and reading one back. * **True** (opening the dialog) — dependents are added, so the tickboxes arrive with the rows that cannot be left behind already ticked. * **False** (the submitted form) — only pairs are coupled. The ticks are the person's answer, and a dependent they unticked deliberately must not be silently restored; `source_problem` refuses the move instead and says which sell would be left short. A pair is coupled either way. Unticking a balance dependent is a judgement somebody is allowed to make and be refused for; splitting a reinvested distribution is not a judgement, it is a shape the data cannot hold. Couple first, then walk the balance — and nothing after, which is worth saying because a second coupling pass looks obviously necessary and is not. **The balance walk can only ever add a sell.** `_walk` drives the running total down on sells alone; buys and DRPs add to it, and `quantity > 0` is a CHECK constraint, so neither can be the row that goes negative. A sell is never half of a reinvested pair, so the walk cannot drag one in, so there is nothing left for a second pass to find. Coupling before the walk matters for the opposite reason: it can add a DRP *trade*, and that trade has to be out of the source's timeline before the balance is judged. """ chosen = Selection(trades=set(trades), dividends=set(dividends)) _couple(session, instrument_id, chosen) if not pull_in: return chosen held = list(session.scalars( select(Trade).where(Trade.instrument_id == instrument_id) )) actions = _actions(session, instrument_id) # Each pass drops the moving rows and asks what breaks next. Terminates: # every iteration adds an id from a finite set, and a row already moving # cannot be the breach because it is not in the walk. while True: remaining = [t for t in held if t.id not in chosen.trades] breach = queries.balance_breach(remaining, actions=actions) if breach is None: break chosen.trades.add(breach.trade.id) chosen.added.add(("trade", breach.trade.id)) return chosen def source_problem( session: Session, instrument_id: int, *, trades: set[int] ) -> str | None: """What this portfolio cannot spare, in words, or None if it can. The counterpart to `target_problem`, and the reason `pull_in=False` is safe: whatever somebody unticks, the move still cannot leave either side holding a negative number of units. The difference is that they are told, rather than overruled. """ remaining = list(session.scalars( select(Trade).where( Trade.instrument_id == instrument_id, Trade.id.not_in(trades or {-1}) ) )) instrument = session.get(Instrument, instrument_id) # catalogue, unscoped breach = queries.balance_breach(sorted(remaining, key=trade_order), actions=_actions(session, instrument_id)) if breach is None: return None return queries.breach_message( instrument.ticker if instrument else "this holding", breach ) def target_problem( session: Session, instrument_id: int, target_id: int, *, user_id: int | None, trades: set[int], dividends: set[int], ) -> str | None: """What the destination cannot accept, in words, or None if it can. Reads the destination through `tenancy.as_portfolio`, which checks the membership itself and restores the binding afterwards — the request carries on with this session. `roles=WRITE_ROLES` because this is a precondition for writing there, so the authorisation lives with the scope change rather than in a template that decided whether to draw a button. """ # A select, not `session.get` per id: `get` can answer from the identity # map without going near the tenancy filter, so it would happily return a # row belonging to some other portfolio. The route filters the posted ids # too; this does not depend on it having done so. moving = list(session.scalars( select(Trade).where( Trade.instrument_id == instrument_id, Trade.id.in_(trades or {-1}) ) )) instrument = session.get(Instrument, instrument_id) # catalogue, unscoped ticker = instrument.ticker if instrument else "this holding" with tenancy.as_portfolio(session, target_id, user_id=user_id, roles=WRITE_ROLES): # A plain select: `session.get` can answer from the identity map, which # would hand back the source's rows it has already loaded. existing = list(session.scalars( select(Trade).where(Trade.instrument_id == instrument_id) )) theirs = _actions(session, instrument_id) # Rows still belonging to the source are not in `existing` yet, so the walk # is the destination's timeline plus what is about to land in it. combined = sorted(existing + moving, key=trade_order) breach = queries.balance_breach(combined, actions=theirs) return None if breach is None else queries.breach_message(ticker, breach) def perform( session: Session, target_id: int, *, user_id: int | None, trades: set[int], dividends: set[int], ) -> None: """Reassign the chosen rows. Validation is the caller's; this only writes. A DCA step that recorded one of these trades is left pointing at a row its own portfolio can no longer see, so the link is cleared and the step goes back to planned — see `main.py`, which owns that because it owns the plan. """ # Selects rather than `session.get`, for the reason in `target_problem`: # `get` can return a row from the identity map that this portfolio does not # own, and this function WRITES. The filter is the last thing standing # between a posted id and somebody else's trade. for row in session.scalars( select(Trade).where(Trade.id.in_(trades or {-1})) ): row.portfolio_id = target_id for row in session.scalars( select(Dividend).where(Dividend.id.in_(dividends or {-1})) ): row.portfolio_id = target_id