Implement fin-hub T24-T26: ingest, runway, coupling, RaaS packaging

Add CSV importers, runway calculator, budget alerts, cross-hub coupling
emitters, finhub CLI, and raas-mvp-packaging docs with full test coverage.
This commit is contained in:
tegwick 2026-07-08 00:59:39 +02:00
parent b6993d4a05
commit 1abbbe85e4
27 changed files with 835 additions and 3 deletions

View file

@ -0,0 +1,6 @@
"""Fin-hub domain services."""
from fin_hub.services.alerts import evaluate_budget_alerts
from fin_hub.services.runway import RunwayResult, compute_runway
__all__ = ["RunwayResult", "compute_runway", "evaluate_budget_alerts"]

View file

@ -0,0 +1,60 @@
"""Budget and runway alert evaluation."""
from __future__ import annotations
from dataclasses import dataclass
from fin_hub.services.runway import RunwayResult
@dataclass(frozen=True)
class BudgetAlert:
code: str
severity: str
summary: str
detail: dict
def as_dict(self) -> dict:
return {
"code": self.code,
"severity": self.severity,
"summary": self.summary,
"detail": self.detail,
}
def evaluate_budget_alerts(
*,
runway: RunwayResult,
allocated: float | None = None,
spent: float | None = None,
) -> list[BudgetAlert]:
alerts: list[BudgetAlert] = []
if runway.below_threshold and runway.monthly_burn > 0:
alerts.append(
BudgetAlert(
code="runway_below_threshold",
severity="high",
summary=(
f"Projected runway {runway.months_remaining:.1f} months "
f"is below threshold {runway.alert_threshold_months:.1f}"
),
detail=runway.as_dict(),
)
)
if allocated is not None and spent is not None and allocated > 0:
utilisation = spent / allocated
if utilisation >= 0.8:
alerts.append(
BudgetAlert(
code="budget_pressure",
severity="medium" if utilisation < 1.0 else "high",
summary=f"Budget utilisation at {utilisation * 100:.0f}%",
detail={
"allocated": allocated,
"spent": spent,
"utilisation": utilisation,
},
)
)
return alerts

View file

@ -0,0 +1,56 @@
"""Runway calculator with burn-rate projection."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
@dataclass(frozen=True)
class RunwayResult:
current_balance: float
monthly_burn: float
months_remaining: float
alert_threshold_months: float
below_threshold: bool
currency: str
computed_at: datetime
def as_dict(self) -> dict:
return {
"current_balance": self.current_balance,
"monthly_burn": self.monthly_burn,
"months_remaining": self.months_remaining,
"alert_threshold_months": self.alert_threshold_months,
"below_threshold": self.below_threshold,
"currency": self.currency,
"computed_at": self.computed_at.isoformat(),
}
def compute_runway(
*,
current_balance: float,
monthly_burns: list[float],
alert_threshold_months: float = 3.0,
currency: str = "EUR",
) -> RunwayResult:
if not monthly_burns:
monthly_burn = 0.0
else:
recent = monthly_burns[-3:]
monthly_burn = sum(recent) / len(recent)
if monthly_burn <= 0:
months_remaining = float("inf") if current_balance > 0 else 0.0
else:
months_remaining = current_balance / monthly_burn
below_threshold = months_remaining < alert_threshold_months
return RunwayResult(
current_balance=current_balance,
monthly_burn=monthly_burn,
months_remaining=months_remaining,
alert_threshold_months=alert_threshold_months,
below_threshold=below_threshold,
currency=currency,
computed_at=datetime.now(timezone.utc),
)