feat(analytics): доходы, ребалансировка, налоги, бенчмарки и цели — фаза 4
Второй источник выплат: sources/tinvest/sync_events.py (GetDividends, GetBondCoupons, GetBondEvents) и sources/moex/payouts.py (ISS bondization + dividends). Приоритет между ними — pricing/payouts.resolve_payouts, решается на чтении, а не на записи: corporate_action уникален по (instrument_id, kind, source, source_id), обе версии сосуществуют, и правило можно поменять без ресинка истории. Амортизация от MOEX идёт в bond_nominal_schedule, а не в corporate_action — этим типом безраздельно владеет ledger/corporate_actions.py. analytics/income.py — metric_income_monthly (факт) и metric_income_calendar (прошлое и прогноз) с basis paid/announced/history на каждой строке, три источника числа не смешиваются. analytics/rebalance.py — сделки по portfolio_target пропорционально внутри бакета, лоты только вниз, покупки не занимают у ещё не свершившихся продаж. analytics/tax.py — оценка, не замена справки брокера: дивиденды/купоны gross, реализованный результат из lot_disposal с переоценкой каждой ноги на свою дату. analytics/benchmarks.py — TWR индекса на сетке портфеля, kind (price/total_return) не скрывается. analytics/goals.py — прогресс цели и нужный взнос по trailing XIRR. Четыре шага зарегистрированы в register_steps: benchmarks после returns (общая сетка дат), rebalance после allocation (её веса, не пересчитывает), income и tax после lots (нужен lot_disposal).
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"""Benchmarks on the portfolio's own grid — the acceptance check from the plan, фаза 4.
|
||||
|
||||
«TWR и MCFTR на одной сетке без дыр в праздники»: the day the index has no quote must show
|
||||
up in `days_skipped`, not quietly distort the return. And a price index must not be allowed
|
||||
to pass as a total-return one — the two differ on identical holdings, and `kind` is what says
|
||||
which is which.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from factories import make_account, make_event, make_instrument, make_price, refresh
|
||||
from fintracker.analytics.benchmarks import index_twr, opening_price, rebuild_benchmark_returns
|
||||
from fintracker.api.schemas.benchmarks import BenchmarkReturnOut
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
Benchmark,
|
||||
BenchmarkKind,
|
||||
EventKind,
|
||||
MetricBenchmarkReturns,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
START = date(2025, 1, 1)
|
||||
|
||||
|
||||
def day(n: int) -> date:
|
||||
return START + timedelta(days=n)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# pure chain
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_missing_quote_is_counted_not_smoothed_over():
|
||||
# the grid is every day; the index has no quote on day 2 (a holiday for it alone)
|
||||
prices = {day(0): D(100), day(1): D(110), day(3): D(121)}
|
||||
chain = index_twr(prices, [day(1), day(2), day(3)], opening=D(100))
|
||||
|
||||
assert chain.days_skipped == 1
|
||||
assert chain.days_used == 2
|
||||
# the move is not lost: day 3 links back to day 1's close, so the chain still telescopes
|
||||
assert chain.value == D("0.210000")
|
||||
|
||||
|
||||
def test_no_quote_at_all_gives_no_comparison_rather_than_zero():
|
||||
chain = index_twr({}, [day(1), day(2)], opening=None)
|
||||
assert chain.value is None
|
||||
assert chain.days_skipped == 2
|
||||
|
||||
|
||||
def test_the_period_may_open_on_a_day_the_index_did_not_trade():
|
||||
prices = {day(0): D(100), day(3): D(105)}
|
||||
# day(1) is a Sunday for the index; the level it actually stood at is day(0)'s close
|
||||
assert opening_price(prices, day(1)) == D(100)
|
||||
assert opening_price(prices, day(-5)) is None
|
||||
|
||||
|
||||
def test_kind_travels_all_the_way_out():
|
||||
# the client has to be able to mark a price-index comparison; the field is not optional
|
||||
assert "kind" in BenchmarkReturnOut.model_fields
|
||||
assert BenchmarkReturnOut.model_fields["kind"].annotation is str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# against a real portfolio
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def portfolio(app) -> dict[str, object]:
|
||||
"""One share held for 40 days, priced every single day, so the grid has no holes."""
|
||||
from fintracker.analytics import today_local
|
||||
|
||||
t = today_local()
|
||||
bought = t - timedelta(days=40)
|
||||
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="GAZP", name="Газпром", asset_class=AssetClass.share)
|
||||
await make_event(bought, account_id=account, kind=EventKind.deposit, amount="10000")
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="100",
|
||||
price="100",
|
||||
amount="-10000",
|
||||
)
|
||||
for n in range(41):
|
||||
await make_price(bought + timedelta(days=n), instrument_id=share, close=100 + n)
|
||||
return {"account": account, "share": share, "bought": bought, "today": t}
|
||||
|
||||
|
||||
async def _add_index(
|
||||
code: str, kind: BenchmarkKind, closes: dict[date, str], *, ticker: str
|
||||
) -> int:
|
||||
instrument = await make_instrument(
|
||||
ticker=ticker, name=code, asset_class=AssetClass.market_index, board="SNDX"
|
||||
)
|
||||
for d, close in closes.items():
|
||||
await make_price(d, instrument_id=instrument, close=close)
|
||||
async with get_sessionmaker()() as session:
|
||||
benchmark = Benchmark(
|
||||
code=code,
|
||||
name=code,
|
||||
kind=kind,
|
||||
instrument_id=instrument,
|
||||
source="moex",
|
||||
currency="RUB",
|
||||
is_default=kind is BenchmarkKind.total_return,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(benchmark)
|
||||
await session.commit()
|
||||
await session.refresh(benchmark)
|
||||
return benchmark.id
|
||||
|
||||
|
||||
async def _rebuild_benchmarks() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_benchmark_returns(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _rows(scope: str = "all") -> dict[tuple[int, str], MetricBenchmarkReturns]:
|
||||
from sqlalchemy import select
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
found = await session.execute(
|
||||
select(MetricBenchmarkReturns).where(MetricBenchmarkReturns.scope == scope)
|
||||
)
|
||||
return {(r.benchmark_id, r.period): r for r in found.scalars()}
|
||||
|
||||
|
||||
async def test_the_index_is_chained_over_the_portfolios_days_and_reports_the_holidays(portfolio):
|
||||
"""The plan's check: one grid, and a day the index misses is visible as a hole."""
|
||||
bought, today = portfolio["bought"], portfolio["today"]
|
||||
holiday = bought + timedelta(days=20)
|
||||
closes = {
|
||||
bought + timedelta(days=n): str(1000 + n * 10)
|
||||
for n in range(41)
|
||||
if bought + timedelta(days=n) != holiday
|
||||
}
|
||||
benchmark = await _add_index("IMOEX", BenchmarkKind.price, closes, ticker="IMOEX")
|
||||
|
||||
await refresh()
|
||||
await _rebuild_benchmarks()
|
||||
|
||||
rows = await _rows()
|
||||
row = rows[(benchmark, "all")]
|
||||
# the portfolio's own row defines the window; the benchmark copied it verbatim
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.models import MetricReturns
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
found = await session.execute(
|
||||
select(MetricReturns).where(MetricReturns.scope == "all", MetricReturns.period == "all")
|
||||
)
|
||||
portfolio_row = found.scalar_one()
|
||||
assert (row.date_from, row.date_to) == (portfolio_row.date_from, portfolio_row.date_to)
|
||||
|
||||
# exactly one day of the compared window had no quote, and it is reported, not absorbed
|
||||
assert row.days_skipped == 1
|
||||
assert row.twr is not None
|
||||
# the chain still spans the whole window: 1000 -> 1400 over the priced days
|
||||
assert row.twr == D("0.400000")
|
||||
assert today >= portfolio_row.date_to
|
||||
|
||||
|
||||
async def test_a_price_index_and_a_total_return_index_do_not_agree(portfolio):
|
||||
"""Same 40 days, same start: the dividend-bearing series ends higher, and says so."""
|
||||
bought = portfolio["bought"]
|
||||
price_closes = {bought + timedelta(days=n): str(1000 + n * 10) for n in range(41)}
|
||||
total_closes = {bought + timedelta(days=n): str(1000 + n * 15) for n in range(41)}
|
||||
imoex = await _add_index("IMOEX", BenchmarkKind.price, price_closes, ticker="IMOEX")
|
||||
mcftr = await _add_index("MCFTR", BenchmarkKind.total_return, total_closes, ticker="MCFTR")
|
||||
|
||||
await refresh()
|
||||
await _rebuild_benchmarks()
|
||||
|
||||
rows = await _rows()
|
||||
assert rows[(imoex, "all")].twr == D("0.400000")
|
||||
assert rows[(mcftr, "all")].twr == D("0.600000")
|
||||
# neither has a hole — the whole point of comparing against MCFTR rather than IMOEX is
|
||||
# that the gap between them is dividends, not a difference in the days measured
|
||||
assert rows[(imoex, "all")].days_skipped == 0
|
||||
assert rows[(mcftr, "all")].days_skipped == 0
|
||||
|
||||
|
||||
async def test_an_index_without_history_yields_no_number(portfolio):
|
||||
"""A benchmark nobody has quotes for is null, never 0 % — and it is reported."""
|
||||
from fintracker.analytics import FINDINGS
|
||||
|
||||
benchmark = await _add_index("RGBITR", BenchmarkKind.total_return, {}, ticker="RGBITR")
|
||||
await refresh()
|
||||
FINDINGS.reset()
|
||||
await _rebuild_benchmarks()
|
||||
|
||||
rows = await _rows()
|
||||
assert rows[(benchmark, "all")].twr is None
|
||||
assert any(f.check_name == "benchmark_no_history" for f in FINDINGS.items)
|
||||
|
||||
|
||||
async def test_nothing_in_the_metric_rows_is_a_float(portfolio):
|
||||
bought = portfolio["bought"]
|
||||
closes = {bought + timedelta(days=n): str(1000 + n * 10) for n in range(41)}
|
||||
await _add_index("MCFTR", BenchmarkKind.total_return, closes, ticker="MCFTR")
|
||||
await refresh()
|
||||
await _rebuild_benchmarks()
|
||||
|
||||
for row in (await _rows()).values():
|
||||
for value in (row.twr, row.twr_annualized):
|
||||
assert value is None or isinstance(value, Decimal)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""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() == {}
|
||||
@@ -0,0 +1,701 @@
|
||||
"""Income: the pure forecast rules first, then the rebuild end to end.
|
||||
|
||||
The checks the plan names are here by name: every payout received in the last 12 months has a
|
||||
`paid` calendar row, and a quarterly payer produces exactly four future entries carrying its
|
||||
last amount. The third test is the one that catches real money: a coupon after an
|
||||
amortisation, which must shrink with the nominal instead of staying at par.
|
||||
"""
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_cbr_rate, make_event, make_instrument, refresh
|
||||
from fintracker.analytics import FINDINGS, today_local
|
||||
from fintracker.analytics.income import (
|
||||
BondFacts,
|
||||
Entry,
|
||||
Payment,
|
||||
Payout,
|
||||
add_months,
|
||||
bond_entries,
|
||||
coupon_per_unit,
|
||||
detect_frequency,
|
||||
drop_shadowed,
|
||||
fold_payments,
|
||||
history_entries,
|
||||
monthly_rows,
|
||||
nominal_at,
|
||||
project_dates,
|
||||
rebuild_income,
|
||||
regular,
|
||||
resolve_actions_fallback,
|
||||
)
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
BondNominalSchedule,
|
||||
CorporateAction,
|
||||
CorporateActionKind,
|
||||
CorporateActionStatus,
|
||||
Event,
|
||||
EventKind,
|
||||
IncomeBasis,
|
||||
Instrument,
|
||||
MetricIncomeCalendar,
|
||||
MetricIncomeMonthly,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# pure rules
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def quarterly(n: int, *, end: date = date(2026, 9, 1)) -> list[date]:
|
||||
return sorted(add_months(end, -3 * k) for k in range(n))
|
||||
|
||||
|
||||
def test_frequency_is_snapped_to_the_three_buckets_the_plan_allows():
|
||||
assert detect_frequency(quarterly(8)) == 4
|
||||
assert detect_frequency([date(2024, 6, 1), date(2024, 12, 1), date(2025, 6, 1)]) == 2
|
||||
assert detect_frequency([date(2024, 6, 1), date(2025, 6, 1), date(2026, 6, 1)]) == 1
|
||||
|
||||
|
||||
def test_a_single_payment_reads_as_annual_and_no_payment_reads_as_nothing():
|
||||
# one payment says nothing about spacing, but dropping a payout we have actually seen
|
||||
# would hide it entirely — annual is the commonest Russian dividend
|
||||
assert detect_frequency([date(2026, 5, 20)]) == 1
|
||||
assert detect_frequency([]) is None
|
||||
|
||||
|
||||
def test_irregular_spacing_is_flagged_but_still_forecast():
|
||||
assert regular(quarterly(5))
|
||||
assert not regular([date(2025, 1, 10), date(2025, 2, 10), date(2026, 5, 10)])
|
||||
|
||||
|
||||
def test_a_quarterly_payer_gives_exactly_four_dates_in_a_year():
|
||||
last = date(2026, 6, 15)
|
||||
dates = project_dates(last, 4, start=date(2026, 7, 1), end=date(2027, 6, 30))
|
||||
assert dates == [date(2026, 9, 15), date(2026, 12, 15), date(2027, 3, 15), date(2027, 6, 15)]
|
||||
|
||||
|
||||
def test_a_coupon_follows_the_nominal_in_force_on_its_own_date():
|
||||
schedule = [(date(2024, 1, 1), D(1000)), (date(2026, 6, 1), D(500))]
|
||||
assert nominal_at(schedule, date(2026, 5, 31)) == D(1000)
|
||||
assert nominal_at(schedule, date(2026, 6, 1)) == D(500)
|
||||
assert nominal_at(schedule, date(2023, 1, 1)) is None
|
||||
# a coupon published against par halves once half the principal has been repaid
|
||||
assert coupon_per_unit(D(40), D(1000), D(500)) == D(20)
|
||||
assert coupon_per_unit(D(40), D(1000), D(1000)) == D(40)
|
||||
assert coupon_per_unit(D(40), None, D(500)) == D(40)
|
||||
|
||||
|
||||
def test_bond_entries_cover_coupon_amortisation_and_redemption():
|
||||
facts = BondFacts(
|
||||
nominal=D(1000),
|
||||
nominal_schedule=((date(2024, 1, 1), D(1000)), (date(2026, 11, 1), D(600))),
|
||||
maturity_date=date(2027, 5, 1),
|
||||
currency="RUB",
|
||||
)
|
||||
coupons = [
|
||||
Payout(1, "coupon", "announced", None, date(2026, 10, 1), D(40), "RUB"),
|
||||
Payout(1, "coupon", "announced", None, date(2027, 4, 1), D(40), "RUB"),
|
||||
]
|
||||
entries = bond_entries(1, facts, coupons, D(10), start=date(2026, 9, 18), end=date(2027, 9, 18))
|
||||
by_kind = {(e.kind, e.expected_date): e for e in entries}
|
||||
assert by_kind[("coupon", date(2026, 10, 1))].amount == D(400)
|
||||
# after the amortisation the same published coupon is worth 60 % of itself
|
||||
assert by_kind[("coupon", date(2027, 4, 1))].amount == D(240)
|
||||
assert by_kind[("amortization", date(2026, 11, 1))].amount == D(4000)
|
||||
assert by_kind[("repayment", date(2027, 5, 1))].amount == D(6000)
|
||||
assert all(e.basis is IncomeBasis.schedule for e in entries)
|
||||
|
||||
|
||||
def test_an_announced_payout_displaces_the_projection_of_the_same_payment():
|
||||
announced = [
|
||||
Entry(
|
||||
1,
|
||||
"dividend",
|
||||
date(2026, 10, 12),
|
||||
date(2026, 10, 9),
|
||||
D(20),
|
||||
D(5),
|
||||
D(100),
|
||||
"RUB",
|
||||
IncomeBasis.announced,
|
||||
)
|
||||
]
|
||||
projected = [
|
||||
Entry(
|
||||
1, "dividend", date(2026, 10, 20), None, D(20), D(4), D(80), "RUB", IncomeBasis.history
|
||||
),
|
||||
Entry(
|
||||
1, "dividend", date(2027, 4, 20), None, D(20), D(4), D(80), "RUB", IncomeBasis.history
|
||||
),
|
||||
]
|
||||
kept = drop_shadowed(announced, projected)
|
||||
# the declared autumn payment wins; the undeclared spring one survives
|
||||
assert [e.expected_date for e in kept] == [date(2027, 4, 20)]
|
||||
|
||||
|
||||
def test_the_fallback_resolver_prefers_the_strongest_status():
|
||||
same_day = date(2026, 10, 12)
|
||||
payouts = [
|
||||
Payout(1, "dividend", "forecast", None, same_day, D(3), "RUB"),
|
||||
Payout(1, "dividend", "announced", None, same_day, D(5), "RUB"),
|
||||
Payout(1, "dividend", "cancelled", None, date(2026, 11, 1), D(9), "RUB"),
|
||||
]
|
||||
resolved = resolve_actions_fallback(payouts)
|
||||
assert [(p.status, p.amount_per_unit) for p in resolved] == [("announced", D(5))]
|
||||
|
||||
|
||||
def payment(d: date, amount: str, *, held: str = "10", kind: str = "dividend") -> Payment:
|
||||
return Payment(
|
||||
account_id=1,
|
||||
instrument_id=1,
|
||||
kind=kind,
|
||||
d=d,
|
||||
currency="RUB",
|
||||
amount=D(amount),
|
||||
tax=D("0"),
|
||||
held_qty=D(held),
|
||||
)
|
||||
|
||||
|
||||
def test_history_extrapolates_the_last_amount_per_unit_onto_the_current_position():
|
||||
payments = [payment(d, "1000") for d in quarterly(8)]
|
||||
entries, steady = history_entries(
|
||||
1, payments, D(5), start=date(2026, 9, 18), end=date(2027, 9, 17)
|
||||
)
|
||||
assert steady
|
||||
assert len(entries) == 4
|
||||
# 1000 ₽ on 10 units, now holding 5 — half the money, not the same money
|
||||
assert {e.amount for e in entries} == {D(500)}
|
||||
assert all(e.basis is IncomeBasis.history for e in entries)
|
||||
|
||||
|
||||
def test_payments_fold_per_instrument_kind_and_day_across_accounts():
|
||||
d = date(2026, 8, 12)
|
||||
entries = fold_payments([payment(d, "600", held="6"), payment(d, "400", held="4")])
|
||||
assert len(entries) == 1
|
||||
assert (entries[0].amount, entries[0].qty, entries[0].per_unit) == (D(1000), D(10), D(100))
|
||||
assert entries[0].basis is IncomeBasis.paid
|
||||
|
||||
|
||||
def test_monthly_rows_group_by_month_kind_and_currency():
|
||||
rows = monthly_rows(
|
||||
[
|
||||
payment(date(2026, 8, 3), "100"),
|
||||
payment(date(2026, 8, 20), "200"),
|
||||
payment(date(2026, 8, 20), "300", kind="coupon"),
|
||||
payment(date(2026, 9, 1), "400"),
|
||||
]
|
||||
)
|
||||
assert rows[(date(2026, 8, 1), "dividend", "RUB")] == (D(300), D(0), 2)
|
||||
assert rows[(date(2026, 8, 1), "coupon", "RUB")] == (D(300), D(0), 1)
|
||||
assert rows[(date(2026, 9, 1), "dividend", "RUB")] == (D(400), D(0), 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# the rebuild, against the database
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def broker_account() -> int:
|
||||
return await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
|
||||
|
||||
async def make_bond(
|
||||
*,
|
||||
ticker: str = "RU000A0",
|
||||
nominal: str = "1000",
|
||||
maturity: date | None = None,
|
||||
currency: str = "RUB",
|
||||
) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
bond = Instrument(
|
||||
asset_class=AssetClass.bond,
|
||||
ticker=ticker,
|
||||
board="TQOB",
|
||||
name=ticker,
|
||||
currency=currency,
|
||||
nominal=D(nominal),
|
||||
nominal_currency=currency,
|
||||
maturity_date=maturity,
|
||||
)
|
||||
session.add(bond)
|
||||
await session.commit()
|
||||
await session.refresh(bond)
|
||||
return bond.id
|
||||
|
||||
|
||||
async def make_payout(
|
||||
d: date,
|
||||
*,
|
||||
account_id: int,
|
||||
instrument_id: int,
|
||||
amount: str,
|
||||
kind: EventKind = EventKind.dividend,
|
||||
tax: str | None = None,
|
||||
currency: str = "RUB",
|
||||
) -> None:
|
||||
"""A received payout. `make_event` has no `tax`, and the tax column is the point here."""
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
Event(
|
||||
account_id=account_id,
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
|
||||
trade_date=d,
|
||||
amount=D(amount),
|
||||
currency=currency,
|
||||
tax=D(tax) if tax is not None else None,
|
||||
tax_currency=currency if tax is not None else None,
|
||||
source="tinvest",
|
||||
source_id=f"pay-{instrument_id}-{d}-{amount}-{kind}",
|
||||
dedupe_key=f"tinvest:pay-{instrument_id}-{d}-{amount}-{kind}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def make_action(
|
||||
*,
|
||||
instrument_id: int,
|
||||
kind: CorporateActionKind,
|
||||
status: CorporateActionStatus,
|
||||
pay_date: date | None = None,
|
||||
record_date: date | None = None,
|
||||
amount_per_unit: str | None = None,
|
||||
currency: str = "RUB",
|
||||
source: str = "moex",
|
||||
) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
CorporateAction(
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
status=status,
|
||||
pay_date=pay_date,
|
||||
record_date=record_date,
|
||||
amount_per_unit=D(amount_per_unit) if amount_per_unit is not None else None,
|
||||
currency=currency,
|
||||
source=source,
|
||||
source_id=f"{kind}-{pay_date}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def make_nominal(instrument_id: int, effective: date, nominal: str) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
BondNominalSchedule(
|
||||
instrument_id=instrument_id,
|
||||
effective_date=effective,
|
||||
nominal=D(nominal),
|
||||
currency="RUB",
|
||||
source="moex",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def rebuild() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_income(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def calendar(scope: str = "all") -> list[MetricIncomeCalendar]:
|
||||
async with get_sessionmaker()() as session:
|
||||
return list(
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricIncomeCalendar)
|
||||
.where(MetricIncomeCalendar.scope == scope)
|
||||
.order_by(MetricIncomeCalendar.expected_date)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
async def monthly(scope: str = "all") -> list[MetricIncomeMonthly]:
|
||||
async with get_sessionmaker()() as session:
|
||||
return list(
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricIncomeMonthly)
|
||||
.where(MetricIncomeMonthly.scope == scope)
|
||||
.order_by(MetricIncomeMonthly.month, MetricIncomeMonthly.kind)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def forecast_within(rows, months: int = 12) -> list[MetricIncomeCalendar]:
|
||||
"""Future rows inside a half-open window of `months`, the way the API reads them."""
|
||||
today = today_local()
|
||||
end = add_months(today, months)
|
||||
return [r for r in rows if r.basis is not IncomeBasis.paid and today <= r.expected_date < end]
|
||||
|
||||
|
||||
async def test_every_payout_of_the_last_year_has_a_paid_calendar_row(app):
|
||||
"""Plan check: каждый полученный дивиденд/купон за 12 мес имеет запись календаря."""
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
bond = await make_bond()
|
||||
await make_event(
|
||||
today - timedelta(days=400),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="20",
|
||||
price="250",
|
||||
amount="-5000",
|
||||
)
|
||||
await make_event(
|
||||
today - timedelta(days=400),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=bond,
|
||||
quantity="10",
|
||||
price="1000",
|
||||
amount="-10000",
|
||||
)
|
||||
paid_days = [30, 120, 210, 300]
|
||||
for offset in paid_days:
|
||||
await make_payout(
|
||||
today - timedelta(days=offset),
|
||||
account_id=account,
|
||||
instrument_id=share,
|
||||
amount="400",
|
||||
)
|
||||
await make_payout(
|
||||
today - timedelta(days=60),
|
||||
account_id=account,
|
||||
instrument_id=bond,
|
||||
amount="400",
|
||||
kind=EventKind.coupon,
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
rows = await calendar()
|
||||
paid = {(r.instrument_id, r.kind, r.expected_date) for r in rows if r.basis is IncomeBasis.paid}
|
||||
for offset in paid_days:
|
||||
assert (share, "dividend", today - timedelta(days=offset)) in paid
|
||||
assert (bond, "coupon", today - timedelta(days=60)) in paid
|
||||
assert len(paid) == len(paid_days) + 1
|
||||
|
||||
|
||||
async def test_a_quarterly_payer_gives_four_future_entries_with_the_last_amount(app):
|
||||
"""Plan check: квартальный плательщик даёт 4 будущих записи с последней суммой."""
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="LKOH", name="Лукойл")
|
||||
await make_event(
|
||||
add_months(today, -30),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
for k in range(1, 9): # eight payments, one a quarter, the last three months ago
|
||||
await make_payout(
|
||||
add_months(today, -3 * k),
|
||||
account_id=account,
|
||||
instrument_id=share,
|
||||
amount="500",
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
future = forecast_within(await calendar())
|
||||
assert len(future) == 4
|
||||
assert {r.basis for r in future} == {IncomeBasis.history}
|
||||
assert {r.amount for r in future} == {D(500)}
|
||||
assert {r.per_unit for r in future} == {D(50)}
|
||||
|
||||
|
||||
async def test_a_coupon_shrinks_with_the_nominal_after_an_amortisation(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
bond = await make_bond(ticker="RU000AMORT")
|
||||
await make_event(
|
||||
today - timedelta(days=30),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=bond,
|
||||
quantity="10",
|
||||
price="1000",
|
||||
amount="-10000",
|
||||
)
|
||||
await make_nominal(bond, today - timedelta(days=700), "1000")
|
||||
await make_nominal(bond, add_months(today, 2), "500")
|
||||
for months in (1, 4):
|
||||
await make_action(
|
||||
instrument_id=bond,
|
||||
kind=CorporateActionKind.coupon,
|
||||
status=CorporateActionStatus.announced,
|
||||
pay_date=add_months(today, months),
|
||||
amount_per_unit="40",
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
coupons = {r.expected_date: r for r in await calendar() if r.kind == "coupon"}
|
||||
before = coupons[add_months(today, 1)]
|
||||
after = coupons[add_months(today, 4)]
|
||||
assert before.basis is IncomeBasis.schedule
|
||||
assert (before.per_unit, before.amount) == (D(40), D(400))
|
||||
# half the principal has been repaid, so the same published coupon pays half
|
||||
assert (after.per_unit, after.amount) == (D(20), D(200))
|
||||
# ...and the amortisation itself is a payment, priced off the step in the schedule
|
||||
amortisation = next(r for r in await calendar() if r.kind == "amortization")
|
||||
assert (amortisation.expected_date, amortisation.amount) == (add_months(today, 2), D(5000))
|
||||
|
||||
|
||||
async def test_a_sold_position_leaves_the_forecast_and_a_halved_one_halves_it(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
kept = await make_instrument(ticker="GAZP", name="Газпром")
|
||||
gone = await make_instrument(ticker="MGNT", name="Магнит")
|
||||
for instrument in (kept, gone):
|
||||
await make_event(
|
||||
add_months(today, -18),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="20",
|
||||
price="100",
|
||||
amount="-2000",
|
||||
)
|
||||
await make_payout(
|
||||
add_months(today, -12),
|
||||
account_id=account,
|
||||
instrument_id=instrument,
|
||||
amount="2000",
|
||||
)
|
||||
await make_event(
|
||||
add_months(today, -2),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=gone,
|
||||
quantity="-20",
|
||||
price="100",
|
||||
amount="2000",
|
||||
)
|
||||
await make_event(
|
||||
add_months(today, -2),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=kept,
|
||||
quantity="-10",
|
||||
price="100",
|
||||
amount="1000",
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
future = forecast_within(await calendar())
|
||||
assert [r.instrument_id for r in future] == [kept]
|
||||
# 2000 ₽ on 20 units, 10 units left: the forecast follows the position, not the history
|
||||
assert future[0].amount == D(1000)
|
||||
|
||||
|
||||
async def test_an_announced_dividend_beats_the_history_of_the_same_payment(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="TATN", name="Татнефть")
|
||||
await make_event(
|
||||
add_months(today, -18),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="500",
|
||||
amount="-5000",
|
||||
)
|
||||
await make_payout(add_months(today, -12), account_id=account, instrument_id=share, amount="300")
|
||||
announced_on = today + timedelta(days=10)
|
||||
await make_action(
|
||||
instrument_id=share,
|
||||
kind=CorporateActionKind.dividend,
|
||||
status=CorporateActionStatus.announced,
|
||||
record_date=today + timedelta(days=7),
|
||||
pay_date=announced_on,
|
||||
amount_per_unit="45",
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
future = forecast_within(await calendar())
|
||||
assert len(future) == 1
|
||||
row = future[0]
|
||||
assert (row.basis, row.expected_date, row.amount) == (
|
||||
IncomeBasis.announced,
|
||||
announced_on,
|
||||
D(450),
|
||||
)
|
||||
assert row.record_date == today + timedelta(days=7)
|
||||
|
||||
|
||||
async def test_history_groups_by_month_kind_and_currency_and_sums_the_tax(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="PHOR", name="ФосАгро")
|
||||
bond = await make_bond(ticker="RU000TAX")
|
||||
await make_event(
|
||||
add_months(today, -12),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
month = add_months(today.replace(day=5), -3)
|
||||
await make_payout(month, account_id=account, instrument_id=share, amount="870", tax="130")
|
||||
await make_payout(
|
||||
month + timedelta(days=10),
|
||||
account_id=account,
|
||||
instrument_id=share,
|
||||
amount="435",
|
||||
tax="65",
|
||||
)
|
||||
await make_payout(
|
||||
month + timedelta(days=2),
|
||||
account_id=account,
|
||||
instrument_id=bond,
|
||||
amount="400",
|
||||
kind=EventKind.coupon,
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
rows = {(r.month, r.kind): r for r in await monthly()}
|
||||
dividends = rows[(month.replace(day=1), "dividend")]
|
||||
assert (dividends.amount, dividends.tax_withheld, dividends.payment_count) == (
|
||||
D(1305),
|
||||
D(195),
|
||||
2,
|
||||
)
|
||||
assert dividends.currency == "RUB"
|
||||
assert rows[(month.replace(day=1), "coupon")].amount == D(400)
|
||||
|
||||
|
||||
async def test_a_payment_without_a_rate_keeps_its_row_and_loses_only_the_rouble_column(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="TCS", name="TCS Group", currency="USD")
|
||||
await make_event(
|
||||
add_months(today, -12),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="10",
|
||||
amount="-100",
|
||||
currency="USD",
|
||||
)
|
||||
paid_on = add_months(today, -2)
|
||||
await make_payout(paid_on, account_id=account, instrument_id=share, amount="20", currency="USD")
|
||||
await refresh()
|
||||
FINDINGS.reset()
|
||||
await rebuild()
|
||||
|
||||
row = next(r for r in await calendar() if r.basis is IncomeBasis.paid)
|
||||
assert (row.amount, row.currency, row.amount_rub) == (D(20), "USD", None)
|
||||
assert next(r for r in await monthly()).amount_rub is None
|
||||
assert any(f.check_name == "income_missing_fx" for f in FINDINGS.items)
|
||||
|
||||
|
||||
async def test_a_rate_on_the_payment_date_fills_the_rouble_column(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="TCS", name="TCS Group", currency="USD")
|
||||
await make_event(
|
||||
add_months(today, -12),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="10",
|
||||
amount="-100",
|
||||
currency="USD",
|
||||
)
|
||||
paid_on = add_months(today, -2)
|
||||
await make_cbr_rate(paid_on, "USD", "90")
|
||||
await make_payout(paid_on, account_id=account, instrument_id=share, amount="20", currency="USD")
|
||||
await refresh()
|
||||
await rebuild()
|
||||
|
||||
row = next(r for r in await calendar() if r.basis is IncomeBasis.paid)
|
||||
assert row.amount_rub == D(1800)
|
||||
|
||||
|
||||
async def test_an_instrument_with_neither_schedule_nor_history_is_a_warning_not_a_zero(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="SILENT", name="Ничего не платит")
|
||||
await make_event(
|
||||
add_months(today, -6),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await refresh()
|
||||
FINDINGS.reset()
|
||||
await rebuild()
|
||||
|
||||
assert forecast_within(await calendar()) == []
|
||||
finding = next(f for f in FINDINGS.items if f.check_name == "income_without_history")
|
||||
assert finding.severity == "warn"
|
||||
assert finding.ref == {"instruments": [share]}
|
||||
|
||||
|
||||
async def test_the_tables_are_rebuilt_from_scratch_on_every_run(app):
|
||||
today = today_local()
|
||||
account = await broker_account()
|
||||
share = await make_instrument(ticker="ROSN", name="Роснефть")
|
||||
await make_event(
|
||||
add_months(today, -12),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_payout(add_months(today, -6), account_id=account, instrument_id=share, amount="500")
|
||||
await refresh()
|
||||
await rebuild()
|
||||
before = len(await calendar())
|
||||
await rebuild()
|
||||
assert len(await calendar()) == before
|
||||
assert before > 0
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Rebalancing: the pure planning rules, then the refresh step end to end."""
|
||||
|
||||
from datetime import 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 FINDINGS, today_local
|
||||
from fintracker.analytics.rebalance import (
|
||||
Position,
|
||||
Target,
|
||||
build_plan,
|
||||
compute_rebalance,
|
||||
rebuild_rebalance,
|
||||
)
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AllocationDimension,
|
||||
AssetClass,
|
||||
EventKind,
|
||||
Instrument,
|
||||
MetricAllocation,
|
||||
MetricRebalance,
|
||||
Portfolio,
|
||||
PortfolioAccount,
|
||||
PortfolioTarget,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
DIM = AllocationDimension
|
||||
|
||||
|
||||
def position(
|
||||
instrument_id: int = 1,
|
||||
*,
|
||||
ticker: str = "SBER",
|
||||
lot: int = 10,
|
||||
qty: str = "100",
|
||||
unit: str = "275",
|
||||
) -> Position:
|
||||
return Position(
|
||||
instrument_id=instrument_id,
|
||||
ticker=ticker,
|
||||
name=ticker,
|
||||
lot=lot,
|
||||
qty=D(qty),
|
||||
unit_value_rub=D(unit),
|
||||
price=D(unit),
|
||||
price_currency="RUB",
|
||||
)
|
||||
|
||||
|
||||
def plan(
|
||||
*,
|
||||
bucket_values: dict[str, Decimal],
|
||||
positions: dict[str, list[Position]],
|
||||
targets: dict[str, Target],
|
||||
cash: str = "1000000",
|
||||
):
|
||||
total = sum((v for v in bucket_values.values() if v > 0), start=D(0))
|
||||
return build_plan(
|
||||
portfolio_id=1,
|
||||
dimension=DIM.asset_class,
|
||||
as_of=today_local(),
|
||||
total_value_rub=total,
|
||||
bucket_values=bucket_values,
|
||||
positions=positions,
|
||||
targets=targets,
|
||||
cash_available_rub=D(cash),
|
||||
)
|
||||
|
||||
|
||||
def bucket(result, name: str):
|
||||
return next(b for b in result.buckets if b.bucket == name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- lots and cash
|
||||
|
||||
|
||||
def test_a_buy_is_whole_lots_even_when_the_money_would_stretch_further():
|
||||
# 100 lots' worth of money, a lot of 10 at 275 => 2750 a lot
|
||||
result = plan(
|
||||
bucket_values={"share": D("27500"), "cash": D("22500")},
|
||||
positions={"share": [position(qty="100", unit="275", lot=10)]},
|
||||
targets={"share": Target(D("0.8")), "cash": Target(D("0.2"))},
|
||||
cash="22500",
|
||||
)
|
||||
trade = bucket(result, "share").trades[0]
|
||||
assert trade.action == "buy"
|
||||
# 0.8 * 50000 - 27500 = 12500 -> 45.45 units -> 4 lots = 40, never 45
|
||||
assert trade.qty == D(40)
|
||||
assert trade.qty % trade.lot == 0
|
||||
assert trade.amount_rub == D(40) * D("275")
|
||||
|
||||
|
||||
def test_a_buy_is_cut_to_the_cash_on_hand_and_says_so():
|
||||
result = plan(
|
||||
bucket_values={"share": D("27500"), "cash": D("22500")},
|
||||
positions={"share": [position(qty="100", unit="275", lot=10)]},
|
||||
targets={"share": Target(D("0.8")), "cash": Target(D("0.2"))},
|
||||
cash="6000",
|
||||
)
|
||||
trade = bucket(result, "share").trades[0]
|
||||
# 6000 buys two lots (5500), not the 4 the target asks for
|
||||
assert trade.qty == D(20)
|
||||
assert trade.blocked_by_cash is True
|
||||
assert trade.amount_rub <= D("6000")
|
||||
|
||||
|
||||
def test_no_cash_at_all_still_reports_the_blocked_buy_rather_than_hiding_it():
|
||||
result = plan(
|
||||
bucket_values={"share": D("27500"), "cash": D("22500")},
|
||||
positions={"share": [position(qty="100", unit="275", lot=10)]},
|
||||
targets={"share": Target(D("0.8")), "cash": Target(D("0.2"))},
|
||||
cash="0",
|
||||
)
|
||||
trade = bucket(result, "share").trades[0]
|
||||
assert trade.qty == D(0)
|
||||
assert trade.blocked_by_cash is True
|
||||
|
||||
|
||||
def test_cash_is_spent_once_across_buckets():
|
||||
result = plan(
|
||||
bucket_values={"share": D("1000"), "bond": D("1000"), "cash": D("8000")},
|
||||
positions={
|
||||
"share": [position(1, ticker="SBER", qty="10", unit="100", lot=1)],
|
||||
"bond": [position(2, ticker="OFZ", qty="10", unit="100", lot=1)],
|
||||
},
|
||||
targets={"share": Target(D("0.45")), "bond": Target(D("0.45")), "cash": Target(D("0.1"))},
|
||||
cash="1000",
|
||||
)
|
||||
spent = sum(t.amount_rub for b in result.buckets for t in b.trades if t.action == "buy")
|
||||
assert spent <= D("1000")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the band
|
||||
|
||||
|
||||
def test_a_drift_inside_the_band_proposes_nothing():
|
||||
result = plan(
|
||||
bucket_values={"share": D("6200"), "bond": D("3800")},
|
||||
positions={"share": [position(qty="62", unit="100", lot=1)]},
|
||||
targets={"share": Target(D("0.6"), D("0.05")), "bond": Target(D("0.4"), D("0.05"))},
|
||||
)
|
||||
share = bucket(result, "share")
|
||||
assert share.drift == D("0.02")
|
||||
assert share.within_band is True
|
||||
assert share.trades == []
|
||||
assert share.delta_value_rub == D(0)
|
||||
|
||||
|
||||
def test_the_same_drift_outside_the_band_proposes_a_trade():
|
||||
result = plan(
|
||||
bucket_values={"share": D("6200"), "bond": D("3800")},
|
||||
positions={
|
||||
"share": [position(qty="62", unit="100", lot=1)],
|
||||
"bond": [position(2, ticker="OFZ", qty="38", unit="100", lot=1)],
|
||||
},
|
||||
targets={"share": Target(D("0.6"), D("0.01")), "bond": Target(D("0.4"), D("0.01"))},
|
||||
)
|
||||
share = bucket(result, "share")
|
||||
assert share.within_band is False
|
||||
assert share.trades[0].action == "sell"
|
||||
assert share.trades[0].qty == D(2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- sells
|
||||
|
||||
|
||||
def test_a_sell_never_exceeds_the_position_and_never_goes_short():
|
||||
# the bucket must shrink by more than it holds: the target moved to zero
|
||||
result = plan(
|
||||
bucket_values={"share": D("1000"), "bond": D("9000")},
|
||||
positions={"share": [position(qty="10", unit="100", lot=1)]},
|
||||
targets={"share": Target(D("0")), "bond": Target(D("1"))},
|
||||
)
|
||||
trade = bucket(result, "share").trades[0]
|
||||
assert trade.action == "sell"
|
||||
assert trade.qty == D(10)
|
||||
assert trade.qty <= D(10)
|
||||
|
||||
|
||||
def test_a_sell_is_capped_to_whole_lots_of_what_is_held():
|
||||
# 25 units of a 10-lot paper: at most two lots can be sold
|
||||
result = plan(
|
||||
bucket_values={"share": D("2500"), "bond": D("7500")},
|
||||
positions={"share": [position(qty="25", unit="100", lot=10)]},
|
||||
targets={"share": Target(D("0")), "bond": Target(D("1"))},
|
||||
)
|
||||
trade = bucket(result, "share").trades[0]
|
||||
assert trade.qty == D(20)
|
||||
|
||||
|
||||
def test_a_bucket_is_trimmed_proportionally_not_from_one_paper():
|
||||
result = plan(
|
||||
bucket_values={"share": D("10000"), "bond": D("0")},
|
||||
positions={
|
||||
"share": [
|
||||
position(1, ticker="BIG", qty="75", unit="100", lot=1),
|
||||
position(2, ticker="SMALL", qty="25", unit="100", lot=1),
|
||||
]
|
||||
},
|
||||
targets={"share": Target(D("0.5")), "bond": Target(D("0.5"))},
|
||||
)
|
||||
by_ticker = {t.ticker: t.qty for t in bucket(result, "share").trades}
|
||||
# 5000 to raise, split 75/25 by value: 37 and 12 units (floored to whole lots)
|
||||
assert by_ticker == {"BIG": D(37), "SMALL": D(12)}
|
||||
|
||||
|
||||
def test_a_bucket_with_nothing_priced_in_it_warns_instead_of_inventing_a_trade():
|
||||
result = plan(
|
||||
bucket_values={"share": D("10000"), "bond": D("0")},
|
||||
positions={},
|
||||
targets={"share": Target(D("0.5")), "bond": Target(D("0.5"))},
|
||||
)
|
||||
assert bucket(result, "share").trades == []
|
||||
assert any("share" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_the_cash_bucket_needs_no_trades_and_produces_no_warning():
|
||||
result = plan(
|
||||
bucket_values={"share": D("5000"), "cash": D("5000")},
|
||||
positions={"share": [position(qty="50", unit="100", lot=1)]},
|
||||
targets={"share": Target(D("0.9")), "cash": Target(D("0.1"))},
|
||||
cash="5000",
|
||||
)
|
||||
assert bucket(result, "cash").trades == []
|
||||
assert not any("cash" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_a_bucket_without_a_target_is_reported_but_never_traded():
|
||||
result = plan(
|
||||
bucket_values={"share": D("5000"), "etf": D("5000")},
|
||||
positions={"etf": [position(2, ticker="TMOS", qty="50", unit="100", lot=1)]},
|
||||
targets={"share": Target(D("1"))},
|
||||
)
|
||||
etf = bucket(result, "etf")
|
||||
assert etf.target_weight is None
|
||||
assert etf.drift is None
|
||||
assert etf.trades == []
|
||||
|
||||
|
||||
def test_every_number_in_the_plan_is_a_decimal():
|
||||
result = plan(
|
||||
bucket_values={"share": D("6200"), "bond": D("3800")},
|
||||
positions={"share": [position(qty="62", unit="100", lot=1)]},
|
||||
targets={"share": Target(D("0.5")), "bond": Target(D("0.5"))},
|
||||
)
|
||||
for b in result.buckets:
|
||||
for value in (b.current_value_rub, b.current_weight, b.delta_value_rub):
|
||||
assert isinstance(value, Decimal)
|
||||
for t in b.trades:
|
||||
for value in (t.qty, t.price, t.amount_rub):
|
||||
assert isinstance(value, Decimal)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- database
|
||||
|
||||
|
||||
async def _portfolio_with(*, unpriced: bool) -> dict[str, int]:
|
||||
"""A broker account in a portfolio: 500 SBER (lot 10), 20 OFZ, the rest in cash."""
|
||||
t = today_local()
|
||||
bought = t - timedelta(days=40)
|
||||
account = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
sber = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
|
||||
ofz = await make_instrument(ticker="OFZ", name="ОФЗ", asset_class=AssetClass.bond)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = await session.get(Instrument, sber)
|
||||
assert instrument is not None
|
||||
instrument.lot = 10
|
||||
portfolio = Portfolio(name="Основной")
|
||||
session.add(portfolio)
|
||||
await session.flush()
|
||||
session.add(PortfolioAccount(portfolio_id=portfolio.id, account_id=account))
|
||||
portfolio_id = portfolio.id
|
||||
await session.commit()
|
||||
|
||||
await make_event(bought, account_id=account, kind=EventKind.deposit, amount="100000")
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=sber,
|
||||
quantity="500",
|
||||
price="100",
|
||||
amount="-50000",
|
||||
)
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=ofz,
|
||||
quantity="20",
|
||||
price="1000",
|
||||
amount="-20000",
|
||||
)
|
||||
ids = {"account": account, "portfolio": portfolio_id, "sber": sber, "ofz": ofz}
|
||||
|
||||
if unpriced:
|
||||
silent = await make_instrument(
|
||||
ticker="SIBN6P4", name="Без цены", asset_class=AssetClass.share, board="SPBRUBND"
|
||||
)
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=silent,
|
||||
quantity="5",
|
||||
price="1000",
|
||||
amount="-5000",
|
||||
)
|
||||
ids["silent"] = silent
|
||||
|
||||
d = bought
|
||||
while d <= t:
|
||||
await make_price(d, instrument_id=sber, close="100")
|
||||
await make_price(d, instrument_id=ofz, close="1000")
|
||||
d += timedelta(days=1)
|
||||
await refresh()
|
||||
return ids
|
||||
|
||||
|
||||
async def _set_targets(portfolio_id: int, rows: list[tuple[str, str, str]]) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
for bucket_name, weight, band in rows:
|
||||
session.add(
|
||||
PortfolioTarget(
|
||||
portfolio_id=portfolio_id,
|
||||
dimension=DIM.asset_class,
|
||||
bucket=bucket_name,
|
||||
target_weight=D(weight),
|
||||
band=D(band),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def portfolio(app) -> dict[str, int]:
|
||||
ids = await _portfolio_with(unpriced=False)
|
||||
await _set_targets(
|
||||
ids["portfolio"],
|
||||
[("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")],
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
async def test_the_step_fills_the_target_columns_of_metric_allocation(portfolio):
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_rebalance(session)
|
||||
await session.commit()
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricAllocation).where(
|
||||
MetricAllocation.scope == f"portfolio:{portfolio['portfolio']}",
|
||||
MetricAllocation.dimension == DIM.asset_class,
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
by_bucket = {r.bucket: r for r in rows}
|
||||
assert by_bucket["share"].target_weight == D("0.6")
|
||||
assert by_bucket["share"].weight == D("0.5")
|
||||
assert by_bucket["share"].drift == D("-0.1")
|
||||
assert by_bucket["cash"].target_weight == D("0.2")
|
||||
assert by_bucket["cash"].drift == D("0.1")
|
||||
|
||||
|
||||
async def test_metric_rebalance_agrees_with_metric_allocation(portfolio):
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_rebalance(session)
|
||||
await session.commit()
|
||||
allocation = {
|
||||
r.bucket: r
|
||||
for r in (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricAllocation).where(
|
||||
MetricAllocation.scope == f"portfolio:{portfolio['portfolio']}",
|
||||
MetricAllocation.dimension == DIM.asset_class,
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
}
|
||||
summaries = {
|
||||
r.bucket: r
|
||||
for r in (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricRebalance).where(MetricRebalance.instrument_id.is_(None))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
}
|
||||
trades = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricRebalance).where(MetricRebalance.instrument_id.is_not(None))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
for name, row in summaries.items():
|
||||
assert row.current_weight == allocation[name].weight
|
||||
assert row.target_weight == allocation[name].target_weight
|
||||
assert row.current_value_rub == allocation[name].value_rub
|
||||
|
||||
# 0.6 of 100 000 is 60 000 against 50 000 held: 100 more shares at 100, lot 10
|
||||
buy = next(t for t in trades if t.instrument_id == portfolio["sber"])
|
||||
assert buy.suggested_qty == D(100)
|
||||
assert buy.suggested_qty is not None
|
||||
assert buy.lot is not None
|
||||
assert buy.suggested_qty % buy.lot == 0
|
||||
assert buy.blocked_by_cash is False
|
||||
# the bond bucket sits exactly on its target and proposes nothing
|
||||
assert summaries["bond"].within_band is True
|
||||
assert not [t for t in trades if t.instrument_id == portfolio["ofz"]]
|
||||
|
||||
|
||||
async def test_an_instrument_without_a_price_is_left_out_but_reported(app):
|
||||
ids = await _portfolio_with(unpriced=True)
|
||||
await _set_targets(
|
||||
ids["portfolio"],
|
||||
[("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")],
|
||||
)
|
||||
FINDINGS.reset()
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_rebalance(session)
|
||||
await session.commit()
|
||||
trades = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricRebalance).where(MetricRebalance.instrument_id.is_not(None))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert ids["silent"] not in {t.instrument_id for t in trades}
|
||||
assert any(
|
||||
f.check_name == "rebalance_incomplete" and "SIBN6P4" in f.detail for f in FINDINGS.items
|
||||
)
|
||||
|
||||
|
||||
async def test_the_what_if_cash_overrides_the_real_balance(portfolio):
|
||||
async with get_sessionmaker()() as session:
|
||||
real = await compute_rebalance(session, portfolio["portfolio"], DIM.asset_class)
|
||||
poor = await compute_rebalance(
|
||||
session, portfolio["portfolio"], DIM.asset_class, cash_available_rub=D("500")
|
||||
)
|
||||
assert real.cash_available_rub == D("30000")
|
||||
rich_trade = next(t for b in real.buckets for t in b.trades)
|
||||
poor_trade = next(t for b in poor.buckets for t in b.trades)
|
||||
assert poor_trade.qty < rich_trade.qty
|
||||
assert poor_trade.blocked_by_cash is True
|
||||
@@ -0,0 +1,367 @@
|
||||
"""The tax year, checked against an example worked out by hand — the plan's фаза-4 check.
|
||||
|
||||
Everything here is an estimate by construction (the broker is the tax agent), so the tests
|
||||
are about the two things that make the estimate worth having: that it is reproducible on
|
||||
paper, and that it never invents a number it does not have.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import (
|
||||
make_account,
|
||||
make_cbr_rate,
|
||||
make_event,
|
||||
make_instrument,
|
||||
)
|
||||
from fintracker.analytics import FINDINGS
|
||||
from fintracker.analytics.tax import TAX_RATE, rebuild_tax_year
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.ledger.rebuild import rebuild_lots
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
EventKind,
|
||||
LotDisposal,
|
||||
MetricTaxYear,
|
||||
)
|
||||
from fintracker.pricing.fx import rebuild_fx_daily
|
||||
|
||||
D = Decimal
|
||||
|
||||
|
||||
async def _rebuild() -> None:
|
||||
"""The three steps a tax year depends on, without the rest of the refresh."""
|
||||
FINDINGS.reset()
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_fx_daily(session)
|
||||
await session.commit()
|
||||
await rebuild_lots(session)
|
||||
await session.commit()
|
||||
await rebuild_tax_year(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _rows() -> dict[tuple[int, int], MetricTaxYear]:
|
||||
async with get_sessionmaker()() as session:
|
||||
found = await session.execute(select(MetricTaxYear))
|
||||
return {(r.year, r.account_id): r for r in found.scalars()}
|
||||
|
||||
|
||||
async def _payment(
|
||||
account_id: int,
|
||||
instrument_id: int,
|
||||
kind: EventKind,
|
||||
d: date,
|
||||
amount: str,
|
||||
*,
|
||||
tax: str | None = None,
|
||||
currency: str = "RUB",
|
||||
) -> None:
|
||||
"""A dividend or coupon as a broker reports it: net cash plus the tax it kept back."""
|
||||
from datetime import UTC, datetime, time
|
||||
|
||||
from fintracker.models import Event
|
||||
|
||||
key = f"{kind}-{instrument_id}-{d}"
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
Event(
|
||||
account_id=account_id,
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
ts=datetime.combine(d, time.min, tzinfo=UTC),
|
||||
trade_date=d,
|
||||
amount=D(amount),
|
||||
currency=currency,
|
||||
tax=None if tax is None else D(tax),
|
||||
tax_currency=None if tax is None else currency,
|
||||
source="tinvest",
|
||||
source_id=key,
|
||||
dedupe_key=f"tinvest:{key}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _broker_account(name: str = "Брокерский") -> int:
|
||||
return await make_account(
|
||||
name=name,
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def usd_rates(app) -> None:
|
||||
"""Two CBR quotes: the dollar rose by 20 % between the purchase and the sale."""
|
||||
await make_cbr_rate(date(2023, 3, 10), "USD", "75")
|
||||
await make_cbr_rate(date(2024, 6, 20), "USD", "90")
|
||||
|
||||
|
||||
async def test_the_worked_example_adds_up(usd_rates):
|
||||
"""Done on paper first; the code has to agree with the paper, not the other way round.
|
||||
|
||||
Purchase 10.03.2023: 10 units at $100 = $1000, CBR 75 ₽/$ -> cost 75 000 ₽
|
||||
Sale 20.06.2024: 10 units at $110 = $1100, CBR 90 ₽/$ -> proceeds 99 000 ₽
|
||||
Realised in roubles = 24 000 ₽
|
||||
of which the price move is $100 x 90 = 9 000 ₽
|
||||
and currency revaluation $1000 x 15 = 15 000 ₽ (in the base, plan §7 q4)
|
||||
|
||||
Plus a rouble lot bought 10.01.2020 and sold the same day in 2024 for +1 000 ₽. It is
|
||||
held over three years, so art. 219.1 takes its result back out of the base.
|
||||
|
||||
gain 24 000 + 1 000 = 25 000 ₽
|
||||
loss 0
|
||||
ЛДВ exempt 1 000 ₽
|
||||
base 25 000 - 1 000 = 24 000 ₽
|
||||
tax 24 000 x 0.13 = 3 120 ₽
|
||||
"""
|
||||
account = await _broker_account()
|
||||
foreign = await make_instrument(ticker="AAPL", name="Apple", currency="USD")
|
||||
old = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
|
||||
await make_event(
|
||||
date(2023, 3, 10),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=foreign,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
currency="USD",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=foreign,
|
||||
quantity="-10",
|
||||
price="110",
|
||||
amount="1100",
|
||||
currency="USD",
|
||||
)
|
||||
await make_event(
|
||||
date(2020, 1, 10),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=old,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=old,
|
||||
quantity="-10",
|
||||
price="200",
|
||||
amount="2000",
|
||||
)
|
||||
|
||||
await _rebuild()
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
assert row.realized_gain_rub == D("25000.00")
|
||||
assert row.realized_loss_rub == D("0.00")
|
||||
assert row.ldv_exempt_rub == D("1000.00")
|
||||
assert row.taxable_base_rub == D("24000.00")
|
||||
assert row.estimated_tax_rub == D("3120.00")
|
||||
# the rate is recorded, not implied, so a future change stays visible in old years
|
||||
assert row.tax_rate == TAX_RATE == D("0.13")
|
||||
|
||||
|
||||
async def test_the_three_year_lot_is_flagged_by_the_ledgers_own_rule(usd_rates):
|
||||
"""`ldv_eligible` is computed once, in `ledger/lots.py`; the tax view only reads it."""
|
||||
account = await _broker_account()
|
||||
instrument = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
await make_event(
|
||||
date(2020, 1, 10),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=instrument,
|
||||
quantity="-10",
|
||||
price="200",
|
||||
amount="2000",
|
||||
)
|
||||
await _rebuild()
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
disposal = (await session.execute(select(LotDisposal))).scalar_one()
|
||||
assert disposal.holding_days >= 3 * 365
|
||||
assert disposal.ldv_eligible is True
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
assert row.ldv_exempt_rub == D("1000.00")
|
||||
assert row.taxable_base_rub == D("0.00")
|
||||
assert row.estimated_tax_rub == D("0.00")
|
||||
|
||||
|
||||
async def test_currency_revaluation_is_not_the_currency_result(usd_rates):
|
||||
"""A position flat in dollars still owes tax when the dollar rose — and the two numbers
|
||||
must not be confused: the base is 15 000 ₽ while the dollar result is exactly zero."""
|
||||
account = await _broker_account()
|
||||
instrument = await make_instrument(ticker="AAPL", name="Apple", currency="USD")
|
||||
await make_event(
|
||||
date(2023, 3, 10),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
currency="USD",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=instrument,
|
||||
quantity="-10",
|
||||
price="100",
|
||||
amount="1000",
|
||||
currency="USD",
|
||||
)
|
||||
await _rebuild()
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
disposal = (await session.execute(select(LotDisposal))).scalar_one()
|
||||
assert disposal.realized_pnl_native == D(0)
|
||||
assert disposal.realized_pnl_rub == D("15000.0000000000")
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
assert row.taxable_base_rub == D("15000.00")
|
||||
assert row.estimated_tax_rub == D("1950.00")
|
||||
# and the fact that a revaluation happened is said out loud, because a Minfin eurobond
|
||||
# hiding among these would be taxed differently and cannot be detected automatically
|
||||
assert any(f.check_name == "tax_currency_revaluation" for f in FINDINGS.items)
|
||||
|
||||
|
||||
async def test_a_leg_without_a_rate_is_left_out_and_reported(usd_rates):
|
||||
"""No rate means no rouble result. Never a substitute — a finding instead."""
|
||||
account = await _broker_account()
|
||||
quoted = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
unquoted = await make_instrument(ticker="0700", name="Tencent", currency="HKD", board="SPBHKEX")
|
||||
|
||||
await make_event(
|
||||
date(2024, 2, 1),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=quoted,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=quoted,
|
||||
quantity="-10",
|
||||
price="150",
|
||||
amount="1500",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 2, 1),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=unquoted,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
currency="HKD",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=unquoted,
|
||||
quantity="-10",
|
||||
price="300",
|
||||
amount="3000",
|
||||
currency="HKD",
|
||||
)
|
||||
await _rebuild()
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
# only the rouble trade is in the base; the HKD one did not inflate or deflate it
|
||||
assert row.realized_gain_rub == D("500.00")
|
||||
assert row.taxable_base_rub == D("500.00")
|
||||
assert row.estimated_tax_rub == D("65.00")
|
||||
assert any(f.check_name == "tax_disposal_no_fx" for f in FINDINGS.items)
|
||||
|
||||
|
||||
async def test_dividends_and_coupons_are_gross_with_the_tax_that_was_withheld(app):
|
||||
"""`amount` is what landed and `tax` is what was taken; gross is their sum."""
|
||||
account = await _broker_account()
|
||||
share = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
bond = await make_instrument(ticker="SU26238", name="ОФЗ", asset_class=AssetClass.bond)
|
||||
|
||||
await _payment(account, share, EventKind.dividend, date(2024, 5, 15), "870", tax="130")
|
||||
await _payment(account, bond, EventKind.coupon, date(2024, 8, 1), "500")
|
||||
|
||||
await _rebuild()
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
assert row.dividends_gross_rub == D("1000.00")
|
||||
assert row.coupons_gross_rub == D("500.00")
|
||||
assert row.tax_withheld_rub == D("130.00")
|
||||
# income is outside the securities base: the agent already withheld on it
|
||||
assert row.taxable_base_rub == D("0.00")
|
||||
|
||||
|
||||
async def test_every_stored_number_is_a_decimal(usd_rates):
|
||||
account = await _broker_account()
|
||||
instrument = await make_instrument(ticker="SBER", name="Сбербанк")
|
||||
await make_event(
|
||||
date(2024, 2, 1),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
date(2024, 6, 20),
|
||||
account_id=account,
|
||||
kind=EventKind.sell,
|
||||
instrument_id=instrument,
|
||||
quantity="-10",
|
||||
price="150",
|
||||
amount="1500",
|
||||
)
|
||||
await _rebuild()
|
||||
|
||||
row = (await _rows())[(2024, account)]
|
||||
for field in (
|
||||
"dividends_gross_rub",
|
||||
"coupons_gross_rub",
|
||||
"tax_withheld_rub",
|
||||
"realized_gain_rub",
|
||||
"realized_loss_rub",
|
||||
"ldv_exempt_rub",
|
||||
"taxable_base_rub",
|
||||
"estimated_tax_rub",
|
||||
"tax_rate",
|
||||
):
|
||||
value = getattr(row, field)
|
||||
assert isinstance(value, Decimal), field
|
||||
assert not isinstance(value, float), field
|
||||
Reference in New Issue
Block a user