193 lines
7.8 KiB
Python
193 lines
7.8 KiB
Python
"""
|
||
vpo.py — El precio máximo de venta de una vivienda protegida, por metros.
|
||
|
||
En una vivienda protegida el precio de venta está topado: cada año se publica
|
||
un módulo en euros por metro cuadrado útil, y el precio máximo es
|
||
|
||
Σ m² útiles de cada pieza × lo que computa × coeficiente × módulo
|
||
|
||
con un coeficiente para la vivienda y el garaje vinculado y otro para el
|
||
trastero y los demás anejos, según el régimen (en Navarra, para una VPO de
|
||
régimen general, 1,30 y 0,52 veces el módulo sin ponderar). En segundas y
|
||
posteriores ventas vale el módulo del día de la venta, así que el tope sube
|
||
(o baja) cada 1 de enero.
|
||
|
||
Ficha (activo["inmueble"]) de una vivienda protegida:
|
||
|
||
protegida True
|
||
tope «metros» (lo de arriba) o «fijo» (una cifra: precio_maximo)
|
||
piezas [{nombre, m2, clase («vivienda» o «anejo»), computa (%)}]
|
||
regimen vpo, vpo_especial, vpt u otro (con coef_vivienda y coef_anejo)
|
||
modulos {«AAAA»: €/m²} los que apunta el usuario; mandan sobre los de
|
||
serie_vpo.json (que trae los oficiales de Navarra)
|
||
|
||
El módulo de un año sin dato (porque aún no se ha publicado o porque falta en
|
||
la tabla) se estima con el último conocido movido con el IPC general del INE
|
||
(de diciembre a diciembre) y se marca como estimado.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
import json
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
|
||
SERIE = Path(__file__).resolve().parent / "serie_vpo.json"
|
||
FICHERO_IPC = "serie_ipc.json" # junto a los ajustes, el IPC que se descarga al actualizar precios
|
||
URL_IPC = "https://servicios.ine.es/wstempus/js/ES/DATOS_SERIE/IPC290751?nult=300"
|
||
FUENTE = "VPO" # los valores que apunta la cartera: el precio máximo de cada año
|
||
CLASES_PIEZA = {"vivienda": "Vivienda o garaje vinculado", "anejo": "Trastero u otro anejo"}
|
||
PIEZAS_INICIALES = [
|
||
{"nombre": "Vivienda", "clase": "vivienda", "computa": 100},
|
||
{"nombre": "Terraza", "clase": "vivienda", "computa": 50},
|
||
{"nombre": "Garaje", "clase": "vivienda", "computa": 100},
|
||
{"nombre": "Trastero", "clase": "anejo", "computa": 100},
|
||
]
|
||
|
||
|
||
@lru_cache(maxsize=4)
|
||
def _leer(ruta: str, _mtime: float) -> dict:
|
||
return json.loads(Path(ruta).read_text(encoding="utf-8"))
|
||
|
||
|
||
def serie(carpeta: Path | None = None) -> dict:
|
||
"""Módulos, regímenes e IPC; con el IPC descargado si es más reciente."""
|
||
s = _leer(str(SERIE), SERIE.stat().st_mtime)
|
||
if carpeta is not None:
|
||
otra = Path(carpeta) / FICHERO_IPC
|
||
try:
|
||
ipc = _leer(str(otra), otra.stat().st_mtime)
|
||
if ipc["datos"] and ipc["datos"][-1][0] > s["ipc"]["datos"][-1][0]:
|
||
s = {**s, "ipc": ipc}
|
||
except (OSError, ValueError, KeyError, IndexError, TypeError):
|
||
pass
|
||
return s
|
||
|
||
|
||
def regimenes(s: dict | None = None) -> dict:
|
||
return (s or serie())["regimenes"]
|
||
|
||
|
||
def es_por_metros(f: dict) -> bool:
|
||
return bool(f.get("protegida")) and f.get("tope") == "metros"
|
||
|
||
|
||
def coeficientes(f: dict, s: dict | None = None) -> tuple[float, float]:
|
||
"""(vivienda, anejo) del régimen de la ficha."""
|
||
r = regimenes(s).get(f.get("regimen") or "vpo") or regimenes(s)["vpo"]
|
||
if r["vivienda"] is None:
|
||
return _f(f.get("coef_vivienda")), _f(f.get("coef_anejo"))
|
||
return r["vivienda"], r["anejo"]
|
||
|
||
|
||
def _diciembre(ipc: list, año: int) -> float | None:
|
||
clave = f"{año}-12"
|
||
return next((v for m, v in ipc if m == clave), None)
|
||
|
||
|
||
def _factor_ipc(ipc: list, año: int) -> float:
|
||
"""Lo que subió el IPC el año anterior a «año» (diciembre sobre diciembre).
|
||
Si aún no está el de diciembre, los últimos doce meses publicados."""
|
||
a, b = _diciembre(ipc, año - 1), _diciembre(ipc, año - 2)
|
||
if a and b:
|
||
return a / b
|
||
if len(ipc) >= 13 and ipc[-13][1]:
|
||
return ipc[-1][1] / ipc[-13][1]
|
||
return 1.0
|
||
|
||
|
||
def modulo(f: dict, año: int, s: dict | None = None) -> tuple[float, str] | None:
|
||
"""(€/m², de dónde) del módulo de ese año: «tuyo» si lo apuntó el usuario,
|
||
«oficial» si viene en la tabla, «estimado» si sale del IPC. None si no hay
|
||
ningún módulo del que partir."""
|
||
s = s or serie()
|
||
propios = {int(k): _f(v) for k, v in (f.get("modulos") or {}).items() if _f(v) > 0}
|
||
oficiales = {int(k): _f(v["valor"]) for k, v in (s["modulos"].get(f.get("comunidad") or "") or {}).items()}
|
||
if año in propios:
|
||
return propios[año], "tuyo"
|
||
if año in oficiales:
|
||
return oficiales[año], "oficial"
|
||
conocidos = {**oficiales, **propios}
|
||
if not conocidos:
|
||
return None
|
||
antes = [a for a in conocidos if a < año]
|
||
if not antes:
|
||
return conocidos[min(conocidos)], "estimado"
|
||
base = max(antes)
|
||
valor = conocidos[base]
|
||
for a in range(base + 1, año + 1):
|
||
valor *= _factor_ipc(s["ipc"]["datos"], a)
|
||
return round(valor, 2), "estimado"
|
||
|
||
|
||
def superficie(f: dict) -> tuple[float, float]:
|
||
"""(m² que computan como vivienda, m² que computan como anejo)."""
|
||
viv = anejo = 0.0
|
||
for p in f.get("piezas") or []:
|
||
m2 = _f(p.get("m2")) * _f(p.get("computa", 100)) / 100
|
||
if p.get("clase") == "anejo":
|
||
anejo += m2
|
||
else:
|
||
viv += m2
|
||
return viv, anejo
|
||
|
||
|
||
def desglose(f: dict, año: int, s: dict | None = None) -> dict | None:
|
||
"""El precio máximo de ese año y de dónde sale (para la ficha)."""
|
||
s = s or serie()
|
||
m = modulo(f, año, s)
|
||
if m is None:
|
||
return None
|
||
cv, ca = coeficientes(f, s)
|
||
viv, anejo = superficie(f)
|
||
piezas = []
|
||
for p in f.get("piezas") or []:
|
||
coef = ca if p.get("clase") == "anejo" else cv
|
||
m2 = _f(p.get("m2")) * _f(p.get("computa", 100)) / 100
|
||
piezas.append({"nombre": p.get("nombre") or "", "m2": _f(p.get("m2")), "computa": _f(p.get("computa", 100)),
|
||
"clase": p.get("clase") or "vivienda", "importe": round(m2 * coef * m[0], 2)})
|
||
return {"año": año, "modulo": m[0], "origen": m[1], "coef_vivienda": cv, "coef_anejo": ca,
|
||
"m2_vivienda": round(viv, 2), "m2_anejo": round(anejo, 2), "piezas": piezas,
|
||
"total": round((viv * cv + anejo * ca) * m[0], 2)}
|
||
|
||
|
||
def tope(f: dict, dia: dt.date | str, s: dict | None = None) -> float | None:
|
||
"""El precio máximo de venta en esa fecha: por metros con el módulo de su
|
||
año, o la cifra fija. None si no es protegida o no se puede calcular."""
|
||
if not f.get("protegida"):
|
||
return None
|
||
if not es_por_metros(f):
|
||
return _f(f.get("precio_maximo")) or None
|
||
año = (dia if isinstance(dia, dt.date) else dt.date.fromisoformat(dia)).year
|
||
d = desglose(f, año, s)
|
||
return d["total"] if d and d["total"] > 0 else None
|
||
|
||
|
||
def estimaciones(f: dict, inicio: str, fin: dt.date, s: dict | None = None) -> list[dict]:
|
||
"""El valor de una vivienda protegida por metros: su precio máximo, que
|
||
cambia cada 1 de enero, desde el año siguiente a la compra hasta «fin»."""
|
||
salida = []
|
||
for año in range(int(inicio[:4]) + 1, fin.year + 1):
|
||
v = tope(f, dt.date(año, 1, 1), s)
|
||
if v:
|
||
salida.append({"fecha": f"{año}-01-01", "valor_total": v})
|
||
return salida
|
||
|
||
|
||
def descargar_ipc(sesion) -> dict:
|
||
"""El IPC general del INE recién descargado, con la forma de serie_vpo.json."""
|
||
r = sesion.get(URL_IPC, timeout=60)
|
||
r.raise_for_status()
|
||
datos = sorted([f"{x['Anyo']}-{x['FK_Periodo']:02d}", round(float(x["Valor"]), 3)]
|
||
for x in r.json().get("Data", []) if x.get("Valor") is not None and 1 <= x.get("FK_Periodo", 0) <= 12)
|
||
if len(datos) < 24:
|
||
raise ValueError("la respuesta del INE no tiene la forma esperada")
|
||
return {**{k: v for k, v in serie()["ipc"].items() if k != "datos"}, "descargado": dt.date.today().isoformat(), "datos": datos}
|
||
|
||
|
||
def _f(v) -> float:
|
||
try:
|
||
return float(v or 0)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|