refactor: Modifikator-Helfer öffentlich, load_scenario_inputs aus run_projection (A13-T1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@ class PlainModifier:
|
|||||||
end_date: date | None = None
|
end_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
||||||
modifiers: list[PlainModifier]) -> Decimal | None:
|
modifiers: list[PlainModifier]) -> Decimal | None:
|
||||||
for m in modifiers:
|
for m in modifiers:
|
||||||
hit = ((m.target_type == "category" and category_id == m.target_id)
|
hit = ((m.target_type == "category" and category_id == m.target_id)
|
||||||
@@ -56,7 +56,7 @@ def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None
|
|||||||
return amount
|
return amount
|
||||||
|
|
||||||
|
|
||||||
def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
def effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
||||||
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
||||||
end = r.end_date
|
end = r.end_date
|
||||||
for m in modifiers:
|
for m in modifiers:
|
||||||
@@ -76,14 +76,14 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned]
|
|||||||
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
||||||
flows: list[tuple[date, Decimal]] = []
|
flows: list[tuple[date, Decimal]] = []
|
||||||
for r in recurring:
|
for r in recurring:
|
||||||
amount = _modified(r.amount, r.category_id, r.id, modifiers)
|
amount = modified_amount(r.amount, r.category_id, r.id, modifiers)
|
||||||
if amount is None:
|
if amount is None:
|
||||||
continue
|
continue
|
||||||
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||||||
r.start_date, _effective_end(r, modifiers)):
|
r.start_date, effective_end(r, modifiers)):
|
||||||
flows.append((d, amount))
|
flows.append((d, amount))
|
||||||
for p in planned:
|
for p in planned:
|
||||||
amount = _modified(p.amount, p.category_id, None, modifiers)
|
amount = modified_amount(p.amount, p.category_id, None, modifiers)
|
||||||
if amount is not None and window_start <= p.due <= window_end:
|
if amount is not None and window_start <= p.due <= window_end:
|
||||||
flows.append((p.due, amount))
|
flows.append((p.due, amount))
|
||||||
for d, payout in loan_payouts:
|
for d, payout in loan_payouts:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -18,36 +19,61 @@ from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
|
|||||||
from app.services.balances import total_balance
|
from app.services.balances import total_balance
|
||||||
|
|
||||||
|
|
||||||
def run_projection(session: Session, scenario: Scenario, horizon_days: int,
|
@dataclass(frozen=True)
|
||||||
start_date: date) -> ProjectionResult:
|
class ScenarioInputs:
|
||||||
end = start_date + timedelta(days=horizon_days)
|
"""Eingaben einer Szenario-Rechnung (Projektion wie Aufteilung)."""
|
||||||
|
recurring: list[PlainRecurring]
|
||||||
|
planned: list[PlainPlanned]
|
||||||
|
loans: list[Loan]
|
||||||
|
modifiers: list[PlainModifier]
|
||||||
|
|
||||||
|
|
||||||
|
def load_scenario_inputs(session: Session, scenario: Scenario | None) -> ScenarioInputs:
|
||||||
|
"""Lädt die Eingaben eines Szenarios. scenario=None ist die reine
|
||||||
|
Planung („System-Plan"): alle Fixposten, alle globalen Einmalposten,
|
||||||
|
alle Kredite, keine Modifikatoren, keine Szenario-Einmalposten."""
|
||||||
recurring = []
|
recurring = []
|
||||||
if scenario.include_recurring:
|
if scenario is None or scenario.include_recurring:
|
||||||
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
||||||
r.due_day, r.start_date, r.end_date, r.category_id)
|
r.due_day, r.start_date, r.end_date, r.category_id)
|
||||||
for r in session.execute(select(RecurringItem)).scalars()]
|
for r in session.execute(
|
||||||
|
select(RecurringItem).order_by(RecurringItem.id)).scalars()]
|
||||||
planned = []
|
planned = []
|
||||||
if scenario.include_planned:
|
if scenario is None or scenario.include_planned:
|
||||||
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||||||
for p in session.execute(select(PlannedItem)).scalars()]
|
for p in session.execute(
|
||||||
|
select(PlannedItem).order_by(PlannedItem.id)).scalars()]
|
||||||
|
if scenario is None:
|
||||||
|
loans = list(session.execute(select(Loan).order_by(Loan.id)).scalars())
|
||||||
|
return ScenarioInputs(recurring, planned, loans, [])
|
||||||
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
|
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
|
||||||
# include_planned steuert nur die globalen PlannedItems.
|
# include_planned steuert nur die globalen PlannedItems.
|
||||||
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
|
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
|
||||||
for p in session.execute(select(ScenarioPlannedItem).where(
|
for p in session.execute(select(ScenarioPlannedItem).where(
|
||||||
ScenarioPlannedItem.scenario_id == scenario.id)).scalars()]
|
ScenarioPlannedItem.scenario_id == scenario.id)
|
||||||
loans = session.execute(
|
.order_by(ScenarioPlannedItem.id)).scalars()]
|
||||||
|
loans = list(session.execute(
|
||||||
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
|
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
|
||||||
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
|
.where(ScenarioLoan.scenario_id == scenario.id)
|
||||||
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
|
.order_by(Loan.id)).scalars())
|
||||||
l.term_months, l.payout_date, l.repayment_type)
|
|
||||||
for l in loans]
|
|
||||||
payouts = [(l.payout_date, Decimal(l.principal)) for l in loans]
|
|
||||||
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
|
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
|
||||||
Decimal(m.value), m.end_date)
|
Decimal(m.value), m.end_date)
|
||||||
for m in session.execute(select(ScenarioModifier).where(
|
for m in session.execute(select(ScenarioModifier).where(
|
||||||
ScenarioModifier.scenario_id == scenario.id)).scalars()]
|
ScenarioModifier.scenario_id == scenario.id)
|
||||||
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,
|
.order_by(ScenarioModifier.id)).scalars()]
|
||||||
start_date, end)
|
return ScenarioInputs(recurring, planned, loans, modifiers)
|
||||||
|
|
||||||
|
|
||||||
|
def run_projection(session: Session, scenario: Scenario, horizon_days: int,
|
||||||
|
start_date: date) -> ProjectionResult:
|
||||||
|
end = start_date + timedelta(days=horizon_days)
|
||||||
|
inputs = load_scenario_inputs(session, scenario)
|
||||||
|
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
|
||||||
|
l.term_months, l.payout_date, l.repayment_type)
|
||||||
|
for l in inputs.loans]
|
||||||
|
payouts = [(l.payout_date, Decimal(l.principal)) for l in inputs.loans]
|
||||||
|
flows = build_cashflows(inputs.recurring, inputs.planned, schedules, payouts,
|
||||||
|
inputs.modifiers, start_date, end)
|
||||||
proj = project(total_balance(session), start_date, flows, horizon_days,
|
proj = project(total_balance(session), start_date, flows, horizon_days,
|
||||||
threshold=get_settings().warn_threshold)
|
threshold=get_settings().warn_threshold)
|
||||||
session.execute(delete(ProjectionPoint).where(
|
session.execute(delete(ProjectionPoint).where(
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.engine.scenario import (PlainModifier, PlainRecurring, effective_end,
|
||||||
|
modified_amount)
|
||||||
|
from app.models.tables import (Loan, PlannedItem, RecurringItem, Scenario,
|
||||||
|
ScenarioLoan, ScenarioModifier,
|
||||||
|
ScenarioPlannedItem)
|
||||||
|
from app.services.projection_service import load_scenario_inputs
|
||||||
|
|
||||||
|
|
||||||
|
def test_modifikator_helfer_oeffentlich():
|
||||||
|
mods = [PlainModifier("recurring", 1, "percent", Decimal("10")),
|
||||||
|
PlainModifier("recurring", 1, "ende", Decimal("0"), date(2030, 6, 30))]
|
||||||
|
assert modified_amount(Decimal("-100.00"), None, 1, mods) == Decimal("-90.00")
|
||||||
|
assert modified_amount(Decimal("-100.00"), None, 2, mods) == Decimal("-100.00")
|
||||||
|
r = PlainRecurring(1, "R", Decimal("-100.00"), "monthly", 1, None,
|
||||||
|
date(2030, 12, 31), None)
|
||||||
|
assert effective_end(r, mods) == date(2030, 6, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def _daten(db):
|
||||||
|
rec = RecurringItem(name="R", amount=Decimal("-10.00"), rhythm="monthly", due_day=1)
|
||||||
|
pln = PlannedItem(name="P", amount=Decimal("-20.00"), due=date(2030, 1, 1))
|
||||||
|
k1 = Loan(name="K1", principal=Decimal("100.00"), annual_rate_pct=Decimal("0"),
|
||||||
|
term_months=2, payout_date=date(2030, 1, 1), repayment_type="annuity")
|
||||||
|
k2 = Loan(name="K2", principal=Decimal("200.00"), annual_rate_pct=Decimal("0"),
|
||||||
|
term_months=2, payout_date=date(2030, 1, 1), repayment_type="annuity")
|
||||||
|
sc = Scenario(name="S", description="", include_recurring=True,
|
||||||
|
include_planned=False)
|
||||||
|
db.add_all([rec, pln, k1, k2, sc])
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
ScenarioLoan(scenario_id=sc.id, loan_id=k1.id),
|
||||||
|
ScenarioModifier(scenario_id=sc.id, target_type="recurring",
|
||||||
|
target_id=rec.id, kind="percent", value=Decimal("50")),
|
||||||
|
ScenarioPlannedItem(scenario_id=sc.id, name="SP", amount=Decimal("-5.00"),
|
||||||
|
due=date(2030, 2, 1)),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
return sc
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_scenario_inputs_szenario(db):
|
||||||
|
sc = _daten(db)
|
||||||
|
inp = load_scenario_inputs(db, sc)
|
||||||
|
assert [r.name for r in inp.recurring] == ["R"]
|
||||||
|
# include_planned=False: globale Einmalposten fehlen, Szenario-eigene zählen immer.
|
||||||
|
assert [p.name for p in inp.planned] == ["SP"]
|
||||||
|
assert inp.planned[0].category_id is None
|
||||||
|
assert [l.name for l in inp.loans] == ["K1"]
|
||||||
|
assert [(m.kind, m.value) for m in inp.modifiers] == [("percent", Decimal("50"))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_scenario_inputs_ohne_fixposten(db):
|
||||||
|
sc = _daten(db)
|
||||||
|
sc.include_recurring = False
|
||||||
|
db.commit()
|
||||||
|
assert load_scenario_inputs(db, sc).recurring == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_scenario_inputs_system_plan(db):
|
||||||
|
_daten(db)
|
||||||
|
inp = load_scenario_inputs(db, None)
|
||||||
|
assert [r.name for r in inp.recurring] == ["R"]
|
||||||
|
assert [p.name for p in inp.planned] == ["P"]
|
||||||
|
assert [l.name for l in inp.loans] == ["K1", "K2"]
|
||||||
|
assert inp.modifiers == []
|
||||||
Reference in New Issue
Block a user