feat(analytics): оценка позиций, XIRR и TWR
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 подпериодов.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"""The dated price lookup: carry forward, never backward, and know when it is stale."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from fintracker.pricing.prices import STALE_AFTER_DAYS, PriceTable, Quote
|
||||
|
||||
D = Decimal
|
||||
GAZP, TBRU = 10, 11
|
||||
|
||||
|
||||
def table() -> PriceTable:
|
||||
return PriceTable(
|
||||
{
|
||||
GAZP: [
|
||||
Quote(D(100), "RUB", date(2025, 3, 3)),
|
||||
Quote(D(110), "RUB", date(2025, 3, 6), accrued_interest=D(5)),
|
||||
]
|
||||
},
|
||||
{GAZP: Quote(D(120), "RUB", date(2025, 3, 10))},
|
||||
)
|
||||
|
||||
|
||||
def at(d: date, instrument_id: int = GAZP) -> Quote:
|
||||
quote = table().at(instrument_id, d)
|
||||
assert quote is not None
|
||||
return quote
|
||||
|
||||
|
||||
def latest(d: date, instrument_id: int = GAZP) -> Quote:
|
||||
quote = table().latest(instrument_id, d)
|
||||
assert quote is not None
|
||||
return quote
|
||||
|
||||
|
||||
def test_a_quiet_day_reuses_the_last_close():
|
||||
assert at(date(2025, 3, 5)).price == D(100)
|
||||
|
||||
|
||||
def test_there_is_no_price_before_the_first_quote():
|
||||
assert table().at(GAZP, date(2025, 3, 2)) is None
|
||||
assert table().at(TBRU, date(2025, 3, 5)) is None
|
||||
|
||||
|
||||
def test_staleness_is_measured_from_the_day_the_price_was_quoted():
|
||||
quoted = date(2025, 3, 6)
|
||||
quote = at(quoted + timedelta(days=30))
|
||||
assert quote.as_of == quoted
|
||||
assert not quote.is_stale_on(quoted + timedelta(days=STALE_AFTER_DAYS))
|
||||
assert quote.is_stale_on(quoted + timedelta(days=STALE_AFTER_DAYS + 1))
|
||||
|
||||
|
||||
def test_total_adds_accrued_interest():
|
||||
assert at(date(2025, 3, 6)).total == D(115)
|
||||
assert at(date(2025, 3, 3)).total == D(100)
|
||||
|
||||
|
||||
def test_the_intraday_price_wins_once_the_daily_bar_is_behind():
|
||||
assert latest(date(2025, 3, 11)).price == D(120)
|
||||
# …but never a price from the future of the day being valued
|
||||
assert latest(date(2025, 3, 7)).price == D(110)
|
||||
|
||||
|
||||
def test_price_last_alone_is_enough_to_value_an_instrument():
|
||||
only_last = PriceTable({}, {TBRU: Quote(D(7), "RUB", date(2025, 3, 10))})
|
||||
assert only_last.at(TBRU, date(2025, 3, 10)) is None
|
||||
quote = only_last.latest(TBRU, date(2025, 3, 10))
|
||||
assert quote is not None and quote.price == D(7)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""XIRR and TWR on synthetic flows — the acceptance check from the plan, §6 phase 2.
|
||||
|
||||
A single contribution: XIRR and TWR must agree, because there is no timing to weigh.
|
||||
A second contribution: XIRR moves, every sub-period of TWR stays exactly where it was.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from fintracker.analytics.returns import Point, annualize, months_back, period_start, twr, xirr
|
||||
|
||||
D = Decimal
|
||||
START = date(2025, 1, 1)
|
||||
|
||||
|
||||
def day(n: int) -> date:
|
||||
return START + timedelta(days=n)
|
||||
|
||||
|
||||
def flat_series(values: dict[int, str], *, flows: dict[int, str] | None = None) -> list[Point]:
|
||||
"""Days 1..365 of a series whose value only changes on the days listed."""
|
||||
flows = flows or {}
|
||||
points: list[Point] = []
|
||||
current = D(values[0])
|
||||
for n in range(1, 366):
|
||||
if n in values:
|
||||
current = D(values[n])
|
||||
points.append(Point(d=day(n), value=current, flow=D(flows.get(n, "0"))))
|
||||
return points
|
||||
|
||||
|
||||
def test_single_contribution_gives_ten_percent_both_ways():
|
||||
# 100 in on day 0, worth 110 a year later
|
||||
assert xirr([day(0), day(365)], [D(-100), D(110)]) == D("0.100000")
|
||||
|
||||
chain = twr(flat_series({0: "100", 365: "110"}), opening_value=D(100))
|
||||
assert chain.value == D("0.100000")
|
||||
assert chain.days_skipped == 0
|
||||
|
||||
|
||||
def test_a_second_contribution_moves_xirr_but_not_twr():
|
||||
one = twr(flat_series({0: "100", 365: "110"}), opening_value=D(100))
|
||||
two = twr(
|
||||
flat_series({0: "100", 182: "150", 365: "165"}, flows={182: "50"}),
|
||||
opening_value=D(100),
|
||||
)
|
||||
# the portfolio still earned exactly 10 % over the year, whoever paid in when
|
||||
assert two.value == one.value == D("0.100000")
|
||||
|
||||
money_weighted = xirr([day(0), day(182), day(365)], [D(-100), D(-50), D(165)])
|
||||
assert money_weighted is not None
|
||||
assert money_weighted > D("0.100000")
|
||||
|
||||
|
||||
def test_a_withdrawal_does_not_read_as_a_loss():
|
||||
chain = twr(
|
||||
flat_series({0: "100", 182: "50", 365: "55"}, flows={182: "-50"}),
|
||||
opening_value=D(100),
|
||||
)
|
||||
assert chain.value == D("0.100000")
|
||||
|
||||
|
||||
def test_an_unvalued_purchase_is_treated_as_leaving_the_portfolio():
|
||||
"""Cash spent on a paper nobody quotes must not read as a crash (the money-market fund)."""
|
||||
points = [
|
||||
Point(d=day(1), value=D(83000), flow=D(17000), unvalued_flow=D(-17000), missing=1),
|
||||
Point(d=day(2), value=D(83830), flow=D(0), unvalued_flow=D(0), missing=1),
|
||||
]
|
||||
chain = twr(points, opening_value=D(83000), opening_missing=1)
|
||||
# day 1: 83000 / (83000 + 17000 - 17000) = 1; day 2: +1 %
|
||||
assert chain.value == D("0.010000")
|
||||
assert chain.days_skipped == 0
|
||||
|
||||
|
||||
def test_a_position_becoming_priced_is_skipped_not_counted_as_profit():
|
||||
points = [
|
||||
Point(d=day(1), value=D(100), flow=D(0), missing=1),
|
||||
Point(d=day(2), value=D(132), flow=D(0), missing=0),
|
||||
Point(d=day(3), value=D(133), flow=D(0), missing=0),
|
||||
]
|
||||
chain = twr(points, opening_value=D(100), opening_missing=1)
|
||||
assert chain.days_skipped == 1
|
||||
# only day 3 is chained: the 32 that appeared on day 2 was never a gain
|
||||
assert chain.value == D("0.007576")
|
||||
|
||||
|
||||
def test_a_period_with_no_usable_day_has_no_twr():
|
||||
points = [Point(d=day(1), value=D(100), flow=D(0), missing=1)]
|
||||
assert twr(points, opening_value=D(100), opening_missing=0).value is None
|
||||
|
||||
|
||||
def test_xirr_needs_flows_on_both_sides():
|
||||
assert xirr([day(0), day(365)], [D(-100), D(-50)]) is None
|
||||
assert xirr([day(0)], [D(-100)]) is None
|
||||
|
||||
|
||||
def test_annualize_only_above_a_year():
|
||||
assert annualize(D("0.1"), 180) is None
|
||||
assert annualize(D("0.21"), 730) == D("0.100000")
|
||||
assert annualize(None, 730) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("period", "expected"),
|
||||
[
|
||||
("1m", date(2026, 2, 28)),
|
||||
("3m", date(2025, 12, 31)),
|
||||
("1y", date(2025, 3, 31)),
|
||||
("ytd", date(2026, 1, 1)),
|
||||
("all", None),
|
||||
],
|
||||
)
|
||||
def test_period_start(period: str, expected: date | None):
|
||||
assert period_start(period, date(2026, 3, 31)) == expected
|
||||
|
||||
|
||||
def test_months_back_clamps_to_a_shorter_month():
|
||||
assert months_back(date(2026, 3, 31), 1) == date(2026, 2, 28)
|
||||
assert months_back(date(2026, 1, 15), 13) == date(2024, 12, 15)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Valuation rules on synthetic positions — no database, no source.
|
||||
|
||||
The invariants under test are the ones the multi-currency rule turns on: a missing price or
|
||||
a missing rate produces NULL and a counter, never a zero; a short position is negative all
|
||||
the way through; and cash spent on an unquoted paper is tracked so returns can see it.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from fintracker.analytics.valuation import (
|
||||
DayValue,
|
||||
Deltas,
|
||||
OpenPosition,
|
||||
combine,
|
||||
days_between,
|
||||
merge_positions,
|
||||
value_holding,
|
||||
value_series,
|
||||
weights,
|
||||
)
|
||||
from fintracker.pricing.prices import STALE_AFTER_DAYS, Quote
|
||||
|
||||
D = Decimal
|
||||
ACC, OTHER = 1, 2
|
||||
GAZP, USD_ETF, SILENT = 10, 11, 12
|
||||
DAY = date(2025, 3, 3)
|
||||
|
||||
|
||||
class Prices:
|
||||
def __init__(self, quotes: dict[int, Quote]) -> None:
|
||||
self._quotes = quotes
|
||||
|
||||
def at(self, instrument_id: int, d: date) -> Quote | None:
|
||||
return self._quotes.get(instrument_id)
|
||||
|
||||
|
||||
class Fx:
|
||||
def __init__(self, rates: dict[str, Decimal]) -> None:
|
||||
self._rates = rates
|
||||
|
||||
def rate(self, d: date, ccy: str | None) -> Decimal | None:
|
||||
return self._rates.get((ccy or "").upper())
|
||||
|
||||
|
||||
def quote(price: str, *, ccy: str = "RUB", nkd: str | None = None, age: int = 0) -> Quote:
|
||||
return Quote(
|
||||
price=D(price),
|
||||
currency=ccy,
|
||||
as_of=DAY - timedelta(days=age),
|
||||
accrued_interest=D(nkd) if nkd else None,
|
||||
)
|
||||
|
||||
|
||||
def deltas(*, positions=None, cash=None, flows=None, instrument_cash=None) -> Deltas:
|
||||
return Deltas(positions or {}, cash or {}, flows or {}, instrument_cash or {})
|
||||
|
||||
|
||||
def test_a_position_is_priced_and_converted_at_the_rate_of_its_day():
|
||||
series = value_series(
|
||||
spine=[DAY],
|
||||
deltas=deltas(positions={(ACC, USD_ETF): {DAY: D(10)}}),
|
||||
prices=Prices({USD_ETF: quote("12", ccy="USD")}),
|
||||
fx=Fx({"USD": D(80), "RUB": D(1)}),
|
||||
)
|
||||
point = series[ACC][0]
|
||||
assert point.market_value_rub == D(9600) # 10 * 12 * 80
|
||||
assert point.complete
|
||||
|
||||
|
||||
def test_a_bond_carries_its_accrued_interest_into_the_value():
|
||||
series = value_series(
|
||||
spine=[DAY],
|
||||
deltas=deltas(positions={(ACC, GAZP): {DAY: D(7)}}),
|
||||
prices=Prices({GAZP: quote("1000", nkd="25")}),
|
||||
fx=Fx({"RUB": D(1)}),
|
||||
)
|
||||
point = series[ACC][0]
|
||||
assert point.market_value_rub == D(7175)
|
||||
assert point.accrued_interest_rub == D(175)
|
||||
|
||||
|
||||
def test_an_unquoted_position_is_counted_not_valued_at_zero():
|
||||
series = value_series(
|
||||
spine=[DAY],
|
||||
deltas=deltas(positions={(ACC, SILENT): {DAY: D(7)}}),
|
||||
prices=Prices({}),
|
||||
fx=Fx({"RUB": D(1)}),
|
||||
)
|
||||
point = series[ACC][0]
|
||||
assert point.market_value_rub == D(0)
|
||||
assert point.missing_price_count == 1
|
||||
assert not point.complete
|
||||
|
||||
|
||||
def test_a_currency_with_no_rate_drops_out_and_is_reported():
|
||||
series = value_series(
|
||||
spine=[DAY],
|
||||
deltas=deltas(cash={(ACC, "XBT"): {DAY: D(5)}}),
|
||||
prices=Prices({}),
|
||||
fx=Fx({"RUB": D(1)}),
|
||||
)
|
||||
point = series[ACC][0]
|
||||
assert point.cash_rub == D(0)
|
||||
assert point.missing_fx_count == 1
|
||||
|
||||
|
||||
def test_a_short_position_is_negative_throughout():
|
||||
series = value_series(
|
||||
spine=[DAY],
|
||||
deltas=deltas(positions={(ACC, GAZP): {DAY: D(-4)}}),
|
||||
prices=Prices({GAZP: quote("100")}),
|
||||
fx=Fx({"RUB": D(1)}),
|
||||
)
|
||||
assert series[ACC][0].market_value_rub == D(-400)
|
||||
|
||||
|
||||
def test_quantities_and_cash_carry_across_days():
|
||||
spine = days_between(DAY, DAY + timedelta(days=2))
|
||||
series = value_series(
|
||||
spine=spine,
|
||||
deltas=deltas(
|
||||
positions={(ACC, GAZP): {DAY: D(2)}},
|
||||
cash={(ACC, "RUB"): {DAY: D(500)}},
|
||||
),
|
||||
prices=Prices({GAZP: quote("100")}),
|
||||
fx=Fx({"RUB": D(1)}),
|
||||
)
|
||||
assert [p.total_rub for p in series[ACC]] == [D(700), D(700), D(700)]
|
||||
|
||||
|
||||
def test_cash_spent_on_an_unquoted_paper_is_recorded_as_an_unvalued_flow():
|
||||
series = value_series(
|
||||
spine=[DAY],
|
||||
deltas=deltas(
|
||||
positions={(ACC, SILENT): {DAY: D(140)}},
|
||||
cash={(ACC, "RUB"): {DAY: D(-17000)}},
|
||||
instrument_cash={(ACC, SILENT, "RUB"): {DAY: D(-17000)}},
|
||||
),
|
||||
prices=Prices({}),
|
||||
fx=Fx({"RUB": D(1)}),
|
||||
)
|
||||
assert series[ACC][0].unvalued_flow_rub == D(-17000)
|
||||
|
||||
|
||||
def test_combine_adds_accounts_day_by_day():
|
||||
series = value_series(
|
||||
spine=[DAY],
|
||||
deltas=deltas(
|
||||
positions={(ACC, GAZP): {DAY: D(1)}, (OTHER, GAZP): {DAY: D(2)}},
|
||||
),
|
||||
prices=Prices({GAZP: quote("100")}),
|
||||
fx=Fx({"RUB": D(1)}),
|
||||
)
|
||||
both = combine(series, [ACC, OTHER])
|
||||
assert [p.market_value_rub for p in both] == [D(300)]
|
||||
assert combine(series, []) == []
|
||||
|
||||
|
||||
def position(**over) -> OpenPosition:
|
||||
base = {
|
||||
"instrument_id": GAZP,
|
||||
"qty": D(10),
|
||||
"cost_native": D(900),
|
||||
"cost_currency": "RUB",
|
||||
"cost_rub": D(900),
|
||||
"first_open": date(2024, 1, 9),
|
||||
}
|
||||
return OpenPosition(**{**base, **over})
|
||||
|
||||
|
||||
def test_a_holding_without_a_price_is_null_with_a_status():
|
||||
value = value_holding(position(), None, D(1), as_of=DAY)
|
||||
assert value.status == "missing"
|
||||
assert value.value_rub is None
|
||||
assert value.unrealized_rub is None
|
||||
|
||||
|
||||
def test_an_old_price_is_used_but_marked_stale():
|
||||
value = value_holding(position(), quote("100", age=STALE_AFTER_DAYS + 1), D(1), as_of=DAY)
|
||||
assert value.status == "stale"
|
||||
assert value.value_rub == D(1000)
|
||||
|
||||
|
||||
def test_unrealized_compares_todays_value_with_the_cost_of_its_own_day():
|
||||
value = value_holding(
|
||||
position(cost_rub=D(72000), cost_currency="USD", cost_native=D(900)),
|
||||
quote("100", ccy="USD"),
|
||||
D(80),
|
||||
as_of=DAY,
|
||||
)
|
||||
assert value.value_rub == D(80000)
|
||||
assert value.unrealized_native == D(100) # 1000 - 900 USD
|
||||
assert value.unrealized_rub == D(8000) # includes the currency revaluation
|
||||
|
||||
|
||||
def test_a_holding_with_no_rate_keeps_the_native_value_and_drops_the_rub_one():
|
||||
value = value_holding(position(), quote("100"), None, as_of=DAY)
|
||||
assert value.value_native == D(1000)
|
||||
assert value.value_rub is None
|
||||
|
||||
|
||||
def test_a_short_holding_profits_when_the_price_falls():
|
||||
short = position(qty=D(-10), cost_native=D(-1000), cost_rub=D(-1000))
|
||||
value = value_holding(short, quote("90"), D(1), as_of=DAY)
|
||||
assert value.value_rub == D(-900)
|
||||
assert value.unrealized_rub == D(100)
|
||||
|
||||
|
||||
def test_weights_ignore_shorts_and_unvalued_holdings():
|
||||
share = weights({1: D(300), 2: D(100), 3: None, 4: D(-50)})
|
||||
assert share[1] == D("0.75")
|
||||
assert share[2] == D("0.25")
|
||||
assert share[3] is None
|
||||
assert weights({1: None})[1] is None
|
||||
|
||||
|
||||
def test_merging_positions_keeps_the_earliest_open_and_drops_a_mixed_currency():
|
||||
merged = merge_positions(
|
||||
[
|
||||
position(qty=D(10), cost_rub=D(900), first_open=date(2024, 5, 1)),
|
||||
position(qty=D(5), cost_currency="USD", cost_rub=D(500), first_open=date(2024, 1, 9)),
|
||||
]
|
||||
)
|
||||
assert merged.qty == D(15)
|
||||
assert merged.cost_rub == D(1400)
|
||||
assert merged.cost_currency is None
|
||||
assert merged.first_open == date(2024, 1, 9)
|
||||
|
||||
|
||||
def test_merging_loses_the_rub_cost_when_any_lot_lacked_a_rate():
|
||||
merged = merge_positions([position(), position(cost_rub=None)])
|
||||
assert merged.cost_rub is None
|
||||
|
||||
|
||||
def test_a_day_value_totals_market_and_cash():
|
||||
point = DayValue(
|
||||
d=DAY,
|
||||
market_value_rub=D(100),
|
||||
accrued_interest_rub=D(0),
|
||||
cash_rub=D(25),
|
||||
external_flow_rub=D(0),
|
||||
)
|
||||
assert point.total_rub == D(125)
|
||||
@@ -0,0 +1,270 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user