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:
parent
b6993d4a05
commit
1abbbe85e4
27 changed files with 835 additions and 3 deletions
6
src/fin_hub/services/__init__.py
Normal file
6
src/fin_hub/services/__init__.py
Normal 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"]
|
||||
60
src/fin_hub/services/alerts.py
Normal file
60
src/fin_hub/services/alerts.py
Normal 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
|
||||
56
src/fin_hub/services/runway.py
Normal file
56
src/fin_hub/services/runway.py
Normal 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),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue