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:
Dmitry
2026-09-19 10:42:50 +03:00
parent ff3b76871d
commit 15f5812ea4
42 changed files with 10607 additions and 3 deletions
+227
View File
@@ -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)
+342
View File
@@ -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() == {}
+701
View File
@@ -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
+477
View File
@@ -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
+367
View File
@@ -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