commit 3ab205b76931023fa9c681cf7d7a1c7f560f1d4c Author: Mateusz Gruszczyński Date: Wed Jun 3 12:36:51 2026 +0200 first commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7c3220a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +RUN mkdir -p /app/exports + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f844998 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Symulator kredytu hipotecznego + +Nowoczesna aplikacja one-page do symulacji kredytu hipotecznego: zmienne stopy w czasie, nadpłaty, raty równe/malejące, skrócenie okresu albo obniżenie raty, wykresy, CSV/PDF, WebSocket live. + +## Start + +```bash +docker compose up --build +``` + +Otwórz: + +```text +http://localhost:8080 +``` + +## Funkcje + +- symulacja rat równych i malejących, +- harmonogram zmiennego oprocentowania w konkretnych miesiącach, +- nadpłaty jednorazowe i cykliczne, +- tryb nadpłaty: skrócenie okresu albo zmniejszenie raty, +- live przeliczenia przez WebSocket, +- wykresy liniowe, słupkowe i kołowe, +- opis oszczędności: odsetki, czas, suma nadpłat, +- eksport CSV i PDF, +- pobieranie aktualnej stopy referencyjnej NBP z oficjalnej strony jako pomocnicza wartość bazowa. + +## Uwaga + +To symulator edukacyjny. Wynik może różnić się od harmonogramu banku, bo banki stosują własne zasady zaokrągleń, dat płatności, prowizji i aktualizacji WIBOR/WIRON. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/__pycache__/__init__.cpython-314.pyc b/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..585e762 Binary files /dev/null and b/app/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/__pycache__/main.cpython-314.pyc b/app/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..238d9ec Binary files /dev/null and b/app/__pycache__/main.cpython-314.pyc differ diff --git a/app/__pycache__/models.cpython-314.pyc b/app/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..3935331 Binary files /dev/null and b/app/__pycache__/models.cpython-314.pyc differ diff --git a/app/__pycache__/simulator.cpython-314.pyc b/app/__pycache__/simulator.cpython-314.pyc new file mode 100644 index 0000000..333ce81 Binary files /dev/null and b/app/__pycache__/simulator.cpython-314.pyc differ diff --git a/app/exports b/app/exports new file mode 120000 index 0000000..25b531d --- /dev/null +++ b/app/exports @@ -0,0 +1 @@ +../exports \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0b5aeef --- /dev/null +++ b/app/main.py @@ -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"}, + ) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..75eece0 --- /dev/null +++ b/app/models.py @@ -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 diff --git a/app/simulator.py b/app/simulator.py new file mode 100644 index 0000000..9bf2ca3 --- /dev/null +++ b/app/simulator.py @@ -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) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..3b26ef7 --- /dev/null +++ b/app/static/app.js @@ -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.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.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]) => `
${label}
${value}
`).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 => `${r.month}${r.rate.toFixed(2)}%${money(r.payment)}${money(r.principal_part)}${money(r.interest_part)}${money(r.overpayment)}${money(r.remaining)}`).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(); diff --git a/app/static/fonts/DejaVuSans-Bold.ttf b/app/static/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..0f4d5e9 Binary files /dev/null and b/app/static/fonts/DejaVuSans-Bold.ttf differ diff --git a/app/static/fonts/DejaVuSans.ttf b/app/static/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..27cff47 Binary files /dev/null and b/app/static/fonts/DejaVuSans.ttf differ diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..07e34d1 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,136 @@ + + + + + + Symulator kredytu hipotecznego + + + + +
+
+
+
+

Symulator kredytu hipotecznego

+

@linuiarz.pl

+
+
+ + + + +
+
+
+ +
+
+
+
+

Parametry

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

Zmiany oprocentowania

+ +
+
+ +
+
+

Nadpłaty

+ +
+
+
+
+
+ +
+
+
+
+
+
+

Saldo i rata w czasie

+ +
+
+
+
+
+
+

Struktura kosztów

+ +
+
+
+
+
+
+

Odsetki rocznie

+ +
+
+
+
+
+
+

Opis wyniku

+

+
+
+
+
+
+
+

Harmonogram

+ + + +
Mies.Oproc.RataKapitałOdsetkiNadpłataSaldo
+
+
+
+
+
+
+
+ + + + + diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..060ff0d --- /dev/null +++ b/app/static/styles.css @@ -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; } diff --git a/dev.py b/dev.py new file mode 100644 index 0000000..01731da --- /dev/null +++ b/dev.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent +REQUIREMENTS = ROOT_DIR / "requirements.txt" +EXPORT_DIR = ROOT_DIR / "exports" + + +def install_requirements() -> None: + if not REQUIREMENTS.exists(): + raise SystemExit("Brak pliku requirements.txt") + subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", str(REQUIREMENTS)]) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Lokalne uruchamianie symulatora kredytu bez Dockera.") + parser.add_argument("--host", default="127.0.0.1", help="Adres hosta, domyślnie 127.0.0.1") + parser.add_argument("--port", type=int, default=8047, help="Port, domyślnie 8047") + parser.add_argument("--no-reload", action="store_true", help="Wyłącz auto-reload podczas developmentu") + parser.add_argument("--install", action="store_true", help="Zainstaluj zależności z requirements.txt przed startem") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + os.chdir(ROOT_DIR) + EXPORT_DIR.mkdir(parents=True, exist_ok=True) + os.environ.setdefault("MORTGAGE_EXPORT_DIR", str(EXPORT_DIR)) + + if args.install: + install_requirements() + + try: + import uvicorn + except ModuleNotFoundError: + print("Brak zależności. Uruchom:") + print(" python dev.py --install") + print("albo:") + print(" python -m pip install -r requirements.txt") + raise SystemExit(1) + + print(f"Start: http://{args.host}:{args.port}") + uvicorn.run( + "app.main:app", + host=args.host, + port=args.port, + reload=not args.no_reload, + app_dir=str(ROOT_DIR), + ) + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..140fb35 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + mortgage-simulator: + build: . + container_name: mortgage-simulator + ports: + - "8147:8000" + volumes: + - ./exports:/app/exports + restart: unless-stopped diff --git a/gitignore b/gitignore new file mode 100644 index 0000000..2acb5bc --- /dev/null +++ b/gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo + +# Virtualenv +venv/ +.env +.venv + +# App data +*.log + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Tests / cache +.pytest_cache/ +.mypy_cache/ + +# Build +dist/ +build/ +*.egg-info/ + +exports/* +*.zip diff --git a/make_zip.py b/make_zip.py new file mode 100644 index 0000000..3b6bf1d --- /dev/null +++ b/make_zip.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +import os +import sys +import zipfile +import subprocess +from pathlib import Path + + +def run_git_command(args, repo_path: Path) -> bytes: + result = subprocess.run( + ["git", *args], + cwd=repo_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + return result.stdout + + +def get_files_to_archive(repo_path: Path) -> list[str]: + output = run_git_command( + ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + repo_path, + ) + files = output.decode("utf-8", errors="surrogateescape").split("\0") + return [f for f in files if f] + + +def make_zip(repo_path: Path, output_zip: Path) -> None: + files = get_files_to_archive(repo_path) + + output_zip = output_zip.resolve() + if output_zip.exists(): + output_zip.unlink() + + with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for rel_path in files: + abs_path = repo_path / rel_path + + if not abs_path.exists(): + continue + + if abs_path.resolve() == output_zip: + continue + + zf.write(abs_path, arcname=rel_path) + + print(f"Utworzono archiwum: {output_zip}") + print(f"Added files: {len(files)}") + + +def main(): + repo_path = Path.cwd() + + if len(sys.argv) > 1: + output_zip = Path(sys.argv[1]) + else: + output_zip = repo_path / f"{repo_path.name}.zip" + + try: + run_git_command(["rev-parse", "--show-toplevel"], repo_path) + except subprocess.CalledProcessError: + print("Error: this directory is not a Git repository.", file=sys.stderr) + sys.exit(1) + + make_zip(repo_path, output_zip) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mortgage-simulator.zip b/mortgage-simulator.zip new file mode 100644 index 0000000..a4dc584 Binary files /dev/null and b/mortgage-simulator.zip differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..77108f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic>=2.10.4 +httpx==0.28.1 +beautifulsoup4==4.12.3 +reportlab==4.2.5 +python-multipart==0.0.20