""" lugares.py — Dónde tienes cada cosa dentro de un mismo activo. Tu bitcoin es UNA bolsa para Hacienda y para la rentabilidad, esté donde esté. Dónde está (un exchange, un monedero, un nodo Lightning…) es un «lugar» del activo, no otro activo: • cada operación puede llevar su lugar («Compra · un exchange»); • mover entre lugares tuyos es un «Traslado»: una sola línea, origen → destino, que no cambia ninguna cifra ni es fiscal. Su comisión es un «Comision» aparte en el lugar de origen (esa sí baja la cantidad); • el saldo de cada lugar sirve para cuadrarlo con el nodo, con el exchange o con la app del nodo Lightning. Un traslado puede tener un extremo sin saber todavía ("" en lugar o en lugar_destino): lo deja así el extracto de un exchange (la retirada sabe de dónde sale, no a qué monedero va) y lo completa el historial de bitcoin al ver llegar esa cantidad a uno de tus monederos. """ from __future__ import annotations import re import unicodedata from .calculo import ENTRADAS_CANTIDAD, SALIDAS_CANTIDAD SIN_LUGAR = "" NOMBRES_CLASE = {"monedero": "Monedero", "exchange": "Exchange", "lightning": "Nodo Lightning", "otro": "Otro"} def _f(v) -> float: try: return float(v or 0) except (TypeError, ValueError): return 0.0 def clave(texto: str) -> str: """Nombre sin tildes ni mayúsculas, para comparar.""" t = unicodedata.normalize("NFD", (texto or "").lower()) return "".join(c for c in t if unicodedata.category(c) != "Mn").strip() def de(activo: dict) -> list[dict]: return list(activo.get("lugares") or []) def por_id(activo: dict) -> dict[str, dict]: return {x["id"]: x for x in de(activo)} def buscar(activo: dict, nombre: str) -> dict | None: k = clave(nombre) return next((x for x in de(activo) if clave(x["nombre"]) == k), None) def buscar_exchange(activo: dict, exchange: str) -> dict | None: """El lugar de un exchange aunque se llame «BTC en Binance» o «Binance BTC».""" ya = buscar(activo, exchange) if ya: return ya k = clave(exchange) for x in de(activo): palabras = re.sub(r"[^a-z0-9]+", " ", clave(x["nombre"])).split() resto = [p for p in palabras if p not in ("btc", "bitcoin", "en", "de", "in")] if resto and " ".join(resto) == re.sub(r"[^a-z0-9]+", " ", k).strip(): return x return None def nuevo_id(nombre: str, existentes) -> str: base = re.sub(r"[^a-z0-9]+", "-", clave(nombre)).strip("-")[:24] or "lugar" ids, i, lid = set(existentes), 2, "l-" + base while lid in ids: lid, i = f"l-{base}-{i}", i + 1 return lid def nombre(activo: dict, lid: str | None) -> str: if not lid: return "sin lugar" x = por_id(activo).get(lid) return x["nombre"] if x else lid def saldos(movs: list[dict], activo: str, hasta: str | None = None) -> dict[str, float]: """Cantidad en cada lugar (clave "" = sin lugar). hasta: fecha AAAA-MM-DD incluida.""" s: dict[str, float] = {} def suma(lid, q): s[lid or SIN_LUGAR] = s.get(lid or SIN_LUGAR, 0.0) + q for m in movs: if m.get("borrado") or m["activo"] != activo or (hasta and m["fecha"] > hasta): continue q, t = _f(m.get("cantidad")), m["tipo"] if t == "Traslado": suma(m.get("lugar"), -q) suma(m.get("lugar_destino"), q) elif t in ENTRADAS_CANTIDAD: suma(m.get("lugar"), q) elif t in SALIDAS_CANTIDAD: suma(m.get("lugar"), -q) # 9 decimales: cabe el medio satoshi de la mitad de un monedero compartido return {k: round(v, 9) for k, v in s.items() if abs(v) > 1e-12} def activo_bitcoin(activos: list[dict], movs: list[dict]) -> dict | None: """El activo donde va el bitcoin: el Cripto «bitcoin» con más operaciones.""" candidatos = [a for a in activos if a["tipo"] == "Cripto" and (a.get("codigo") or "").lower() == "bitcoin" and a.get("estado", "abierta") != "cerrada"] if not candidatos: return None cuenta = {a["id"]: 0 for a in candidatos} for m in movs: if m["activo"] in cuenta: cuenta[m["activo"]] += 1 return max(candidatos, key=lambda a: (cuenta[a["id"]], a.get("nombre") == "Bitcoin"))