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