"""Goal progress: the projection rules, then the refresh step end to end.""" from datetime import date, timedelta from decimal import Decimal import pytest from sqlalchemy import select from factories import make_account, make_event, make_instrument, make_price, refresh from fintracker.analytics import today_local from fintracker.analytics.goals import ( MAX_HORIZON_MONTHS, MIN_XIRR_HISTORY_DAYS, evaluate, monthly_needed, months_between, pick_rate, rebuild_goal_progress, ) from fintracker.db import get_sessionmaker from fintracker.models import ( AccountKind, AccountRole, AssetClass, EventKind, Goal, MetricGoalProgress, ) D = Decimal def progress_of( *, current: str = "100000", target: str = "200000", target_date: date | None = None, monthly: str | None = None, xirr: str | None = None, as_of: date | None = None, ): return evaluate( goal_id=1, as_of=as_of or today_local(), current=D(current), target=D(target), target_date=target_date, monthly_contribution=None if monthly is None else D(monthly), trailing_xirr=None if xirr is None else D(xirr), ) # --------------------------------------------------------------------------- the projection def test_a_growing_portfolio_gets_a_date_in_the_future(): result = progress_of(current="100000", target="200000", xirr="0.2") assert result.basis == "xirr" assert result.projected_date is not None assert result.projected_date > today_local() # 20 % a year doubles in a bit under four years assert result.projected_date < today_local() + timedelta(days=365 * 5) def test_a_flat_portfolio_with_no_contributions_gets_null_not_a_far_date(): result = progress_of(current="100000", target="200000", xirr="0") assert result.basis == "xirr" assert result.projected_date is None def test_a_falling_portfolio_with_no_contributions_gets_null(): result = progress_of(current="100000", target="200000", xirr="-0.1") assert result.basis == "xirr" assert result.assumed_rate == D("-0.100000") assert result.projected_date is None def test_a_falling_portfolio_may_still_be_reached_by_contributions(): result = progress_of(current="100000", target="200000", xirr="-0.02", monthly="20000") assert result.basis == "xirr" assert result.projected_date is not None def test_without_a_trailing_return_the_plan_is_the_contributions(): result = progress_of(current="100000", target="200000", monthly="10000") assert result.basis == "contribution" assert result.assumed_rate == D("0.000000") # 100 000 left to raise at 10 000 a month is ten months of deposits assert result.projected_date == _add(today_local(), 10) def test_with_neither_a_return_nor_a_contribution_there_is_nothing_to_project(): result = progress_of(current="100000", target="200000") assert result.basis == "none" assert result.projected_date is None assert result.assumed_rate is None def test_a_goal_already_met_is_projected_to_today(): result = progress_of(current="300000", target="200000", xirr="0.1") assert result.projected_date == today_local() assert result.progress == D("1.500000") def test_the_projection_gives_up_rather_than_naming_a_date_beyond_the_horizon(): # 0.01 % a year against a target ten times away: reachable in theory, not in 30 years result = progress_of(current="100000", target="1000000", xirr="0.0001") assert result.projected_date is None assert MAX_HORIZON_MONTHS == 360 def _add(d: date, months: int) -> date: from fintracker.analytics.goals import add_months return add_months(d, months) # --------------------------------------------------------------------------- monthly needed def test_monthly_needed_is_null_without_a_deadline(): assert progress_of(monthly="1000").monthly_needed_rub is None def test_monthly_needed_is_computed_when_there_is_a_deadline(): as_of = date(2026, 1, 1) result = progress_of( current="100000", target="220000", target_date=date(2027, 1, 1), as_of=as_of ) # no growth assumed: 120 000 over 12 months assert result.monthly_needed_rub == D("10000.00") def test_monthly_needed_is_zero_when_the_trend_already_gets_there(): as_of = date(2026, 1, 1) result = progress_of( current="100000", target="105000", target_date=date(2027, 1, 1), xirr="0.2", as_of=as_of, ) assert result.monthly_needed_rub == D(0) assert result.on_track is True def test_monthly_needed_is_null_once_the_deadline_has_passed(): as_of = date(2026, 1, 1) result = progress_of(target_date=date(2025, 1, 1), as_of=as_of, monthly="1000") assert result.monthly_needed_rub is None def test_a_deadline_the_trend_misses_is_not_on_track(): as_of = date(2026, 1, 1) result = progress_of( current="100000", target="200000", target_date=date(2026, 6, 1), monthly="1000", as_of=as_of, ) assert result.on_track is False def test_monthly_needed_accounts_for_the_assumed_growth(): as_of = date(2026, 1, 1) flat = progress_of(current="100000", target="220000", target_date=date(2027, 1, 1), as_of=as_of) growing = progress_of( current="100000", target="220000", target_date=date(2027, 1, 1), xirr="0.2", as_of=as_of, ) assert growing.monthly_needed_rub is not None assert flat.monthly_needed_rub is not None assert growing.monthly_needed_rub < flat.monthly_needed_rub def test_months_between_counts_whole_months_only(): assert months_between(date(2026, 1, 15), date(2027, 1, 14)) == 11 assert months_between(date(2026, 1, 15), date(2027, 1, 15)) == 12 assert months_between(date(2026, 5, 1), date(2026, 1, 1)) == 0 # --------------------------------------------------------------------------- rate choice def test_a_short_window_is_not_extrapolated_into_a_forecast(): rows = [ { "period": "1m", "date_from": date(2026, 8, 18), "date_to": date(2026, 9, 18), "xirr": D("3.5"), } ] assert pick_rate(rows) == (None, "") def test_the_shortest_qualifying_window_wins(): rows = [ { "period": "all", "date_from": date(2020, 1, 1), "date_to": date(2026, 9, 18), "xirr": D("0.05"), }, { "period": "1y", "date_from": date(2025, 9, 18), "date_to": date(2026, 9, 18), "xirr": D("0.18"), }, { "period": "3m", "date_from": date(2026, 6, 18), "date_to": date(2026, 9, 18), "xirr": D("9"), }, ] assert pick_rate(rows) == (D("0.18"), "1y") assert MIN_XIRR_HISTORY_DAYS == 180 def test_a_period_without_an_xirr_is_skipped(): rows = [ { "period": "1y", "date_from": date(2025, 9, 18), "date_to": date(2026, 9, 18), "xirr": None, } ] assert pick_rate(rows) == (None, "") def test_every_number_in_the_progress_is_a_decimal(): result = progress_of( current="100000", target="200000", target_date=date(2030, 1, 1), xirr="0.1" ) for value in (result.current_value_rub, result.target_amount_rub, result.progress): assert isinstance(value, Decimal) assert isinstance(result.assumed_rate, Decimal) assert isinstance(result.monthly_needed_rub, Decimal) def test_monthly_needed_refuses_a_zero_month_window(): assert monthly_needed(current=D(1), target=D(2), months=0, annual_rate=None) is None # --------------------------------------------------------------------------- database async def _portfolio(close_today: str) -> None: """A year of history: 100 000 in, 1000 shares at 100, ending at `close_today`.""" t = today_local() start = t - timedelta(days=365) account = await make_account( name="Брокерский", kind=AccountKind.broker, role=AccountRole.investment, balance=None, include_in_net_worth=False, source="tinvest", ) share = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share) await make_event(start, account_id=account, kind=EventKind.deposit, amount="100000") await make_event( start, account_id=account, kind=EventKind.buy, instrument_id=share, quantity="1000", price="100", amount="-100000", ) d = start while d <= t: close = "100" if d < t else close_today await make_price(d, instrument_id=share, close=close) d += timedelta(days=1) await refresh() async def _goal(**kwargs) -> int: async with get_sessionmaker()() as session: goal = Goal(**kwargs) session.add(goal) await session.commit() await session.refresh(goal) return goal.id async def _rebuild() -> dict[int, MetricGoalProgress]: async with get_sessionmaker()() as session: await rebuild_goal_progress(session) await session.commit() rows = (await session.execute(select(MetricGoalProgress))).scalars().all() return {r.goal_id: r for r in rows} @pytest.mark.parametrize( ("close_today", "reachable"), [("150", True), ("100", False), ("60", False)], ) async def test_the_projection_follows_the_real_trend(app, close_today: str, reachable: bool): await _portfolio(close_today) goal_id = await _goal(name="Капитал", scope="all", target_amount=D("1000000")) rows = await _rebuild() row = rows[goal_id] assert row.current_value_rub == D(close_today) * 1000 assert row.progress == (D(close_today) * 1000 / D("1000000")).quantize(D("0.000001")) assert row.basis == "xirr" if reachable: assert row.projected_date is not None and row.projected_date > today_local() else: assert row.projected_date is None async def test_a_deadline_produces_a_monthly_need_and_none_without_one(app): await _portfolio("100") dated = await _goal( name="С датой", scope="all", target_amount=D("400000"), target_date=today_local() + timedelta(days=365), ) undated = await _goal(name="Без даты", scope="all", target_amount=D("400000")) rows = await _rebuild() needed = rows[dated].monthly_needed_rub assert needed is not None assert needed > 0 assert rows[undated].monthly_needed_rub is None async def test_an_archived_goal_is_not_computed(app): await _portfolio("100") await _goal(name="Старое", scope="all", target_amount=D("1000"), archived=True) assert await _rebuild() == {}