This commit is contained in:
Mateusz Gruszczyński
2026-06-03 13:02:07 +02:00
parent fdc946989f
commit 7cb2eddafe
8 changed files with 532 additions and 84 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
@@ -16,4 +16,4 @@ COPY app ./app
RUN mkdir -p /app/exports
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8147"]
+130 -29
View File
@@ -9,6 +9,7 @@ from datetime import datetime
from pathlib import Path
import httpx
from io import BytesIO
from bs4 import BeautifulSoup
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
@@ -21,6 +22,8 @@ from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
from reportlab.graphics.shapes import Drawing, Line, PolyLine, Rect, String, Wedge
from openpyxl import load_workbook
from .models import SimulationRequest
from .simulator import simulate
@@ -94,29 +97,97 @@ async def ws_simulate(websocket: WebSocket):
except Exception as exc:
await websocket.send_json({"error": str(exc)})
@app.get("/api/rate/nbp")
async def nbp_rate():
"""Pomocniczo pobiera stopę referencyjną NBP z oficjalnej strony HTML."""
url = "https://nbp.pl/en/monetary-policy/mpc-decisions/interest-rates/"
"""
Pobiera średnie oprocentowanie nowych kredytów mieszkaniowych PLN z XLSX NBP.
Uwaga: to nie jest stopa referencyjna NBP, tylko średnie oprocentowanie kredytów.
"""
url = "https://static.nbp.pl/dane/statystyka/inne/stopy_proc_pl_srdW.xlsx"
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
resp = await client.get(url, headers={"User-Agent": "mortgage-simulator/1.0"})
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
resp = await client.get(
url,
headers={
"User-Agent": "Mozilla/5.0 mortgage-simulator/1.0",
"Accept": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*",
},
)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
text = " ".join(soup.get_text(" ").split())
patterns = [
r"Reference rate[^0-9]{0,80}(\d+[\.,]\d+|\d+)\s*%",
r"minimum money market intervention rate[^0-9]{0,80}(\d+[\.,]\d+|\d+)\s*%",
]
for pattern in patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
value = float(match.group(1).replace(",", "."))
return {"source": "NBP", "url": url, "rate": value, "fetched_at": datetime.utcnow().isoformat() + "Z"}
return JSONResponse({"error": "Nie znaleziono stopy w treści strony NBP", "url": url}, status_code=502)
wb = load_workbook(BytesIO(resp.content), data_only=True, read_only=True)
ws = wb["4 OPN2PLN"]
target_row = None
for row in ws.iter_rows():
values = [cell.value for cell in row]
joined = " ".join(str(v).lower() for v in values if v is not None)
if "nieruchomości mieszkaniowe" in joined or "nieruchomosci mieszkaniowe" in joined:
target_row = values
break
if not target_row:
return JSONResponse(
{
"error": "Nie znaleziono wiersza: na nieruchomości mieszkaniowe",
"url": url,
"sheet": "4 OPN2PLN",
},
status_code=502,
)
# Daty są w wierszu 2, wartości w znalezionym wierszu.
dates = [cell.value for cell in ws[2]]
latest_rate = None
latest_date = None
for idx in range(len(target_row) - 1, -1, -1):
value = target_row[idx]
if isinstance(value, (int, float)):
latest_rate = float(value)
latest_date_cell = dates[idx] if idx < len(dates) else None
if isinstance(latest_date_cell, datetime):
latest_date = latest_date_cell.date().isoformat()
elif latest_date_cell:
latest_date = str(latest_date_cell)
break
if latest_rate is None:
return JSONResponse(
{
"error": "Nie znaleziono wartości liczbowej oprocentowania kredytu mieszkaniowego",
"url": url,
"sheet": "4 OPN2PLN",
},
status_code=502,
)
# W XLSX wartości są jako ułamek, np. 0.0591 = 5.91%.
rate_percent = round(latest_rate * 100, 4)
return {
"source": "NBP",
"kind": "average_new_housing_loan_pln_rate",
"label": "Średnie oprocentowanie nowych kredytów mieszkaniowych PLN",
"url": url,
"sheet": "4 OPN2PLN",
"period": latest_date,
"rate": rate_percent,
"fetched_at": datetime.utcnow().isoformat() + "Z",
}
except Exception as exc:
return JSONResponse({"error": str(exc), "url": url}, status_code=502)
return JSONResponse(
{"error": str(exc), "url": url},
status_code=502,
)
@app.post("/api/export/csv")
@@ -124,9 +195,9 @@ def export_csv(req: SimulationRequest):
result = simulate(req)
buf = io.StringIO()
writer = csv.writer(buf, delimiter=";")
writer.writerow(["miesiac", "oprocentowanie", "rata", "kapital", "odsetki", "nadplata", "saldo"])
writer.writerow(["miesiac", "data_splaty", "dni", "oprocentowanie", "rata", "kapital", "odsetki", "nadplata", "prowizja_nadplaty", "saldo", "karencja", "odsetki_narastajaco", "koszt_narastajaco", "nadplaty_narastajaco"])
for row in result.schedule:
writer.writerow([row.month, row.rate, row.payment, row.principal_part, row.interest_part, row.overpayment, row.remaining])
writer.writerow([row.month, row.due_date, row.days, row.rate, row.payment, row.principal_part, row.interest_part, row.overpayment, row.overpayment_fee, row.remaining, row.grace_type.value, row.cumulative_interest, row.cumulative_cost, row.cumulative_overpayment])
writer.writerow([])
writer.writerow(["Podsumowanie"])
for key, value in result.summary.model_dump().items():
@@ -278,6 +349,8 @@ def export_pdf(req: SimulationRequest):
["Stopa bazowa", _pct(req.base_rate), "Marza", _pct(req.margin)],
["Oprocentowanie startowe", _pct(req.base_rate + req.margin), "Typ rat", _installment_label(req.installment_type.value)],
["Efekt nadplat", _effect_label(req.overpayment_effect.value), "Liczba rat po symulacji", str(result.summary.months)],
["Data startu", req.loan_start_date.isoformat(), "Dzien splaty", str(req.due_day)],
["Przesuwaj dni wolne", "tak" if req.move_due_date_to_business_day else "nie", "Data konca", result.summary.payoff_date or "-"],
]
story.append(Paragraph("Parametry wejściowe", styles["Heading2"]))
story.append(Table(params, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([
@@ -296,16 +369,17 @@ def export_pdf(req: SimulationRequest):
["Liczba rat", s.months, "Bazowo", s.baseline_months],
["Suma zaplacona", _money(s.total_paid), "Suma odsetek", _money(s.total_interest)],
["Oszczednosc na odsetkach", _money(s.interest_saved), "Skrocenie okresu", f"{s.months_saved} mies."],
["Suma nadplat", _money(s.total_overpayment), "Srednia / maks. rata", f"{_money(s.average_payment)} / {_money(s.max_payment)}"],
["Suma nadplat", _money(s.total_overpayment), "Prowizje nadplat", _money(s.total_overpayment_fees)],
["Srednia / maks. rata", f"{_money(s.average_payment)} / {_money(s.max_payment)}", "Data splaty", s.payoff_date or "-"],
]
story.append(Paragraph("Podsumowanie", styles["Heading2"]))
story.append(Table(summary, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([
story.append(Table(summary, colWidths=[4.3 * cm, 4.25 * cm, 3.7 * cm, 4.35 * cm], style=TableStyle([
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#ffffff")),
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
("FONTNAME", (0, 0), (0, -1), APP_FONT_BOLD),
("FONTNAME", (2, 0), (2, -1), APP_FONT_BOLD),
("FONTSIZE", (0, 0), (-1, -1), 7.4),
("FONTSIZE", (0, 0), (-1, -1), 7.0),
("VALIGN", (0, 0), (-1, -1), "TOP"),
])))
story.append(Spacer(1, 0.35 * cm))
@@ -344,34 +418,61 @@ def export_pdf(req: SimulationRequest):
story.append(Spacer(1, 0.35 * cm))
story.append(Paragraph("Nadplaty", styles["Heading2"]))
over_rows = [["Miesiac", "Kwota", "Powtarzanie", "Do miesiaca"]]
over_rows = [["Miesiac", "Kwota", "Prowizja", "Powtarzanie", "Do miesiaca"]]
if req.overpayments:
for op in sorted(req.overpayments, key=lambda x: x.month):
over_rows.append([str(op.month), _money(op.amount), _repeat_label(op.repeat), str(op.until_month or "-")])
over_rows.append([str(op.month), _money(op.amount), _pct(op.commission_percent), _repeat_label(op.repeat), str(op.until_month or "-")])
else:
over_rows.append(["-", "-", "-", "-"])
story.append(Table(over_rows, repeatRows=1, colWidths=[3 * cm, 4 * cm, 4 * cm, 4 * cm], style=TableStyle([
over_rows.append(["-", "-", "-", "-", "-"])
story.append(Table(over_rows, repeatRows=1, colWidths=[2.4 * cm, 3.4 * cm, 2.4 * cm, 3.4 * cm, 3.4 * cm], style=TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
("FONTSIZE", (0, 0), (-1, -1), 8),
])))
story.append(Spacer(1, 0.35 * cm))
story.append(Paragraph("Kredyt historyczny", styles["Heading2"]))
hist_rows = [["Miesiac", "Oprocentowanie", "Karencja", "Nadplata", "Prowizja", "Opis"]]
if req.historical_months:
for item in sorted(req.historical_months, key=lambda x: x.month):
hist_rows.append([
str(item.month),
_pct(item.annual_rate) if item.annual_rate is not None else "-",
item.grace_type.value,
_money(item.overpayment),
_pct(item.overpayment_commission_percent),
item.note or "-",
])
else:
hist_rows.append(["-", "-", "-", "-", "-", "-"])
story.append(Table(hist_rows, repeatRows=1, colWidths=[2 * cm, 2.6 * cm, 2.6 * cm, 2.8 * cm, 2.2 * cm, 4.5 * cm], style=TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
("FONTSIZE", (0, 0), (-1, -1), 7.5),
])))
story.append(PageBreak())
story.append(Paragraph("Kompletny harmonogram wszystkich rat", styles["Heading2"]))
schedule_rows = [["Mies.", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Saldo"]]
schedule_rows = [["Mies.", "Data", "Dni", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Prow.", "Koszt nar.", "Saldo"]]
for row in result.schedule:
schedule_rows.append([
row.month,
row.due_date,
row.days,
_pct(row.rate),
_money(row.payment),
_money(row.principal_part),
_money(row.interest_part),
_money(row.overpayment),
_money(row.overpayment_fee),
_money(row.cumulative_cost),
_money(row.remaining),
])
table = Table(schedule_rows, repeatRows=1, colWidths=[1.35 * cm, 1.7 * cm, 2.55 * cm, 2.55 * cm, 2.55 * cm, 2.55 * cm, 3.0 * cm])
table = Table(schedule_rows, repeatRows=1, colWidths=[0.9 * cm, 1.75 * cm, 0.75 * cm, 1.15 * cm, 1.85 * cm, 1.85 * cm, 1.85 * cm, 1.85 * cm, 1.5 * cm, 2.0 * cm, 2.05 * cm])
table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
+30
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import date
from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field
@@ -15,6 +16,12 @@ class OverpaymentEffect(str, Enum):
lower_payment = "lower_payment"
class GraceType(str, Enum):
none = "none"
interest_only = "interest_only"
full = "full"
class RateChange(BaseModel):
month: int = Field(ge=1, description="Miesiac od startu kredytu")
annual_rate: float = Field(ge=0, le=30, description="Roczne oprocentowanie procentowo")
@@ -25,6 +32,16 @@ class Overpayment(BaseModel):
amount: float = Field(gt=0)
repeat: Literal["once", "monthly", "yearly"] = "once"
until_month: int | None = Field(default=None, ge=1)
commission_percent: float = Field(default=0, ge=0, le=20, description="Prowizja od nadplaty w procentach")
class HistoricalMonth(BaseModel):
month: int = Field(ge=1)
annual_rate: float | None = Field(default=None, ge=0, le=30)
grace_type: GraceType = GraceType.none
overpayment: float = Field(default=0, ge=0)
overpayment_commission_percent: float = Field(default=0, ge=0, le=20)
note: str = ""
class SimulationRequest(BaseModel):
@@ -34,18 +51,29 @@ class SimulationRequest(BaseModel):
base_rate: float = Field(ge=0, le=30, default=5.75)
installment_type: InstallmentType = InstallmentType.equal
overpayment_effect: OverpaymentEffect = OverpaymentEffect.shorten
loan_start_date: date = Field(default_factory=date.today)
due_day: int = Field(default=5, ge=1, le=28, description="Dzien splaty raty")
move_due_date_to_business_day: bool = True
rate_changes: list[RateChange] = Field(default_factory=list)
overpayments: list[Overpayment] = Field(default_factory=list)
historical_months: list[HistoricalMonth] = Field(default_factory=list)
class ScheduleRow(BaseModel):
month: int
due_date: str
days: int
rate: float
payment: float
principal_part: float
interest_part: float
overpayment: float
overpayment_fee: float
remaining: float
grace_type: GraceType = GraceType.none
cumulative_interest: float
cumulative_cost: float
cumulative_overpayment: float
class Summary(BaseModel):
@@ -53,12 +81,14 @@ class Summary(BaseModel):
total_paid: float
total_interest: float
total_overpayment: float
total_overpayment_fees: float
interest_saved: float
months_saved: int
baseline_interest: float
baseline_months: int
average_payment: float
max_payment: float
payoff_date: str | None = None
class SimulationResponse(BaseModel):
+165 -27
View File
@@ -1,14 +1,94 @@
from __future__ import annotations
from datetime import date, timedelta
from math import pow
from .models import SimulationRequest, ScheduleRow, Summary, SimulationResponse, InstallmentType, OverpaymentEffect
from .models import (
GraceType,
HistoricalMonth,
InstallmentType,
OverpaymentEffect,
ScheduleRow,
SimulationRequest,
SimulationResponse,
Summary,
)
def _round_money(value: float) -> float:
return round(max(value, 0.0) + 1e-9, 2)
def _rate_for_month(req: SimulationRequest, month: int) -> float:
def _add_months(base: date, months: int, day: int) -> date:
month_index = base.month - 1 + months
year = base.year + month_index // 12
month = month_index % 12 + 1
return date(year, month, min(day, 28))
def _easter(year: int) -> date:
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
def _polish_holidays(year: int) -> set[date]:
easter = _easter(year)
return {
date(year, 1, 1),
date(year, 1, 6),
easter,
easter + timedelta(days=1),
date(year, 5, 1),
date(year, 5, 3),
easter + timedelta(days=60),
date(year, 8, 15),
date(year, 11, 1),
date(year, 11, 11),
date(year, 12, 25),
date(year, 12, 26),
}
def _is_business_day(value: date) -> bool:
return value.weekday() < 5 and value not in _polish_holidays(value.year)
def _move_to_business_day(value: date, enabled: bool) -> date:
if not enabled:
return value
while not _is_business_day(value):
value += timedelta(days=1)
return value
def _due_date_for_month(req: SimulationRequest, month: int) -> date:
raw = _add_months(req.loan_start_date, month, req.due_day)
return _move_to_business_day(raw, req.move_due_date_to_business_day)
def _historical_map(req: SimulationRequest) -> dict[int, HistoricalMonth]:
return {item.month: item for item in req.historical_months}
def _rate_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth] | None = None) -> float:
historical = historical or _historical_map(req)
hist = historical.get(month)
if hist and hist.annual_rate is not None:
return hist.annual_rate
changes = sorted(req.rate_changes, key=lambda x: x.month)
rate = req.base_rate + req.margin
for change in changes:
@@ -19,6 +99,11 @@ def _rate_for_month(req: SimulationRequest, month: int) -> float:
return rate
def _grace_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth]) -> GraceType:
hist = historical.get(month)
return hist.grace_type if hist else GraceType.none
def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> float:
if months_left <= 0:
return balance
@@ -28,89 +113,140 @@ def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> f
return balance * monthly_rate * factor / (factor - 1)
def _overpayment_for_month(req: SimulationRequest, month: int) -> float:
def _scheduled_overpayment_for_month(req: SimulationRequest, month: int) -> tuple[float, float]:
total = 0.0
fee = 0.0
for op in req.overpayments:
until = op.until_month or month
active = False
if op.repeat == "once" and month == op.month:
total += op.amount
active = True
elif op.repeat == "monthly" and month >= op.month and month <= until:
total += op.amount
active = True
elif op.repeat == "yearly" and month >= op.month and month <= until and (month - op.month) % 12 == 0:
active = True
if active:
total += op.amount
return total
fee += op.amount * op.commission_percent / 100
return total, fee
def _historical_overpayment_for_month(historical: dict[int, HistoricalMonth], month: int) -> tuple[float, float]:
hist = historical.get(month)
if not hist or hist.overpayment <= 0:
return 0.0, 0.0
return hist.overpayment, hist.overpayment * hist.overpayment_commission_percent / 100
def _overpayment_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth]) -> tuple[float, float]:
scheduled_amount, scheduled_fee = _scheduled_overpayment_for_month(req, month)
hist_amount, hist_fee = _historical_overpayment_for_month(historical, month)
return scheduled_amount + hist_amount, scheduled_fee + hist_fee
def _simulate_raw(req: SimulationRequest, include_overpayments: bool = True) -> list[ScheduleRow]:
balance = float(req.principal)
total_months = int(req.years * 12)
rows: list[ScheduleRow] = []
fixed_payment = None
recalculation_month = 1
fixed_payment: float | None = None
month = 1
previous_due = req.loan_start_date
historical = _historical_map(req)
previous_rate: float | None = None
cumulative_interest = 0.0
cumulative_cost = 0.0
cumulative_overpayment = 0.0
while balance > 0.005 and month <= total_months + 600:
months_left = max(total_months - month + 1, 1)
annual_rate = _rate_for_month(req, month)
monthly_rate = annual_rate / 100 / 12
interest = balance * monthly_rate
due_date = _due_date_for_month(req, month)
days = max((due_date - previous_due).days, 1)
annual_rate = _rate_for_month(req, month, historical)
daily_rate = annual_rate / 100 / 365
formula_monthly_rate = annual_rate / 100 / 12
interest = balance * daily_rate * days
grace_type = _grace_for_month(req, month, historical)
if req.installment_type == InstallmentType.equal:
if fixed_payment is None or req.overpayment_effect == OverpaymentEffect.lower_payment:
fixed_payment = _monthly_payment(balance, monthly_rate, months_left)
needs_recalc = fixed_payment is None or annual_rate != previous_rate
if include_overpayments and req.overpayment_effect == OverpaymentEffect.lower_payment:
needs_recalc = True
if needs_recalc:
fixed_payment = _monthly_payment(balance, formula_monthly_rate, months_left)
payment = fixed_payment
principal_part = max(payment - interest, 0.0)
else:
principal_base = req.principal / total_months
if req.overpayment_effect == OverpaymentEffect.lower_payment:
active_months_left = max(total_months - month + 1, 1)
principal_base = balance / active_months_left
principal_base = balance / months_left
payment = principal_base + interest
principal_part = principal_base
if grace_type == GraceType.interest_only:
principal_part = 0.0
payment = interest
elif grace_type == GraceType.full:
principal_part = 0.0
payment = 0.0
interest = 0.0
if principal_part > balance:
principal_part = balance
payment = interest + principal_part
balance -= principal_part
overpayment = _overpayment_for_month(req, month) if include_overpayments else 0.0
overpayment = min(overpayment, balance)
overpayment = 0.0
overpayment_fee = 0.0
if include_overpayments:
requested_overpayment, requested_fee = _overpayment_for_month(req, month, historical)
overpayment = min(requested_overpayment, balance)
overpayment_fee = requested_fee * (overpayment / requested_overpayment) if requested_overpayment > 0 else 0.0
balance -= overpayment
cumulative_interest += interest
cumulative_overpayment += overpayment
cumulative_cost += interest + overpayment_fee
rows.append(ScheduleRow(
month=month,
due_date=due_date.isoformat(),
days=days,
rate=round(annual_rate, 4),
payment=_round_money(payment),
principal_part=_round_money(principal_part),
interest_part=_round_money(interest),
overpayment=_round_money(overpayment),
overpayment_fee=_round_money(overpayment_fee),
remaining=_round_money(balance),
grace_type=grace_type,
cumulative_interest=_round_money(cumulative_interest),
cumulative_cost=_round_money(cumulative_cost),
cumulative_overpayment=_round_money(cumulative_overpayment),
))
if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.lower_payment:
fixed_payment = None
recalculation_month = month + 1
if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.shorten:
# przy skroceniu okresu rata zostaje z grubsza taka sama; petla zakonczy sie szybciej
pass
previous_due = due_date
previous_rate = annual_rate
month += 1
if month - recalculation_month > 1200:
break
return rows
def simulate(req: SimulationRequest) -> SimulationResponse:
actual = _simulate_raw(req, include_overpayments=True)
baseline_req = req.model_copy(update={"overpayments": []})
baseline_req = req.model_copy(update={"overpayments": [], "historical_months": [
h.model_copy(update={"overpayment": 0.0, "overpayment_commission_percent": 0.0})
for h in req.historical_months
]})
baseline = _simulate_raw(baseline_req, include_overpayments=False)
total_interest = sum(r.interest_part for r in actual)
total_overpayment = sum(r.overpayment for r in actual)
total_paid = sum(r.payment + r.overpayment for r in actual)
total_overpayment_fees = sum(r.overpayment_fee for r in actual)
total_paid = sum(r.payment + r.overpayment + r.overpayment_fee for r in actual)
baseline_interest = sum(r.interest_part for r in baseline)
payments = [r.payment for r in actual]
@@ -119,11 +255,13 @@ def simulate(req: SimulationRequest) -> SimulationResponse:
total_paid=_round_money(total_paid),
total_interest=_round_money(total_interest),
total_overpayment=_round_money(total_overpayment),
interest_saved=_round_money(baseline_interest - total_interest),
total_overpayment_fees=_round_money(total_overpayment_fees),
interest_saved=_round_money(baseline_interest - total_interest - total_overpayment_fees),
months_saved=max(len(baseline) - len(actual), 0),
baseline_interest=_round_money(baseline_interest),
baseline_months=len(baseline),
average_payment=_round_money(sum(payments) / len(payments)) if payments else 0,
max_payment=_round_money(max(payments)) if payments else 0,
payoff_date=actual[-1].due_date if actual else None,
)
return SimulationResponse(schedule=actual, summary=summary)
+125 -14
View File
@@ -2,12 +2,17 @@ const $ = (id) => document.getElementById(id);
const money = (v) => new Intl.NumberFormat('pl-PL', { style: 'currency', currency: 'PLN', maximumFractionDigits: 0 }).format(v || 0);
const num = (id) => Number($(id).value || 0);
let lineChart, pieChart, barChart;
let lineChart, pieChart, barChart, detailChart;
let socket;
let debounceTimer;
let lastRequest = null;
window.lastSimulationData = null;
function todayIso() {
const d = new Date();
return d.toISOString().slice(0, 10);
}
function setTheme(theme) {
document.body.dataset.theme = theme;
localStorage.setItem('mortgage-theme', theme);
@@ -43,9 +48,22 @@ function buildRequest() {
month: Number(row.querySelector('[data-field="month"]').value || 1),
amount: Number(row.querySelector('[data-field="amount"]').value || 0),
repeat: row.querySelector('[data-field="repeat"]').value,
until_month: Number(row.querySelector('[data-field="until"]').value || 0) || null
until_month: Number(row.querySelector('[data-field="until"]').value || 0) || null,
commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0)
})).filter(x => x.month > 0 && x.amount > 0);
const historicalMonths = [...document.querySelectorAll('#historicalMonths .dynamic-row')].map(row => {
const rateRaw = row.querySelector('[data-field="rate"]').value;
return {
month: Number(row.querySelector('[data-field="month"]').value || 1),
annual_rate: rateRaw === '' ? null : Number(rateRaw),
grace_type: row.querySelector('[data-field="grace"]').value,
overpayment: Number(row.querySelector('[data-field="overpayment"]').value || 0),
overpayment_commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0),
note: row.querySelector('[data-field="note"]').value || ''
};
}).filter(x => x.month > 0);
return {
principal: num('principal'),
years: num('years'),
@@ -53,8 +71,12 @@ function buildRequest() {
base_rate: num('baseRate'),
installment_type: $('installmentType').value,
overpayment_effect: $('overpaymentEffect').value,
loan_start_date: $('loanStartDate').value || todayIso(),
due_day: num('dueDay') || 5,
move_due_date_to_business_day: $('moveDueDate').checked,
rate_changes: rateChanges,
overpayments: overpayments
overpayments: overpayments,
historical_months: historicalMonths
};
}
@@ -68,7 +90,7 @@ function recalc() {
function addRateRow(month = 13, rate = 7.0) {
const div = document.createElement('div');
div.className = 'dynamic-row';
div.className = 'dynamic-row rate-row';
div.innerHTML = `
<div><label class="form-label">Od miesiąca</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
<div><label class="form-label">Oproc. roczne %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate}"></div>
@@ -79,14 +101,15 @@ function addRateRow(month = 13, rate = 7.0) {
recalc();
}
function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '') {
function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '', commission = 0) {
const div = document.createElement('div');
div.className = 'dynamic-row overpay';
div.innerHTML = `
<div><label class="form-label">Miesiąc</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
<div><label class="form-label">Kwota</label><input data-field="amount" type="number" min="1" class="form-control form-control-sm" value="${amount}"></div>
<div><label class="form-label">Prowizja %</label><input data-field="commission" type="number" min="0" step="0.01" class="form-control form-control-sm" value="${commission}"></div>
<div><label class="form-label">Powtarzaj</label><select data-field="repeat" class="form-select form-select-sm"><option value="once">raz</option><option value="monthly">co miesiąc</option><option value="yearly">co rok</option></select></div>
<div><label class="form-label">Do mies.</label><input data-field="until" type="number" min="1" class="form-control form-control-sm" value="${until}"></div>
<div><label class="form-label">Do mies.</label><input data-field="until" type="number" min="1" class="form-control form-control-sm" value="${until || ''}"></div>
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
div.querySelector('[data-field="repeat"]').value = repeat;
div.querySelector('button').onclick = () => { div.remove(); recalc(); };
@@ -95,21 +118,41 @@ function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until =
recalc();
}
function addHistoricalRow(month = 1, rate = '', grace = 'none', overpayment = 0, commission = 0, note = '') {
const div = document.createElement('div');
div.className = 'dynamic-row historical';
div.innerHTML = `
<div><label class="form-label">Miesiąc</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
<div><label class="form-label">Oproc. %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate ?? ''}" placeholder="auto"></div>
<div><label class="form-label">Karencja</label><select data-field="grace" class="form-select form-select-sm"><option value="none">brak</option><option value="interest_only">tylko odsetki</option><option value="full">pełna</option></select></div>
<div><label class="form-label">Nadpłata</label><input data-field="overpayment" type="number" min="0" class="form-control form-control-sm" value="${overpayment || 0}"></div>
<div><label class="form-label">Prow. %</label><input data-field="commission" type="number" min="0" step="0.01" class="form-control form-control-sm" value="${commission || 0}"></div>
<div><label class="form-label">Opis</label><input data-field="note" type="text" class="form-control form-control-sm" value="${String(note || '').replaceAll('"', '&quot;')}"></div>
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
div.querySelector('[data-field="grace"]').value = grace || 'none';
div.querySelector('button').onclick = () => { div.remove(); recalc(); };
div.querySelectorAll('input,select').forEach(x => x.addEventListener('input', recalc));
$('historicalMonths').appendChild(div);
recalc();
}
function render(data) {
window.lastSimulationData = data;
const s = data.summary;
$('summaryCards').innerHTML = [
['Odsetki', money(s.total_interest)],
['Oszczędność', money(s.interest_saved)],
['Oszczędność netto', money(s.interest_saved)],
['Nadpłaty', money(s.total_overpayment)],
['Prowizje', money(s.total_overpayment_fees)],
['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`],
['Data spłaty', s.payoff_date || '-'],
['Skrócenie', `${s.months_saved} mies.`],
['Średnia rata', money(s.average_payment)]
].map(([label, value]) => `<div class="col-6 col-md-4 col-xxl-2"><div class="card border-0 shadow-sm stat-card"><div class="card-body py-3"><div class="stat-label">${label}</div><div class="stat-value">${value}</div></div></div></div>`).join('');
].map(([label, value]) => `<div class="col-6 col-md-4 col-xxl-3"><div class="card border-0 shadow-sm stat-card"><div class="card-body py-3"><div class="stat-label">${label}</div><div class="stat-value">${value}</div></div></div></div>`).join('');
$('resultText').textContent = `Dzięki nadpłatom w wysokości ${money(s.total_overpayment)} oszczędzasz około ${money(s.interest_saved)} na odsetkach. Kredyt kończy się po ${s.months} miesiącach zamiast ${s.baseline_months}. Łącznie płacisz ${money(s.total_paid)}, z czego odsetki to ${money(s.total_interest)}.`;
$('resultText').textContent = `Dzięki nadpłatom ${money(s.total_overpayment)} oszczędność netto wynosi około ${money(s.interest_saved)} po uwzględnieniu prowizji ${money(s.total_overpayment_fees)}. Kredyt kończy się ${s.payoff_date || '—'} po ${s.months} miesiącach zamiast ${s.baseline_months}. Odsetki: ${money(s.total_interest)}.`;
$('scheduleTable').innerHTML = data.schedule.slice(0, 180).map(r => `<tr><td>${r.month}</td><td>${r.rate.toFixed(2)}%</td><td>${money(r.payment)}</td><td>${money(r.principal_part)}</td><td>${money(r.interest_part)}</td><td>${money(r.overpayment)}</td><td>${money(r.remaining)}</td></tr>`).join('');
$('scheduleTable').innerHTML = data.schedule.slice(0, 240).map(r => `<tr><td>${r.month}</td><td>${r.due_date}</td><td>${r.days}</td><td>${r.rate.toFixed(2)}%</td><td>${money(r.payment)}</td><td>${money(r.principal_part)}</td><td>${money(r.interest_part)}</td><td>${money(r.overpayment)}</td><td>${money(r.overpayment_fee)}</td><td>${money(r.cumulative_cost)}</td><td>${money(r.remaining)}</td></tr>`).join('');
renderCharts(data);
}
@@ -119,6 +162,10 @@ function renderCharts(data) {
const labels = data.schedule.map(r => r.month);
const balance = data.schedule.map(r => r.remaining);
const payment = data.schedule.map(r => r.payment);
const principal = data.schedule.map(r => r.principal_part);
const interest = data.schedule.map(r => r.interest_part);
const cumulativeCost = data.schedule.map(r => r.cumulative_cost);
const overpayments = data.schedule.map(r => r.overpayment);
const yearly = new Map();
data.schedule.forEach(r => {
const year = Math.ceil(r.month / 12);
@@ -135,7 +182,7 @@ function renderCharts(data) {
pieChart?.destroy();
pieChart = new Chart($('pieChart'), {
type: 'pie',
data: { labels: ['Kapitał', 'Odsetki', 'Nadpłaty'], datasets: [{ data: [lastRequest.principal, data.summary.total_interest, data.summary.total_overpayment] }] },
data: { labels: ['Kapitał', 'Odsetki', 'Nadpłaty', 'Prowizje'], datasets: [{ data: [lastRequest.principal, data.summary.total_interest, data.summary.total_overpayment, data.summary.total_overpayment_fees] }] },
options: { responsive: true, maintainAspectRatio: false }
});
@@ -145,6 +192,20 @@ function renderCharts(data) {
data: { labels: [...yearly.keys()].map(x => `Rok ${x}`), datasets: [{ label: 'Odsetki', data: [...yearly.values()] }] },
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
});
detailChart?.destroy();
detailChart = new Chart($('detailChart'), {
data: {
labels,
datasets: [
{ type: 'bar', label: 'Kapitał w racie', data: principal, stack: 'rata', yAxisID: 'y' },
{ type: 'bar', label: 'Odsetki w racie', data: interest, stack: 'rata', yAxisID: 'y' },
{ type: 'bar', label: 'Nadpłata', data: overpayments, stack: 'rata', yAxisID: 'y' },
{ type: 'line', label: 'Koszt narastająco', data: cumulativeCost, yAxisID: 'y1' }
]
},
options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false } } } }
});
}
async function download(endpoint, filename) {
@@ -158,6 +219,51 @@ async function download(endpoint, filename) {
URL.revokeObjectURL(url);
}
function exportJson() {
const blob = new Blob([JSON.stringify(buildRequest(), null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'parametry-kredytu.json';
a.click();
URL.revokeObjectURL(url);
}
function clearRows() {
$('rateChanges').innerHTML = '';
$('overpayments').innerHTML = '';
$('historicalMonths').innerHTML = '';
}
function applyRequest(data) {
$('principal').value = data.principal ?? 600000;
$('years').value = data.years ?? 25;
$('margin').value = data.margin ?? 2;
$('baseRate').value = data.base_rate ?? 5.75;
$('installmentType').value = data.installment_type ?? 'equal';
$('overpaymentEffect').value = data.overpayment_effect ?? 'shorten';
$('loanStartDate').value = data.loan_start_date ?? todayIso();
$('dueDay').value = data.due_day ?? 5;
$('moveDueDate').checked = data.move_due_date_to_business_day ?? true;
clearRows();
(data.rate_changes || []).forEach(x => addRateRow(x.month, x.annual_rate));
(data.overpayments || []).forEach(x => addOverpaymentRow(x.month, x.amount, x.repeat, x.until_month || '', x.commission_percent || 0));
(data.historical_months || []).forEach(x => addHistoricalRow(x.month, x.annual_rate ?? '', x.grace_type || 'none', x.overpayment || 0, x.overpayment_commission_percent || 0, x.note || ''));
recalc();
}
function importJsonFile(file) {
const reader = new FileReader();
reader.onload = () => {
try {
applyRequest(JSON.parse(reader.result));
} catch (e) {
alert(`Nie udało się wczytać JSON: ${e.message}`);
}
};
reader.readAsText(file, 'utf-8');
}
async function loadNbp() {
const btn = $('loadNbp');
const old = btn.textContent;
@@ -176,14 +282,19 @@ async function loadNbp() {
}
}
['principal','years','baseRate','margin','installmentType','overpaymentEffect'].forEach(id => $(id).addEventListener('input', recalc));
['principal','years','baseRate','margin','installmentType','overpaymentEffect','loanStartDate','dueDay','moveDueDate'].forEach(id => $(id).addEventListener('input', recalc));
$('addRate').onclick = () => addRateRow();
$('addOverpayment').onclick = () => addOverpaymentRow();
$('addHistorical').onclick = () => addHistoricalRow();
$('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv');
$('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf');
$('exportJson').onclick = exportJson;
$('importJson').onclick = () => $('jsonFile').click();
$('jsonFile').onchange = (e) => e.target.files?.[0] && importJsonFile(e.target.files[0]);
$('loadNbp').onclick = loadNbp;
$('loanStartDate').value = todayIso();
initTheme();
addOverpaymentRow(24, 20000, 'once', '');
addOverpaymentRow(36, 500, 'monthly', 120);
addOverpaymentRow(24, 20000, 'once', '', 0);
addOverpaymentRow(36, 500, 'monthly', 120, 0);
connectWs();
+36 -1
View File
@@ -18,8 +18,11 @@
<div class="d-flex gap-2 flex-wrap justify-content-end">
<button id="themeToggle" class="btn btn-outline-secondary btn-sm" type="button" aria-label="Przełącz motyw">☀️ Jasny</button>
<button id="loadNbp" class="btn btn-outline-primary btn-sm">Pobierz stopę NBP</button>
<button id="exportJson" class="btn btn-outline-secondary btn-sm">Eksport JSON</button>
<button id="importJson" class="btn btn-outline-secondary btn-sm">Import JSON</button>
<button id="exportCsv" class="btn btn-outline-secondary btn-sm">CSV</button>
<button id="exportPdf" class="btn btn-primary btn-sm">PDF</button>
<input id="jsonFile" type="file" accept="application/json,.json" hidden>
</div>
</div>
</header>
@@ -60,6 +63,20 @@
<option value="lower_payment">Zmniejszenie raty</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Data startu kredytu</label>
<input id="loanStartDate" type="date" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label">Dzień spłaty raty</label>
<input id="dueDay" type="number" min="1" max="28" class="form-control form-control-sm" value="5">
</div>
<div class="col-12">
<label class="form-check small mb-0">
<input id="moveDueDate" class="form-check-input" type="checkbox" checked>
<span class="form-check-label">Przesuwaj spłatę na pierwszy dzień roboczy, jeśli wypada w dzień wolny</span>
</label>
</div>
</div>
<hr>
@@ -75,6 +92,16 @@
<button id="addOverpayment" class="btn btn-sm btn-outline-primary">+</button>
</div>
<div id="overpayments" class="stack"></div>
<hr>
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<h3 class="h6 mb-0">Kredyt historyczny</h3>
<div class="text-muted small">Miesięczne oprocentowanie, karencja i faktyczne nadpłaty</div>
</div>
<button id="addHistorical" class="btn btn-sm btn-outline-primary">+</button>
</div>
<div id="historicalMonths" class="stack"></div>
</div>
</div>
</section>
@@ -106,6 +133,14 @@
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0 chart-card">
<div class="card-body">
<h2 class="h6">Podział raty i koszt narastająco</h2>
<canvas id="detailChart"></canvas>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-body">
@@ -119,7 +154,7 @@
<div class="card-body table-responsive">
<h2 class="h6">Harmonogram</h2>
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Mies.</th><th>Oproc.</th><th>Rata</th><th>Kapitał</th><th>Odsetki</th><th>Nadpłata</th><th>Saldo</th></tr></thead>
<thead><tr><th>Mies.</th><th>Data</th><th>Dni</th><th>Oproc.</th><th>Rata</th><th>Kapitał</th><th>Odsetki</th><th>Nadpłata</th><th>Prow.</th><th>Koszt nar.</th><th>Saldo</th></tr></thead>
<tbody id="scheduleTable"></tbody>
</table>
</div>
+39 -7
View File
@@ -27,13 +27,13 @@
}
body[data-theme="dark"] {
--bg: #05070d;
--bg: #03050a;
--text: #e5e7eb;
--card: #0d1422;
--card: #0b1220;
--muted: #9aa7bb;
--border: #10192a;
--row: #111a2b;
--input-bg: #070b13;
--border: #2a3547;
--row: #101827;
--input-bg: #050914;
--input-text: #e5e7eb;
--table-border: #3b4658;
}
@@ -100,14 +100,15 @@ body {
padding: .55rem;
border-radius: 12px;
}
.dynamic-row.overpay { grid-template-columns: .75fr 1fr .9fr .75fr auto; }
.dynamic-row.overpay { grid-template-columns: .75fr 1fr .75fr .9fr .75fr auto; }
.dynamic-row.historical { grid-template-columns: .65fr .8fr 1fr .9fr .7fr 1.2fr auto; }
.stat-card { border-radius: 16px; }
.stat-value { font-size: 1.15rem; font-weight: 700; }
.table { font-size: .84rem; }
.btn { border-radius: 999px; }
@media (max-width: 768px) {
.dynamic-row, .dynamic-row.overpay { grid-template-columns: 1fr 1fr; }
.dynamic-row, .dynamic-row.overpay, .dynamic-row.historical { grid-template-columns: 1fr 1fr; }
.dynamic-row button { grid-column: span 2; }
}
@@ -115,3 +116,34 @@ body {
body[data-theme="dark"] .card { box-shadow: 0 14px 38px rgba(0, 0, 0, .34) !important; }
body[data-theme="dark"] .btn-outline-secondary { border-color: var(--border); color: var(--text); }
body[data-theme="dark"] .btn-outline-primary { border-color: #52637a; }
.form-check-input { background-color: var(--input-bg); border-color: var(--border); }
body[data-theme="dark"] hr { border-color: var(--border); opacity: 1; }
@media (min-width: 1200px) {
.slim-card {
max-height: calc(100vh - 2rem);
display: flex;
flex-direction: column;
}
.slim-card > .card-body {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
padding-right: 1rem;
}
}
.slim-card > .card-body::-webkit-scrollbar {
width: 6px;
}
.slim-card > .card-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 999px;
}
.slim-card > .card-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.28);
}
+1
View File
@@ -5,3 +5,4 @@ httpx==0.28.1
beautifulsoup4==4.12.3
reportlab==4.2.5
python-multipart==0.0.20
openpyxl>=3.1.5