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
+183
View File
@@ -0,0 +1,183 @@
"""Goal CRUD and progress over HTTP (docs/ai/phase4-contract.md §4).
The router is mounted here rather than taken from `create_app`: wiring it into
`api/app.py` belongs to the phase-4 integration.
"""
from collections.abc import AsyncIterator
from datetime import timedelta
from decimal import Decimal
import pytest
from httpx import ASGITransport, AsyncClient
from factories import make_account, make_event, make_instrument, make_price, refresh
from fintracker.analytics import today_local
from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind
D = Decimal
PREFIX = "/api/v1"
@pytest.fixture
async def client(app) -> AsyncIterator[AsyncClient]:
from fintracker.api.routers import goals
app.include_router(goals.router, prefix=PREFIX)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def portfolio(app) -> None:
"""A year of history ending 50 % up: 1000 shares bought at 100, now worth 150."""
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:
await make_price(d, instrument_id=share, close="100" if d < t else "150")
d += timedelta(days=1)
await refresh()
async def create(client, auth_headers, **body):
payload = {"name": "Капитал", "scope": "all", "target_amount": "1000000"} | body
return await client.post(f"{PREFIX}/goals", json=payload, headers=auth_headers)
# --------------------------------------------------------------------------- CRUD
async def test_a_goal_round_trips(client, auth_headers, portfolio):
r = await create(client, auth_headers, target_date="2030-01-01", monthly_contribution="30000")
assert r.status_code == 201, r.text
body = r.json()
assert body["name"] == "Капитал"
assert Decimal(body["target_amount"]) == 1000000
assert body["target_date"] == "2030-01-01"
assert Decimal(body["monthly_contribution"]) == 30000
assert body["archived"] is False
listing = await client.get(f"{PREFIX}/goals", headers=auth_headers)
assert [g["id"] for g in listing.json()] == [body["id"]]
async def test_a_duplicate_name_is_a_conflict(client, auth_headers, portfolio):
await create(client, auth_headers)
r = await create(client, auth_headers)
assert r.status_code == 409
async def test_a_scope_the_metrics_never_built_is_refused(client, auth_headers, portfolio):
r = await create(client, auth_headers, scope="portfolio:999")
assert r.status_code == 404
async def test_patch_changes_only_what_is_sent(client, auth_headers, portfolio):
created = (await create(client, auth_headers, monthly_contribution="1000")).json()
r = await client.patch(
f"{PREFIX}/goals/{created['id']}",
json={"target_amount": "500000"},
headers=auth_headers,
)
assert r.status_code == 200
body = r.json()
assert Decimal(body["target_amount"]) == 500000
assert Decimal(body["monthly_contribution"]) == 1000
async def test_archived_goals_are_hidden_unless_asked_for(client, auth_headers, portfolio):
created = (await create(client, auth_headers)).json()
await client.patch(
f"{PREFIX}/goals/{created['id']}", json={"archived": True}, headers=auth_headers
)
assert (await client.get(f"{PREFIX}/goals", headers=auth_headers)).json() == []
shown = await client.get(
f"{PREFIX}/goals", params={"include_archived": "true"}, headers=auth_headers
)
assert [g["id"] for g in shown.json()] == [created["id"]]
async def test_a_goal_can_be_deleted(client, auth_headers, portfolio):
created = (await create(client, auth_headers)).json()
r = await client.delete(f"{PREFIX}/goals/{created['id']}", headers=auth_headers)
assert r.status_code == 204
assert (await client.get(f"{PREFIX}/goals", headers=auth_headers)).json() == []
assert (
await client.delete(f"{PREFIX}/goals/{created['id']}", headers=auth_headers)
).status_code == 404
async def test_goals_need_a_token(client, portfolio):
assert (await client.get(f"{PREFIX}/goals")).status_code == 401
# --------------------------------------------------------------------------- progress
async def test_progress_is_computed_from_the_live_metrics(client, auth_headers, portfolio):
created = (await create(client, auth_headers, target_amount="300000")).json()
r = await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers)
assert r.status_code == 200, r.text
body = r.json()
assert Decimal(body["current_value_rub"]) == 150000
assert Decimal(body["target_amount_rub"]) == 300000
assert Decimal(body["progress"]) == Decimal("0.5")
assert body["basis"] == "xirr"
assert body["projected_date"] is not None
assert body["projected_date"] > str(today_local())
assert body["monthly_needed_rub"] is None
assert body["on_track"] is None
async def test_a_deadline_produces_a_monthly_need_and_an_on_track_flag(
client, auth_headers, portfolio
):
created = (
await create(
client,
auth_headers,
target_amount="10000000",
target_date=str(today_local() + timedelta(days=365)),
)
).json()
body = (
await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers)
).json()
assert Decimal(body["monthly_needed_rub"]) > 0
assert body["on_track"] is False
async def test_every_money_field_is_a_string(client, auth_headers, portfolio):
created = (await create(client, auth_headers)).json()
body = (
await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers)
).json()
for key in ("current_value_rub", "target_amount_rub", "progress"):
assert isinstance(body[key], str)
for key in ("assumed_rate", "monthly_needed_rub"):
assert body[key] is None or isinstance(body[key], str)
async def test_progress_of_an_unknown_goal_is_a_404(client, auth_headers, portfolio):
r = await client.get(f"{PREFIX}/goals/999/progress", headers=auth_headers)
assert r.status_code == 404
+320
View File
@@ -0,0 +1,320 @@
"""`/income` over a small portfolio: one paid dividend, one announced, one bond coupon.
The router is not wired into `create_app` yet (that is done separately), so the fixture mounts
it on the same application the rest of the API tests use.
"""
from collections.abc import AsyncIterator
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from factories import make_account, make_event, make_instrument, refresh
from fintracker.analytics import today_local
from fintracker.analytics.income import add_months, rebuild_income
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
AssetClass,
BondNominalSchedule,
CorporateAction,
CorporateActionKind,
CorporateActionStatus,
Event,
EventKind,
Instrument,
)
D = Decimal
PREFIX = "/api/v1/income"
async def make_bond(*, ticker: str, maturity: date | None = None) -> int:
async with get_sessionmaker()() as session:
bond = Instrument(
asset_class=AssetClass.bond,
ticker=ticker,
board="TQOB",
name=ticker,
currency="RUB",
nominal=D(1000),
nominal_currency="RUB",
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,
) -> None:
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="RUB",
tax=D(tax) if tax is not None else None,
tax_currency="RUB" if tax is not None else None,
source="tinvest",
source_id=f"pay-{instrument_id}-{d}-{amount}",
dedupe_key=f"tinvest:pay-{instrument_id}-{d}-{amount}",
)
)
await session.commit()
async def make_action(
*,
instrument_id: int,
kind: CorporateActionKind,
status: CorporateActionStatus,
pay_date: date,
record_date: date | None = None,
amount_per_unit: str,
) -> 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),
currency="RUB",
source="moex",
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()
@pytest.fixture
async def income_client(app, user) -> AsyncIterator[AsyncClient]:
from fintracker.api.routers import income
app.include_router(income.router, prefix="/api/v1")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
r = await c.post("/api/v1/auth/login", json=user)
assert r.status_code == 200, r.text
c.headers["Authorization"] = f"Bearer {r.json()['access_token']}"
yield c
@pytest.fixture
async def portfolio(app) -> dict[str, int]:
"""A share that paid twice and has a declared payout, and an amortising bond."""
today = today_local()
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="Сбербанк России")
bond = await make_bond(ticker="RU000API", maturity=add_months(today, 30))
await make_event(
add_months(today, -20),
account_id=account,
kind=EventKind.buy,
instrument_id=share,
quantity="20",
price="250",
amount="-5000",
)
await make_event(
add_months(today, -20),
account_id=account,
kind=EventKind.buy,
instrument_id=bond,
quantity="10",
price="1000",
amount="-10000",
)
await make_payout(
add_months(today, -13), account_id=account, instrument_id=share, amount="696.80", tax="104"
)
await make_payout(
add_months(today, -1), account_id=account, instrument_id=share, amount="696.80", tax="104"
)
await make_action(
instrument_id=share,
kind=CorporateActionKind.dividend,
status=CorporateActionStatus.announced,
record_date=today + timedelta(days=21),
pay_date=today + timedelta(days=24),
amount_per_unit="34.84",
)
await make_nominal(bond, add_months(today, -24), "1000")
await make_action(
instrument_id=bond,
kind=CorporateActionKind.coupon,
status=CorporateActionStatus.announced,
pay_date=add_months(today, 2),
amount_per_unit="40",
)
await refresh()
await rebuild()
return {"account": account, "share": share, "bond": bond}
def floats(value: Any, path: str = "$") -> list[str]:
"""Every place a float leaked into the payload — money must travel as a string."""
if isinstance(value, bool):
return []
if isinstance(value, float):
return [path]
if isinstance(value, dict):
return [p for k, v in value.items() for p in floats(v, f"{path}.{k}")]
if isinstance(value, list):
return [p for i, v in enumerate(value) for p in floats(v, f"{path}[{i}]")]
return []
async def test_calendar_shows_the_future_with_money_as_strings_and_a_basis_on_every_row(
income_client: AsyncClient, portfolio: dict[str, int]
):
r = await income_client.get(f"{PREFIX}/calendar")
assert r.status_code == 200, r.text
body = r.json()
assert floats(body) == []
assert body["currency"] == "RUB"
assert body["entries"], body
for entry in body["entries"]:
assert isinstance(entry["amount"], str)
assert isinstance(entry["qty"], str)
assert entry["basis"] in {"schedule", "announced", "history"}
by_key = {(e["kind"], e["basis"]): e for e in body["entries"]}
# the declared autumn dividend, and next year's payment that only history knows about
announced = by_key[("dividend", "announced")]
assert announced["amount"] == "696.8000000000"
assert announced["ticker"] == "SBER"
assert announced["per_unit"] == "34.8400000000"
assert ("dividend", "history") in by_key
assert by_key[("coupon", "schedule")]["amount"] == "400.0000000000"
assert set(body["by_basis"]) == {"announced", "history", "schedule"}
total = sum(D(v) for v in body["by_basis"].values())
assert D(body["total_expected_rub"]) == total
async def test_include_paid_adds_the_history_rows_and_nothing_else(
income_client: AsyncClient, portfolio: dict[str, int]
):
today = today_local()
window = {"date_from": str(add_months(today, -24)), "date_to": str(add_months(today, 12))}
without = (await income_client.get(f"{PREFIX}/calendar", params=window)).json()
assert {e["basis"] for e in without["entries"]} == {"announced", "history", "schedule"}
with_paid = (
await income_client.get(f"{PREFIX}/calendar", params={**window, "include_paid": "true"})
).json()
paid = [e for e in with_paid["entries"] if e["basis"] == "paid"]
assert len(paid) == 2
assert paid[0]["tax_withheld"] == "104.0000000000"
# a payment already received is not an expectation: the totals must not move
assert with_paid["total_expected_rub"] == without["total_expected_rub"]
assert "paid" not in with_paid["by_basis"]
async def test_history_groups_by_month_and_totals_the_tax(
income_client: AsyncClient, portfolio: dict[str, int]
):
r = await income_client.get(f"{PREFIX}/history")
assert r.status_code == 200, r.text
body = r.json()
assert floats(body) == []
assert [row["kind"] for row in body["rows"]] == ["dividend", "dividend"]
assert {row["payment_count"] for row in body["rows"]} == {1}
assert D(body["totals"]["amount_rub"]) == D("1393.60")
assert D(body["totals"]["tax_withheld_rub"]) == D("208")
filtered = (await income_client.get(f"{PREFIX}/history", params={"kind": "coupon"})).json()
assert filtered["rows"] == []
async def test_forecast_splits_every_month_by_basis(
income_client: AsyncClient, portfolio: dict[str, int]
):
r = await income_client.get(f"{PREFIX}/forecast", params={"months": 12})
assert r.status_code == 200, r.text
body = r.json()
assert floats(body) == []
assert body["months"], body
bases = {basis for month in body["months"] for basis in month["by_basis"]}
assert bases <= {"schedule", "announced", "history"}
for month in body["months"]:
assert D(month["amount_rub"]) == sum(D(v) for v in month["by_basis"].values())
assert D(body["total_rub"]) == sum(D(m["amount_rub"]) for m in body["months"])
assert isinstance(body["warnings"], list)
async def test_forecast_rejects_a_horizon_the_table_was_not_built_for(
income_client: AsyncClient, portfolio: dict[str, int]
):
assert (await income_client.get(f"{PREFIX}/forecast", params={"months": 0})).status_code == 422
assert (await income_client.get(f"{PREFIX}/forecast", params={"months": 37})).status_code == 422
async def test_an_unknown_scope_is_a_404_not_an_empty_calendar(
income_client: AsyncClient, portfolio: dict[str, int]
):
r = await income_client.get(f"{PREFIX}/calendar", params={"scope": "account:999"})
assert r.status_code == 404
async def test_the_endpoints_require_a_token(income_client: AsyncClient, app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as anon:
assert (await anon.get(f"{PREFIX}/calendar")).status_code == 401
async def test_the_endpoints_are_pure_reads_and_repeat_themselves(
income_client: AsyncClient, portfolio: dict[str, int]
):
first = (await income_client.get(f"{PREFIX}/forecast")).json()
second = (await income_client.get(f"{PREFIX}/forecast")).json()
assert first == second
+285
View File
@@ -0,0 +1,285 @@
"""Target weights and rebalancing over HTTP (docs/ai/phase4-contract.md §2).
The router is mounted here rather than taken from `create_app`: wiring it into
`api/app.py` belongs to the phase-4 integration, and these tests should not wait on it.
"""
from collections.abc import AsyncIterator
from datetime import timedelta
from decimal import Decimal
import pytest
from httpx import ASGITransport, AsyncClient
from factories import make_account, make_event, make_instrument, make_price, refresh
from fintracker.analytics import today_local
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
AssetClass,
EventKind,
Instrument,
Portfolio,
PortfolioAccount,
)
D = Decimal
PREFIX = "/api/v1"
@pytest.fixture
async def client(app) -> AsyncIterator[AsyncClient]:
from fintracker.api.routers import rebalance
app.include_router(rebalance.router, prefix=PREFIX)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def portfolio(app) -> dict[str, int]:
"""500 SBER (lot 10) at 100, 20 ОФЗ at 1000, 30 000 ₽ left 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",
)
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 {"portfolio": portfolio_id, "sber": sber, "ofz": ofz}
def targets(*rows: tuple[str, str, str]) -> dict:
return {
"dimension": "asset_class",
"targets": [{"bucket": b, "target_weight": w, "band": band} for b, w, band in rows],
}
async def put(client, auth_headers, portfolio_id: int, body: dict):
return await client.put(
f"{PREFIX}/portfolios/{portfolio_id}/targets", json=body, headers=auth_headers
)
# --------------------------------------------------------------------------- targets
async def test_targets_round_trip_and_report_their_sum(client, auth_headers, portfolio):
r = await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
)
assert r.status_code == 200, r.text
body = r.json()
assert Decimal(body["weights_sum"]) == 1
assert [t["bucket"] for t in body["targets"]] == ["bond", "cash", "share"]
assert Decimal(body["targets"][0]["target_weight"]) == Decimal("0.2")
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/targets", headers=auth_headers
)
assert r.status_code == 200
assert r.json() == body
async def test_weights_that_do_not_add_up_are_refused_with_the_actual_sum(
client, auth_headers, portfolio
):
r = await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.3", "0.01")),
)
assert r.status_code == 422
body = r.json()
assert "0.9" in body["detail"]
assert Decimal(body["weights_sum"]) == Decimal("0.9")
async def test_a_set_is_replaced_whole_not_merged(client, auth_headers, portfolio):
await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
)
r = await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.7", "0.01"), ("cash", "0.3", "0.01")),
)
assert r.status_code == 200
assert [t["bucket"] for t in r.json()["targets"]] == ["cash", "share"]
async def test_a_duplicated_bucket_is_refused(client, auth_headers, portfolio):
r = await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.5", "0.01"), ("share", "0.5", "0.01")),
)
assert r.status_code == 422
assert "share" in r.json()["detail"]
async def test_an_unknown_dimension_is_refused(client, auth_headers, portfolio):
body = targets(("share", "1", "0.01"))
body["dimension"] = "mood"
r = await put(client, auth_headers, portfolio["portfolio"], body)
assert r.status_code == 422
async def test_an_unknown_portfolio_is_a_404(client, auth_headers, portfolio):
r = await client.get(f"{PREFIX}/portfolios/999/targets", headers=auth_headers)
assert r.status_code == 404
async def test_targets_need_a_token(client, portfolio):
r = await client.get(f"{PREFIX}/portfolios/{portfolio['portfolio']}/targets")
assert r.status_code == 401
# --------------------------------------------------------------------------- suggestions
async def test_the_suggestion_respects_the_lot_and_the_cash(client, auth_headers, portfolio):
await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
)
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
)
assert r.status_code == 200, r.text
body = r.json()
assert Decimal(body["total_value_rub"]) == 100000
assert Decimal(body["cash_available_rub"]) == 30000
share = next(b for b in body["buckets"] if b["bucket"] == "share")
assert Decimal(share["current_weight"]) == Decimal("0.5")
assert Decimal(share["target_weight"]) == Decimal("0.6")
assert Decimal(share["drift"]) == Decimal("-0.1")
assert share["within_band"] is False
trade = share["trades"][0]
assert trade["action"] == "buy"
assert trade["lot"] == 10
assert Decimal(trade["suggested_qty"]) % 10 == 0
assert Decimal(trade["suggested_qty"]) == 100
assert trade["blocked_by_cash"] is False
bond = next(b for b in body["buckets"] if b["bucket"] == "bond")
assert bond["within_band"] is True
assert bond["trades"] == []
async def test_the_what_if_cash_blocks_the_buy(client, auth_headers, portfolio):
await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
)
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance",
params={"cash_available": "2500"},
headers=auth_headers,
)
assert r.status_code == 200
trade = next(t for b in r.json()["buckets"] for t in b["trades"] if b["bucket"] == "share")
assert Decimal(trade["suggested_qty"]) == 20
assert trade["blocked_by_cash"] is True
async def test_a_wide_band_silences_every_suggestion(client, auth_headers, portfolio):
await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.5"), ("bond", "0.2", "0.5"), ("cash", "0.2", "0.5")),
)
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
)
body = r.json()
assert all(b["within_band"] for b in body["buckets"] if b["target_weight"] is not None)
assert all(t["suggested_qty"] is None for b in body["buckets"] for t in b["trades"])
async def test_without_targets_there_is_nothing_to_rebalance(client, auth_headers, portfolio):
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
)
assert r.status_code == 200
assert all(b["target_weight"] is None for b in r.json()["buckets"])
async def test_every_money_field_is_a_string(client, auth_headers, portfolio):
await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
)
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
)
body = r.json()
for key in ("total_value_rub", "cash_available_rub"):
assert isinstance(body[key], str)
for b in body["buckets"]:
for key in ("current_value_rub", "current_weight", "delta_value_rub"):
assert isinstance(b[key], str)
for t in b["trades"]:
for key in ("suggested_qty", "price", "amount_rub"):
assert t[key] is None or isinstance(t[key], str)
+144
View File
@@ -0,0 +1,144 @@
"""`GET /tax` and `GET /tax/lots` — the screen that prices selling before the three-year mark.
The router is not wired into `api/app.py` by this module's author, so the tests mount it on a
copy of the application. That keeps the check honest about the routes' own behaviour while
leaving the inclusion order to whoever owns `app.py`.
"""
import json
from collections.abc import AsyncIterator
from datetime import timedelta
from decimal import Decimal
import pytest
from httpx import ASGITransport, AsyncClient
from factories import make_account, make_event, make_instrument, make_price
from fintracker.analytics import today_local
from fintracker.analytics.tax import rebuild_tax_year
from fintracker.api.routers import tax as tax_router
from fintracker.db import get_sessionmaker
from fintracker.ledger.rebuild import rebuild_lots
from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind
from fintracker.pricing.fx import rebuild_fx_daily
D = Decimal
LDV_DAYS = 3 * 365
@pytest.fixture
async def tax_client(app, auth_headers) -> AsyncIterator[AsyncClient]:
"""The application plus the tax router, which `app.py` does not include yet."""
app.include_router(tax_router.router, prefix="/api/v1")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
c.headers.update(auth_headers)
yield c
@pytest.fixture
async def lots(app) -> dict[str, object]:
"""Two open lots of the same paper: one past the ЛДВ mark, one still short of it."""
t = today_local()
old_date = t - timedelta(days=LDV_DAYS + 30)
young_date = t - timedelta(days=400)
account = await make_account(
name="ИИС",
kind=AccountKind.broker,
role=AccountRole.investment,
balance=None,
include_in_net_worth=False,
source="tinvest",
)
old_share = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
young_share = await make_instrument(ticker="GAZP", name="Газпром", asset_class=AssetClass.share)
await make_event(
old_date,
account_id=account,
kind=EventKind.buy,
instrument_id=old_share,
quantity="10",
price="100",
amount="-1000",
)
await make_event(
young_date,
account_id=account,
kind=EventKind.buy,
instrument_id=young_share,
quantity="20",
price="240",
amount="-4800",
)
await make_price(t, instrument_id=old_share, close="150")
await make_price(t, instrument_id=young_share, close="275.89")
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()
return {
"account": account,
"old": old_share,
"young": young_share,
"old_date": old_date,
"young_date": young_date,
"today": t,
}
async def test_lots_show_the_days_left_to_the_exemption(tax_client, lots):
r = await tax_client.get("/api/v1/tax/lots")
assert r.status_code == 200, r.text
body = r.json()
assert body["estimated"] is True
assert body["tax_rate"] == "0.13"
assert body["disclaimer"]
by_ticker = {lot["ticker"]: lot for lot in body["lots"]}
young = by_ticker["GAZP"]
ldv_date = lots["young_date"] + timedelta(days=LDV_DAYS)
assert young["ldv_date"] == ldv_date.isoformat()
assert young["days_to_ldv"] == (ldv_date - lots["today"]).days
assert young["ldv_eligible"] is False
# 20 x 275.89 = 5517.80 against a cost of 4800 -> 717.80 unrealised, 13 % of it is 93.31
assert young["market_value_rub"] == "5517.80"
assert young["cost_rub"] == "4800.00"
assert young["unrealized_gain_rub"] == "717.80"
assert young["tax_if_sold_now_rub"] == "93.31"
async def test_a_lot_past_three_years_costs_nothing_to_sell(tax_client, lots):
body = (await tax_client.get("/api/v1/tax/lots")).json()
old = {lot["ticker"]: lot for lot in body["lots"]}["SBER"]
assert old["ldv_eligible"] is True
assert old["days_to_ldv"] == 0
# 10 x 150 = 1500 against 1000 is a real gain, and art. 219.1 makes it untaxed
assert old["unrealized_gain_rub"] == "500.00"
assert old["tax_if_sold_now_rub"] == "0.00"
async def test_the_year_summary_is_marked_an_estimate(tax_client, lots):
r = await tax_client.get("/api/v1/tax", params={"year": lots["today"].year})
assert r.status_code == 200, r.text
body = r.json()
assert body["estimated"] is True
assert body["tax_rate"] == "0.13"
assert "брокер" in body["disclaimer"]
# nothing was sold, so the year is empty — but it is still a well-formed answer
assert body["totals"]["estimated_tax_rub"] == "0.00"
assert body["accounts"] == []
async def test_no_float_reaches_the_wire(tax_client, lots):
def reject_float(raw: str) -> None:
raise AssertionError(f"float in the response: {raw}")
for path in ("/api/v1/tax", "/api/v1/tax/lots"):
r = await tax_client.get(path)
assert r.status_code == 200, r.text
json.loads(r.text, parse_float=reject_float)
View File
+204
View File
@@ -0,0 +1,204 @@
"""Which feed wins when both describe the same payout, and what happens to the difference.
These run on plain `CorporateAction` objects with no session: the priority rule is a read
rule, and `analytics/income.py` has to be able to apply it to whatever it already loaded.
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from fintracker.analytics import FINDINGS
from fintracker.models.pricing import CorporateAction, CorporateActionKind, CorporateActionStatus
from fintracker.pricing.payouts import (
nominal_outranks,
resolve,
resolve_payouts,
weaker_or_equal_nominal_sources,
)
DIV = CorporateActionKind.dividend
CPN = CorporateActionKind.coupon
def action(
*,
kind=DIV,
source="tinvest",
amount: str | None = "10",
status=CorporateActionStatus.announced,
instrument_id=1,
record_date=None,
ex_date=None,
pay_date=None,
currency="RUB",
source_id=None,
) -> CorporateAction:
return CorporateAction(
instrument_id=instrument_id,
kind=kind,
status=status,
record_date=record_date,
ex_date=ex_date,
pay_date=pay_date,
amount_per_unit=None if amount is None else Decimal(amount),
currency=currency,
ratio=None,
source=source,
source_id=source_id or f"{source}-1",
)
def test_bond_coupon_is_read_from_moex():
"""bondization is the issuer's registered schedule; T-Invest answers for a window."""
moex = action(kind=CPN, source="moex", amount="34.90", pay_date=date(2026, 11, 5))
tinvest = action(kind=CPN, source="tinvest", amount="34.90", pay_date=date(2026, 11, 5))
resolved = resolve_payouts([tinvest, moex], report=False)
assert len(resolved) == 1
assert resolved[0].source == "moex"
def test_share_dividend_is_read_from_tinvest():
"""T-Invest states what will settle on the account; MOEX states the register."""
moex = action(source="moex", amount="52", record_date=date(2026, 7, 10))
tinvest = action(
source="tinvest", amount="52", record_date=date(2026, 7, 10), pay_date=date(2026, 7, 24)
)
resolved = resolve_payouts([moex, tinvest], report=False)
assert len(resolved) == 1
assert resolved[0].source == "tinvest"
def test_feeds_merge_on_the_record_date_they_share():
"""MOEX states only the register date and T-Invest also states the payment date.
Keying the merge on the payment date alone would leave them in separate buckets and
count the dividend twice.
"""
moex = action(source="moex", amount="52", record_date=date(2026, 7, 10))
tinvest = action(
source="tinvest", amount="52", record_date=date(2026, 7, 10), pay_date=date(2026, 7, 24)
)
assert len(resolve_payouts([moex, tinvest], report=False)) == 1
def test_two_different_payouts_of_one_paper_stay_two():
interim = action(source="tinvest", amount="17", record_date=date(2026, 1, 12))
final = action(source="tinvest", amount="52", record_date=date(2026, 7, 10), source_id="t-2")
assert len(resolve_payouts([interim, final], report=False)) == 2
def test_a_mismatch_in_amount_produces_a_finding_and_keeps_the_winner():
"""The winner is still the winner; the gap goes to the quality report, not to /dev/null."""
FINDINGS.reset()
tinvest = action(source="tinvest", amount="45.24", record_date=date(2026, 7, 10))
moex = action(source="moex", amount="52.00", record_date=date(2026, 7, 10))
resolved = resolve_payouts([moex, tinvest])
assert [a.source for a in resolved] == ["tinvest"]
assert resolved[0].amount_per_unit == Decimal("45.24")
finding = next(f for f in FINDINGS.items if f.check_name == "payout_amount_mismatch")
assert finding.severity == "warn"
assert finding.ref == {"instruments": [1]}
FINDINGS.reset()
def test_amounts_within_rounding_are_not_a_mismatch():
FINDINGS.reset()
tinvest = action(source="tinvest", amount="34.90", pay_date=date(2026, 11, 5), kind=CPN)
moex = action(source="moex", amount="34.9000000001", pay_date=date(2026, 11, 5), kind=CPN)
resolve_payouts([tinvest, moex])
assert FINDINGS.items == []
def test_a_missing_amount_is_a_gap_not_a_disagreement():
"""A floating coupon whose rate is unfixed arrives dated and priceless — every refresh."""
FINDINGS.reset()
moex = action(source="moex", amount=None, pay_date=date(2027, 2, 5), kind=CPN)
tinvest = action(source="tinvest", amount="34.90", pay_date=date(2027, 2, 5), kind=CPN)
resolved = resolve_payouts([moex, tinvest])
assert resolved[0].source == "moex"
assert FINDINGS.items == []
def test_a_paid_row_outranks_an_announcement_from_the_stronger_feed():
"""Money that has moved beats a feed's announcement of the same payout, either way round."""
ledger = action(
kind=CPN,
source="tinvest",
amount="34.90",
status=CorporateActionStatus.paid,
pay_date=date(2026, 5, 5),
)
announced = action(
kind=CPN,
source="moex",
amount="34.90",
status=CorporateActionStatus.announced,
pay_date=date(2026, 5, 5),
)
resolved = resolve_payouts([announced, ledger], report=False)
assert [(a.source, a.status) for a in resolved] == [("tinvest", CorporateActionStatus.paid)]
def test_different_papers_never_merge():
a = action(source="moex", record_date=date(2026, 7, 10), instrument_id=1)
b = action(source="moex", record_date=date(2026, 7, 10), instrument_id=2)
assert len(resolve_payouts([a, b], report=False)) == 2
def test_an_undated_row_survives_on_its_own():
dated = action(source="tinvest", record_date=date(2026, 7, 10))
undated = action(source="tinvest", amount="9", source_id="t-2")
assert len(resolve_payouts([dated, undated], report=False)) == 2
def test_conflicts_are_reported_per_group_not_summed_into_one_line():
result = resolve(
[
action(source="tinvest", amount="45", record_date=date(2026, 7, 10)),
action(source="moex", amount="52", record_date=date(2026, 7, 10)),
action(source="tinvest", amount="10", record_date=date(2026, 1, 9), source_id="t-2"),
action(source="moex", amount="12", record_date=date(2026, 1, 9), source_id="m-2"),
]
)
assert len(result.payouts) == 2
assert len(result.conflicts) == 2
def test_nominal_schedule_precedence_is_a_write_rule():
"""`bond_nominal_schedule` is keyed without `source`, so the two feeds share one row."""
assert nominal_outranks("moex", "tinvest")
assert not nominal_outranks("tinvest", "moex")
assert nominal_outranks("moex", "moex") # a feed must be able to correct itself
assert set(weaker_or_equal_nominal_sources("moex")) == {"moex", "tinvest", "ledger"}
assert set(weaker_or_equal_nominal_sources("tinvest")) == {"tinvest", "ledger"}
def test_no_float_anywhere_in_a_resolution():
resolved = resolve_payouts(
[
action(source="tinvest", amount="45.24", record_date=date(2026, 7, 10)),
action(source="moex", amount="52.00", record_date=date(2026, 7, 10)),
],
report=False,
)
assert all(not isinstance(a.amount_per_unit, float) for a in resolved)
assert isinstance(resolved[0].amount_per_unit, Decimal)
+365
View File
@@ -0,0 +1,365 @@
"""MOEX ISS as the second payout feed: bondization and the dividend register.
Every request goes through respx — the ISS is never actually called.
"""
from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
import httpx
import respx
from sqlalchemy import select
from factories import make_account, make_event
from fintracker.analytics import today_local
from fintracker.config import Settings
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
AssetClass,
BondNominalSchedule,
CorporateAction,
CorporateActionKind,
CorporateActionStatus,
EventKind,
Instrument,
)
from fintracker.sources.moex.client import AmortisationRow, CouponRow, MoexClient
from fintracker.sources.moex.payouts import (
MoexDividendRow,
MoexPayoutsSource,
coupon_payout,
dividend_payout,
fetch_dividends,
nominal_schedule,
)
ISS = "https://iss.moex.com/iss"
D = Decimal
TODAY = today_local()
PAST = TODAY - timedelta(days=30)
FUTURE = TODAY + timedelta(days=30)
def block(name: str, columns: list[str], data: list[list]) -> dict:
return {name: {"columns": columns, "data": data}}
def bondization(coupons: list[list], amortisations: list[list] | None = None) -> dict:
return {
"coupons": {
"columns": ["coupondate", "value", "valueprc", "faceunit"],
"data": coupons,
},
"amortizations": {
"columns": ["amortdate", "value", "facevalue", "faceunit"],
"data": amortisations or [],
},
}
# --- mapping -------------------------------------------------------------------------
def test_a_coupon_row_maps_to_a_moex_sourced_corporate_action():
payout = coupon_payout(
CouponRow(coupon_date=FUTURE, value=D("34.90"), value_pct=D("7.0"), currency="RUB"),
instrument_id=9,
today=TODAY,
)
assert payout is not None
assert payout.kind is CorporateActionKind.coupon
assert payout.source == "moex"
assert payout.source_id == f"cpn:{FUTURE.isoformat()}"
assert payout.pay_date == FUTURE
assert payout.amount_per_unit == D("34.90")
assert payout.currency == "RUB"
assert payout.status is CorporateActionStatus.announced
def test_a_coupon_already_paid_is_stored_as_paid():
payout = coupon_payout(
CouponRow(coupon_date=PAST, value=D("34.90"), value_pct=None, currency="RUB"),
instrument_id=9,
today=TODAY,
)
assert payout is not None
assert payout.status is CorporateActionStatus.paid
def test_a_floating_coupon_without_a_rate_keeps_its_date_and_loses_its_amount():
"""The date is published long before the rate is fixed, and the calendar needs it."""
payout = coupon_payout(
CouponRow(coupon_date=FUTURE, value=None, value_pct=None, currency="RUB"),
instrument_id=9,
today=TODAY,
)
assert payout is not None
assert payout.amount_per_unit is None
def test_a_dividend_register_row_states_the_record_date_and_nothing_else():
payout = dividend_payout(
MoexDividendRow(
secid="SBER", registry_close_date=date(2026, 7, 10), value=D("52"), currency="RUB"
),
instrument_id=7,
today=TODAY,
)
assert payout is not None
assert payout.record_date == date(2026, 7, 10)
assert payout.pay_date is None
assert payout.source_id == "div:2026-07-10"
assert payout.amount_per_unit == D("52")
def test_amortisations_run_the_nominal_down_to_zero():
warnings: list[str] = []
points = nominal_schedule(
[
AmortisationRow(
amort_date=date(2028, 5, 5), value=D(500), face_value=D(500), currency="SUR"
),
AmortisationRow(
amort_date=date(2026, 5, 5), value=D(250), face_value=D(1000), currency="SUR"
),
AmortisationRow(
amort_date=date(2027, 5, 5), value=D(250), face_value=D(750), currency="SUR"
),
],
instrument_id=9,
currency="RUB",
warnings=warnings,
)
assert [(p.effective_date, p.nominal) for p in points] == [
(date(2026, 5, 5), D(750)),
(date(2027, 5, 5), D(500)),
(date(2028, 5, 5), D(0)),
]
assert all(p.source == "moex" for p in points)
assert warnings == []
def test_a_plan_that_does_not_add_up_to_the_stated_nominal_is_reported():
warnings: list[str] = []
nominal_schedule(
[
AmortisationRow(
amort_date=date(2026, 5, 5), value=D(250), face_value=D(1000), currency="SUR"
),
AmortisationRow(
amort_date=date(2027, 5, 5), value=D(250), face_value=D(750), currency="SUR"
),
],
instrument_id=9,
currency="RUB",
warnings=warnings,
secid="RU000A",
)
assert len(warnings) == 1
assert "1000" in warnings[0]
def test_nominal_points_are_decimal_never_float():
points = nominal_schedule(
[
AmortisationRow(
amort_date=date(2026, 5, 5), value=D("1000"), face_value=None, currency=None
)
],
instrument_id=9,
currency="RUB",
warnings=[],
)
assert all(isinstance(p.nominal, Decimal) and not isinstance(p.nominal, float) for p in points)
# --- client --------------------------------------------------------------------------
@respx.mock
async def test_bondization_gives_the_whole_coupon_schedule_not_just_the_near_ones():
"""This is why bonds are read from MOEX: the plan runs to maturity, in one request."""
schedule = [
[(TODAY + timedelta(days=30 * n)).isoformat(), 34.9, 7.0, "SUR"] for n in range(1, 25)
]
respx.get(url__startswith=f"{ISS}/securities/RU000A/bondization").mock(
return_value=httpx.Response(200, json=bondization(schedule))
)
async with MoexClient() as moex:
coupons, amortisations = await moex.bondization("RU000A")
within_a_year = [
c for c in coupons if c.coupon_date and c.coupon_date <= TODAY + timedelta(days=365)
]
assert len(within_a_year) >= 12
assert all(c.value == D("34.9") for c in within_a_year)
assert amortisations == []
@respx.mock
async def test_the_dividend_extract_is_read_by_column_name():
respx.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock(
return_value=httpx.Response(
200,
json=block(
"dividends",
["secid", "isin", "registryclosedate", "value", "currencyid"],
[["SBER", "RU0009029540", "2026-07-10", 34.84, "SUR"]],
),
)
)
async with httpx.AsyncClient(trust_env=False) as http:
rows = await fetch_dividends(http, "SBER")
assert rows == [
MoexDividendRow(
secid="SBER",
registry_close_date=date(2026, 7, 10),
value=D("34.84"),
currency="RUB", # ISS says SUR
)
]
# --- sync ----------------------------------------------------------------------------
async def seed(asset_class: AssetClass, ticker: str) -> int:
account_id = await make_account(
name="Брокерский",
kind=AccountKind.broker,
role=AccountRole.investment,
balance=None,
include_in_net_worth=False,
source="tinvest",
source_id=f"tinv-{ticker}",
)
async with get_sessionmaker()() as session:
instrument = Instrument(
asset_class=asset_class,
ticker=ticker,
board="TQBR",
name=ticker,
currency="RUB",
)
session.add(instrument)
await session.commit()
await session.refresh(instrument)
instrument_id = instrument.id
await make_event(
PAST, account_id=account_id, kind=EventKind.buy, instrument_id=instrument_id, quantity=10
)
return instrument_id
async def rows_of(model, **where):
async with get_sessionmaker()() as session:
stmt = select(model)
for column, value in where.items():
stmt = stmt.where(getattr(model, column) == value)
return list((await session.execute(stmt)).scalars().all())
def mock_iss(mock_http) -> None:
mock_http.get(url__startswith=f"{ISS}/securities/RU000A/bondization").mock(
return_value=httpx.Response(
200,
json=bondization(
[
["2027-05-05", 34.9, 7.0, "SUR"],
["2028-05-05", 34.9, 7.0, "SUR"],
],
[
["2027-05-05", 250, 1000, "SUR"],
["2028-05-05", 750, 750, "SUR"],
],
),
)
)
mock_http.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock(
return_value=httpx.Response(
200,
json=block(
"dividends",
["secid", "registryclosedate", "value", "currencyid"],
[["SBER", "2026-07-10", 34.84, "SUR"]],
),
)
)
async def test_a_bond_run_writes_coupons_and_the_nominal_schedule(app, mock_http, run_sync):
instrument_id = await seed(AssetClass.bond, "RU000A")
mock_iss(mock_http)
await run_sync(MoexPayoutsSource(), settings=Settings())
actions = await rows_of(CorporateAction, instrument_id=instrument_id)
assert {(a.kind, a.source, a.source_id) for a in actions} == {
(CorporateActionKind.coupon, "moex", "cpn:2027-05-05"),
(CorporateActionKind.coupon, "moex", "cpn:2028-05-05"),
}
schedule = sorted(
await rows_of(BondNominalSchedule, instrument_id=instrument_id),
key=lambda p: p.effective_date,
)
assert [(p.effective_date, p.nominal) for p in schedule] == [
(date(2027, 5, 5), D(750)),
(date(2028, 5, 5), D(0)),
]
async def test_a_share_run_writes_the_register_dividend(app, mock_http, run_sync):
instrument_id = await seed(AssetClass.share, "SBER")
mock_iss(mock_http)
await run_sync(MoexPayoutsSource(), settings=Settings())
actions = await rows_of(CorporateAction, instrument_id=instrument_id)
assert [(a.kind, a.source, a.amount_per_unit) for a in actions] == [
(CorporateActionKind.dividend, "moex", D("34.84"))
]
async def test_a_second_run_stores_no_duplicates(app, mock_http, run_sync):
instrument_id = await seed(AssetClass.bond, "RU000A")
mock_iss(mock_http)
await run_sync(MoexPayoutsSource(), settings=Settings())
await run_sync(MoexPayoutsSource(), settings=Settings())
assert len(await rows_of(CorporateAction, instrument_id=instrument_id)) == 2
assert len(await rows_of(BondNominalSchedule, instrument_id=instrument_id)) == 2
async def test_moex_wins_the_nominal_row_a_weaker_source_already_wrote(app, mock_http, run_sync):
"""`bond_nominal_schedule` is keyed without `source`, so precedence is decided on write."""
instrument_id = await seed(AssetClass.bond, "RU000A")
async with get_sessionmaker()() as session:
session.add(
BondNominalSchedule(
instrument_id=instrument_id,
effective_date=date(2027, 5, 5),
nominal=D(800),
currency="RUB",
source="tinvest",
)
)
await session.commit()
mock_iss(mock_http)
await run_sync(MoexPayoutsSource(), settings=Settings())
schedule = await rows_of(BondNominalSchedule, instrument_id=instrument_id)
row = next(p for p in schedule if p.effective_date == date(2027, 5, 5))
assert (row.nominal, row.source) == (D(750), "moex")
@@ -0,0 +1,454 @@
"""T-Invest payout feeds: what the three RPCs mean once they are flattened.
The mapping tests are pure — the sync ones run against the real database with a stand-in
client, so no test here touches the network or needs a token.
"""
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import ClassVar
import pytest
from sqlalchemy import select
from factories import make_account, make_event
from fintracker.analytics import today_local
from fintracker.config import Settings
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
AssetClass,
BondNominalSchedule,
CorporateAction,
CorporateActionKind,
CorporateActionStatus,
EventKind,
Instrument,
RawTinvestEvent,
)
from fintracker.sources.tinvest import sync_events
from fintracker.sources.tinvest.client import BondCouponRow, BondEventRow, DividendRow
from fintracker.sources.tinvest.sync_events import (
TinvestEventsSource,
coupon_payout,
dividend_payout,
msk_date,
nominal_schedule,
status_for,
)
D = Decimal
TODAY = today_local()
PAST = TODAY - timedelta(days=30)
FUTURE = TODAY + timedelta(days=30)
def utc(day: date) -> datetime:
"""How the API stamps a trading day: midnight UTC, i.e. 3 a.m. in Moscow."""
return datetime(day.year, day.month, day.day, tzinfo=UTC)
def dividend(**over) -> DividendRow:
base = {
"instrument_uid": "uid-share",
"amount": D("45.24"),
"currency": "RUB",
"payment_date": utc(date(2026, 7, 24)),
"declared_date": utc(date(2026, 5, 30)),
"record_date": utc(date(2026, 7, 10)),
"last_buy_date": utc(date(2026, 7, 9)),
"dividend_type": "Regular Cash",
"regularity": "Annual",
"payload": {"dividend_net": {"value": "45.24", "currency": "rub"}},
}
return DividendRow(**{**base, **over})
def coupon(**over) -> BondCouponRow:
base = {
"instrument_uid": "uid-bond",
"coupon_number": 7,
"coupon_date": utc(date(2026, 11, 5)),
"fix_date": utc(date(2026, 11, 4)),
"pay_one_bond": D("34.90"),
"currency": "RUB",
"coupon_type": "COUPON_TYPE_CONSTANT",
"coupon_period": 182,
"payload": {"coupon_number": 7},
}
return BondCouponRow(**{**base, **over})
def redemption(day: date, amount: str, **over) -> BondEventRow:
base = {
"instrument_uid": "uid-bond",
"event_type": "EVENT_TYPE_MTY",
"event_number": 1,
"event_date": utc(day),
"fix_date": utc(day),
"pay_date": utc(day),
"pay_one_bond": D(amount),
"currency": "RUB",
"payload": {},
}
return BondEventRow(**{**base, **over})
# --- mapping -------------------------------------------------------------------------
def test_a_midnight_utc_stamp_is_the_moscow_trading_day():
"""Read as UTC, every payout would move one day earlier than the exchange printed it."""
assert msk_date(datetime(2026, 7, 10, 0, 0, tzinfo=UTC)) == date(2026, 7, 10)
assert msk_date(datetime(2026, 7, 9, 21, 30, tzinfo=UTC)) == date(2026, 7, 10)
assert msk_date(None) is None
def test_dividend_carries_every_date_the_feed_states():
payout = dividend_payout(dividend(), instrument_id=7, today=TODAY)
assert payout is not None
assert payout.kind is CorporateActionKind.dividend
assert payout.record_date == date(2026, 7, 10)
assert payout.pay_date == date(2026, 7, 24)
# last_buy_date is the last day a purchase still earns it — the ex-side date on offer
assert payout.ex_date == date(2026, 7, 9)
assert payout.amount_per_unit == D("45.24")
assert payout.currency == "RUB"
assert payout.source == "tinvest"
assert payout.source_id == "div:2026-07-10"
def test_dividend_without_any_date_is_dropped():
assert (
dividend_payout(
dividend(record_date=None, payment_date=None, declared_date=None),
instrument_id=7,
today=TODAY,
)
is None
)
def test_a_past_payout_is_paid_and_a_future_one_announced():
assert status_for(PAST, TODAY) is CorporateActionStatus.paid
assert status_for(FUTURE, TODAY) is CorporateActionStatus.announced
assert status_for(None, TODAY) is CorporateActionStatus.announced
def test_coupon_maps_to_the_coupon_kind_keyed_on_its_number():
warnings: list[str] = []
payout = coupon_payout(coupon(), instrument_id=9, today=TODAY, warnings=warnings)
assert payout is not None
assert payout.kind is CorporateActionKind.coupon
assert payout.pay_date == date(2026, 11, 5)
assert payout.record_date == date(2026, 11, 4)
assert payout.amount_per_unit == D("34.90")
assert payout.source_id == "cpn:7"
assert warnings == []
def test_an_unknown_coupon_type_warns_instead_of_becoming_a_plain_coupon():
warnings: list[str] = []
payout = coupon_payout(
coupon(coupon_type="COUPON_TYPE_UNSPECIFIED"),
instrument_id=9,
today=TODAY,
warnings=warnings,
)
assert payout is None
assert len(warnings) == 1
assert "COUPON_TYPE_UNSPECIFIED" in warnings[0]
def test_a_zero_coupon_is_not_a_payout():
warnings: list[str] = []
assert (
coupon_payout(
coupon(pay_one_bond=D(0), coupon_type="COUPON_TYPE_DISCOUNT"),
instrument_id=9,
today=TODAY,
warnings=warnings,
)
is None
)
assert warnings == []
def test_redemptions_run_the_nominal_down_to_zero():
"""An amortised bond repays the principal in slices; the nominal after each is what is left."""
warnings: list[str] = []
points = nominal_schedule(
[
redemption(date(2027, 5, 5), "250"),
redemption(date(2026, 5, 5), "250"), # out of order on purpose
redemption(date(2028, 5, 5), "500"),
],
instrument_id=9,
currency="RUB",
warnings=warnings,
)
assert [(p.effective_date, p.nominal) for p in points] == [
(date(2026, 5, 5), D(750)),
(date(2027, 5, 5), D(500)),
(date(2028, 5, 5), D(0)),
]
assert all(p.source == "tinvest" and p.currency == "RUB" for p in points)
assert warnings == []
def test_a_bullet_bond_gets_a_single_point_at_maturity():
points = nominal_schedule(
[redemption(date(2029, 3, 1), "1000")], instrument_id=9, currency="RUB", warnings=[]
)
assert [(p.effective_date, p.nominal) for p in points] == [(date(2029, 3, 1), D(0))]
def test_an_unknown_bond_event_type_warns_and_is_skipped():
warnings: list[str] = []
points = nominal_schedule(
[redemption(date(2026, 5, 5), "250", event_type="EVENT_TYPE_CONV")],
instrument_id=9,
currency="RUB",
warnings=warnings,
)
assert points == []
assert "EVENT_TYPE_CONV" in warnings[0]
def test_a_redemption_with_no_money_is_warned_about_not_read_as_zero():
"""A silent zero would shift every later nominal upward by the missing slice."""
warnings: list[str] = []
points = nominal_schedule(
[
redemption(date(2026, 5, 5), "250", pay_one_bond=None),
redemption(date(2027, 5, 5), "750"),
],
instrument_id=9,
currency="RUB",
warnings=warnings,
)
assert [p.nominal for p in points] == [D(0)]
assert "без суммы" in warnings[0]
def test_every_mapped_amount_is_a_decimal_never_a_float():
payout = dividend_payout(dividend(), instrument_id=7, today=TODAY)
points = nominal_schedule(
[redemption(date(2026, 5, 5), "250"), redemption(date(2027, 5, 5), "750")],
instrument_id=9,
currency="RUB",
warnings=[],
)
assert payout is not None
assert isinstance(payout.amount_per_unit, Decimal)
assert all(isinstance(p.nominal, Decimal) and not isinstance(p.nominal, float) for p in points)
# --- sync ----------------------------------------------------------------------------
class FakeClient:
"""Stands in for `TinvestClient`: same three coroutines, no gRPC and no token."""
calls: ClassVar[list[str]] = []
def __init__(self, token: str, **_: object) -> None:
self.token = token
async def __aenter__(self) -> FakeClient:
return self
async def __aexit__(self, *exc: object) -> None:
return None
async def dividends(self, uid: str, **_: object) -> list[DividendRow]:
FakeClient.calls.append(f"dividends:{uid}")
return [dividend(instrument_uid=uid)] if uid == "uid-share" else []
async def bond_coupons(self, uid: str, **_: object) -> list[BondCouponRow]:
FakeClient.calls.append(f"coupons:{uid}")
return [
coupon(instrument_uid=uid),
coupon(instrument_uid=uid, coupon_number=8, coupon_date=utc(date(2027, 5, 5))),
]
async def bond_events(self, uid: str, **_: object) -> list[BondEventRow]:
FakeClient.calls.append(f"events:{uid}")
return [redemption(date(2027, 5, 5), "250"), redemption(date(2028, 5, 5), "750")]
@pytest.fixture
def fake_client(monkeypatch):
FakeClient.calls = []
monkeypatch.setattr(sync_events, "TinvestClient", FakeClient)
return FakeClient
async def seed(asset_class: AssetClass, uid: str, ticker: str) -> int:
"""One instrument with a confirmed ledger event — which is what puts it in scope."""
account_id = await make_account(
name="Брокерский",
kind=AccountKind.broker,
role=AccountRole.investment,
balance=None,
include_in_net_worth=False,
source="tinvest",
source_id=f"tinv-{ticker}",
)
async with get_sessionmaker()() as session:
instrument = Instrument(
asset_class=asset_class,
tinvest_uid=uid,
ticker=ticker,
board="TQBR",
name=ticker,
currency="RUB",
)
session.add(instrument)
await session.commit()
await session.refresh(instrument)
instrument_id = instrument.id
await make_event(
PAST, account_id=account_id, kind=EventKind.buy, instrument_id=instrument_id, quantity=10
)
return instrument_id
async def rows(model, **where):
async with get_sessionmaker()() as session:
stmt = select(model)
for column, value in where.items():
stmt = stmt.where(getattr(model, column) == value)
return list((await session.execute(stmt)).scalars().all())
def settings() -> Settings:
return Settings(tinvest_token="test-token")
async def test_a_share_run_writes_the_raw_row_and_the_corporate_action(app, fake_client, run_sync):
instrument_id = await seed(AssetClass.share, "uid-share", "SBER")
result = await run_sync(TinvestEventsSource(), settings=settings())
assert result.counts["dividends"] == 1
action = (await rows(CorporateAction, instrument_id=instrument_id))[0]
assert action.kind is CorporateActionKind.dividend
assert action.source == "tinvest"
assert action.source_id == "div:2026-07-10"
assert action.amount_per_unit == D("45.24")
assert action.record_date == date(2026, 7, 10)
raw = (await rows(RawTinvestEvent, instrument_uid="uid-share"))[0]
assert raw.kind == "dividend"
assert raw.payload["dividend_net"]["value"] == "45.24"
async def test_a_bond_run_writes_coupons_and_a_descending_nominal_schedule(
app, fake_client, run_sync
):
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
await run_sync(TinvestEventsSource(), settings=settings())
actions = await rows(CorporateAction, instrument_id=instrument_id)
assert {a.kind for a in actions} == {CorporateActionKind.coupon}
assert {a.source_id for a in actions} == {"cpn:7", "cpn:8"}
schedule = sorted(
await rows(BondNominalSchedule, instrument_id=instrument_id),
key=lambda p: p.effective_date,
)
assert [(p.effective_date, p.nominal, p.source) for p in schedule] == [
(date(2027, 5, 5), D(750), "tinvest"),
(date(2028, 5, 5), D(0), "tinvest"),
]
async def test_a_bond_never_writes_an_amortization_row(app, fake_client, run_sync):
"""`ledger/corporate_actions.py` owns that kind and prunes anything it did not derive."""
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
await run_sync(TinvestEventsSource(), settings=settings())
actions = await rows(CorporateAction, instrument_id=instrument_id)
assert not any(
a.kind in (CorporateActionKind.amortization, CorporateActionKind.repayment) for a in actions
)
async def test_a_share_is_never_asked_for_coupons(app, fake_client, run_sync):
await seed(AssetClass.share, "uid-share", "SBER")
await run_sync(TinvestEventsSource(), settings=settings())
assert FakeClient.calls == ["dividends:uid-share"]
async def test_a_second_run_stores_no_duplicates(app, fake_client, run_sync):
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
await run_sync(TinvestEventsSource(), settings=settings())
await run_sync(TinvestEventsSource(), settings=settings())
assert len(await rows(CorporateAction, instrument_id=instrument_id)) == 2
assert len(await rows(BondNominalSchedule, instrument_id=instrument_id)) == 2
assert len(await rows(RawTinvestEvent, instrument_uid="uid-bond")) == 4
async def test_a_ledger_derived_paid_row_is_not_overwritten_by_the_feed(app, fake_client, run_sync):
"""Money that arrived outranks an announcement — and the feed cannot even reach that row."""
instrument_id = await seed(AssetClass.bond, "uid-bond", "RU000A")
async with get_sessionmaker()() as session:
session.add(
CorporateAction(
instrument_id=instrument_id,
kind=CorporateActionKind.amortization,
status=CorporateActionStatus.paid,
ex_date=date(2027, 5, 5),
pay_date=date(2027, 5, 5),
amount_per_unit=D("250"),
currency="RUB",
source="tinvest",
source_id="2027-05-05",
)
)
await session.commit()
await run_sync(TinvestEventsSource(), settings=settings())
derived = [
a
for a in await rows(CorporateAction, instrument_id=instrument_id)
if a.kind is CorporateActionKind.amortization
]
assert len(derived) == 1
assert derived[0].status is CorporateActionStatus.paid
assert derived[0].amount_per_unit == D("250")
async def test_an_instrument_outside_the_ledger_is_never_asked_about(app, fake_client, run_sync):
async with get_sessionmaker()() as session:
session.add(
Instrument(
asset_class=AssetClass.share,
tinvest_uid="uid-never-held",
ticker="GAZP",
board="TQBR",
name="GAZP",
currency="RUB",
)
)
await session.commit()
result = await run_sync(TinvestEventsSource(), settings=settings())
assert FakeClient.calls == []
assert result.changed is False