This commit is contained in:
Mateusz Gruszczyński
2026-06-03 13:02:07 +02:00
parent fdc946989f
commit 7cb2eddafe
8 changed files with 532 additions and 84 deletions
+133 -32
View File
@@ -9,6 +9,7 @@ from datetime import datetime
from pathlib import Path
import httpx
from io import BytesIO
from bs4 import BeautifulSoup
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
@@ -21,6 +22,8 @@ from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
from reportlab.graphics.shapes import Drawing, Line, PolyLine, Rect, String, Wedge
from openpyxl import load_workbook
from .models import SimulationRequest
from .simulator import simulate
@@ -94,39 +97,107 @@ async def ws_simulate(websocket: WebSocket):
except Exception as exc:
await websocket.send_json({"error": str(exc)})
@app.get("/api/rate/nbp")
async def nbp_rate():
"""Pomocniczo pobiera stopę referencyjną NBP z oficjalnej strony HTML."""
url = "https://nbp.pl/en/monetary-policy/mpc-decisions/interest-rates/"
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)
"""
Pobiera średnie oprocentowanie nowych kredytów mieszkaniowych PLN z XLSX NBP.
Uwaga: to nie jest stopa referencyjna NBP, tylko średnie oprocentowanie kredytów.
"""
url = "https://static.nbp.pl/dane/statystyka/inne/stopy_proc_pl_srdW.xlsx"
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
resp = await client.get(
url,
headers={
"User-Agent": "Mozilla/5.0 mortgage-simulator/1.0",
"Accept": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*",
},
)
resp.raise_for_status()
wb = load_workbook(BytesIO(resp.content), data_only=True, read_only=True)
ws = wb["4 OPN2PLN"]
target_row = None
for row in ws.iter_rows():
values = [cell.value for cell in row]
joined = " ".join(str(v).lower() for v in values if v is not None)
if "nieruchomości mieszkaniowe" in joined or "nieruchomosci mieszkaniowe" in joined:
target_row = values
break
if not target_row:
return JSONResponse(
{
"error": "Nie znaleziono wiersza: na nieruchomości mieszkaniowe",
"url": url,
"sheet": "4 OPN2PLN",
},
status_code=502,
)
# Daty są w wierszu 2, wartości w znalezionym wierszu.
dates = [cell.value for cell in ws[2]]
latest_rate = None
latest_date = None
for idx in range(len(target_row) - 1, -1, -1):
value = target_row[idx]
if isinstance(value, (int, float)):
latest_rate = float(value)
latest_date_cell = dates[idx] if idx < len(dates) else None
if isinstance(latest_date_cell, datetime):
latest_date = latest_date_cell.date().isoformat()
elif latest_date_cell:
latest_date = str(latest_date_cell)
break
if latest_rate is None:
return JSONResponse(
{
"error": "Nie znaleziono wartości liczbowej oprocentowania kredytu mieszkaniowego",
"url": url,
"sheet": "4 OPN2PLN",
},
status_code=502,
)
# W XLSX wartości są jako ułamek, np. 0.0591 = 5.91%.
rate_percent = round(latest_rate * 100, 4)
return {
"source": "NBP",
"kind": "average_new_housing_loan_pln_rate",
"label": "Średnie oprocentowanie nowych kredytów mieszkaniowych PLN",
"url": url,
"sheet": "4 OPN2PLN",
"period": latest_date,
"rate": rate_percent,
"fetched_at": datetime.utcnow().isoformat() + "Z",
}
except Exception as exc:
return JSONResponse(
{"error": str(exc), "url": url},
status_code=502,
)
@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"])
writer.writerow(["miesiac", "data_splaty", "dni", "oprocentowanie", "rata", "kapital", "odsetki", "nadplata", "prowizja_nadplaty", "saldo", "karencja", "odsetki_narastajaco", "koszt_narastajaco", "nadplaty_narastajaco"])
for row in result.schedule:
writer.writerow([row.month, row.rate, row.payment, row.principal_part, row.interest_part, row.overpayment, row.remaining])
writer.writerow([row.month, row.due_date, row.days, row.rate, row.payment, row.principal_part, row.interest_part, row.overpayment, row.overpayment_fee, row.remaining, row.grace_type.value, row.cumulative_interest, row.cumulative_cost, row.cumulative_overpayment])
writer.writerow([])
writer.writerow(["Podsumowanie"])
for key, value in result.summary.model_dump().items():
@@ -278,6 +349,8 @@ def export_pdf(req: SimulationRequest):
["Stopa bazowa", _pct(req.base_rate), "Marza", _pct(req.margin)],
["Oprocentowanie startowe", _pct(req.base_rate + req.margin), "Typ rat", _installment_label(req.installment_type.value)],
["Efekt nadplat", _effect_label(req.overpayment_effect.value), "Liczba rat po symulacji", str(result.summary.months)],
["Data startu", req.loan_start_date.isoformat(), "Dzien splaty", str(req.due_day)],
["Przesuwaj dni wolne", "tak" if req.move_due_date_to_business_day else "nie", "Data konca", result.summary.payoff_date or "-"],
]
story.append(Paragraph("Parametry wejściowe", styles["Heading2"]))
story.append(Table(params, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([
@@ -296,16 +369,17 @@ def export_pdf(req: SimulationRequest):
["Liczba rat", s.months, "Bazowo", s.baseline_months],
["Suma zaplacona", _money(s.total_paid), "Suma odsetek", _money(s.total_interest)],
["Oszczednosc na odsetkach", _money(s.interest_saved), "Skrocenie okresu", f"{s.months_saved} mies."],
["Suma nadplat", _money(s.total_overpayment), "Srednia / maks. rata", f"{_money(s.average_payment)} / {_money(s.max_payment)}"],
["Suma nadplat", _money(s.total_overpayment), "Prowizje nadplat", _money(s.total_overpayment_fees)],
["Srednia / maks. rata", f"{_money(s.average_payment)} / {_money(s.max_payment)}", "Data splaty", s.payoff_date or "-"],
]
story.append(Paragraph("Podsumowanie", styles["Heading2"]))
story.append(Table(summary, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([
story.append(Table(summary, colWidths=[4.3 * cm, 4.25 * cm, 3.7 * cm, 4.35 * cm], style=TableStyle([
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#ffffff")),
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
("FONTNAME", (0, 0), (0, -1), APP_FONT_BOLD),
("FONTNAME", (2, 0), (2, -1), APP_FONT_BOLD),
("FONTSIZE", (0, 0), (-1, -1), 7.4),
("FONTSIZE", (0, 0), (-1, -1), 7.0),
("VALIGN", (0, 0), (-1, -1), "TOP"),
])))
story.append(Spacer(1, 0.35 * cm))
@@ -344,34 +418,61 @@ def export_pdf(req: SimulationRequest):
story.append(Spacer(1, 0.35 * cm))
story.append(Paragraph("Nadplaty", styles["Heading2"]))
over_rows = [["Miesiac", "Kwota", "Powtarzanie", "Do miesiaca"]]
over_rows = [["Miesiac", "Kwota", "Prowizja", "Powtarzanie", "Do miesiaca"]]
if req.overpayments:
for op in sorted(req.overpayments, key=lambda x: x.month):
over_rows.append([str(op.month), _money(op.amount), _repeat_label(op.repeat), str(op.until_month or "-")])
over_rows.append([str(op.month), _money(op.amount), _pct(op.commission_percent), _repeat_label(op.repeat), str(op.until_month or "-")])
else:
over_rows.append(["-", "-", "-", "-"])
story.append(Table(over_rows, repeatRows=1, colWidths=[3 * cm, 4 * cm, 4 * cm, 4 * cm], style=TableStyle([
over_rows.append(["-", "-", "-", "-", "-"])
story.append(Table(over_rows, repeatRows=1, colWidths=[2.4 * cm, 3.4 * cm, 2.4 * cm, 3.4 * cm, 3.4 * cm], style=TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
("FONTSIZE", (0, 0), (-1, -1), 8),
])))
story.append(Spacer(1, 0.35 * cm))
story.append(Paragraph("Kredyt historyczny", styles["Heading2"]))
hist_rows = [["Miesiac", "Oprocentowanie", "Karencja", "Nadplata", "Prowizja", "Opis"]]
if req.historical_months:
for item in sorted(req.historical_months, key=lambda x: x.month):
hist_rows.append([
str(item.month),
_pct(item.annual_rate) if item.annual_rate is not None else "-",
item.grace_type.value,
_money(item.overpayment),
_pct(item.overpayment_commission_percent),
item.note or "-",
])
else:
hist_rows.append(["-", "-", "-", "-", "-", "-"])
story.append(Table(hist_rows, repeatRows=1, colWidths=[2 * cm, 2.6 * cm, 2.6 * cm, 2.8 * cm, 2.2 * cm, 4.5 * cm], style=TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
("FONTSIZE", (0, 0), (-1, -1), 7.5),
])))
story.append(PageBreak())
story.append(Paragraph("Kompletny harmonogram wszystkich rat", styles["Heading2"]))
schedule_rows = [["Mies.", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Saldo"]]
schedule_rows = [["Mies.", "Data", "Dni", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Prow.", "Koszt nar.", "Saldo"]]
for row in result.schedule:
schedule_rows.append([
row.month,
row.due_date,
row.days,
_pct(row.rate),
_money(row.payment),
_money(row.principal_part),
_money(row.interest_part),
_money(row.overpayment),
_money(row.overpayment_fee),
_money(row.cumulative_cost),
_money(row.remaining),
])
table = Table(schedule_rows, repeatRows=1, colWidths=[1.35 * cm, 1.7 * cm, 2.55 * cm, 2.55 * cm, 2.55 * cm, 2.55 * cm, 3.0 * cm])
table = Table(schedule_rows, repeatRows=1, colWidths=[0.9 * cm, 1.75 * cm, 0.75 * cm, 1.15 * cm, 1.85 * cm, 1.85 * cm, 1.85 * cm, 1.85 * cm, 1.5 * cm, 2.0 * cm, 2.05 * cm])
table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),