Ctrl K
ComunidadGuías…

tests/test_plans.py

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

Archivo original ↗Preguntar a KiBot
"""The investment plan and the calendar.

What comes next is always *computed* — the rotation says what order to buy in,
`planned_purchase` records what actually happened, and next is the slot after
the last recorded one. The subtle part is that recording is keyed on the plan
entry rather than on a count, so editing the rotation afterwards cannot
silently re-point "next" at the wrong holding.
"""

from __future__ import annotations

import datetime as dt
from decimal import Decimal

import pytest
from freezegun import freeze_time
from sqlalchemy import select

import factories as fac
from app import calendarview, plans, tenancy
from app.models import (
    InvestmentPlan,
    InvestmentPlanEntry,
    PlannedPurchase,
)

TODAY = "2026-08-02"


# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #

def make_plan(db, tickers, *, interval_days=28, start_date=None, amount="500",
              active=True, name="Regular buys"):
    """A plan plus its rotation, in the given order. A ticker may repeat — that
    is how a 2:1 weighting is expressed."""
    plan = tenancy.owned(db, InvestmentPlan(
        name=name, interval_days=interval_days, active=active,
        amount=Decimal(amount) if amount else None,
        start_date=fac.d(start_date) if start_date else None))
    db.add(plan)
    db.flush()
    for position, ticker in enumerate(tickers):
        inst = db.scalars(
            select(fac.Instrument).where(fac.Instrument.ticker == ticker)
        ).first() or fac.make_instrument(db, ticker)
        db.add(InvestmentPlanEntry(plan_id=plan.id, position=position,
                                   instrument_id=inst.id))
    db.flush()
    db.refresh(plan)
    return plan


def record(db, plan, position, due_date, status="done"):
    """Record a purchase against a specific slot in the rotation."""
    entry = next(e for e in plan.entries if e.position == position)
    row = tenancy.owned(db, PlannedPurchase(
        due_date=fac.d(due_date), instrument_id=entry.instrument_id,
        plan_entry_id=entry.id, status=status))
    db.add(row)
    db.flush()
    return row


# --------------------------------------------------------------------------- #
# Which plan is in play
# --------------------------------------------------------------------------- #

def test_only_the_active_plan_is_returned(pf):
    make_plan(pf, ["ALPHA"], active=False, name="Retired plan")
    live = make_plan(pf, ["BETAX"], name="Current plan")

    assert plans.active_plan(pf).id == live.id


def test_with_no_plan_there_is_nothing_upcoming(pf):
    sched = plans.schedule(pf)

    assert sched["plan"] is None
    assert sched["next"] == []


def test_a_plan_with_an_empty_rotation_has_nothing_upcoming(pf):
    make_plan(pf, [])

    assert plans.schedule(pf)["next"] == []


# --------------------------------------------------------------------------- #
# When the next buy falls
# --------------------------------------------------------------------------- #

@freeze_time(TODAY)
def test_the_start_date_is_itself_the_first_buy(pf):
    """The anchor is the first purchase, not one interval after it — stepping
    a cycle forward before the first buy would leave a new plan looking dormant
    for a month."""
    make_plan(pf, ["ALPHA", "BETAX"], interval_days=28, start_date="2026-08-10")

    upcoming = plans.schedule(pf, upcoming=2)["next"]

    assert upcoming[0].due_date == dt.date(2026, 8, 10)
    # …and the one after it is exactly one interval later.
    assert upcoming[1].due_date == dt.date(2026, 9, 7)   # 10 Aug + 28 days


@freeze_time(TODAY)
def test_with_history_the_schedule_steps_from_the_last_recorded_buy(pf):
    plan = make_plan(pf, ["ALPHA", "BETAX"], interval_days=28, start_date="2026-01-05")
    record(pf, plan, 0, "2026-07-06")

    upcoming = plans.schedule(pf, upcoming=1)["next"]

    assert upcoming[0].due_date == dt.date(2026, 8, 3)   # 6 Jul + 28 days


@freeze_time(TODAY)
def test_with_no_start_date_and_nothing_recorded_the_first_buy_is_today(pf):
    make_plan(pf, ["ALPHA"], interval_days=28)

    assert plans.schedule(pf)["next"][0].due_date == dt.date(2026, 8, 2)


@freeze_time("2026-08-02 20:00:00")
def test_before_ten_in_the_morning_today_is_still_the_portfolios_today(pf):
    """At 6am on the 3rd in Melbourne it is still the 2nd in UTC. Every other
    test froze at midnight UTC, 10am in Melbourne, when the two agree, so a
    UTC date in place of `clock.today()` passed."""
    make_plan(pf, ["ALPHA"], interval_days=28)       # no start date: the first buy is today

    grid = calendarview.month_grid(pf, 2026, 8)

    assert plans.schedule(pf)["next"][0].due_date == dt.date(2026, 8, 3)
    assert [d.date for week in grid["weeks"] for d in week if d.is_today] == [
        dt.date(2026, 8, 3)]


@freeze_time(TODAY)
def test_the_schedule_lists_three_buys_unless_asked_for_more(pf):
    make_plan(pf, ["ALPHA"], interval_days=28, start_date="2026-08-03")

    assert len(plans.schedule(pf)["next"]) == 3


@freeze_time(TODAY)
def test_each_upcoming_buy_points_at_its_own_slot(pf):
    """The ticker comes from the projection and the slot from the rotation;
    they have to be the same slot, or a link or a prefill goes to the wrong
    holding."""
    plan = make_plan(pf, ["ALPHA", "BETAX", "GAMMA"], start_date="2026-01-05")
    record(pf, plan, 0, "2026-07-06")
    slots = {e.id: e for e in plan.entries}

    upcoming = plans.schedule(pf, upcoming=3)["next"]

    assert [(b.ticker, slots[b.entry_id].instrument.ticker,
             pf.get(fac.Instrument, b.instrument_id).ticker) for b in upcoming] == [
        ("BETAX", "BETAX", "BETAX"), ("GAMMA", "GAMMA", "GAMMA"), ("ALPHA", "ALPHA", "ALPHA")]


@freeze_time(TODAY)
def test_the_last_buy_is_the_latest_recorded_whatever_order_it_was_written(pf):
    """By due date: the July buy, written first, is the last one, so GAMMA is
    next, from 6 July."""
    plan = make_plan(pf, ["ALPHA", "BETAX", "GAMMA"], start_date="2026-01-05")
    record(pf, plan, 1, "2026-07-06")
    record(pf, plan, 0, "2026-06-08")

    nxt = plans.next_buy(pf)

    assert (nxt.ticker, nxt.due_date) == ("GAMMA", dt.date(2026, 8, 3))


@freeze_time(TODAY)
def test_the_last_done_is_the_latest_bought_not_skipped(pf):
    plan = make_plan(pf, ["ALPHA", "BETAX", "GAMMA"], start_date="2026-01-05")
    assert plans.schedule(pf)["last_done"] is None
    record(pf, plan, 0, "2026-05-11")
    record(pf, plan, 1, "2026-06-08")
    record(pf, plan, 2, "2026-07-06", status="skipped")

    assert plans.schedule(pf)["last_done"].due_date == dt.date(2026, 6, 8)


def test_no_rotation_or_no_interval_projects_nothing():
    """`project` is pure, and both the schedule and the editor's preview go
    through it: an empty draft, or an interval of 0, is no dates rather than a
    crash or a column of the same day."""
    day = dt.date(2026, 8, 3)

    assert plans.project([], 28, position=0, last_due=day, upcoming=3) == []
    assert plans.project(["ALPHA"], 0, position=0, last_due=day, upcoming=3) == []
    assert plans.project(["ALPHA"], 1, position=0, last_due=day, upcoming=2) == [
        (dt.date(2026, 8, 4), "ALPHA"), (dt.date(2026, 8, 5), "ALPHA")]


@freeze_time(TODAY)
def test_with_no_plan_a_draft_starts_at_its_first_slot(pf):
    alpha, betax = (fac.make_instrument(pf, t) for t in ("ALPHA", "BETAX"))

    rows = plans.preview(pf, [(alpha.id, "ALPHA"), (betax.id, "BETAX")], 28,
                         fac.d("2026-08-10"), upcoming=2)

    assert rows == [(dt.date(2026, 8, 10), "ALPHA"), (dt.date(2026, 9, 7), "BETAX")]


@freeze_time(TODAY)
def test_a_plan_whose_rotation_was_emptied_can_still_preview_a_draft(pf):
    """Removing the last holding in a rotation leaves a plan with no slots and
    a buy recorded against one of them."""
    plan = make_plan(pf, ["ALPHA"], start_date="2026-01-05")
    record(pf, plan, 0, "2026-07-06")
    alpha = plan.entries[0].instrument
    assert plans.remove_instrument(pf, alpha.id) is True
    betax = fac.make_instrument(pf, "BETAX")

    rows = plans.preview(pf, [(betax.id, "BETAX")], 28, None, upcoming=1)

    assert rows == [(dt.date(2026, 8, 3), "BETAX")]


@freeze_time(TODAY)
def test_a_draft_with_every_slot_taken_out_previews_nothing(pf):
    """What the editor asks for when the last chip is pulled out, with buys
    already recorded."""
    plan = make_plan(pf, ["ALPHA"], start_date="2026-01-05")
    record(pf, plan, 0, "2026-07-06")

    assert plans.preview(pf, [], 28, None) == []


def test_removing_an_instrument_no_plan_has_says_so(pf):
    make_plan(pf, ["ALPHA"])
    other = fac.make_instrument(pf, "OTHER")

    assert plans.remove_instrument(pf, other.id) is False


# --------------------------------------------------------------------------- #
# Which slot comes next
# --------------------------------------------------------------------------- #

@freeze_time(TODAY)
def test_next_is_the_slot_after_the_last_one_recorded(pf):
    plan = make_plan(pf, ["ALPHA", "BETAX", "GAMMA"], start_date="2026-01-05")
    record(pf, plan, 0, "2026-07-06")

    assert plans.next_buy(pf).ticker == "BETAX"


@freeze_time(TODAY)
def test_the_rotation_wraps_at_the_end(pf):
    plan = make_plan(pf, ["ALPHA", "BETAX"], start_date="2026-01-05")
    record(pf, plan, 1, "2026-07-06")

    assert plans.next_buy(pf).ticker == "ALPHA"


@freeze_time(TODAY)
def test_next_follows_the_recorded_slot_not_the_number_of_purchases(pf):
    """The reason `plan_entry_id` exists. One purchase recorded against slot 2
    means slot 0 is next; a count of purchases would say slot 1. If the app
    ever went back to counting, this is the test that catches it."""
    plan = make_plan(pf, ["ALPHA", "BETAX", "GAMMA"], start_date="2026-01-05")
    record(pf, plan, 2, "2026-07-06")

    # 1 purchase recorded, so a naive count would land on BETAX (index 1).
    assert plans.next_buy(pf).ticker == "ALPHA"


@freeze_time(TODAY)
def test_editing_another_slot_leaves_the_sequence_where_it_was(pf):
    """The guarantee `plan_entry_id` buys: as long as the slot you last bought
    still exists, changing a *different* slot cannot move your place in the
    cycle."""
    plan = make_plan(pf, ["ALPHA", "BETAX", "GAMMA"], start_date="2026-01-05")
    record(pf, plan, 0, "2026-07-06")
    assert plans.next_buy(pf).ticker == "BETAX"

    delta = fac.make_instrument(pf, "DELTA")
    ids = [e.instrument_id for e in sorted(plan.entries, key=lambda e: e.position)]
    plans.set_entries(pf, plan, ids[:2] + [delta.id])   # swap GAMMA out for DELTA

    assert plans.next_buy(pf).ticker == "BETAX"


@freeze_time(TODAY)
def test_appending_a_slot_puts_it_next_in_the_cycle(pf):
    """Extending the rotation after buying the last slot does move "next" onto
    the new one — the sequence follows position, so a slot appended after the
    one just bought is genuinely what comes next. Pinned because it looks like
    a surprise until you follow the cycle."""
    plan = make_plan(pf, ["ALPHA", "BETAX", "GAMMA"], start_date="2026-01-05")
    record(pf, plan, 2, "2026-07-06")
    assert plans.next_buy(pf).ticker == "ALPHA"     # wraps while the cycle is 3 long

    delta = fac.make_instrument(pf, "DELTA")
    ids = [e.instrument_id for e in sorted(plan.entries, key=lambda e: e.position)]
    plans.set_entries(pf, plan, ids + [delta.id])

    # Slot 3 now exists and follows the slot just bought, so it is next.
    assert plans.next_buy(pf).ticker == "DELTA"


@freeze_time(TODAY)
def test_a_repeated_instrument_is_honoured_as_a_weighting(pf):
    """Listing the same holding twice is how a 2:1 split is expressed, so the
    cycle must visit it twice rather than collapsing the duplicates."""
    make_plan(pf, ["ALPHA", "BETAX", "ALPHA"], start_date="2026-01-05")

    upcoming = plans.schedule(pf, upcoming=4)["next"]

    assert [b.ticker for b in upcoming] == ["ALPHA", "BETAX", "ALPHA", "ALPHA"]


# --------------------------------------------------------------------------- #
# Editing the rotation
# --------------------------------------------------------------------------- #

def test_an_unchanged_slot_keeps_its_row(pf):
    """History is recorded against the entry id, so a slot that did not change
    must keep it — otherwise editing the plan would orphan what was recorded."""
    plan = make_plan(pf, ["ALPHA", "BETAX", "GAMMA"])
    before = {e.position: e.id for e in plan.entries}

    ids = [e.instrument_id for e in sorted(plan.entries, key=lambda e: e.position)]
    plans.set_entries(pf, plan, ids[:2])   # drop the last slot

    after = {e.position: e.id for e in plan.entries}
    assert after[0] == before[0]
    assert after[1] == before[1]
    assert len(after) == 2


def test_shrinking_the_rotation_renumbers_without_a_collision(pf):
    plan = make_plan(pf, ["ALPHA", "BETAX", "GAMMA"])
    alpha_id = next(e.instrument_id for e in plan.entries if e.position == 0)

    plans.set_entries(pf, plan, [alpha_id])

    assert [e.position for e in plan.entries] == [0]


def test_reversing_the_rotation_is_allowed(pf):
    """Positions are unique per plan, so a straight swap would collide unless
    the rows are parked out of range first."""
    plan = make_plan(pf, ["ALPHA", "BETAX"])
    ids = [e.instrument_id for e in sorted(plan.entries, key=lambda e: e.position)]

    plans.set_entries(pf, plan, list(reversed(ids)))

    ordered = sorted(plan.entries, key=lambda e: e.position)
    assert [e.instrument_id for e in ordered] == list(reversed(ids))
    assert [e.position for e in ordered] == [0, 1]


# --------------------------------------------------------------------------- #
# Projected distributions
# --------------------------------------------------------------------------- #

@freeze_time(TODAY)
def test_the_published_cycle_drives_the_projection(pf):
    """A quarterly ETF pays roughly every 91 days; the next date is the last
    one plus the median gap."""
    alpha = fac.make_instrument(pf, "ALPHA", name="Alpha Index Fund")
    fac.add_trade(pf, alpha, "2026-01-05", "buy", 100, "10.00")
    for ex_date in ("2025-09-20", "2025-12-20", "2026-03-20", "2026-06-20"):
        fac.add_market_dividend(pf, alpha, ex_date, "0.50")
    pf.commit()

    projected = calendarview.projected_dividends(pf, fac.d("2026-10-31"))

    # Gaps are 91, 90 and 92 days → median 91; 20 Jun 2026 + 91 = 19 Sep 2026.
    assert [e.date for e in projected] == [dt.date(2026, 9, 19)]
    assert projected[0].kind == "dividend-expected"
    assert projected[0].expected is True


@freeze_time(TODAY)
def test_a_projection_estimates_the_amount_from_the_units_held(pf):
    alpha = fac.make_instrument(pf, "ALPHA", name="Alpha Index Fund")
    fac.add_trade(pf, alpha, "2026-01-05", "buy", 100, "10.00")
    for ex_date in ("2025-12-20", "2026-03-20", "2026-06-20"):
        fac.add_market_dividend(pf, alpha, ex_date, "0.50")
    pf.commit()

    projected = calendarview.projected_dividends(pf, fac.d("2026-10-31"))

    # 100 units x the latest published 0.50 per unit = 50.00
    assert projected[0].amount == Decimal("50.00")


@freeze_time(TODAY)
def test_a_position_that_has_been_sold_projects_nothing(pf):
    """Income for a holding you no longer own would be a real defect, not just
    noise on the calendar."""
    alpha = fac.make_instrument(pf, "ALPHA", name="Alpha Index Fund")
    fac.add_trade(pf, alpha, "2026-01-05", "buy", 100, "10.00")
    fac.add_trade(pf, alpha, "2026-02-05", "sell", 100, "11.00")
    for ex_date in ("2025-12-20", "2026-03-20", "2026-06-20"):
        fac.add_market_dividend(pf, alpha, ex_date, "0.50")
    pf.commit()

    assert calendarview.projected_dividends(pf, fac.d("2026-12-31")) == []


@freeze_time(TODAY)
def test_a_single_payment_is_not_a_cycle(pf):
    alpha = fac.make_instrument(pf, "ALPHA", name="Alpha Index Fund")
    fac.add_trade(pf, alpha, "2026-01-05", "buy", 100, "10.00")
    fac.add_market_dividend(pf, alpha, "2026-06-20", "0.50")
    pf.commit()

    assert calendarview.projected_dividends(pf, fac.d("2026-12-31")) == []


@freeze_time(TODAY)
def test_the_securitys_own_record_is_preferred_over_the_holders(pf):
    """Someone who bought last month has no personal history to find a cycle
    in, but the fund has been paying quarterly for years."""
    alpha = fac.make_instrument(pf, "ALPHA", name="Alpha Index Fund")
    fac.add_trade(pf, alpha, "2026-06-01", "buy", 100, "10.00")
    fac.add_dividend(pf, alpha, "2026-06-20", "50.00")     # their only payment
    for ex_date in ("2025-09-20", "2025-12-20", "2026-03-20", "2026-06-20"):
        fac.add_market_dividend(pf, alpha, ex_date, "0.50")
    pf.commit()

    projected = calendarview.projected_dividends(pf, fac.d("2026-10-31"))

    assert [e.date for e in projected] == [dt.date(2026, 9, 19)]
    assert "published history" in projected[0].detail


@freeze_time(TODAY)
def test_projections_stop_at_the_horizon(pf):
    alpha = fac.make_instrument(pf, "ALPHA", name="Alpha Index Fund")
    fac.add_trade(pf, alpha, "2026-01-05", "buy", 100, "10.00")
    for ex_date in ("2025-12-20", "2026-03-20", "2026-06-20"):
        fac.add_market_dividend(pf, alpha, ex_date, "0.50")
    pf.commit()

    near = calendarview.projected_dividends(pf, fac.d("2026-09-30"))
    far = calendarview.projected_dividends(pf, fac.d("2027-06-30"))

    assert len(near) == 1
    assert len(far) > len(near)
    assert all(e.date <= dt.date(2027, 6, 30) for e in far)


@freeze_time(TODAY)
def test_the_amount_is_this_funds_latest_published_one(pf):
    """Not the earliest published, and not another fund's. With every
    published amount the same 0.50 and one fund publishing, neither was
    tested. Written out of order, so the cycle depends on the dates' order
    too: 20 Dec, 20 Mar and 20 Jun are 90 and 92 days apart."""
    alpha = fac.make_instrument(pf, "ALPHA", name="Alpha Index Fund")
    fac.add_trade(pf, alpha, "2026-01-05", "buy", 100, "10.00")
    for ex_date, amount in (("2025-12-20", "0.48"), ("2026-06-20", "0.55"),
                            ("2026-03-20", "0.50")):
        fac.add_market_dividend(pf, alpha, ex_date, amount)
    other = fac.make_instrument(pf, "OTHER")
    fac.add_market_dividend(pf, other, "2026-07-30", "9.99")    # later, and not ALPHA's
    pf.commit()

    projected = calendarview.projected_dividends(pf, fac.d("2026-10-31"))

    assert [(e.ticker, e.date, e.amount) for e in projected] == [
        ("ALPHA", dt.date(2026, 9, 19), Decimal("55.00"))]


@freeze_time(TODAY)
def test_without_a_published_record_the_amount_comes_from_the_last_payment(pf):
    """Per unit: the last payment over the units held before it, scaled to the
    units held now. A sale before it counts against, a buy after it does not
    count, and an earlier payment of another size is not the last one."""
    alpha = fac.make_instrument(pf, "ALPHA")
    fac.add_trade(pf, alpha, "2025-06-02", "buy", 100, "10.00")
    fac.add_dividend(pf, alpha, "2025-12-20", "30.00")           # not the last
    fac.add_trade(pf, alpha, "2026-02-02", "sell", 20, "10.00")  # 80 held at the last
    fac.add_dividend(pf, alpha, "2026-03-20", "40.00")           # 0.50 a unit
    fac.add_trade(pf, alpha, "2026-04-02", "buy", 40, "10.00")   # 120 held now
    pf.commit()

    [projected] = calendarview.projected_dividends(pf, fac.d("2026-09-30"))

    assert (projected.date, projected.amount) == (dt.date(2026, 9, 16), Decimal("60.00"))
    assert "your history" in projected.detail


@freeze_time(TODAY)
def test_units_a_payment_reinvested_did_not_earn_it(pf):
    """A reinvestment is allotted on the payment date. Counted as held that
    day, its own units shrank the figure per unit, so a reinvesting holding's
    projection never grew: 0.50 on 100 units, with 4 reinvested, is 52.00
    next time, not 50.00."""
    alpha = fac.make_instrument(pf, "ALPHA")
    fac.add_trade(pf, alpha, "2025-06-02", "buy", 100, "10.00")
    fac.add_dividend(pf, alpha, "2025-12-20", "50.00")
    fac.add_drp(pf, alpha, "2026-03-20", "50.00", 4, "12.50")
    pf.commit()

    [projected] = calendarview.projected_dividends(pf, fac.d("2026-09-30"))

    assert projected.amount == Decimal("52.00")


@freeze_time(TODAY)
def test_a_last_payment_with_nothing_held_before_it_gives_no_amount(pf):
    """Sold out before the last payment was recorded, and bought back since:
    there is no figure per unit to scale, so the date comes without one."""
    alpha = fac.make_instrument(pf, "ALPHA")
    fac.add_trade(pf, alpha, "2025-06-02", "buy", 100, "10.00")
    fac.add_dividend(pf, alpha, "2025-12-20", "50.00")
    fac.add_trade(pf, alpha, "2026-03-01", "sell", 100, "10.00")
    fac.add_dividend(pf, alpha, "2026-03-20", "50.00")           # earned before the sale
    fac.add_trade(pf, alpha, "2026-04-02", "buy", 100, "10.00")
    pf.commit()

    [projected] = calendarview.projected_dividends(pf, fac.d("2026-09-30"))

    assert (projected.date, projected.amount) == (dt.date(2026, 9, 16), None)


@freeze_time(TODAY)
def test_one_unit_held_at_the_last_payment_is_enough_to_work_it_out(pf):
    alpha = fac.make_instrument(pf, "ALPHA")
    fac.add_trade(pf, alpha, "2025-06-02", "buy", 1, "10.00")
    fac.add_dividend(pf, alpha, "2025-12-20", "0.50")
    fac.add_dividend(pf, alpha, "2026-03-20", "0.50")
    fac.add_trade(pf, alpha, "2026-04-02", "buy", 9, "10.00")    # ten now
    pf.commit()

    [projected] = calendarview.projected_dividends(pf, fac.d("2026-09-30"))

    assert projected.amount == Decimal("5.00")


@freeze_time(TODAY)
def test_the_published_amount_beats_one_worked_out_from_the_holders_payments(pf):
    alpha = fac.make_instrument(pf, "ALPHA")
    fac.add_trade(pf, alpha, "2026-01-05", "buy", 100, "10.00")
    fac.add_dividend(pf, alpha, "2026-03-20", "40.00")           # 0.40 a unit, theirs
    for ex_date in ("2025-12-20", "2026-03-20", "2026-06-20"):
        fac.add_market_dividend(pf, alpha, ex_date, "0.55")
    pf.commit()

    [projected] = calendarview.projected_dividends(pf, fac.d("2026-10-31"))

    assert projected.amount == Decimal("55.00")


@freeze_time(TODAY)
@pytest.mark.parametrize("published, cycle", [(1, 61), (2, 91)], ids=["one", "two"])
def test_two_published_payments_show_a_cycle_and_one_does_not(pf, published, cycle):
    """With one published payment the holder's own are followed instead:
    theirs are 61 days apart, the fund's 91."""
    alpha = fac.make_instrument(pf, "ALPHA")
    fac.add_trade(pf, alpha, "2025-06-02", "buy", 100, "10.00")
    for paid in ("2026-01-20", "2026-03-22", "2026-05-22"):
        fac.add_dividend(pf, alpha, paid, "50.00")
    for ex_date in ("2026-03-20", "2026-06-19")[2 - published:]:
        fac.add_market_dividend(pf, alpha, ex_date, "0.50")
    pf.commit()

    projected = calendarview.projected_dividends(pf, fac.d("2026-12-31"))

    assert f"~{cycle}-day cycle" in projected[0].detail


@freeze_time(TODAY)
def test_a_payment_due_today_and_one_on_the_last_day_asked_for_are_both_in(pf):
    """1 Feb and 3 May are 91 days apart, so the next is today, 2 Aug, and
    the one after is 1 Nov."""
    alpha = fac.make_instrument(pf, "ALPHA")
    fac.add_trade(pf, alpha, "2026-01-05", "buy", 1, "10.00")    # one unit is a holding
    for ex_date in ("2026-02-01", "2026-05-03"):
        fac.add_market_dividend(pf, alpha, ex_date, "0.50")
    pf.commit()

    projected = calendarview.projected_dividends(pf, fac.d("2026-11-01"))

    assert [e.date for e in projected] == [dt.date(2026, 8, 2), dt.date(2026, 11, 1)]


@freeze_time(TODAY)
def test_payments_expected_on_one_day_are_listed_in_ticker_order(pf):
    for ticker in ("ZETA", "ALPHA"):                              # ids the other way
        inst = fac.make_instrument(pf, ticker)
        fac.add_trade(pf, inst, "2026-01-05", "buy", 10, "10.00")
        for ex_date in ("2026-03-20", "2026-06-20"):
            fac.add_market_dividend(pf, inst, ex_date, "0.50")
    pf.commit()

    upcoming = calendarview.month_grid(pf, 2026, 8)["upcoming"]

    assert [e.ticker for e in upcoming if e.kind == "dividend-expected"][:2] == [
        "ALPHA", "ZETA"]


# --------------------------------------------------------------------------- #
# Recorded events and the grid
# --------------------------------------------------------------------------- #

@freeze_time(TODAY)
def test_recorded_events_cover_trades_and_distributions_in_the_window(pf):
    alpha = fac.make_instrument(pf, "ALPHA", name="Alpha Index Fund")
    fac.add_trade(pf, alpha, "2026-08-05", "buy", 10, "10.00")
    fac.add_trade(pf, alpha, "2026-08-06", "sell", 5, "11.00")
    fac.add_dividend(pf, alpha, "2026-08-07", "12.00")
    fac.add_trade(pf, alpha, "2026-09-01", "buy", 1, "10.00")   # outside the window
    pf.commit()

    events = calendarview.recorded_events(pf, fac.d("2026-08-01"), fac.d("2026-08-31"))

    assert sorted((e.date, e.kind) for e in events) == [
        (dt.date(2026, 8, 5), "buy"),
        (dt.date(2026, 8, 6), "sell"),
        (dt.date(2026, 8, 7), "dividend"),
    ]


@freeze_time(TODAY)
def test_the_month_grid_is_monday_first_and_covers_the_overhang(pf):
    grid = calendarview.month_grid(pf, 2026, 8)

    days = [day for week in grid["weeks"] for day in week]
    assert all(len(week) == 7 for week in grid["weeks"])
    # 1 Aug 2026 is a Saturday, so the grid opens on Monday 27 July.
    assert days[0].date == dt.date(2026, 7, 27)
    assert days[0].in_month is False
    assert grid["month_label"] == "August 2026"
    assert grid["prev"] == (2026, 7)
    assert grid["next"] == (2026, 9)


@freeze_time(TODAY)
def test_today_is_flagged_on_the_grid(pf):
    grid = calendarview.month_grid(pf, 2026, 8)

    flagged = [d.date for week in grid["weeks"] for d in week if d.is_today]
    assert flagged == [dt.date(2026, 8, 2)]


@freeze_time(TODAY)
def test_events_land_on_their_own_day(pf):
    alpha = fac.make_instrument(pf, "ALPHA", name="Alpha Index Fund")
    fac.add_trade(pf, alpha, "2026-08-05", "buy", 10, "10.00")
    pf.commit()

    grid = calendarview.month_grid(pf, 2026, 8)

    day = next(d for week in grid["weeks"] for d in week if d.date == dt.date(2026, 8, 5))
    assert [e.kind for e in day.events] == ["buy"]


def test_a_planned_buy_counts_as_expected():
    planned = calendarview.CalEvent(date=dt.date(2026, 8, 3), kind="buy-planned", ticker="X")
    bought = calendarview.CalEvent(date=dt.date(2026, 8, 3), kind="buy", ticker="X")

    assert (planned.expected, bought.expected) == (True, False)


@freeze_time(TODAY)
def test_a_recorded_distribution_says_whether_it_was_reinvested(pf):
    alpha = fac.make_instrument(pf, "ALPHA")
    fac.add_trade(pf, alpha, "2026-01-05", "buy", 10, "10.00")
    fac.add_dividend(pf, alpha, "2026-08-05", "12.00")
    fac.add_drp(pf, alpha, "2026-08-06", "10.00", 1, "10.00")
    pf.commit()

    events = calendarview.recorded_events(pf, fac.d("2026-08-01"), fac.d("2026-08-31"))

    assert sorted((e.date.day, e.detail) for e in events if e.kind == "dividend") == [
        (5, "paid"), (6, "reinvested")]


@freeze_time(TODAY)
def test_planned_buys_run_up_to_and_including_the_day_asked_for(pf):
    make_plan(pf, ["ALPHA"], interval_days=7, start_date="2026-08-03")
    pf.commit()

    buys = calendarview.planned_buys(pf, fac.d("2026-08-17"))

    assert [e.date for e in buys] == [dt.date(2026, 8, 3), dt.date(2026, 8, 10),
                                      dt.date(2026, 8, 17)]


@freeze_time("2026-08-10")
def test_a_day_lists_what_happened_then_what_is_expected(pf):
    """Each by kind, then ticker. BETA's payments, 9 Feb and 11 May, put its
    next one today."""
    alpha, beta, zeta = (fac.make_instrument(pf, t) for t in ("ALPHA", "BETA", "ZETA"))
    fac.add_trade(pf, beta, "2026-01-05", "buy", 10, "10.00")
    fac.add_trade(pf, zeta, "2026-01-05", "buy", 10, "10.00")
    for ex_date in ("2026-02-09", "2026-05-11"):
        fac.add_market_dividend(pf, beta, ex_date, "0.50")
    fac.add_trade(pf, zeta, "2026-08-10", "sell", 5, "11.00")    # written first
    fac.add_trade(pf, alpha, "2026-08-10", "buy", 5, "10.00")
    make_plan(pf, ["ALPHA"], start_date="2026-08-10")
    pf.commit()

    grid = calendarview.month_grid(pf, 2026, 8)

    day = next(d for week in grid["weeks"] for d in week if d.date == dt.date(2026, 8, 10))
    assert [(e.kind, e.ticker) for e in day.events] == [
        ("buy", "ALPHA"), ("sell", "ZETA"), ("buy-planned", "ALPHA"),
        ("dividend-expected", "BETA")]


@freeze_time("2026-07-27")
def test_a_buy_on_the_grids_first_day_is_on_it(pf):
    make_plan(pf, ["ALPHA"], start_date="2026-07-27")
    pf.commit()

    first = calendarview.month_grid(pf, 2026, 8)["weeks"][0][0]

    assert (first.date, [e.kind for e in first.events]) == (
        dt.date(2026, 7, 27), ["buy-planned"])


@freeze_time("2026-07-10")
def test_an_overdue_buy_leads_the_coming_up_list(pf):
    """The 6 July buy, not yet recorded, is the plan's next. The list began at
    3 August, so the buy actually owed never showed, and Skip on the first row
    that did was refused for not being the next buy."""
    make_plan(pf, ["ALPHA"], start_date="2026-07-06")
    pf.commit()

    upcoming = calendarview.month_grid(pf, 2026, 7)["upcoming"]

    assert [(e.kind, e.date) for e in upcoming][:2] == [
        ("buy-planned", dt.date(2026, 7, 6)), ("buy-planned", dt.date(2026, 8, 3))]


@freeze_time(TODAY)
def test_coming_up_lists_twelve(pf):
    make_plan(pf, ["ALPHA"], interval_days=7, start_date="2026-08-03")
    pf.commit()

    assert len(calendarview.month_grid(pf, 2026, 8)["upcoming"]) == 12