first commit

This commit is contained in:
Mateusz Gruszczyński
2026-06-03 12:36:51 +02:00
commit 3ab205b769
22 changed files with 1261 additions and 0 deletions
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
../exports
+396
View File
@@ -0,0 +1,396 @@
from __future__ import annotations
import csv
import io
import math
import os
import re
from datetime import datetime
from pathlib import Path
import httpx
from bs4 import BeautifulSoup
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
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 .models import SimulationRequest
from .simulator import simulate
BASE_DIR = Path(__file__).resolve().parent
EXPORT_DIR = Path(os.environ.get("MORTGAGE_EXPORT_DIR", "./app/exports"))
EXPORT_DIR.mkdir(exist_ok=True)
FONT_DIR = BASE_DIR / "static" / "fonts"
FONT_PATH = FONT_DIR / "DejaVuSans.ttf"
FONT_BOLD_PATH = FONT_DIR / "DejaVuSans-Bold.ttf"
FONT_OBLIQUE_PATH = FONT_DIR / "DejaVuSans-Oblique.ttf"
if FONT_PATH.exists():
pdfmetrics.registerFont(TTFont("AppFont", str(FONT_PATH)))
APP_FONT = "AppFont"
if FONT_BOLD_PATH.exists():
pdfmetrics.registerFont(TTFont("AppFont-Bold", str(FONT_BOLD_PATH)))
APP_FONT_BOLD = "AppFont-Bold"
else:
APP_FONT_BOLD = APP_FONT
if FONT_OBLIQUE_PATH.exists():
pdfmetrics.registerFont(TTFont("AppFont-Oblique", str(FONT_OBLIQUE_PATH)))
APP_FONT_OBLIQUE = "AppFont-Oblique"
else:
APP_FONT_OBLIQUE = APP_FONT
else:
APP_FONT = "Helvetica"
APP_FONT_BOLD = "Helvetica-Bold"
APP_FONT_OBLIQUE = "Helvetica-Oblique"
app = FastAPI(title="Symulator kredytu hipotecznego", version="1.0.0")
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
@app.middleware("http")
async def no_cache_headers(request, call_next):
response = await call_next(request)
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
return response
@app.get("/favicon.ico")
def favicon() -> Response:
return Response(status_code=204)
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return (BASE_DIR / "static" / "index.html").read_text(encoding="utf-8")
@app.post("/api/simulate")
def api_simulate(req: SimulationRequest):
return simulate(req)
@app.websocket("/ws/simulate")
async def ws_simulate(websocket: WebSocket):
await websocket.accept()
try:
while True:
data = await websocket.receive_json()
req = SimulationRequest.model_validate(data)
result = simulate(req)
await websocket.send_json(result.model_dump())
except WebSocketDisconnect:
return
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/"
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
resp = await client.get(url, headers={"User-Agent": "mortgage-simulator/1.0"})
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)
except Exception as exc:
return JSONResponse({"error": str(exc), "url": url}, status_code=502)
@app.post("/api/export/csv")
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"])
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([])
writer.writerow(["Podsumowanie"])
for key, value in result.summary.model_dump().items():
writer.writerow([key, value])
data = buf.getvalue().encode("utf-8-sig")
return StreamingResponse(
io.BytesIO(data),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=symulacja-kredytu.csv"},
)
def _money(value: float) -> str:
return f"{value:,.2f} PLN".replace(",", " ")
def _pct(value: float) -> str:
return f"{value:.2f}%"
def _repeat_label(value: str) -> str:
return {"once": "jednorazowo", "monthly": "co miesiac", "yearly": "co rok"}.get(value, value)
def _effect_label(value: str) -> str:
return {"shorten": "skrocenie okresu", "lower_payment": "zmniejszenie raty"}.get(value, value)
def _installment_label(value: str) -> str:
return {"equal": "rowne", "decreasing": "malejace"}.get(value, value)
def _add_page_number(canvas_obj, doc):
canvas_obj.saveState()
canvas_obj.setFont(APP_FONT, 7)
canvas_obj.setFillColor(colors.HexColor("#6b7280"))
canvas_obj.drawString(1.4 * cm, 0.9 * cm, "Symulator edukacyjny - wyniki orientacyjne")
canvas_obj.drawRightString(A4[0] - 1.4 * cm, 0.9 * cm, f"Strona {doc.page}")
canvas_obj.restoreState()
def _line_chart(rows, value_attr: str, title: str, width=480, height=165) -> Drawing:
drawing = Drawing(width, height)
margin_l, margin_r, margin_b, margin_t = 42, 12, 24, 24
plot_w = width - margin_l - margin_r
plot_h = height - margin_t - margin_b
x0, y0 = margin_l, margin_b
values = [float(getattr(r, value_attr)) for r in rows]
if not values:
values = [0]
max_v = max(values) or 1
count = max(len(values) - 1, 1)
drawing.add(String(0, height - 12, title, fontName=APP_FONT_BOLD, fontSize=9, fillColor=colors.HexColor("#111827")))
drawing.add(Line(x0, y0, x0, y0 + plot_h, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7))
drawing.add(Line(x0, y0, x0 + plot_w, y0, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7))
for i in range(1, 5):
gy = y0 + plot_h * i / 4
drawing.add(Line(x0, gy, x0 + plot_w, gy, strokeColor=colors.HexColor("#e5e7eb"), strokeWidth=0.4))
label = _money(max_v * i / 4).replace(" PLN", "")
drawing.add(String(2, gy - 3, label, fontName=APP_FONT, fontSize=6, fillColor=colors.HexColor("#6b7280")))
points = []
step = max(1, math.ceil(len(values) / 420))
sampled = values[::step]
sampled_count = max(len(sampled) - 1, 1)
for idx, value in enumerate(sampled):
x = x0 + plot_w * idx / sampled_count
y = y0 + plot_h * (value / max_v)
points.extend([x, y])
drawing.add(PolyLine(points, strokeColor=colors.HexColor("#2563eb"), strokeWidth=1.4))
drawing.add(String(x0, 6, "1", fontName=APP_FONT, fontSize=6, fillColor=colors.HexColor("#6b7280")))
drawing.add(String(x0 + plot_w - 18, 6, str(len(values)), fontName=APP_FONT, fontSize=6, fillColor=colors.HexColor("#6b7280")))
return drawing
def _bar_chart(yearly: dict[int, float], title: str, width=480, height=165) -> Drawing:
drawing = Drawing(width, height)
margin_l, margin_r, margin_b, margin_t = 42, 12, 24, 24
plot_w = width - margin_l - margin_r
plot_h = height - margin_t - margin_b
x0, y0 = margin_l, margin_b
items = list(yearly.items())[:35]
max_v = max([v for _, v in items], default=1) or 1
drawing.add(String(0, height - 12, title, fontName=APP_FONT_BOLD, fontSize=9, fillColor=colors.HexColor("#111827")))
drawing.add(Line(x0, y0, x0, y0 + plot_h, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7))
drawing.add(Line(x0, y0, x0 + plot_w, y0, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7))
for i in range(1, 5):
gy = y0 + plot_h * i / 4
drawing.add(Line(x0, gy, x0 + plot_w, gy, strokeColor=colors.HexColor("#e5e7eb"), strokeWidth=0.4))
if items:
gap = 2
bar_w = max(3, (plot_w / len(items)) - gap)
for idx, (year, value) in enumerate(items):
bh = plot_h * value / max_v
x = x0 + idx * (plot_w / len(items)) + gap / 2
drawing.add(Rect(x, y0, bar_w, bh, fillColor=colors.HexColor("#64748b"), strokeColor=None))
if len(items) <= 25 or idx % 2 == 0:
drawing.add(String(x, 6, str(year), fontName=APP_FONT, fontSize=5.5, fillColor=colors.HexColor("#6b7280")))
return drawing
def _pie_chart(parts: list[tuple[str, float, str]], title: str, width=240, height=165) -> Drawing:
drawing = Drawing(width, height)
drawing.add(String(0, height - 12, title, fontName=APP_FONT_BOLD, fontSize=9, fillColor=colors.HexColor("#111827")))
total = sum(v for _, v, _ in parts) or 1
cx, cy, r = 65, 78, 45
start = 90
for name, value, color in parts:
extent = 360 * value / total
drawing.add(Wedge(cx, cy, r, start, start + extent, fillColor=colors.HexColor(color), strokeColor=colors.white, strokeWidth=0.5))
start += extent
ly = height - 38
for name, value, color in parts:
drawing.add(Rect(130, ly - 2, 7, 7, fillColor=colors.HexColor(color), strokeColor=None))
pct = value / total * 100
drawing.add(String(142, ly, f"{name}: {pct:.1f}%", fontName=APP_FONT, fontSize=7, fillColor=colors.HexColor("#111827")))
ly -= 14
return drawing
@app.post("/api/export/pdf")
def export_pdf(req: SimulationRequest):
result = simulate(req)
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=1.2 * cm,
leftMargin=1.2 * cm,
topMargin=1.3 * cm,
bottomMargin=1.4 * cm,
title="Symulacja kredytu hipotecznego",
)
styles = getSampleStyleSheet()
for style in styles.byName.values():
style.fontName = APP_FONT
styles["Title"].fontName = APP_FONT_BOLD
styles["Heading2"].fontName = APP_FONT_BOLD
styles.add(ParagraphStyle(name="Small", parent=styles["Normal"], fontSize=8, leading=10, fontName=APP_FONT))
styles.add(ParagraphStyle(name="Tiny", parent=styles["Normal"], fontSize=6.7, leading=8, fontName=APP_FONT))
story = []
story.append(Paragraph("Symulacja kredytu hipotecznego - pelny raport", styles["Title"]))
story.append(Paragraph(f"Wygenerowano: {datetime.now().strftime('%Y-%m-%d %H:%M')}", styles["Small"]))
story.append(Spacer(1, 0.25 * cm))
params = [
["Kwota kredytu", _money(req.principal), "Okres", f"{req.years} lat"],
["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)],
]
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([
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#f8fafc")),
("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),
("VALIGN", (0, 0), (-1, -1), "TOP"),
])))
story.append(Spacer(1, 0.35 * cm))
s = result.summary
summary = [
["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)}"],
]
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([
("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),
("VALIGN", (0, 0), (-1, -1), "TOP"),
])))
story.append(Spacer(1, 0.35 * cm))
story.append(Paragraph("Wykresy", styles["Heading2"]))
yearly = {}
for row in result.schedule:
year = math.ceil(row.month / 12)
yearly[year] = yearly.get(year, 0.0) + row.interest_part
story.append(_line_chart(result.schedule, "remaining", "Saldo kredytu w czasie"))
story.append(Spacer(1, 0.15 * cm))
story.append(_line_chart(result.schedule, "payment", "Rata w czasie"))
story.append(Spacer(1, 0.15 * cm))
story.append(Table([[
_bar_chart(yearly, "Odsetki rocznie", width=285, height=150),
_pie_chart([
("Kapital", req.principal, "#2563eb"),
("Odsetki", s.total_interest, "#64748b"),
("Nadplaty", s.total_overpayment, "#16a34a"),
], "Struktura kosztow", width=220, height=150),
]], colWidths=[9.6 * cm, 7.3 * cm]))
story.append(PageBreak())
story.append(Paragraph("Zmienne oprocentowanie", styles["Heading2"]))
rate_rows = [["Od miesiaca", "Oprocentowanie roczne"]]
rate_rows.append(["1", _pct(req.base_rate + req.margin)])
for change in sorted(req.rate_changes, key=lambda x: x.month):
rate_rows.append([str(change.month), _pct(change.annual_rate)])
story.append(Table(rate_rows, repeatRows=1, colWidths=[4 * cm, 6 * 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("Nadplaty", styles["Heading2"]))
over_rows = [["Miesiac", "Kwota", "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 "-")])
else:
over_rows.append(["-", "-", "-", "-"])
story.append(Table(over_rows, repeatRows=1, colWidths=[3 * cm, 4 * cm, 4 * cm, 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(PageBreak())
story.append(Paragraph("Kompletny harmonogram wszystkich rat", styles["Heading2"]))
schedule_rows = [["Mies.", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Saldo"]]
for row in result.schedule:
schedule_rows.append([
row.month,
_pct(row.rate),
_money(row.payment),
_money(row.principal_part),
_money(row.interest_part),
_money(row.overpayment),
_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.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
("GRID", (0, 0), (-1, -1), 0.2, colors.HexColor("#cbd5e1")),
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
("FONTSIZE", (0, 0), (-1, -1), 6.5),
("LEADING", (0, 0), (-1, -1), 7.5),
("ALIGN", (0, 1), (-1, -1), "RIGHT"),
("ALIGN", (0, 0), (-1, 0), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f8fafc")]),
]))
story.append(table)
doc.build(story, onFirstPage=_add_page_number, onLaterPages=_add_page_number)
buffer.seek(0)
return Response(
buffer.read(),
media_type="application/pdf",
headers={"Content-Disposition": "attachment; filename=symulacja-kredytu.pdf"},
)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field
class InstallmentType(str, Enum):
equal = "equal"
decreasing = "decreasing"
class OverpaymentEffect(str, Enum):
shorten = "shorten"
lower_payment = "lower_payment"
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")
class Overpayment(BaseModel):
month: int = Field(ge=1)
amount: float = Field(gt=0)
repeat: Literal["once", "monthly", "yearly"] = "once"
until_month: int | None = Field(default=None, ge=1)
class SimulationRequest(BaseModel):
principal: float = Field(gt=0)
years: int = Field(ge=1, le=50)
margin: float = Field(ge=0, le=20, default=2.0)
base_rate: float = Field(ge=0, le=30, default=5.75)
installment_type: InstallmentType = InstallmentType.equal
overpayment_effect: OverpaymentEffect = OverpaymentEffect.shorten
rate_changes: list[RateChange] = Field(default_factory=list)
overpayments: list[Overpayment] = Field(default_factory=list)
class ScheduleRow(BaseModel):
month: int
rate: float
payment: float
principal_part: float
interest_part: float
overpayment: float
remaining: float
class Summary(BaseModel):
months: int
total_paid: float
total_interest: float
total_overpayment: float
interest_saved: float
months_saved: int
baseline_interest: float
baseline_months: int
average_payment: float
max_payment: float
class SimulationResponse(BaseModel):
schedule: list[ScheduleRow]
summary: Summary
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
from math import pow
from .models import SimulationRequest, ScheduleRow, Summary, SimulationResponse, InstallmentType, OverpaymentEffect
def _round_money(value: float) -> float:
return round(max(value, 0.0) + 1e-9, 2)
def _rate_for_month(req: SimulationRequest, month: int) -> float:
changes = sorted(req.rate_changes, key=lambda x: x.month)
rate = req.base_rate + req.margin
for change in changes:
if month >= change.month:
rate = change.annual_rate
else:
break
return rate
def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> float:
if months_left <= 0:
return balance
if monthly_rate == 0:
return balance / months_left
factor = pow(1 + monthly_rate, months_left)
return balance * monthly_rate * factor / (factor - 1)
def _overpayment_for_month(req: SimulationRequest, month: int) -> float:
total = 0.0
for op in req.overpayments:
until = op.until_month or month
if op.repeat == "once" and month == op.month:
total += op.amount
elif op.repeat == "monthly" and month >= op.month and month <= until:
total += op.amount
elif op.repeat == "yearly" and month >= op.month and month <= until and (month - op.month) % 12 == 0:
total += op.amount
return total
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
month = 1
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
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)
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
payment = principal_base + interest
principal_part = principal_base
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)
balance -= overpayment
rows.append(ScheduleRow(
month=month,
rate=round(annual_rate, 4),
payment=_round_money(payment),
principal_part=_round_money(principal_part),
interest_part=_round_money(interest),
overpayment=_round_money(overpayment),
remaining=_round_money(balance),
))
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
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 = _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)
baseline_interest = sum(r.interest_part for r in baseline)
payments = [r.payment for r in actual]
summary = Summary(
months=len(actual),
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),
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,
)
return SimulationResponse(schedule=actual, summary=summary)
+189
View File
@@ -0,0 +1,189 @@
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 socket;
let debounceTimer;
let lastRequest = null;
window.lastSimulationData = null;
function setTheme(theme) {
document.body.dataset.theme = theme;
localStorage.setItem('mortgage-theme', theme);
const btn = $('themeToggle');
if (btn) btn.textContent = theme === 'dark' ? '☀️ Jasny' : '🌙 Ciemny';
}
function initTheme() {
const saved = localStorage.getItem('mortgage-theme') || 'dark';
setTheme(saved);
$('themeToggle').onclick = () => setTheme(document.body.dataset.theme === 'dark' ? 'light' : 'dark');
}
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${proto}://${location.host}/ws/simulate`);
socket.onopen = () => recalc();
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.error) return console.error(data.error);
render(data);
};
socket.onclose = () => setTimeout(connectWs, 1200);
}
function buildRequest() {
const rateChanges = [...document.querySelectorAll('#rateChanges .dynamic-row')].map(row => ({
month: Number(row.querySelector('[data-field="month"]').value || 1),
annual_rate: Number(row.querySelector('[data-field="rate"]').value || 0)
})).filter(x => x.month > 0 && x.annual_rate >= 0);
const overpayments = [...document.querySelectorAll('#overpayments .dynamic-row')].map(row => ({
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
})).filter(x => x.month > 0 && x.amount > 0);
return {
principal: num('principal'),
years: num('years'),
margin: num('margin'),
base_rate: num('baseRate'),
installment_type: $('installmentType').value,
overpayment_effect: $('overpaymentEffect').value,
rate_changes: rateChanges,
overpayments: overpayments
};
}
function recalc() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
lastRequest = buildRequest();
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(lastRequest));
}, 120);
}
function addRateRow(month = 13, rate = 7.0) {
const div = document.createElement('div');
div.className = 'dynamic-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>
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
div.querySelector('button').onclick = () => { div.remove(); recalc(); };
div.querySelectorAll('input').forEach(x => x.addEventListener('input', recalc));
$('rateChanges').appendChild(div);
recalc();
}
function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '') {
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">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>
<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(); };
div.querySelectorAll('input,select').forEach(x => x.addEventListener('input', recalc));
$('overpayments').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)],
['Nadpłaty', money(s.total_overpayment)],
['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`],
['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('');
$('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)}.`;
$('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('');
renderCharts(data);
}
function renderCharts(data) {
if (!data) return;
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 yearly = new Map();
data.schedule.forEach(r => {
const year = Math.ceil(r.month / 12);
yearly.set(year, (yearly.get(year) || 0) + r.interest_part);
});
lineChart?.destroy();
lineChart = new Chart($('lineChart'), {
type: 'line',
data: { labels, datasets: [{ label: 'Saldo', data: balance, yAxisID: 'y' }, { label: 'Rata', data: payment, yAxisID: 'y1' }] },
options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { y: { beginAtZero: true }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false } } } }
});
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] }] },
options: { responsive: true, maintainAspectRatio: false }
});
barChart?.destroy();
barChart = new Chart($('barChart'), {
type: 'bar',
data: { labels: [...yearly.keys()].map(x => `Rok ${x}`), datasets: [{ label: 'Odsetki', data: [...yearly.values()] }] },
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
});
}
async function download(endpoint, filename) {
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildRequest()) });
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function loadNbp() {
const btn = $('loadNbp');
const old = btn.textContent;
btn.textContent = 'Pobieram...';
try {
const res = await fetch('/api/rate/nbp');
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Błąd pobierania');
$('baseRate').value = data.rate;
recalc();
btn.textContent = `NBP: ${data.rate}%`;
setTimeout(() => btn.textContent = old, 2500);
} catch (e) {
alert(`Nie udało się pobrać stopy NBP: ${e.message}`);
btn.textContent = old;
}
}
['principal','years','baseRate','margin','installmentType','overpaymentEffect'].forEach(id => $(id).addEventListener('input', recalc));
$('addRate').onclick = () => addRateRow();
$('addOverpayment').onclick = () => addOverpaymentRow();
$('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv');
$('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf');
$('loadNbp').onclick = loadNbp;
initTheme();
addOverpaymentRow(24, 20000, 'once', '');
addOverpaymentRow(36, 500, 'monthly', 120);
connectWs();
Binary file not shown.
Binary file not shown.
+136
View File
@@ -0,0 +1,136 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Symulator kredytu hipotecznego</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/styles.css" rel="stylesheet">
</head>
<body data-theme="dark">
<main class="container-fluid app-shell py-3">
<header class="hero card border-0 shadow-sm mb-3">
<div class="card-body d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<h1 class="h3 mb-1">Symulator kredytu hipotecznego</h1>
<p class="text-muted mb-0">@linuiarz.pl</p>
</div>
<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="exportCsv" class="btn btn-outline-secondary btn-sm">CSV</button>
<button id="exportPdf" class="btn btn-primary btn-sm">PDF</button>
</div>
</div>
</header>
<div class="row g-3">
<section class="col-12 col-xl-4">
<div class="card shadow-sm border-0 sticky-xl-top slim-card">
<div class="card-body">
<h2 class="h5 mb-3">Parametry</h2>
<div class="row g-2">
<div class="col-6">
<label class="form-label">Kwota kredytu</label>
<input id="principal" type="number" class="form-control form-control-sm" value="600000" min="1">
</div>
<div class="col-6">
<label class="form-label">Okres lat</label>
<input id="years" type="number" class="form-control form-control-sm" value="25" min="1" max="50">
</div>
<div class="col-6">
<label class="form-label">Stopa bazowa %</label>
<input id="baseRate" type="number" step="0.01" class="form-control form-control-sm" value="5.75">
</div>
<div class="col-6">
<label class="form-label">Marża %</label>
<input id="margin" type="number" step="0.01" class="form-control form-control-sm" value="2.00">
</div>
<div class="col-6">
<label class="form-label">Typ rat</label>
<select id="installmentType" class="form-select form-select-sm">
<option value="equal">Równe</option>
<option value="decreasing">Malejące</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Efekt nadpłat</label>
<select id="overpaymentEffect" class="form-select form-select-sm">
<option value="shorten">Skrócenie okresu</option>
<option value="lower_payment">Zmniejszenie raty</option>
</select>
</div>
</div>
<hr>
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h6 mb-0">Zmiany oprocentowania</h3>
<button id="addRate" class="btn btn-sm btn-outline-primary">+</button>
</div>
<div id="rateChanges" class="stack"></div>
<hr>
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h6 mb-0">Nadpłaty</h3>
<button id="addOverpayment" class="btn btn-sm btn-outline-primary">+</button>
</div>
<div id="overpayments" class="stack"></div>
</div>
</div>
</section>
<section class="col-12 col-xl-8">
<div class="row g-3 mb-3" id="summaryCards"></div>
<div class="row g-3">
<div class="col-12 col-lg-8">
<div class="card shadow-sm border-0 chart-card">
<div class="card-body">
<h2 class="h6">Saldo i rata w czasie</h2>
<canvas id="lineChart"></canvas>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card shadow-sm border-0 chart-card">
<div class="card-body">
<h2 class="h6">Struktura kosztów</h2>
<canvas id="pieChart"></canvas>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0 chart-card-sm">
<div class="card-body">
<h2 class="h6">Odsetki rocznie</h2>
<canvas id="barChart"></canvas>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-body">
<h2 class="h6">Opis wyniku</h2>
<p id="resultText" class="mb-0 text-muted"></p>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0">
<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>
<tbody id="scheduleTable"></tbody>
</table>
</div>
</div>
</div>
</div>
</section>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
@font-face {
font-family: "AppSans";
src: url("/static/fonts/DejaVuSans.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "AppSans";
src: url("/static/fonts/DejaVuSans-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root {
--bg: #f4f7fb;
--text: #1e2a36;
--card: #ffffff;
--muted: #6c7684;
--border: #edf1f7;
--row: #f8fafc;
--input-bg: #ffffff;
--input-text: #1e2a36;
--table-border: #e7edf5;
}
body[data-theme="dark"] {
--bg: #05070d;
--text: #e5e7eb;
--card: #0d1422;
--muted: #9aa7bb;
--border: #10192a;
--row: #111a2b;
--input-bg: #070b13;
--input-text: #e5e7eb;
--table-border: #3b4658;
}
body {
font-family: "AppSans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.card,
.hero,
.stat-card {
background: var(--card);
color: var(--text);
border: 1px solid var(--border) !important;
}
.text-muted,
.form-label,
.stat-label,
#resultText {
color: var(--muted) !important;
}
.form-control,
.form-select {
background-color: var(--input-bg);
color: var(--input-text);
border-color: var(--border);
}
.form-control:focus,
.form-select:focus {
background-color: var(--input-bg);
color: var(--input-text);
}
.form-control::placeholder { color: var(--muted); }
.table {
color: var(--text);
--bs-table-color: var(--text);
--bs-table-bg: transparent;
--bs-table-border-color: var(--table-border);
}
.app-shell { max-width: 1680px; }
.hero { border-radius: 20px; }
.slim-card { top: 1rem; border-radius: 18px; }
.chart-card canvas { max-height: 330px; }
.chart-card-sm canvas { max-height: 230px; }
.form-label { font-size: .78rem; margin-bottom: .2rem; }
.form-control, .form-select { border-radius: 10px; }
.stack { display: grid; gap: .45rem; }
.dynamic-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: .4rem;
align-items: end;
background: var(--row);
border: 1px solid var(--border);
padding: .55rem;
border-radius: 12px;
}
.dynamic-row.overpay { grid-template-columns: .75fr 1fr .9fr .75fr 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 button { grid-column: span 2; }
}
.card { box-shadow: 0 12px 34px rgba(0, 0, 0, .16) !important; }
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; }