645 lines
31 KiB
Python
645 lines
31 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
|
|
|
|
|
|
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
|