"""Valuation and returns end to end: ledger + prices + rates -> metric tables.""" from datetime import date, timedelta from decimal import Decimal from sqlalchemy import select from factories import ( make_account, make_cbr_rate, 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, MetricDataQuality, MetricHolding, MetricPortfolioValueDaily, MetricReturns, ) D = Decimal 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 rates(days: list[date], ccy: str, value: str) -> None: for d in days: await make_cbr_rate(d, ccy, value) async def holdings(scope: str = "all") -> dict[int, MetricHolding]: async with get_sessionmaker()() as session: rows = ( (await session.execute(select(MetricHolding).where(MetricHolding.scope == scope))) .scalars() .all() ) return {r.instrument_id: r for r in rows} async def value_days(scope: str = "all") -> dict[date, MetricPortfolioValueDaily]: async with get_sessionmaker()() as session: rows = ( ( await session.execute( select(MetricPortfolioValueDaily).where( MetricPortfolioValueDaily.scope == scope ) ) ) .scalars() .all() ) return {r.d: r for r in rows} async def findings() -> dict[str, MetricDataQuality]: async with get_sessionmaker()() as session: rows = (await session.execute(select(MetricDataQuality))).scalars().all() return {r.check_name: r for r in rows} async def test_a_bought_position_is_valued_and_the_cash_it_cost_is_gone(app): t = today_local() bought = t - timedelta(days=3) account = await broker_account() gazp = await make_instrument(ticker="GAZP") await make_event(bought, account_id=account, kind=EventKind.deposit, amount="10000") await make_event( bought, account_id=account, kind=EventKind.buy, instrument_id=gazp, quantity="10", price="900", amount="-9000", ) for offset in range(4): await make_price(t - timedelta(days=offset), instrument_id=gazp, close="950") await refresh() holding = (await holdings())[gazp] assert holding.qty == D(10) assert holding.value_rub == D(9500) assert holding.cost_total_rub == D(9000) assert holding.unrealized_pnl_rub == D(500) assert holding.price_status == "ok" assert holding.weight == D(1) today = (await value_days())[t] assert today.market_value_rub == D(9500) assert today.cash_rub == D(1000) assert today.total_rub == D(10500) assert today.invested_net_rub == D(10000) assert today.pnl_total_rub == D(500) async def test_a_foreign_position_is_converted_at_the_rate_of_each_day(app): t = today_local() bought = t - timedelta(days=2) account = await broker_account() etf = await make_instrument(ticker="SPY", currency="USD", board="SPBXM") await make_event( bought, account_id=account, kind=EventKind.deposit, amount="800", currency="USD" ) await make_event( bought, account_id=account, kind=EventKind.buy, instrument_id=etf, quantity="8", price="100", amount="-800", currency="USD", ) for offset in range(3): d = t - timedelta(days=offset) await make_price(d, instrument_id=etf, close="110", currency="USD") await rates([t - timedelta(days=n) for n in range(5)], "USD", "80") await refresh() holding = (await holdings())[etf] assert holding.value_native == D(880) assert holding.value_rub == D(70400) # 880 USD * 80 assert holding.unrealized_pnl_native == D(80) async def test_a_paper_nobody_quotes_is_null_not_zero(app): t = today_local() account = await broker_account() silent = await make_instrument(ticker="SIBN6P4", asset_class=AssetClass.bond, board="SPBRUBND") await make_event( t - timedelta(days=5), account_id=account, kind=EventKind.deposit, amount="7000" ) await make_event( t - timedelta(days=5), account_id=account, kind=EventKind.buy, instrument_id=silent, quantity="7", price="1000", amount="-7000", ) await refresh() holding = (await holdings())[silent] assert holding.qty == D(7) assert holding.price_status == "missing" assert holding.value_rub is None assert holding.unrealized_pnl_rub is None assert holding.weight is None today = (await value_days())[t] assert today.market_value_rub == D(0) assert today.missing_price_count == 1 assert today.pnl_total_rub is None # the total is incomplete, so it is not reported assert "holding_without_price" in await findings() async def test_a_card_funded_purchase_is_an_external_flow_and_leaves_the_cash_alone(app): t = today_local() bought = t - timedelta(days=1) account = await broker_account() fund = await make_instrument(ticker="TMOS", asset_class=AssetClass.etf, board="TQTF") await make_event( bought, account_id=account, kind=EventKind.buy, instrument_id=fund, quantity="100", price="50", amount="-5000", meta={"operation_type": "OPERATION_TYPE_BUY_CARD", "card_funded": True}, ) for offset in range(2): await make_price(t - timedelta(days=offset), instrument_id=fund, close="50") await refresh() today = (await value_days())[t] assert today.cash_rub == D(0) # the card paid, the account balance never moved assert today.market_value_rub == D(5000) assert today.invested_net_rub == D(5000) assert today.pnl_total_rub == D(0) async def test_returns_are_reported_per_period_and_agree_with_the_flows(app): t = today_local() start = t - timedelta(days=370) account = await broker_account() gazp = await make_instrument(ticker="GAZP") await make_event(start, account_id=account, kind=EventKind.deposit, amount="1000") await make_event( start, account_id=account, kind=EventKind.buy, instrument_id=gazp, quantity="10", price="100", amount="-1000", ) d = start while d <= t: await make_price(d, instrument_id=gazp, close="100" if d < t - timedelta(days=5) else "110") d += timedelta(days=1) await refresh() async with get_sessionmaker()() as session: rows = { r.period: r for r in ( (await session.execute(select(MetricReturns).where(MetricReturns.scope == "all"))) .scalars() .all() ) } assert set(rows) >= {"1m", "3m", "1y", "all"} whole = rows["all"] assert whole.value_end_rub == D(1100) assert whole.external_flow_rub == D(0) # the deposit IS the opening value assert whole.abs_pnl_rub == D(100) assert whole.twr == D("0.100000") assert whole.xirr is not None and whole.xirr > D(0) async def test_scopes_cover_the_whole_ledger_and_each_account(app): t = today_local() first = await broker_account() second = await broker_account() gazp = await make_instrument(ticker="GAZP") for account in (first, second): await make_event( t - timedelta(days=1), account_id=account, kind=EventKind.buy, instrument_id=gazp, quantity="1", price="100", amount="-100", ) await make_price(t, instrument_id=gazp, close="100") await make_price(t - timedelta(days=1), instrument_id=gazp, close="100") await refresh() assert (await holdings())[gazp].qty == D(2) assert (await holdings(f"account:{first}"))[gazp].qty == D(1) assert (await holdings(f"account:{second}"))[gazp].qty == D(1)