caudal/programa/cartera/interprete.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

657 lines
32 KiB
Python

"""
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"(?<![\w.,])([1-9]\d{0,2}(?:\.\d{3})+(?:,\d+)?(?![\d.])|\d+(?:[.,]\d+)?)(\s*mil\b)?"
UNIDADES = [
(r"(€|eur\b|euros?\b)", "eur"),
(r"(\$|usd\b|dolar(es)?\b)", "usd"),
(r"(sats?\b|satoshis?\b)", "sats"),
(r"(btc\b|bitcoins?\b)", "btc"),
(r"(participaciones|participacion|partic\.?|particip\.?)", "partes"),
(r"(acciones|accion|titulos)\b", "partes"),
(r"(onzas?\b|oz\b)", "oz"),
(r"(gramos?\b|gr?\b)", "g"),
(r"(hectareas?\b|ha\b)", "ha"),
]
PALABRAS_VACIAS = {"de", "del", "la", "el", "los", "las", "fi", "pp", "fondo", "plan", "clase", "fisico",
"fisica", "caja", "fuerte", "pensiones", "y", "en", "con", "prestamo", "inc", "sa", "etf",
"ucits", "acc", "dist", "usd", "eur"}
GRAMOS_POR_ONZA = 31.1035
ID_NUEVO = "__nuevo__"
ISIN = re.compile(r"\b([A-Z]{2}[A-Z0-9]{9}\d)\b")
TICKER = re.compile(r"(?<![\w.])([A-Z][A-Z0-9]{1,5}(?:\.[A-Z]{1,3})?)(?![\w.])")
NO_SON_TICKER = {"BTC", "ETF", "USD", "EUR", "VL", "PP", "FI", "TIR", "SA", "DCA", "OK"}
# Palabras que no forman parte del nombre de un activo al buscarlo en internet
RUIDO_BUSQUEDA = PALABRAS_VACIAS - {"etf", "plan"} | {
"he", "hemos", "me", "han", "hoy", "ayer", "a", "al", "por", "un", "una", "unos", "unas", "mas", "para",
"compre", "compro", "comprado", "aporte", "aportado", "adquiri", "invertido", "inverti", "meti", "suscribi",
"acciones", "accion", "participaciones", "participacion", "titulos", "euros", "euro", "eur", "dolares",
"usd", "comision", "cada", "precio", "total", "nuevo", "nueva", "nuevas", "nuevos", "empresa", "mil",
"dia", "el", "la", "lo", "las", "los", "y", "que", "cotiza"}
def normalizar(texto: str) -> 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
_MESES = ("ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sept", "oct", "nov", "dic")
def _fecha_texto(f: dt.date) -> str:
"""Como las fechas del resto de la aplicación: «14 oct 2026»."""
return f"{f.day} {_MESES[f.month - 1]} {f.year}"
# ── 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 == "RentaFija":
clase = (a.get("renta_fija") or {}).get("clase")
alias |= {"letra": {"letra", "letras", "letra del tesoro"}, "bono": {"bono", "bonos", "obligacion"},
"deposito": {"deposito", "plazo fijo", "deposito a plazo"}}.get(clase, set())
if tipo == "Inmueble":
clase = (a.get("inmueble") or {}).get("clase")
alias |= {"inmueble", "alquiler", "inquilino"} | ({"piso", "casa", "vivienda", "apartamento"} if clase == "vivienda" else
{clase.replace("_", " ")} if clase else set())
if tipo == "CuentaTrading":
alias |= {"cuenta de trading", "trading", "cuenta de broker", "broker"}
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