caudal/programa/cartera/vpo.py
2026-10-20 17:45:13 -05:00

193 lines
7.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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