""" interprete.py — Convierte frases normales en operaciones propuestas. «ayer aporté 100 € al plan» «he minado 12.345 sats» «compré 0,0012 btc por 85 € el 3 de octubre» «pagué 50 $ del hosting» «vendí 10 participaciones del fondo b a 250 €» «cobré 5.300 € del préstamo el 20/06/2026» Sin inteligencia artificial ni internet: reglas para verbos, números con unidad, fechas y nombres de activos. Nunca guarda nada: devuelve propuestas que el usuario revisa y acepta. Si falta algo, lo dice («¿de qué activo?»). """ from __future__ import annotations import datetime as dt import re import unicodedata from .calculo import Precios from .vista import cantidad_en MESES = {"enero": 1, "febrero": 2, "marzo": 3, "abril": 4, "mayo": 5, "junio": 6, "julio": 7, "agosto": 8, "septiembre": 9, "setiembre": 9, "octubre": 10, "noviembre": 11, "diciembre": 12, "ene": 1, "feb": 2, "mar": 3, "abr": 4, "may": 5, "jun": 6, "jul": 7, "ago": 8, "sep": 9, "sept": 9, "oct": 10, "nov": 11, "dic": 12} DIAS_SEMANA = {"lunes": 0, "martes": 1, "miercoles": 2, "jueves": 3, "viernes": 4, "sabado": 5, "domingo": 6} NOMBRES_TIPO = {"Compra": "Compra", "Venta": "Venta", "Reembolso": "Reembolso", "Comision": "Comisión", "Gasto": "Gasto", "Devolucion": "Devolución de un gasto", "Mineria": "Minería", "Dividendo": "Dividendo", "Recompensa": "Recompensa", "Amortizacion": "Amortización anticipada"} GASTOS = r"\bhosting\b|\balojamiento\b|\bcustodia\b|\bgastos?\b|\bfianza\b|\bmineros?\b|\basics?\b|\baparatos?\b" # Verbo → tipo de operación (se comprueban en orden; el texto ya va sin tildes) DEVUELVEN = r"\bme (han )?devuelto\b|\bdevolvieron\b|\bdevolucion\b|\breembolsaron\b|\bme (han )?abonado\b" VERBOS = [ # «devuelto» + un gasto (en cualquier orden) antes de que «hosting» lo tome # por un gasto nuevo: es un gasto que se anula, no uno que se apunta (r"(?=.*(" + DEVUELVEN + r"))(?=.*(" + GASTOS + r"))", "Devolucion"), (r"\bsaldo\b|\bvale\b|\bvalorad[oa]\b|\bten(go|ia|iamos|emos) en\b|\bhay en\b|\bqueda(n)? en\b", "Saldo"), (r"\btraspas", "Traspaso"), (r"\bamorti[zc]\w*", "Amortizacion"), (r"\bmin(e|ado|ada|ando|eria|o)\b|\bminad", "Mineria"), (r"\bsave ?back\b|\brecompensa\w*|\bbonificacion\w*|\bcashback\b|\bregal(o|ado|aron)\b", "Recompensa"), (r"\bhosting\b|\balojamiento\b|\bcustodia\b|\bgasto\b|\bgaste\b", "Gasto"), (r"\bvend(i|o|ido|emos)\b", "Venta"), (r"\b(reembols|rescat)\w*", "Reembolso"), (r"\bdividend\w*|\bcupon(es)?\b|\bintereses\b", "Dividendo"), (r"\bretir(e|o|ado|ada)\b", "Retirada"), (r"\bcobr(e|o|ado|ada)\b|\bme (han )?devuelto\b|\bdevolvieron\b", "Cobro"), (r"\bprest(e|o|ado)\b", "Compra"), (r"\bround ?ups?\b|\bredondeo\w*|\baport(e|o|ado|acion)\w*|\bcompr(e|o|ado|a)\b|\badquiri\w*|\binverti\w*|\bmeti\b|\bsuscrib\w*" r"|\bingres(e|o|ado)\b|\bdeposit(e|o|ado)\b", "Compra"), (r"\bcomision\b", "Comision"), (r"\bpag(ue|o|ado)\b", "Gasto"), ] # 70.500 / 12.345 = miles; 0.0012 / 1.5 / 0,5 = decimales NUMERO = r"(? str: t = unicodedata.normalize("NFD", texto.lower()) return "".join(c for c in t if unicodedata.category(c) != "Mn") def _numero(s: str, mil: str | None = None) -> float: if "," in s: v = float(s.replace(".", "").replace(",", ".")) elif re.fullmatch(r"\d{1,3}(\.\d{3})+", s) and not s.startswith("0."): v = float(s.replace(".", "")) else: v = float(s) return v * 1000 if mil else v def _es(n: float, decimales: int = 2) -> str: texto = f"{n:,.{decimales}f}".replace(",", "X").replace(".", ",").replace("X", ".") if decimales > 2: texto = texto.rstrip("0").rstrip(",") return texto def _fecha_texto(f: dt.date) -> str: return f.strftime("%d/%m/%Y") # ── Troceado en operaciones ────────────────────────────────────────────────── def trocear(texto: str) -> list[str]: """Separa varias operaciones: por punto, punto y coma, salto de línea o «y» seguido de otro verbo («compré X y vendí Y»).""" partes = re.split(r"(?:\.\s+|\.$|;|\n)+", texto.strip()) salida = [] for p in partes: n = normalizar(p) cortes = [0] for m in re.finditer(r"\s+(?:y|e|ademas|tambien)\s+", n): resto = n[m.end():] if any(re.match(r"(?:(?:he|hemos|me|le|les)\s+)?(?:" + re.sub(r"^\\b", "", patron) + ")", resto) for patron, _ in VERBOS): cortes.append(m.start()) cortes.append(m.end()) trozos = [p[cortes[i]:cortes[i + 1]] for i in range(0, len(cortes) - 1, 2)] + [p[cortes[-1]:]] \ if len(cortes) > 1 else [p] salida += [t.strip(" ,") for t in trozos if t.strip(" ,")] return salida # ── Fechas ─────────────────────────────────────────────────────────────────── def _extraer_fecha(n: str, hoy: dt.date) -> tuple[dt.date | None, str]: """Devuelve (fecha o None, texto sin la fecha).""" def quitar(m): return n[:m.start()] + " " + n[m.end():] m = re.search(r"\b(antes de ayer|anteayer)\b", n) if m: return hoy - dt.timedelta(days=2), quitar(m) m = re.search(r"\bayer\b", n) if m: return hoy - dt.timedelta(days=1), quitar(m) m = re.search(r"\bhoy\b", n) if m: return hoy, quitar(m) m = re.search(r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b", n) if m: try: return dt.date(int(m[1]), int(m[2]), int(m[3])), quitar(m) except ValueError: pass m = re.search(r"\b(\d{1,2})[/-](\d{1,2})(?:[/-](\d{2,4}))?\b", n) if m: try: año = int(m[3]) + (2000 if m[3] and len(m[3]) == 2 else 0) if m[3] else hoy.year f = dt.date(año, int(m[2]), int(m[1])) if not m[3] and f > hoy: f = f.replace(year=f.year - 1) return f, quitar(m) except ValueError: pass m = re.search(r"\b(?:el\s+)?(?:dia\s+)?(\d{1,2})\s+(?:de\s+)?(" + "|".join(sorted(MESES, key=len, reverse=True)) + r")\b\.?(?:\s+(?:de\s+|del\s+)?(\d{4}))?", n) if m: try: año = int(m[3]) if m[3] else hoy.year f = dt.date(año, MESES[m[2]], int(m[1])) if not m[3] and f > hoy: f = f.replace(year=f.year - 1) return f, quitar(m) except ValueError: pass m = re.search(r"\b(?:el\s+)?dia\s+(\d{1,2})\b|\bel\s+(\d{1,2})\b(?!\s*(?:€|\$|%|eur|usd|dolar|sats?|btc|partic|accion|onza|oz|gramo|ha\b|hectarea))", n) if m: dia = int(m[1] or m[2]) try: f = hoy.replace(day=dia) if f > hoy: anterior = hoy.replace(day=1) - dt.timedelta(days=1) f = anterior.replace(day=min(dia, anterior.day)) return f, quitar(m) except ValueError: pass m = re.search(r"\b(?:el\s+)?(" + "|".join(DIAS_SEMANA) + r")(?:\s+pasado)?\b", n) if m: atras = (hoy.weekday() - DIAS_SEMANA[m[1]]) % 7 return hoy - dt.timedelta(days=atras), quitar(m) return None, n # ── Cantidades ─────────────────────────────────────────────────────────────── def _extraer_numeros(n: str) -> list[dict]: """Números con su unidad y su papel (importe, precio, cantidad, comisión).""" salida = [] for m in re.finditer(NUMERO, n): valor = _numero(m[1], m[2]) antes, despues = n[max(0, m.start() - 25):m.start()], n[m.end():m.end() + 40] unidad = None for patron, u in UNIDADES: if re.match(r"\s*" + patron, despues): unidad = u despues_unidad = re.sub(r"^\s*" + patron, "", despues) break else: despues_unidad = despues if re.search(r"(€|\$)\s*$", antes): unidad = "eur" if antes.rstrip().endswith("€") else "usd" papel = None if unidad in ("eur", "usd"): if re.search(r"(\ba|\bprecio(\s+de)?|\bvl(\s+de)?|\bcotizacion(\s+de)?)\s*(€|\$)?\s*$", antes) or \ re.match(r"\s*(cada|por\s+(participacion|accion|unidad|btc|onza|ha\b)|/\s*\w+|el\s+(btc|bitcoin)|la\s+(participacion|accion|onza))", despues_unidad): papel = "precio" elif re.search(r"retencion", antes[-20:]) or re.match(r"\s*(de\s+)?(retencion|retenidos)", despues_unidad): papel = "retencion" elif re.search(r"comision", antes[-20:]) or re.match(r"\s*(de\s+)?(comision|gastos)", despues_unidad): papel = "comision" else: papel = "importe" elif unidad in ("sats", "btc", "partes", "oz", "g", "ha"): papel = "cantidad" elif re.search(r"(\ba|\bprecio(\s+de)?|\bvl(\s+de)?)\s*$", antes): papel = "precio" salida.append({"valor": valor, "unidad": unidad, "papel": papel, "inicio": m.start()}) return salida # ── Activos ────────────────────────────────────────────────────────────────── def _fichas(activos: list[dict], movimientos: list[dict], recurrentes: list[dict]) -> list[dict]: fichas = [] for a in activos: nombre = normalizar(a.get("nombre", "")) palabras = [w for w in re.findall(r"[a-z0-9]+", nombre) if w not in PALABRAS_VACIAS and len(w) > 1] letras = re.findall(r"\bclase\s+([a-z])\b", nombre) alias = set() codigo = normalizar(a.get("codigo", "")) if codigo: alias.add(codigo) alias.add(normalizar(a["id"])) tipo = a["tipo"] if tipo == "ETF": alias |= {"etf"} if tipo == "Cuenta": alias |= {"cuenta", "banco", "saldo"} if tipo == "PlanPensiones": alias |= {"plan", "plan de pensiones", "pensiones"} if tipo == "Cripto" and (codigo == "bitcoin" or "bitcoin" in nombre): alias |= {"btc", "bitcoin", "bitcoins", "sats", "satoshis"} if tipo == "Metal": if codigo == "xau" or "oro" in nombre: alias |= {"oro"} if codigo == "xag" or "plata" in nombre: alias |= {"plata"} if tipo == "Prestamo": alias |= {"prestamo"} if tipo == "Deuda": alias |= {"prestamo", "hipoteca", "deuda"} if tipo == "Tierra": alias |= {"parcela", "tierra", "finca", "campo"} if tipo == "CuentaTrading": alias |= {"cuenta de trading", "trading"} for extra in (a.get("alias") or "").split(","): if normalizar(extra).strip(): alias.add(normalizar(extra).strip()) mineria = any(m["activo"] == a["id"] and m["tipo"] in ("Mineria", "Gasto") for m in movimientos) or \ any(r["activo"] == a["id"] and r["tipo"] == "Gasto" for r in recurrentes) if mineria: alias |= {"hosting", "mineros", "mineria", "minado", "pool"} fichas.append({"activo": a, "palabras": palabras, "letras": letras, "alias": alias, "abierta": a.get("estado", "abierta") != "cerrada"}) return fichas def _buscar_activo(n: str, fichas: list[dict], tipo_op: str | None, unidades: set) -> tuple[dict | None, list[dict]]: puntos = [] for f in fichas: p = 0.0 for alias in f["alias"]: if re.search(r"\b" + re.escape(alias) + r"\b", n): p += 3 if len(alias) > 3 else 2 coincidencias = sum(1 for w in f["palabras"] if re.search(r"\b" + re.escape(w) + r"\b", n)) p += coincidencias * 2 for letra in f["letras"]: if re.search(r"\b(clase\s+)?" + letra + r"\b", n) and coincidencias: p += 2 # pistas por unidad y tipo de operación t = f["activo"]["tipo"] if unidades & {"sats", "btc"} and "btc" in f["alias"]: p += 3 if tipo_op == "Saldo" and t == "Cuenta": p += 2 if tipo_op == "Amortizacion": p += 2 if t == "Deuda" else -1 if tipo_op in ("Mineria", "Gasto", "Devolucion") and "hosting" in f["alias"]: p += 1 if "oz" in unidades or "g" in unidades: p += 0.5 if t == "Metal" else 0 if "ha" in unidades and t == "Tierra": p += 2 if p > 0 and not f["abierta"]: p -= 0.5 if p > 0: puntos.append((p, f)) if not puntos: # Sin pistas: si solo hay un activo abierto que encaje con la operación abiertas = [f for f in fichas if f["abierta"]] if tipo_op == "Saldo": cands = [f for f in abiertas if f["activo"]["tipo"] == "Cuenta"] elif tipo_op == "Amortizacion": cands = [f for f in abiertas if f["activo"]["tipo"] == "Deuda"] elif tipo_op == "Mineria": cands = [f for f in abiertas if "btc" in f["alias"]] or [f for f in abiertas if f["activo"]["tipo"] == "Cripto"] elif tipo_op in ("Gasto", "Devolucion"): cands = [f for f in abiertas if "hosting" in f["alias"]] else: cands = [] if len(cands) == 1: return cands[0]["activo"], [] return None, [f["activo"] for f in (cands or abiertas)] puntos.sort(key=lambda x: -x[0]) mejor = puntos[0][0] empatados = [f for p, f in puntos if p == mejor] if len(empatados) > 1: abiertos = [f for f in empatados if f["abierta"]] if len(abiertos) == 1: return abiertos[0]["activo"], [] return None, [f["activo"] for f in empatados] return puntos[0][1]["activo"], [] # ── Interpretación ─────────────────────────────────────────────────────────── def termino_busqueda(frase: str) -> str: """Lo que hay que buscar en internet para un activo que aún no se tiene: un ISIN, un ticker en mayúsculas o, si no, las palabras que quedan.""" m = ISIN.search(frase.upper()) if m: return m.group(1) for t in TICKER.findall(frase): if t not in NO_SON_TICKER: return t n = normalizar(frase) _, n = _extraer_fecha(n, dt.date.today()) n = re.sub(NUMERO, " ", n) n = re.sub(r"[€$%.,;:()«»\"']", " ", n) palabras = [w for w in n.split() if w not in RUIDO_BUSQUEDA and not re.fullmatch(r"\d+", w) and not any(re.fullmatch(p, w) for p, _ in UNIDADES)] return " ".join(palabras[:5]) def interpretar(texto: str, datos, hoy: dt.date, cambio=None, activo_forzado: str | None = None, activo_nuevo: dict | None = None) -> list[dict]: """Lista de propuestas, una por operación detectada en el texto. Cada propuesta: {frase, completa, accion ("movimiento"|"cierre"|"alta"), movimiento {...} o cierre {...}, descripcion, avisos [], faltan [], opciones [], busqueda (texto a buscar si el activo no existe), activo_nuevo}. activo_nuevo = {nombre, codigo, tipo, unidad, precio?, fecha_precio?}: un activo que aún no está en la cartera (elegido de la búsqueda); se usa para esta propuesta sin guardarlo.""" activos = datos.vivos("activos") movs = datos.vivos("movimientos") registros_precio = list(datos.vivos("precios")) if activo_nuevo: activos = activos + [{**activo_nuevo, "id": ID_NUEVO, "estado": "abierta"}] activo_forzado = ID_NUEVO fichas = _fichas(activos, movs, datos.vivos("recurrentes")) precios = Precios(registros_precio) return [_una(frase, fichas, movs, precios, hoy, cambio, activo_forzado, activo_nuevo) for frase in trocear(texto)[:10]] def _una(frase: str, fichas, movs, precios: Precios, hoy: dt.date, cambio, activo_forzado, activo_nuevo: dict | None = None) -> dict: r = {"frase": frase, "completa": False, "accion": "movimiento", "movimiento": None, "cierre": None, "saldo": None, "descripcion": "", "avisos": [], "faltan": [], "opciones": [], "busqueda": None, "activo_nuevo": None} n = normalizar(frase) fecha, sin_fecha = _extraer_fecha(n, hoy) if fecha is None: fecha = hoy r["avisos"].append("No dices la fecha: pongo hoy.") if fecha > hoy: r["faltan"].append("la fecha está en el futuro") verbo = next((t for patron, t in VERBOS if re.search(patron, sin_fecha)), None) numeros = _extraer_numeros(sin_fecha) unidades = {x["unidad"] for x in numeros if x["unidad"]} if verbo is None and unidades & {"sats", "btc"} and \ re.search(r"\b(recibi\w*|llegad[oa]s?|llegaron|entrad[oa]s?|entraron|pool|minad\w*)\b", sin_fecha): verbo = "Mineria" if not re.search(r"\b(pool|minad\w*|mineria|mineros)\b", sin_fecha): r["avisos"].append("Lo tomo como BTC minado. Si fue otra cosa, corrígelo.") if verbo == "Saldo" and activo_forzado is None and activo_nuevo is None: # «saldo de bankinter 1.234 €»: no es una operación, es poner el saldo pass if verbo == "Traspaso": r["faltan"].append("los traspasos se registran con «Más → Traspaso entre fondos, planes o cripto»") return r if verbo is None: r["faltan"].append("qué hiciste (compré, vendí, aporté, cobré, pagué, he minado...)") if activo_forzado: activo = next((f["activo"] for f in fichas if f["activo"]["id"] == activo_forzado), None) opciones = [] else: activo, opciones = _buscar_activo(sin_fecha, fichas, verbo, unidades) if activo is None: r["faltan"].append("de qué activo se trata") r["opciones"] = [{"id": a["id"], "nombre": a["nombre"]} for a in opciones[:8]] if verbo == "Compra": r["busqueda"] = termino_busqueda(frase) or None return r if activo_nuevo: r["accion"] = "alta" r["activo_nuevo"] = activo_nuevo if verbo not in ("Compra", None): r["faltan"].append("un activo nuevo solo se puede dar de alta con una compra") tipo_activo = activo["tipo"] # Ajuste del verbo al tipo de activo tipo = verbo if verbo == "Retirada": tipo = "Venta" if tipo_activo in ("CuentaTrading", "Cripto", "ETF", "Accion", "Metal") else "Reembolso" elif verbo == "Cobro": tipo = "Reembolso" elif verbo == "Comision" and not unidades & {"sats", "btc", "partes", "oz", "g"}: tipo = "Gasto" importe = precio = cantidad = comision = retencion = valor_recibido = None moneda = "EUR" gramos_a_onzas = False for x in numeros: v, u, papel = x["valor"], x["unidad"], x["papel"] if papel == "cantidad": if u == "sats": v /= 1e8 if u == "g" and (activo.get("unidad") or "").lower() in ("oz", "onza", "onzas"): v /= GRAMOS_POR_ONZA gramos_a_onzas = True if u == "oz" and (activo.get("unidad") or "").lower() in ("g", "gramos"): v *= GRAMOS_POR_ONZA cantidad = v if cantidad is None else cantidad elif papel == "precio": precio = v if precio is None else precio if u == "usd": r["faltan"].append("el precio en euros (solo sé convertir importes en dólares)") elif papel == "comision": comision = v elif papel == "retencion": retencion = v elif papel == "importe": if importe is None: importe, moneda = v, "USD" if u == "usd" else "EUR" elif papel is None and u is None: # número sin unidad: en préstamos y gastos es un importe; si no, cantidad if tipo_activo in ("Prestamo", "Deuda") or tipo in ("Gasto", "Devolucion", "Dividendo", "Amortizacion"): importe = v if importe is None else importe elif cantidad is None: cantidad = v if gramos_a_onzas and precio is not None: precio *= GRAMOS_POR_ONZA r["avisos"].append("Cantidad pasada de gramos a onzas; el precio se entiende por gramo.") importe_original = None if moneda == "USD" and importe is not None: importe_original = importe tasa = cambio(fecha, "USD") if cambio else None if tasa: importe = importe * tasa r["avisos"].append(f"{_es(importe_original)} $ al cambio del {_fecha_texto(fecha)}: {_es(importe)} €.") else: r["faltan"].append("el importe en euros (no he podido consultar el cambio del dólar)") importe = None q_actual = cantidad_en(movs, activo["id"], fecha) vende_todo = bool(re.search(r"\b(todo|todas|todos|entero|entera|completo|completa|total)\b", sin_fecha)) solo_valor = activo.get("valoracion") == "total" if tipo_activo == "Deuda" and verbo != "Amortizacion" and \ re.search(r"\b(quedan?|queda por pagar|pendiente|debo|debia|debemos)\b", sin_fecha): verbo = "Saldo" # «a la hipoteca le quedan 99.500 €»: lo pendiente según el banco if verbo == "Saldo" or (tipo_activo == "Cuenta" and verbo in (None, "Compra")): # En una cuenta no hay operaciones: lo que se dice es el saldo r["faltan"] = [f for f in r["faltan"] if not f.startswith("qué hiciste")] r["accion"] = "saldo" valor = next((x["valor"] for x in numeros if x["papel"] in ("importe", None) and x["unidad"] in ("eur", None)), None) if valor is None: r["faltan"].append("el saldo en euros") else: r["saldo"] = {"activo": activo["id"], "fecha": fecha.isoformat(), "valor": valor} r["completa"] = not r["faltan"] r["descripcion"] = ((f"Valor de {activo['nombre']}" if solo_valor else f"Pendiente de {activo['nombre']} según el banco" if tipo_activo == "Deuda" else f"Saldo de {activo['nombre']}") + f" el {_fecha_texto(fecha)}: {_es(valor)} €" if valor is not None else "") return r if tipo_activo == "Deuda" or tipo == "Amortizacion": # Préstamo recibido: solo amortizaciones anticipadas (las cuotas se calculan solas) r["faltan"] = [f for f in r["faltan"] if not f.startswith("qué hiciste")] if tipo_activo != "Deuda": r["faltan"].append("las amortizaciones son solo para préstamos recibidos") elif tipo != "Amortizacion": r["faltan"].append("en un préstamo solo se apuntan amortizaciones anticipadas (di «amorticé ...»)") if importe is None: r["faltan"].append("el importe amortizado") plazo = re.search(r"\b(plazo|duracion|anos|meses|antes)\b", sin_fecha) cuota = re.search(r"\bcuotas?\b|\bmensualidad\w*", sin_fecha) modalidad = "plazo" if plazo and not cuota else "cuota" if cuota and not plazo else None if modalidad is None: r["faltan"].append("si reduces el plazo o la cuota") r["movimiento"] = {"activo": activo["id"], "tipo": "Amortizacion", "fecha": fecha.isoformat(), "cantidad": None, "precio": None, "importe": importe, "modalidad": modalidad, "notas": frase.strip()[:200], "estimado": False} r["completa"] = not r["faltan"] r["descripcion"] = _describir(r, activo) return r # Reglas por tipo if solo_valor and tipo in ("Compra", "Venta", "Reembolso"): cantidad = precio = None if importe is None: r["faltan"].append("el importe en euros") elif tipo == "Recompensa": valor_recibido, importe = importe, None if cantidad is None and valor_recibido: reg = precios.ultimo(activo["id"], fecha) if reg and reg.get("precio") and (fecha - dt.date.fromisoformat(reg["fecha"])).days <= 7: cantidad = valor_recibido / reg["precio"] r["avisos"].append("Cantidad estimada con el precio de ese día: confírmala con el extracto.") r["estimado"] = True precio = None if cantidad is None: r["faltan"].append(f"la cantidad recibida ({activo.get('unidad') or 'acciones'})") elif tipo == "Mineria": precio = importe = None if cantidad is None: r["faltan"].append("la cantidad recibida (por ejemplo «12.345 sats» o «0,0001 btc»)") elif tipo == "Dividendo": cantidad = precio = None if importe is None and "el importe en euros (no he podido consultar el cambio del dólar)" not in r["faltan"]: r["faltan"].append("el importe cobrado") elif tipo == "Gasto": cantidad = precio = None if importe is None and "el importe en euros (no he podido consultar el cambio del dólar)" not in r["faltan"]: r["faltan"].append("el importe del gasto") elif tipo == "Devolucion": cantidad = precio = None if importe is None and "el importe en euros (no he podido consultar el cambio del dólar)" not in r["faltan"]: r["faltan"].append("el importe que te han devuelto") else: if tipo_activo == "Prestamo": if tipo == "Compra": cantidad, precio = 1.0, importe else: cantidad = q_actual if q_actual > 0 else 1.0 precio = importe / cantidad if importe else None if tipo in ("Venta", "Reembolso") and cantidad is None and vende_todo: cantidad = q_actual if cantidad and precio and importe is None: importe = cantidad * precio elif cantidad and importe is not None and precio is None: precio = importe / cantidad elif precio and importe is not None and cantidad is None: cantidad = importe / precio if cantidad is None and importe is not None and tipo in ("Compra", "Venta", "Reembolso"): reg = precios.ultimo(activo["id"], fecha) if activo_nuevo and activo_nuevo.get("precio") and activo_nuevo.get("fecha_precio"): # Activo recién buscado: su último precio, aunque sea posterior a la compra reg = {"precio": float(activo_nuevo["precio"]), "fecha": activo_nuevo["fecha_precio"]} if reg and reg.get("precio") and abs((fecha - dt.date.fromisoformat(reg["fecha"])).days) <= 7: precio = reg["precio"] cantidad = importe / precio r["avisos"].append(f"Cantidad estimada con el precio del {_fecha_texto(dt.date.fromisoformat(reg['fecha']))} " f"({_es(precio, 4)} €): confírmala con el extracto.") r["estimado"] = True else: r["faltan"].append(f"la cantidad ({activo.get('unidad') or 'unidades'}) o el precio: no tengo precio de ese día") if cantidad is None and importe is None: r["faltan"].append("el importe o la cantidad") if tipo in ("Venta", "Reembolso", "Comision") and cantidad and cantidad > q_actual + 1e-9: r["faltan"].append(f"no tenías tanto: el {_fecha_texto(fecha)} tenías {_es(q_actual, 8)} {activo.get('unidad', '')}".strip()) # ¿Deja la posición a cero? → proponer cerrar la inversión if not activo_nuevo and tipo in ("Venta", "Reembolso") and cantidad and abs(q_actual - cantidad) <= max(1e-6, q_actual * 1e-6) \ and not r["faltan"] and activo.get("estado", "abierta") != "cerrada": r["accion"] = "cierre" r["cierre"] = {"id": activo["id"], "fecha": fecha.isoformat(), "importe": importe or 0.0} notas = frase.strip()[:200] r["movimiento"] = {"activo": activo["id"], "tipo": tipo, "fecha": fecha.isoformat(), "cantidad": cantidad, "precio": precio, "importe": importe, "comisiones": comision, "notas": notas, "estimado": bool(r.get("estimado"))} if retencion is not None: r["movimiento"]["retencion"] = retencion if valor_recibido is not None: r["movimiento"]["valor_recibido"] = valor_recibido if importe_original is not None: r["movimiento"].update({"importe_original": importe_original, "moneda": "USD"}) r["completa"] = not r["faltan"] r["descripcion"] = _describir(r, activo) return r def _describir(r: dict, activo: dict) -> str: m = r["movimiento"] fecha = _fecha_texto(dt.date.fromisoformat(m["fecha"])) unidad = activo.get("unidad") or "" partes = [] if activo["tipo"] in ("Prestamo", "Deuda") and m["importe"] is not None: partes.append(f"{_es(m['importe'])} €") elif m["cantidad"] is not None: partes.append(f"{_es(m['cantidad'], 8)} {unidad}".strip()) if m["precio"] is not None and activo["tipo"] not in ("Prestamo", "Deuda"): partes.append(f"a {_es(m['precio'], 4)} €") if m["importe"] is not None and activo["tipo"] not in ("Prestamo", "Deuda"): partes.append(f"= {_es(m['importe'])} €" if m["cantidad"] is not None else f"{_es(m['importe'])} €") if m.get("comisiones"): partes.append(f"(comisión {_es(m['comisiones'])} €)") if m.get("retencion"): partes.append(f"(retención {_es(m['retencion'])} €)") if m.get("modalidad"): partes.append(f"(reduce {m['modalidad']})") if m.get("valor_recibido"): partes.append(f"(valor al recibirlo {_es(m['valor_recibido'])} €)") base = f"{NOMBRES_TIPO.get(m['tipo'], m['tipo'])} · {activo['nombre']} · {fecha}" if r["accion"] == "alta": base = f"Alta de «{activo['nombre']}» ({activo.get('codigo') or 'sin código'}) y {base[0].lower()}{base[1:]}" if partes: base += " — " + " ".join(partes) if r["accion"] == "cierre": base += ". Deja la posición a cero: se cerrará la inversión." return base