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
+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"},
)