Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions alembic/versions/0004_fx_rates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""fx rates table"""

import sqlalchemy as sa
from alembic import op

revision = "0004_fx_rates"
down_revision = "0003_batch_amount_sign"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"fx_rates",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("base_currency", sa.String(length=3), nullable=False),
sa.Column("quote_currency", sa.String(length=3), nullable=False),
sa.Column("rate", sa.String(length=32), nullable=False),
sa.Column("effective_at", sa.Date(), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("retrieved_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"base_currency", "quote_currency", "effective_at", name="uq_fx_rates_base_quote_date"
),
)
op.create_index("ix_fx_rates_effective_at", "fx_rates", ["effective_at"])


def downgrade() -> None:
op.drop_index("ix_fx_rates_effective_at", table_name="fx_rates")
op.drop_table("fx_rates")
92 changes: 66 additions & 26 deletions src/pfa/analytics/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,17 @@ def __init__(
self.budgets = budgets
self.goals = goals

def monthly_summary(self, period: date) -> MonthlySummary:
def _filter_currency(
self, transactions: list[TransactionModel], currency: str
) -> list[TransactionModel]:
curr = currency.upper()
return [t for t in transactions if (getattr(t, "currency", None) or "GBP").upper() == curr]

def monthly_summary(self, period: date, currency: str = "GBP") -> MonthlySummary:
start, end = month_bounds(period)
rows = self.transactions.between(start, end)
all_rows = self.transactions.between(start, end)
curr = currency.upper()
rows = self._filter_currency(all_rows, curr)
income = sum(row.amount_minor for row in rows if row.kind == TransactionKind.INCOME.value)
spending = sum(_spending(row) for row in rows)
essential = sum(_spending(row) for row in rows if row.category in _ESSENTIAL)
Expand Down Expand Up @@ -102,6 +110,7 @@ def monthly_summary(self, period: date) -> MonthlySummary:
)
return MonthlySummary(
period=start.strftime("%Y-%m"),
currency=curr,
income_minor=income,
spending_minor=spending,
essential_spending_minor=essential,
Expand All @@ -114,8 +123,9 @@ def monthly_summary(self, period: date) -> MonthlySummary:
transaction_count=len(rows),
)

def category_spending(self, period: date) -> list[CategoryTotal]:
rows = self.transactions.between(*month_bounds(period))
def category_spending(self, period: date, currency: str = "GBP") -> list[CategoryTotal]:
all_rows = self.transactions.between(*month_bounds(period))
rows = self._filter_currency(all_rows, currency)
totals: dict[str, list[int]] = defaultdict(lambda: [0, 0])
for row in rows:
value = _spending(row)
Expand All @@ -127,8 +137,9 @@ def category_spending(self, period: date) -> list[CategoryTotal]:
for key, value in sorted(totals.items(), key=lambda item: -item[1][0])
]

def merchant_spending(self, period: date) -> list[MerchantTotal]:
rows = self.transactions.between(*month_bounds(period))
def merchant_spending(self, period: date, currency: str = "GBP") -> list[MerchantTotal]:
all_rows = self.transactions.between(*month_bounds(period))
rows = self._filter_currency(all_rows, currency)
totals: dict[str, list[int]] = defaultdict(lambda: [0, 0])
for row in rows:
value = _spending(row)
Expand All @@ -140,10 +151,12 @@ def merchant_spending(self, period: date) -> list[MerchantTotal]:
for key, value in sorted(totals.items(), key=lambda item: -item[1][0])
]

def compare_periods(self, current: date, previous: date | None = None) -> PeriodComparison:
def compare_periods(
self, current: date, previous: date | None = None, currency: str = "GBP"
) -> PeriodComparison:
previous = previous or (current.replace(day=1) - timedelta(days=1))
current_summary = self.monthly_summary(current)
previous_summary = self.monthly_summary(previous)
current_summary = self.monthly_summary(current, currency=currency)
previous_summary = self.monthly_summary(previous, currency=currency)
fields = (
"income_minor",
"spending_minor",
Expand All @@ -159,19 +172,31 @@ def compare_periods(self, current: date, previous: date | None = None) -> Period
current=current_summary, previous=previous_summary, changes_minor=changes
)

def largest_transactions(self, period: date, limit: int = 10) -> list[TransactionModel]:
rows = self.transactions.between(*month_bounds(period))
def largest_transactions(
self, period: date, limit: int = 10, currency: str = "GBP"
) -> list[TransactionModel]:
all_rows = self.transactions.between(*month_bounds(period))
rows = self._filter_currency(all_rows, currency)
return sorted(rows, key=lambda row: _spending(row), reverse=True)[:limit]

def recurring_payments(self) -> list[dict[str, object]]:
return detect_recurring(self.transactions.all())
def recurring_payments(self, currency: str = "GBP") -> list[dict[str, object]]:
all_rows = self.transactions.all()
rows = self._filter_currency(all_rows, currency)
return detect_recurring(rows)

def budget_status(self, period: date) -> list[BudgetStatus]:
def budget_status(self, period: date, currency: str = "GBP") -> list[BudgetStatus]:
curr = currency.upper()
actual_by_category = {
item.category: item.total_minor for item in self.category_spending(period)
item.category: item.total_minor
for item in self.category_spending(period, currency=curr)
}
statuses = []
for budget in self.budgets.active_on(month_bounds(period)[0]):
active_budgets = [
b
for b in self.budgets.active_on(month_bounds(period)[0])
if (getattr(b, "currency", None) or "GBP").upper() == curr
]
for budget in active_budgets:
actual = (
sum(actual_by_category.values())
if budget.category is None
Expand Down Expand Up @@ -207,38 +232,53 @@ def goal_progress(self) -> list[GoalProgress]:
for goal in self.goals.active()
]

def cashflow(self, period: date) -> dict[str, int | str]:
summary = self.monthly_summary(period)
def cashflow(self, period: date, currency: str = "GBP") -> dict[str, int | str]:
summary = self.monthly_summary(period, currency=currency)
return {
"period": summary.period,
"currency": summary.currency,
"income_minor": summary.income_minor,
"spending_minor": summary.spending_minor,
"net_cashflow_minor": summary.net_cashflow_minor,
}

def unusual_transactions(self, period: date) -> list[dict[str, object]]:
return unusual_transactions(self.transactions.all(), period)
def unusual_transactions(self, period: date, currency: str = "GBP") -> list[dict[str, object]]:
all_rows = self.transactions.all()
rows = self._filter_currency(all_rows, currency)
return unusual_transactions(rows, period)

def category_spikes(
self, current: date, previous: date | None = None
self, current: date, previous: date | None = None, currency: str = "GBP"
) -> list[dict[str, object]]:
previous = previous or (current.replace(day=1) - timedelta(days=1))
return category_spikes(self.transactions.all(), current, previous)
all_rows = self.transactions.all()
rows = self._filter_currency(all_rows, currency)
return category_spikes(rows, current, previous)

def category_trend(
self, category: str, as_of: date, months: int = 6
self, category: str, as_of: date, months: int = 6, currency: str = "GBP"
) -> list[dict[str, int | str]]:
return category_trend(self.transactions.all(), category, as_of, months)
all_rows = self.transactions.all()
rows = self._filter_currency(all_rows, currency)
return category_trend(rows, category, as_of, months)


def current_cash(
accounts: list[AccountModel], transactions: list[TransactionModel], as_of: date | None = None
accounts: list[AccountModel],
transactions: list[TransactionModel],
currency: str = "GBP",
as_of: date | None = None,
) -> int:
curr = currency.upper()
opening = sum(
account.opening_balance_minor
for account in accounts
if account.account_type not in {item.value for item in NON_CASH_ACCOUNT_TYPES}
and (getattr(account, "currency", None) or "GBP").upper() == curr
)
return opening + sum(
_cash_delta(row) for row in transactions if as_of is None or row.transaction_date <= as_of
_cash_delta(row)
for row in transactions
if (getattr(row, "currency", None) or "GBP").upper() == curr
and (as_of is None or row.transaction_date <= as_of)
)
130 changes: 121 additions & 9 deletions src/pfa/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager, suppress
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Annotated, Literal

Expand Down Expand Up @@ -39,6 +40,7 @@
from pfa.ingestion.upload import stage_upload, sweep_upload_dir
from pfa.observability import TimedOperation
from pfa.services.answers import deterministic_answer
from pfa.services.fx import fetch_and_store_fx_rates
from pfa.services.health import health_report
from pfa.services.review import monthly_review_evidence
from pfa.services.runtime import close_services, open_services
Expand Down Expand Up @@ -132,6 +134,28 @@ class ScenarioRequest(BaseModel):
cost_minor: int = Field(ge=0)
horizon_months: int = Field(default=3, ge=1, le=120)
month: str | None = None
currency: str = "GBP"


class FxRateResponse(BaseModel):
id: int
base_currency: str
quote_currency: str
rate: str # decimal string - never float; see domain/fx.py
effective_at: date
source: str | None = None


class FxRateSetRequest(BaseModel):
base_currency: str
quote_currency: str
rate: str # decimal string - never float; see domain/fx.py
effective_at: date | None = None


class FxFetchRequest(BaseModel):
base_currency: str = "GBP"
on_date: date | None = None


def _issue_response(issue: CandidateIssue) -> CandidateIssueResponse:
Expand Down Expand Up @@ -406,29 +430,114 @@ def transactions(
finally:
close_services(engine, services)

@app.get("/fx/rates", response_model=list[FxRateResponse])
def get_fx_rates(
base: str | None = None,
quote: str | None = None,
) -> list[FxRateResponse]:
engine, services = open_services(active_settings)
try:
rates = services.uow.fx_rates.all()
if base:
rates = [r for r in rates if r.base_currency == base.upper()]
if quote:
rates = [r for r in rates if r.quote_currency == quote.upper()]
return [
FxRateResponse(
id=r.id,
base_currency=r.base_currency,
quote_currency=r.quote_currency,
rate=r.rate,
effective_at=r.effective_at,
source=r.source,
)
for r in rates
]
finally:
close_services(engine, services)

@app.post("/fx/rates", response_model=FxRateResponse)
def set_fx_rate(request: FxRateSetRequest) -> FxRateResponse:
try:
rate = Decimal(request.rate)
except InvalidOperation as exc:
raise HTTPException(status_code=422, detail=f"invalid rate {request.rate!r}") from exc
engine, services = open_services(active_settings)
try:
effective_at = request.effective_at or date.today()
model = services.uow.fx_rates.set_rate(
request.base_currency.upper(),
request.quote_currency.upper(),
rate,
effective_at=effective_at,
)
response = FxRateResponse(
id=model.id,
base_currency=model.base_currency,
quote_currency=model.quote_currency,
rate=model.rate,
effective_at=model.effective_at,
source=model.source,
)
close_services(engine, services)
return response
except Exception:
close_services(engine, services, False)
raise

@app.post("/fx/fetch", response_model=list[FxRateResponse])
def fetch_fx_rates(request: FxFetchRequest) -> list[FxRateResponse]:
engine, services = open_services(active_settings)
try:
models = fetch_and_store_fx_rates(
services.uow,
base_currency=request.base_currency.upper(),
on_date=request.on_date or date.today(),
)
response = [
FxRateResponse(
id=m.id,
base_currency=m.base_currency,
quote_currency=m.quote_currency,
rate=m.rate,
effective_at=m.effective_at,
source=m.source,
)
for m in models
]
close_services(engine, services)
return response
except Exception:
close_services(engine, services, False)
raise

@app.get("/analytics/monthly")
def monthly(month: str | None = None) -> dict[str, object]:
def monthly(month: str | None = None, currency: str = "GBP") -> dict[str, object]:
engine, services = open_services(active_settings)
try:
return services.analytics.monthly_summary(_month(month)).model_dump()
return services.analytics.monthly_summary(_month(month), currency=currency).model_dump()
finally:
close_services(engine, services)

@app.get("/analytics/categories")
def categories(month: str | None = None) -> list[dict[str, object]]:
def categories(month: str | None = None, currency: str = "GBP") -> list[dict[str, object]]:
engine, services = open_services(active_settings)
try:
return [
item.model_dump() for item in services.analytics.category_spending(_month(month))
item.model_dump()
for item in services.analytics.category_spending(_month(month), currency=currency)
]
finally:
close_services(engine, services)

@app.get("/budgets")
def budgets(month: str | None = None) -> list[dict[str, object]]:
def budgets(month: str | None = None, currency: str = "GBP") -> list[dict[str, object]]:
engine, services = open_services(active_settings)
try:
return [item.model_dump() for item in services.analytics.budget_status(_month(month))]
return [
item.model_dump()
for item in services.analytics.budget_status(_month(month), currency=currency)
]
finally:
close_services(engine, services)

Expand All @@ -445,7 +554,10 @@ def purchase(request: ScenarioRequest) -> dict[str, object]:
engine, services = open_services(active_settings)
try:
return services.planning.simulate_purchase(
request.cost_minor, request.horizon_months, _month(request.month)
request.cost_minor,
request.horizon_months,
_month(request.month),
currency=request.currency,
).model_dump()
finally:
close_services(engine, services)
Expand Down Expand Up @@ -481,10 +593,10 @@ def chat(request: ChatRequest) -> dict[str, str]:
close_services(engine, services)

@app.get("/reviews/monthly")
def review(month: str | None = None) -> dict[str, object]:
def review(month: str | None = None, currency: str = "GBP") -> dict[str, object]:
engine, services = open_services(active_settings)
try:
return monthly_review_evidence(services.analytics, _month(month))
return monthly_review_evidence(services.analytics, _month(month), currency=currency)
finally:
close_services(engine, services)

Expand Down
Loading