231 lines
12 KiB
Python
231 lines
12 KiB
Python
"""
|
|
inmuebles.py — Viviendas, garajes, locales y terrenos: su ficha, su valor
|
|
estimado sin tener que apuntarlo a mano y las cifras de su alquiler.
|
|
|
|
Un inmueble es un activo de tipo «Inmueble» que se sigue por su valor total
|
|
(como un plan del que solo se sabe el saldo): la compra en euros, con sus
|
|
gastos; los alquileres cobrados (Dividendo), los gastos (Gasto) y, si se
|
|
vende, la venta. Su ficha va en activo["inmueble"]:
|
|
|
|
clase vivienda, garaje, trastero, local, nave, terreno urbano o rústico
|
|
uso alquilado, vacío, o propio (la vivienda habitual o de uso
|
|
propio: cuenta en el patrimonio, no como inversión)
|
|
comunidad la comunidad autónoma, para el índice de precios
|
|
auto True = el valor se estima solo con el índice del INE
|
|
precio_compra el precio de la escritura, sin gastos (si no, la compra entera)
|
|
referencia la referencia catastral o la dirección, para distinguirlos
|
|
valor_catastral y porcentaje_construccion: para la parte fiscal
|
|
protegida vivienda protegida: vale su precio máximo de venta, una cifra
|
|
fija («precio_maximo») o calculado por metros con el módulo de
|
|
cada año (ver vpo.py)
|
|
intereses_hipoteca lo pagado en intereses de una hipoteca ya cancelada: no
|
|
es coste para Hacienda, pero sí lo que costó de verdad
|
|
|
|
El valor estimado: se parte del precio de compra, o de la última tasación
|
|
apuntada a mano, y se mueve con el Índice de Precios de Vivienda del INE
|
|
(segunda mano, de su comunidad), trimestre a trimestre. Esos valores se
|
|
guardan como precios con la fuente «IPV», así que todo lo demás (evolución,
|
|
patrimonio, valor a 31 de diciembre) los usa sin saber de dónde salen. El
|
|
índice es de vivienda: en garajes, locales y terrenos urbanos es solo una
|
|
aproximación, y por eso allí no se activa de partida. Las fincas rústicas
|
|
van con el precio de la tierra de su cultivo (ver tierra.py). Una vivienda
|
|
protegida no sigue el índice: vale su precio máximo de venta de cada año
|
|
(fuente «VPO»), porque es lo que se podría cobrar por ella.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
from . import vpo
|
|
|
|
SERIE = Path(__file__).resolve().parent / "serie_ipv.json"
|
|
FICHERO_ACTUALIZADO = "serie_ipv.json" # junto a los ajustes, la que se descarga al actualizar precios
|
|
FUENTE = "IPV"
|
|
FUENTE_COMPRA = "compra" # el valor del primer día: lo que costó
|
|
URL_INE = "https://servicios.ine.es/wstempus/js/ES/DATOS_TABLA/80270?nult=200"
|
|
|
|
CLASES = {"vivienda": "Vivienda", "garaje": "Garaje", "trastero": "Trastero", "local": "Local u oficina",
|
|
"nave": "Nave", "terreno_urbano": "Terreno urbano", "terreno_rustico": "Terreno rústico", "otro": "Otro"}
|
|
USOS = {"alquilado": "Alquilado", "vacio": "Vacío o de uso ocasional", "propio": "Vivienda habitual o uso propio"}
|
|
|
|
|
|
@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:
|
|
"""La serie del índice: la que trae el programa o, si es más reciente, la
|
|
descargada al actualizar precios (en «carpeta», junto a los ajustes)."""
|
|
buenas = [_leer(str(SERIE), SERIE.stat().st_mtime)]
|
|
if carpeta is not None:
|
|
otra = Path(carpeta) / FICHERO_ACTUALIZADO
|
|
try:
|
|
buenas.append(_leer(str(otra), otra.stat().st_mtime))
|
|
except (OSError, ValueError):
|
|
pass
|
|
return max(buenas, key=lambda s: s["series"]["nacional"]["segunda_mano"][-1][0])
|
|
|
|
|
|
def comunidades(s: dict | None = None) -> dict[str, str]:
|
|
s = s or serie()
|
|
return {k: v["nombre"] for k, v in s["series"].items()}
|
|
|
|
|
|
def trimestre(fecha: dt.date) -> str:
|
|
return f"{fecha.year}T{(fecha.month - 1) // 3 + 1}"
|
|
|
|
|
|
def fin_de_trimestre(t: str) -> dt.date:
|
|
año, n = int(t[:4]), int(t[5])
|
|
return dt.date(año, 12, 31) if n == 4 else dt.date(año, 3 * n + 1, 1) - dt.timedelta(days=1)
|
|
|
|
|
|
def indice(datos: list, t: str) -> float:
|
|
"""El índice de ese trimestre; antes del primero, el primero; después del último, el último."""
|
|
valor = datos[0][1]
|
|
for trim, v in datos:
|
|
if trim > t:
|
|
break
|
|
valor = v
|
|
return valor
|
|
|
|
|
|
def _f(v) -> float:
|
|
try:
|
|
return float(v or 0)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def es_propio(a: dict) -> bool:
|
|
"""La vivienda habitual o de uso propio: patrimonio, no inversión."""
|
|
return a.get("tipo") == "Inmueble" and (a.get("inmueble") or {}).get("uso") == "propio"
|
|
|
|
|
|
def tope(a: dict, dia: dt.date | str, sv: dict | None = None) -> float | None:
|
|
"""El precio máximo de venta de una vivienda protegida en esa fecha, si lo tiene."""
|
|
return vpo.tope(a.get("inmueble") or {}, dia, sv)
|
|
|
|
|
|
def punto_de_partida(a: dict, movs: list[dict], sv: dict | None = None) -> tuple[str, float] | None:
|
|
"""(fecha, valor) del día de la compra: el precio de la escritura o, si no
|
|
se puso, lo que costó la compra entera."""
|
|
compras = sorted((m for m in movs if m["tipo"] == "Compra"), key=lambda m: m["fecha"])
|
|
if not compras:
|
|
return None
|
|
inicio = compras[0]["fecha"]
|
|
f = a.get("inmueble") or {}
|
|
base = _f(f.get("precio_compra")) or sum(_f(m.get("importe")) for m in compras if m["fecha"] == inicio)
|
|
maximo = tope(a, inicio, sv)
|
|
return (inicio, round(min(base, maximo) if maximo else base, 2)) if base > 0 else None
|
|
|
|
|
|
def estimaciones(a: dict, movs: list[dict], precios: list[dict], s: dict, hoy: dt.date,
|
|
sv: dict | None = None) -> list[dict]:
|
|
"""Los valores estimados de un inmueble: [{fecha, valor_total, fuente}].
|
|
|
|
Se parte de la compra y de cada valor apuntado a mano (una tasación), y
|
|
cada tramo se mueve con el índice hasta el siguiente valor a mano, la
|
|
venta o el último trimestre publicado. Una vivienda protegida vale, cada
|
|
1 de enero, su precio máximo de venta de ese año."""
|
|
f = a.get("inmueble") or {}
|
|
if a.get("tipo") != "Inmueble" or not f.get("auto"):
|
|
return []
|
|
if f.get("protegida"):
|
|
compras = sorted(m["fecha"] for m in movs if m["tipo"] == "Compra")
|
|
if not compras:
|
|
return []
|
|
venta = min((m["fecha"] for m in movs if m["tipo"] == "Venta"), default=None)
|
|
fin = min(hoy, dt.date.fromisoformat(venta)) if venta else hoy
|
|
return [{**e, "fuente": vpo.FUENTE} for e in vpo.estimaciones(f, compras[0], fin, sv)]
|
|
region = s["series"].get(f.get("comunidad") or "") or s["series"]["nacional"]
|
|
datos = region.get("segunda_mano") or region["general"]
|
|
partida = punto_de_partida(a, movs)
|
|
if partida is None:
|
|
return []
|
|
inicio, base = partida
|
|
anclas = [(dt.date.fromisoformat(inicio), base)]
|
|
for r in sorted(precios, key=lambda r: r["fecha"]):
|
|
if r.get("fuente") == "manual" and _f(r.get("valor_total") or r.get("precio")) > 0 and r["fecha"] >= inicio:
|
|
anclas.append((dt.date.fromisoformat(r["fecha"]), _f(r.get("valor_total") or r.get("precio"))))
|
|
venta = min((m["fecha"] for m in movs if m["tipo"] == "Venta"), default=None)
|
|
fin = min(hoy, dt.date.fromisoformat(venta)) if venta else hoy
|
|
ultimo = fin_de_trimestre(datos[-1][0])
|
|
salida = []
|
|
for i, (desde, valor) in enumerate(anclas):
|
|
hasta = anclas[i + 1][0] if i + 1 < len(anclas) else fin
|
|
punto = indice(datos, trimestre(desde))
|
|
t = trimestre(desde)
|
|
while True:
|
|
año, n = int(t[:4]), int(t[5])
|
|
t = f"{año + 1}T1" if n == 4 else f"{año}T{n + 1}"
|
|
dia = fin_de_trimestre(t)
|
|
if dia > ultimo or dia >= hasta or dia > fin:
|
|
break
|
|
v = valor * indice(datos, t) / punto
|
|
salida.append({"fecha": dia.isoformat(), "valor_total": round(v, 2), "fuente": FUENTE})
|
|
return salida
|
|
|
|
|
|
def descargar(sesion) -> dict:
|
|
"""La serie del INE recién descargada, con la misma forma que la que trae el programa."""
|
|
r = sesion.get(URL_INE, timeout=60)
|
|
r.raise_for_status()
|
|
nombres = {v["nombre_ine"]: k for k, v in _NOMBRES_INE.items()}
|
|
clase = {"General": "general", "Vivienda nueva": "nueva", "Vivienda segunda mano": "segunda_mano"}
|
|
series: dict = {}
|
|
for s in r.json():
|
|
partes = [p.strip() for p in s.get("Nombre", "").split(".")]
|
|
if len(partes) < 3 or partes[2] != "Índice" or partes[0] not in nombres or partes[1] not in clase:
|
|
continue
|
|
slug = nombres[partes[0]]
|
|
datos = sorted((f"{x['Anyo']}T{x['FK_Periodo'] - 18}", round(float(x["Valor"]), 3))
|
|
for x in s.get("Data", []) if x.get("Valor") is not None and 19 <= x.get("FK_Periodo", 0) <= 22)
|
|
if datos:
|
|
series.setdefault(slug, {"nombre": _NOMBRES_INE[slug]["nombre"]})[clase[partes[1]]] = datos
|
|
if "nacional" not in series or len(series) < 18:
|
|
raise ValueError("la respuesta del INE no tiene la forma esperada")
|
|
return {**{k: v for k, v in serie().items() if k != "series"}, "descargado": dt.date.today().isoformat(), "series": series}
|
|
|
|
|
|
_NOMBRES_INE = {
|
|
"nacional": {"nombre_ine": "Nacional", "nombre": "Toda España"},
|
|
"andalucia": {"nombre_ine": "Andalucía", "nombre": "Andalucía"},
|
|
"aragon": {"nombre_ine": "Aragón", "nombre": "Aragón"},
|
|
"asturias": {"nombre_ine": "Asturias, Principado de", "nombre": "Asturias"},
|
|
"baleares": {"nombre_ine": "Balears, Illes", "nombre": "Illes Balears"},
|
|
"canarias": {"nombre_ine": "Canarias", "nombre": "Canarias"},
|
|
"cantabria": {"nombre_ine": "Cantabria", "nombre": "Cantabria"},
|
|
"castilla_leon": {"nombre_ine": "Castilla y León", "nombre": "Castilla y León"},
|
|
"castilla_la_mancha": {"nombre_ine": "Castilla - La Mancha", "nombre": "Castilla-La Mancha"},
|
|
"cataluna": {"nombre_ine": "Cataluña", "nombre": "Cataluña"},
|
|
"valenciana": {"nombre_ine": "Comunitat Valenciana", "nombre": "Comunitat Valenciana"},
|
|
"extremadura": {"nombre_ine": "Extremadura", "nombre": "Extremadura"},
|
|
"galicia": {"nombre_ine": "Galicia", "nombre": "Galicia"},
|
|
"madrid": {"nombre_ine": "Madrid, Comunidad de", "nombre": "Comunidad de Madrid"},
|
|
"murcia": {"nombre_ine": "Murcia, Región de", "nombre": "Región de Murcia"},
|
|
"navarra": {"nombre_ine": "Navarra, Comunidad Foral de", "nombre": "Navarra"},
|
|
"pais_vasco": {"nombre_ine": "País Vasco", "nombre": "País Vasco"},
|
|
"rioja": {"nombre_ine": "Rioja, La", "nombre": "La Rioja"},
|
|
"ceuta": {"nombre_ine": "Ceuta", "nombre": "Ceuta"},
|
|
"melilla": {"nombre_ine": "Melilla", "nombre": "Melilla"},
|
|
}
|
|
|
|
|
|
def alquiler(movs: list[dict], hoy: dt.date) -> dict:
|
|
"""Las cifras del alquiler de los últimos doce meses."""
|
|
desde = (hoy - dt.timedelta(days=365)).isoformat()
|
|
recientes = [m for m in movs if desde < m["fecha"] <= hoy.isoformat()]
|
|
cobrado = round(sum(_f(m.get("importe")) for m in recientes if m["tipo"] == "Dividendo"), 2)
|
|
gastos = round(sum(_f(m.get("importe")) for m in recientes if m["tipo"] == "Gasto" and not m.get("amortizable"))
|
|
- sum(_f(m.get("importe")) for m in recientes if m["tipo"] == "Devolucion"), 2)
|
|
compra = round(sum(_f(m.get("importe")) for m in movs if m["tipo"] == "Compra")
|
|
+ sum(_f(m.get("importe")) for m in movs if m["tipo"] == "Gasto" and m.get("amortizable")), 2)
|
|
return {"alquiler_12m": cobrado, "gastos_12m": gastos, "compra": compra,
|
|
"rentabilidad_bruta": round(cobrado / compra, 4) if compra > 0 else None,
|
|
"rentabilidad_neta": round((cobrado - gastos) / compra, 4) if compra > 0 else None}
|