feat(analytics): брокерские потоки по месяцам и ручные цены инструментов
GET /analytics/cashflow-broker читает metric_cash_flow_broker. POST
/instruments/{id}/prices апсертит price_manual по (instrument_id, d);
pricing/prices.py подмешивает его в ту же серию, что price_daily, с тем же
протягиванием и порогом устаревания.
This commit is contained in:
@@ -17,6 +17,7 @@ from sqlalchemy import func, select
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.schemas.analytics import (
|
||||
AllocationBucket,
|
||||
CashFlowBrokerMonth,
|
||||
HoldingOut,
|
||||
ReturnsOut,
|
||||
ScopeOut,
|
||||
@@ -28,6 +29,7 @@ from fintracker.models import (
|
||||
AllocationDimension,
|
||||
Instrument,
|
||||
MetricAllocation,
|
||||
MetricCashFlowBroker,
|
||||
MetricHolding,
|
||||
MetricPortfolioValueDaily,
|
||||
MetricRefreshLog,
|
||||
@@ -137,6 +139,32 @@ async def allocation(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/cashflow-broker", name="cashflow_broker")
|
||||
async def cashflow_broker(
|
||||
session: SessionDep, _: CurrentUser, scope: ScopeParam = DEFAULT_SCOPE
|
||||
) -> list[CashFlowBrokerMonth]:
|
||||
"""Money moved across the brokerage boundary, by month: the other half of cash flow
|
||||
that `metric_cash_flow_monthly` (ZenMoney spending) does not cover."""
|
||||
await resolve_scope(session, scope)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(MetricCashFlowBroker)
|
||||
.where(MetricCashFlowBroker.scope == scope)
|
||||
.order_by(MetricCashFlowBroker.month)
|
||||
)
|
||||
).scalars()
|
||||
return [
|
||||
CashFlowBrokerMonth(
|
||||
month=r.month,
|
||||
deposits_rub=r.deposits_rub,
|
||||
withdrawals_rub=r.withdrawals_rub,
|
||||
net_rub=r.net_rub,
|
||||
event_count=r.event_count,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/summary", name="summary")
|
||||
async def summary(
|
||||
session: SessionDep, _: CurrentUser, scope: ScopeParam = DEFAULT_SCOPE
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
from datetime import date, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Query, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.analytics import today_local
|
||||
@@ -21,10 +21,12 @@ from fintracker.api.schemas.analytics import (
|
||||
InstrumentDetail,
|
||||
InstrumentOut,
|
||||
LotOut,
|
||||
PriceManualIn,
|
||||
PriceManualOut,
|
||||
PricePoint,
|
||||
)
|
||||
from fintracker.api.scopes import DEFAULT_SCOPE, resolve_scope
|
||||
from fintracker.models import AssetClass, Instrument, Lot, PriceDaily
|
||||
from fintracker.models import AssetClass, Instrument, Lot, PriceDaily, PriceManual
|
||||
|
||||
router = APIRouter(prefix="/instruments", tags=["instruments"])
|
||||
|
||||
@@ -103,6 +105,24 @@ async def get_instrument(
|
||||
.order_by(PriceDaily.d)
|
||||
)
|
||||
).scalars()
|
||||
manual_prices = (
|
||||
await session.execute(
|
||||
select(PriceManual)
|
||||
.where(PriceManual.instrument_id == instrument_id, PriceManual.d >= since)
|
||||
.order_by(PriceManual.d)
|
||||
)
|
||||
).scalars()
|
||||
|
||||
points = [
|
||||
PricePoint(d=p.d, close=p.close, currency=p.currency, accrued_interest=p.accrued_interest)
|
||||
for p in prices
|
||||
] + [
|
||||
PricePoint(
|
||||
d=p.d, close=p.price, currency=p.currency, accrued_interest=None, source="manual"
|
||||
)
|
||||
for p in manual_prices
|
||||
]
|
||||
points.sort(key=lambda p: p.d)
|
||||
|
||||
return InstrumentDetail(
|
||||
instrument=_instrument(instrument),
|
||||
@@ -122,12 +142,50 @@ async def get_instrument(
|
||||
for lot in lots
|
||||
],
|
||||
events=await events_of_instrument(session, instrument_id),
|
||||
prices=[
|
||||
PricePoint(
|
||||
d=p.d, close=p.close, currency=p.currency, accrued_interest=p.accrued_interest
|
||||
prices=points,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{instrument_id}/prices", name="set_manual_price", status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def set_manual_price(
|
||||
instrument_id: int, body: PriceManualIn, session: SessionDep, _: CurrentUser
|
||||
) -> PriceManualOut:
|
||||
"""Set (or replace) the manual price of `instrument_id` on `body.d`.
|
||||
|
||||
For what no exchange quotes: real estate, crypto, a custom holding, an OTC bond nobody
|
||||
lists. One row per (instrument, day) — a second call for the same day replaces it rather
|
||||
than piling up duplicates the price series would then have to pick between. Does not
|
||||
itself refresh `metric_*`; call `POST /metrics/refresh` after, same as `/rules/apply`.
|
||||
"""
|
||||
instrument = await session.get(Instrument, instrument_id)
|
||||
if instrument is None:
|
||||
raise Problem(404, "Not Found", f"Нет инструмента #{instrument_id}")
|
||||
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(PriceManual).where(
|
||||
PriceManual.instrument_id == instrument_id, PriceManual.d == body.d
|
||||
)
|
||||
for p in prices
|
||||
],
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
existing = PriceManual(instrument_id=instrument_id, d=body.d)
|
||||
session.add(existing)
|
||||
existing.price = body.price
|
||||
existing.currency = body.currency.upper()
|
||||
existing.note = body.note
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return PriceManualOut(
|
||||
id=existing.id,
|
||||
instrument_id=existing.instrument_id,
|
||||
d=existing.d,
|
||||
price=existing.price,
|
||||
currency=existing.currency,
|
||||
note=existing.note,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -166,6 +166,33 @@ class PricePoint(BaseModel):
|
||||
close: Money
|
||||
currency: str
|
||||
accrued_interest: MoneyOpt
|
||||
source: str = "daily"
|
||||
""""daily" (price_daily, from an exchange) or "manual" (`price_manual`)."""
|
||||
|
||||
|
||||
class PriceManualIn(BaseModel):
|
||||
d: date
|
||||
price: Money
|
||||
currency: str
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class PriceManualOut(BaseModel):
|
||||
id: int
|
||||
instrument_id: int
|
||||
d: date
|
||||
price: Money
|
||||
currency: str
|
||||
note: str | None
|
||||
|
||||
|
||||
class CashFlowBrokerMonth(BaseModel):
|
||||
month: date
|
||||
"""First day of the month."""
|
||||
deposits_rub: Money
|
||||
withdrawals_rub: Money
|
||||
net_rub: Money
|
||||
event_count: int
|
||||
|
||||
|
||||
class InstrumentOut(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user