caudal/programa/cartera/inmuebles.py
esetheyers 7e18576f86 Caudal 0.2.0
Taxes for the whole of Spain (common territory, Navarre, Álava, Bizkaia and Gipuzkoa); flats, garages and land valued on their own; Treasury bills, bonds and deposits priced on BME; broker accounts; a guided start; a page per investment with its chart and the tax of selling today; net worth at the top; undo when deleting; Woodpecker CI for the tests and the Linux build.
2026-10-15 16:49:15 -05:00

218 lines
11 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: su valor nunca pasa de «precio_maximo»
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).
"""
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_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) -> float | None:
"""El precio máximo de venta de una vivienda protegida, si lo tiene."""
f = a.get("inmueble") or {}
return _f(f.get("precio_maximo")) or None if f.get("protegida") else None
def punto_de_partida(a: dict, movs: list[dict]) -> 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)
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) -> list[dict]:
"""Los valores trimestrales estimados de un inmueble: [{fecha, valor_total}].
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."""
f = a.get("inmueble") or {}
if a.get("tipo") != "Inmueble" or not f.get("auto"):
return []
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, "inmueble": {**f, "protegida": False}}, 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])
maximo = tope(a)
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
if maximo:
v = min(v, maximo)
salida.append({"fecha": dia.isoformat(), "valor_total": round(v, 2)})
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}