Files
fin-tracker/backend/src/fintracker/api/app.py
T
Dmitry 15f5812ea4 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).
2026-09-19 10:42:50 +03:00

160 lines
5.2 KiB
Python

"""FastAPI application factory."""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from fastapi.routing import APIRoute
from fintracker import __version__
from fintracker.api.errors import install_error_handlers
from fintracker.api.routers import (
accounts,
analytics,
auth,
benchmarks,
cashflow,
categories,
events,
goals,
health,
imports,
income,
instruments,
links,
metrics,
networth,
rebalance,
rules,
sync,
tax,
transactions,
)
from fintracker.api.web import mount_web
from fintracker.config import get_settings
from fintracker.db import reset_engine
log = logging.getLogger(__name__)
API_PREFIX = "/api/v1"
def _operation_id(route: APIRoute) -> str:
# "auth_login" instead of "login_api_v1_auth_login_post": readable Dart method names
tag = route.tags[0] if route.tags else "default"
return f"{tag}_{route.name}"
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
if settings.is_dev_secret:
log.warning("JWT_SECRET is the insecure development default")
yield
await reset_engine()
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title="fin-tracker",
version=__version__,
lifespan=_lifespan,
generate_unique_id_function=_operation_id,
docs_url=f"{API_PREFIX}/docs",
openapi_url=f"{API_PREFIX}/openapi.json",
redoc_url=None,
)
if settings.cors_origin_list:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
install_error_handlers(app)
_install_openapi(app)
app.include_router(health.router, prefix=API_PREFIX)
app.include_router(auth.router, prefix=API_PREFIX)
app.include_router(sync.router, prefix=API_PREFIX)
app.include_router(accounts.router, prefix=API_PREFIX)
app.include_router(categories.router, prefix=API_PREFIX)
app.include_router(transactions.router, prefix=API_PREFIX)
app.include_router(rules.router, prefix=API_PREFIX)
app.include_router(networth.router, prefix=API_PREFIX)
app.include_router(cashflow.router, prefix=API_PREFIX)
app.include_router(analytics.router, prefix=API_PREFIX)
app.include_router(events.router, prefix=API_PREFIX)
app.include_router(imports.router, prefix=API_PREFIX)
# Before `instruments`: FastAPI matches in registration order, and `/instruments/{id}`
# typed `int` does not fall through on a non-numeric segment — it answers 422. So
# `/instruments/pending` has to be declared first or it becomes unreachable.
app.include_router(imports.pending_router, prefix=API_PREFIX)
app.include_router(instruments.router, prefix=API_PREFIX)
app.include_router(links.router, prefix=API_PREFIX)
app.include_router(metrics.router, prefix=API_PREFIX)
app.include_router(goals.router, prefix=API_PREFIX)
app.include_router(income.router, prefix=API_PREFIX)
app.include_router(rebalance.router, prefix=API_PREFIX)
app.include_router(tax.router, prefix=API_PREFIX)
app.include_router(benchmarks.router, prefix=API_PREFIX)
app.include_router(benchmarks.analytics_router, prefix=API_PREFIX)
if settings.web_dir is not None:
mount_web(app, settings.web_dir, API_PREFIX)
return app
PROBLEM_SCHEMA = {
"title": "Problem",
"type": "object",
"description": "RFC 7807 error body (application/problem+json)",
"required": ["status", "title"],
"properties": {
"status": {"type": "integer"},
"title": {"type": "string"},
"detail": {"type": "string"},
"errors": {"type": "array", "items": {}},
},
}
def _install_openapi(app: FastAPI) -> None:
"""Post-process the schema: every error is a Problem, never FastAPI's anyOf-heavy
HTTPValidationError (which client generators cannot represent)."""
def custom_openapi() -> dict[str, Any]:
if app.openapi_schema:
return app.openapi_schema
schema = get_openapi(
title=app.title,
version=app.version,
routes=app.routes,
servers=[{"url": "/"}],
)
components = schema.setdefault("components", {}).setdefault("schemas", {})
components.pop("HTTPValidationError", None)
components.pop("ValidationError", None)
components["Problem"] = PROBLEM_SCHEMA
problem_ref = {
"description": "Error",
"content": {
"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}
},
}
for path_item in schema.get("paths", {}).values():
for op in path_item.values():
responses = op.get("responses", {})
responses.pop("422", None)
responses["default"] = problem_ref
app.openapi_schema = schema
return schema
app.openapi = custom_openapi # type: ignore[method-assign]