pricing/prices.py — цена на дату: последний close тянется вперёд, после 10 дней
считается протухшей, но всё ещё используется. Назад не тянется никогда — цена из
будущего это выдумка, а не оценка.
valuation берёт два разных источника намеренно. Дневная серия — реплей event
(только он отвечает, сколько стоило в марте), текущие холдинги — из lot, где
есть себестоимость и учтены сплиты. Расхождение между ними на последний день
становится находкой, а не поводом выбрать одно из двух. Отчётная единица —
scope: all, account:<id>, portfolio:<id>.
returns читает только metric_portfolio_value_daily. XIRR — по внешним потокам и
терминальной стоимости; TWR — цепочкой V_t / (V_{t-1} + F_t). План пишет формулу
как (V_t - F_t) / V_{t-1}, то есть с потоком в конце дня; поток в начале даёт то
же число при нулевом потоке, не требует особого случая на первый день и относит
движение рынка к деньгам, которые в этот день уже работали.
Покупка бумаги без цены трактуется как вывод из оцениваемого портфеля
(unvalued_flow_rub): иначе деньги уходят из оценки, а бумага в неё не попадает,
и день читается как обвал — именно это фонд денежного рынка без фида MOEX
устроил серии 2024 года. Пропускается только день, в который меняется ЧИСЛО
неоценённых позиций, и их счётчик уходит в metric_data_quality.
Нет цены или курса — NULL и замечание, не ноль: SIBN6P4 в холдингах именно так и
выглядит. Валютные «позиции» из сверки исключены, их двойник — cash_snapshot, а
не лот; после этого расхождений с брокером ровно пять известных.
Проверка из плана закрыта тестами: взнос 100 и 110 через год дают XIRR 10,0 % и
TWR 10,0 %, второй взнос двигает XIRR и не трогает TWR подпериодов.
271 lines
8.2 KiB
Python
271 lines
8.2 KiB
Python
"""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)
|