Ctrl K
ComunidadGuías…

tests/test_moves.py

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

Archivo original ↗Preguntar a KiBot
"""Choosing what moves between portfolios, and what has to follow it.

Issue #36. The reporter expected the hard part to be copying trade details onto
a new instrument id in the destination. It is not: `Instrument` is a shared
catalogue, unique on `(exchange, ticker)` and deliberately NOT portfolio-scoped,
so a move is `portfolio_id = target` and nothing is copied at all.

The hard part is deciding what travels together.

  * **Balance.** Take a buy out and a sell left behind can exceed what is held.
    `queries.balance_breach` reports the FIRST such point, so the set is a
    fixpoint rather than one query — pull the offender in, walk again.
  * **Pairs.** A reinvested distribution is one event in two tables, joined by
    `Dividend.reinvest_trade_id`. Every reader assumes the two sit in the same
    portfolio, so the pair is coupled in BOTH directions and cannot be split.
  * **Cash.** Distributions do not touch the unit balance, so nothing forces
    them — but a portfolio that no longer holds the units has no business
    holding the income either, so they are offered.

Which DRP buy "belongs" to which portfolio is NOT derivable, and this module
does not guess: 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. What is enforced is only what would otherwise leave the
data inconsistent.
"""

from __future__ import annotations

import datetime as dt
from decimal import Decimal

import pytest

import factories as fac
from app import moves, tenancy
from app.models import PortfolioMember, Trade


@pytest.fixture
def home(db):
    """A portfolio with ACME: a buy, a later sell, a DRP pair and cash."""
    user = fac.make_user(db, "mover@example.test")
    source = fac.make_portfolio(db, "Source", owner=user)
    target = fac.make_portfolio(db, "Target", owner=user)
    db.flush()
    tenancy.bind(db, source.id, user.id)
    acme = fac.make_instrument(db, "ACME", asset_class="share")
    return db, user, source, target, acme


# --------------------------------------------------------------------------- #
# What the dialog is offered
# --------------------------------------------------------------------------- #

def test_every_trade_and_distribution_for_the_instrument_is_offered(home):
    db, _user, _source, _target, acme = home
    fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    fac.add_trade(db, acme, "2026-03-02", "sell", 40, "5.00")
    fac.add_drp(db, acme, "2026-04-01", "12.50", 3, "4.20")
    fac.add_dividend(db, acme, "2026-07-01", "25.00")
    db.flush()

    rows = moves.movable_rows(db, acme.id)

    # FOUR rows for five database rows: the reinvested distribution has no row
    # of its own, because it is half of the `drp` trade that carries the units.
    assert [r.kind for r in rows] == ["buy", "sell", "drp", "dividend"]
    assert [r.date for r in rows] == [
        dt.date(2026, 1, 5), dt.date(2026, 3, 2),
        dt.date(2026, 4, 1), dt.date(2026, 7, 1),
    ]
    drp = next(r for r in rows if r.kind == "drp")
    assert drp.pairs_with is not None


def test_a_row_carries_what_tells_two_trades_apart(home):
    """Units, value, date and note — enough to pick the right one of two buys
    of the same instrument, which is the whole reason this is a list."""
    db, _user, _source, _target, acme = home
    fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00",
                  brokerage="9.50", note="first tranche")
    db.flush()

    (row,) = moves.movable_rows(db, acme.id)

    assert row.units == Decimal("100")
    assert row.value == Decimal("400.00")   # units x price, brokerage excluded
    assert row.note == "first tranche"
    # Unset, not ten o'clock: MARKET_OPEN is what an unticked "set a time" box
    # stores, so reporting it as a time would show one on every trade.
    assert row.time is None


def test_a_cash_distribution_has_no_units_and_shows_its_cash(home):
    db, _user, _source, _target, acme = home
    fac.add_dividend(db, acme, "2026-07-01", "25.00")
    db.flush()

    (row,) = moves.movable_rows(db, acme.id)

    assert row.units is None
    assert row.value == Decimal("25.00")


def test_only_this_portfolios_rows_are_offered(home):
    """The tenancy filter does the work; this pins that nobody bypassed it."""
    db, user, source, target, acme = home
    fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    tenancy.bind(db, target.id, user.id)
    fac.add_trade(db, acme, "2026-02-05", "buy", 7, "9.00")
    tenancy.bind(db, source.id, user.id)
    db.flush()

    rows = moves.movable_rows(db, acme.id)

    assert [r.units for r in rows] == [Decimal("100")]


# --------------------------------------------------------------------------- #
# What has to follow what was ticked
# --------------------------------------------------------------------------- #

def test_a_trade_nothing_depends_on_moves_alone(home):
    """The case the issue is actually about: a trade entered against the wrong
    portfolio, noticed before anything was built on it."""
    db, _user, _source, _target, acme = home
    trade = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    db.flush()

    chosen = moves.expand(db, acme.id, trades={trade.id}, dividends=set())

    assert chosen.trades == {trade.id}
    assert chosen.dividends == set()
    assert chosen.added == set()


def test_a_sell_left_behind_is_pulled_in(home):
    """Move the buy and the sell can no longer be covered, so it comes too."""
    db, _user, _source, _target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    sell = fac.add_trade(db, acme, "2026-03-02", "sell", 100, "5.00")
    db.flush()

    chosen = moves.expand(db, acme.id, trades={buy.id}, dividends=set())

    assert chosen.trades == {buy.id, sell.id}
    assert chosen.added == {("trade", sell.id)}


def test_the_pull_in_repeats_until_the_source_balances(home):
    """`balance_breach` reports only the FIRST breach, so one pass is not
    enough — this is why the set is a fixpoint and not a query."""
    db, _user, _source, _target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    first = fac.add_trade(db, acme, "2026-02-02", "sell", 60, "5.00")
    second = fac.add_trade(db, acme, "2026-03-02", "sell", 40, "6.00")
    db.flush()

    chosen = moves.expand(db, acme.id, trades={buy.id}, dividends=set())

    assert chosen.trades == {buy.id, first.id, second.id}


def test_a_buy_that_is_not_needed_is_left_alone(home):
    """Only what actually breaks follows. Moving the later buy strands
    nothing, because the earlier one still covers the sell."""
    db, _user, _source, _target, acme = home
    fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    later = fac.add_trade(db, acme, "2026-02-05", "buy", 100, "4.50")
    fac.add_trade(db, acme, "2026-03-02", "sell", 80, "5.00")
    db.flush()

    chosen = moves.expand(db, acme.id, trades={later.id}, dividends=set())

    assert chosen.trades == {later.id}
    assert chosen.added == set()


def test_pulling_in_can_be_turned_off(home):
    """The pre-tick is a DEFAULT, not a decision.

    `expand` fills the dialog's tickboxes, so it pulls dependents in. The
    submitted form is the person's answer, and re-running the pull-in there
    would silently restore anything they unticked — so the POST path asks for
    `pull_in=False` and is validated instead. See `source_problem`.
    """
    db, _user, _source, _target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    fac.add_trade(db, acme, "2026-03-02", "sell", 100, "5.00")
    db.flush()

    chosen = moves.expand(db, acme.id, trades={buy.id}, dividends=set(),
                          pull_in=False)

    assert chosen.trades == {buy.id}
    assert chosen.added == set()


def test_a_pair_is_still_coupled_with_the_pull_in_off(home):
    """Unticking is allowed for a balance dependent and not for a pair: one is
    a judgement, the other the data model cannot represent."""
    db, _user, _source, _target, acme = home
    trade, dividend = fac.add_drp(db, acme, "2026-04-01", "12.50", 3, "4.20")
    db.flush()

    chosen = moves.expand(db, acme.id, trades={trade.id}, dividends=set(),
                          pull_in=False)

    assert chosen.dividends == {dividend.id}


# --------------------------------------------------------------------------- #
# Whether the SOURCE can spare it
# --------------------------------------------------------------------------- #

def test_a_selection_that_strands_the_source_is_reported(home):
    """What replaces the pull-in when somebody unticks a dependent: the move
    is refused and the message names the sell that would be left short."""
    db, _user, _source, _target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    fac.add_trade(db, acme, "2026-03-02", "sell", 100, "5.00")
    db.flush()

    problem = moves.source_problem(db, acme.id, trades={buy.id})

    assert problem is not None
    assert "ACME" in problem


def test_each_side_is_walked_with_its_own_splits(home):
    """The source split 2 for 1 and then sold 150: it can spare nothing, and
    the destination, which recorded no split, cannot take the sale alone."""
    db, user, _source, target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    fac.add_action(db, acme, "2026-02-02", "split", ratio=(2, 1))
    sell = fac.add_trade(db, acme, "2026-03-02", "sell", 150, "2.50")
    db.flush()

    assert moves.source_problem(db, acme.id, trades=set()) is None
    assert "ACME" in moves.source_problem(db, acme.id, trades={buy.id})
    assert moves.target_problem(db, acme.id, target.id, user_id=user.id,
                                trades={buy.id, sell.id}, dividends=set()) is not None


def test_a_selection_the_source_can_spare_is_accepted(home):
    db, _user, _source, _target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    sell = fac.add_trade(db, acme, "2026-03-02", "sell", 100, "5.00")
    db.flush()

    assert moves.source_problem(db, acme.id,
                                trades={buy.id, sell.id}) is None


# --------------------------------------------------------------------------- #
# The DRP pair, which cannot be split
# --------------------------------------------------------------------------- #

def test_ticking_a_reinvested_buy_brings_its_distribution(home):
    db, _user, _source, _target, acme = home
    trade, dividend = fac.add_drp(db, acme, "2026-04-01", "12.50", 3, "4.20")
    db.flush()

    chosen = moves.expand(db, acme.id, trades={trade.id}, dividends=set())

    assert chosen.dividends == {dividend.id}
    assert chosen.added == {("dividend", dividend.id)}


def test_ticking_the_distribution_brings_its_reinvested_buy(home):
    """Coupled both ways. Half a pair in each portfolio would leave the source
    showing income whose units are invisible, and the target the reverse."""
    db, _user, _source, _target, acme = home
    trade, dividend = fac.add_drp(db, acme, "2026-04-01", "12.50", 3, "4.20")
    db.flush()

    chosen = moves.expand(db, acme.id, trades=set(), dividends={dividend.id})

    assert chosen.trades == {trade.id}


def test_the_balance_walk_can_only_ever_pull_in_a_sell(home):
    """Why one coupling pass is enough, stated as a test.

    A second pass after the walk looks necessary — it would matter if the walk
    could drag in a DRP trade, whose dividend nobody ticked. It cannot: only a
    sell drives the running total negative (buys and DRPs add to it, and
    `quantity > 0` is a CHECK constraint), and a sell is never half of a pair.

    Here the source keeps the DRP's 3 units and balances on them, so the pair
    stays put while the sell follows the buy out.
    """
    db, _user, _source, _target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    drp_trade, drp_dividend = fac.add_drp(db, acme, "2026-02-01", "12.50", 3, "4.20")
    sell = fac.add_trade(db, acme, "2026-03-02", "sell", 103, "5.00")
    db.flush()

    chosen = moves.expand(db, acme.id, trades={buy.id}, dividends=set())

    assert chosen.trades == {buy.id, sell.id}
    assert drp_trade.id not in chosen.trades
    assert drp_dividend.id not in chosen.dividends
    assert all(kind == "trade" for kind, _ in chosen.added)


def test_cash_is_never_pulled_in_on_its_own(home):
    """A distribution does not affect the balance, so nothing forces it. It is
    offered, not assumed — moving it is a judgement about where the income
    belongs, and the dialog asks."""
    db, _user, _source, _target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    fac.add_dividend(db, acme, "2026-07-01", "25.00")
    db.flush()

    chosen = moves.expand(db, acme.id, trades={buy.id}, dividends=set())

    assert chosen.dividends == set()


# --------------------------------------------------------------------------- #
# Whether the destination can take it
# --------------------------------------------------------------------------- #

def test_a_selection_the_target_cannot_absorb_is_refused(home):
    """A sell moved into a portfolio holding nothing would go negative there.
    Fixing it means ticking more buys, which is a choice — so this refuses and
    names the trade rather than guessing at the answer."""
    db, user, _source, target, acme = home
    fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    sell = fac.add_trade(db, acme, "2026-03-02", "sell", 40, "5.00")
    db.flush()

    problem = moves.target_problem(
        db, acme.id, target.id, user_id=user.id,
        trades={sell.id}, dividends=set(),
    )

    assert problem is not None
    assert "ACME" in problem


def test_a_selection_the_target_can_absorb_is_accepted(home):
    db, user, _source, target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    sell = fac.add_trade(db, acme, "2026-03-02", "sell", 40, "5.00")
    db.flush()

    problem = moves.target_problem(
        db, acme.id, target.id, user_id=user.id,
        trades={buy.id, sell.id}, dividends=set(),
    )

    assert problem is None


def test_the_target_check_leaves_the_session_where_it_found_it(home):
    """It reads another portfolio to do its job. The request carries on
    afterwards, so the binding has to come back."""
    db, user, source, target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    db.flush()

    moves.target_problem(db, acme.id, target.id, user_id=user.id,
                         trades={buy.id}, dividends=set())

    assert tenancy.current_portfolio_id(db) == source.id


def test_a_target_the_user_cannot_write_to_is_refused(home):
    """Authorisation is not the template's job — the check is server-side."""
    db, user, _source, _target, acme = home
    outsider = fac.make_portfolio(db, "Someone else's")
    db.add(PortfolioMember(portfolio_id=outsider.id, user_id=user.id,
                           role="viewer"))
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    db.flush()

    with pytest.raises(tenancy.TenancyError):
        moves.target_problem(db, acme.id, outsider.id, user_id=user.id,
                             trades={buy.id}, dividends=set())


# --------------------------------------------------------------------------- #
# Performing it
# --------------------------------------------------------------------------- #

def test_the_move_reassigns_the_rows_and_nothing_else(home):
    db, user, source, target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    kept = fac.add_trade(db, acme, "2026-02-05", "buy", 10, "4.50")
    db.flush()
    buy_id, kept_id = buy.id, kept.id

    moves.perform(db, target.id, user_id=user.id,
                  trades={buy_id}, dividends=set())
    db.flush()

    assert db.get(Trade, buy_id).portfolio_id == target.id
    assert db.get(Trade, kept_id).portfolio_id == source.id


def test_the_instrument_is_not_touched(home):
    """No copying, no new instrument, no second catalogue row — the whole
    premise of the issue, pinned so a future refactor cannot reintroduce it."""
    db, user, _source, target, acme = home
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    db.flush()
    before = acme.id

    moves.perform(db, target.id, user_id=user.id,
                  trades={buy.id}, dividends=set())
    db.flush()

    assert db.get(Trade, buy.id).instrument_id == before


# --------------------------------------------------------------------------- #
# With another holding in the same portfolio
# --------------------------------------------------------------------------- #
# Every test above holds ACME alone, so a query that dropped its instrument
# filter saw only ACME anyway. A mutation run took that filter out of the rows
# offered, both timeline checks and the move itself, and nothing failed. Here
# WIDGET sits beside it, holding what mixing the two up would get wrong.

@pytest.fixture
def beside(home):
    db, _user, _source, _target, acme = home
    widget = fac.make_instrument(db, "WIDGET", asset_class="share")
    fac.add_trade(db, widget, "2026-02-01", "buy", 500, "1.00")
    fac.add_dividend(db, widget, "2026-05-01", "30.00")
    fac.add_drp(db, widget, "2026-06-01", "6.00", 5, "1.20")
    db.flush()
    return home, widget


def test_another_holdings_rows_are_not_offered(beside):
    (db, _user, _source, _target, acme), widget = beside
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    cash = fac.add_dividend(db, acme, "2026-07-01", "25.00")
    db.flush()

    offered = moves.movable_rows(db, acme.id)

    assert {(r.is_trade, r.row_id) for r in offered} == {(True, buy.id), (False, cash.id)}


def test_another_holdings_reinvestment_is_not_pulled_in(beside):
    (db, _user, _source, _target, acme), widget = beside
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    db.flush()

    chosen = moves.expand(db, acme.id, trades={buy.id}, dividends=set())

    assert (chosen.trades, chosen.dividends) == ({buy.id}, set())


def test_another_holdings_units_do_not_cover_a_stranded_sell(beside):
    (db, _user, _source, _target, acme), _widget = beside
    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    fac.add_trade(db, acme, "2026-03-02", "sell", 100, "5.00")
    db.flush()

    assert moves.source_problem(db, acme.id, trades={buy.id}) is not None


def test_the_targets_other_holdings_do_not_absorb_a_sell(beside):
    (db, user, source, target, acme), widget = beside
    fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    sell = fac.add_trade(db, acme, "2026-03-02", "sell", 40, "5.00")
    tenancy.bind(db, target.id, user.id)
    fac.add_trade(db, widget, "2026-01-02", "buy", 500, "1.00")
    tenancy.bind(db, source.id, user.id)
    db.flush()

    problem = moves.target_problem(db, acme.id, target.id, user_id=user.id,
                                   trades={sell.id}, dividends=set())

    assert problem is not None and "ACME" in problem


def test_the_move_takes_only_what_was_chosen(beside):
    (db, user, source, target, acme), widget = beside
    from app.models import Dividend

    buy = fac.add_trade(db, acme, "2026-01-05", "buy", 100, "4.00")
    cash = fac.add_dividend(db, acme, "2026-07-01", "25.00")
    db.flush()

    moves.perform(db, target.id, user_id=user.id, trades={buy.id}, dividends={cash.id})
    db.flush()

    tenancy.allow_unscoped(db)
    moved = {(type(r).__name__, r.instrument_id) for model in (Trade, Dividend)
             for r in db.query(model).filter(model.portfolio_id == target.id)}
    stayed = {(type(r).__name__, r.instrument_id) for model in (Trade, Dividend)
              for r in db.query(model).filter(model.portfolio_id == source.id)}
    assert moved == {("Trade", acme.id), ("Dividend", acme.id)}
    assert stayed == {("Trade", widget.id), ("Dividend", widget.id)}