""" fifo.py — Ganancias y pérdidas de cada transmisión por FIFO, año a año. Cómo se cuenta: • Cada compra abre un LOTE: cuánto, cuándo y lo que costó de verdad (el importe pagado, con sus comisiones y su prima). • Cada venta o reembolso consume lotes empezando por el más antiguo. Su ganancia o pérdida es lo cobrado menos el coste de los lotes consumidos. • Los valores homogéneos forman una BOLSA y el FIFO va por bolsa. Cada activo es la suya, salvo las criptomonedas: todos los activos de la misma moneda son una sola bolsa, estén en el monedero, el exchange o el nodo que estén. Criterios aplicados (ver CRITERIOS, que es lo que se enseña en pantalla): fifo «Cuando existan valores homogéneos se considerará que los transmitidos son aquellos que adquirió en primer lugar» (art. 37.2 de la Ley 35/2006, del IRPF). cripto La DGT aplica ese mismo FIFO a las criptomonedas, por moneda y sin distinguir dónde están (consultas V1604-18, V0975-22). OJO: el TSJ del País Vasco (9-ene-2025, contra la Hacienda de Gipuzkoa) ha dicho que el FIFO de «valores» no es aplicable a las criptomonedas. Aquí se sigue el criterio administrativo, que es el prudente. traspaso Un traspaso entre fondos de inversión no tributa: las participaciones nuevas conservan el coste y la fecha de las traspasadas (art. 94.1.a de la Ley 35/2006). Si al apuntar el traspaso se dio el coste fiscal de la gestora, manda ese. traslado Mover la misma moneda entre lugares tuyos (o entre dos activos de la misma moneda, como BTC y un nodo Lightning) no es una transmisión. comision La comisión de red pagada en la propia moneda se trata como una pequeña transmisión a valor de mercado (criterio prudente). Alternativa: opciones["comision"] = "coste", que la deja sin efecto y reparte su coste entre lo que queda. sin_desembolso Lo minado o recibido gratis entra por su valor de mercado de ese día: es lo que se declara como ingreso al recibirlo, y al venderlo solo tributa lo que haya subido desde entonces. Alternativa: opciones["sin_desembolso"] = "cero". pago Pagar o regalar con una criptomoneda es transmitirla a su valor de mercado: en la cartera ya es una Venta. Lo que NO entra: los planes de pensiones (al rescatarlos son rendimientos del trabajo, no ganancias), las cuentas de trading y los activos de los que solo se apunta el valor total (sin participaciones no hay lotes). Salen listados aparte, con el motivo. La regla de la recompra («de los dos meses») Una pérdida al vender acciones, ETF o fondos no se puede restar si se compraron valores homogéneos dentro del plazo anterior o posterior a la venta: dos meses si cotizan, un año si no (art. 33.5.f y g de la Ley 35/2006). Queda aplazada y se integra a medida que se venden los valores recomprados. Aquí: • solo bloquea lo recomprado que sigue en cartera tras la venta (si lo comprado el mes anterior es justo lo que se vende, no hay recompra); • si se recompra menos de lo vendido, se aplaza la parte proporcional; • cada valor recomprado bloquea una sola vez; • un traspaso a otro fondo no libera la pérdida: viaja con las participaciones. No se aplica a las criptomonedas, que no son «valores o participaciones», salvo donde la norma del territorio lo diga expresamente (Bizkaia y Álava: reglas/.json → «recompra_cripto», con su plazo y desde qué año). En los fondos el plazo es discutible (no cotizan, pero lo habitual es aplicarles los dos meses): opciones["plazo_fondos"] = 2 o 12. """ from __future__ import annotations import datetime as dt from .. import spot_btc from . import actividad as _actividad from .. import rentafija as _rentafija from ..calculo import Precios, por_valor CRITERIOS = [ {"id": "fifo", "titulo": "Se vende lo primero que se compró (FIFO)", "texto": "En valores homogéneos, los transmitidos son los adquiridos en primer lugar.", "fuente": "Art. 37.2 Ley 35/2006 (IRPF)"}, {"id": "cripto", "titulo": "Cada criptomoneda, una sola bolsa", "texto": "El FIFO va por moneda, sin distinguir monedero, exchange o nodo. Es el criterio de la DGT; " "el TSJ del País Vasco lo ha puesto en duda (9-ene-2025), pero aquí se sigue el administrativo.", "fuente": "DGT V1604-18, V0975-22"}, {"id": "traspaso", "titulo": "Los traspasos entre fondos no tributan", "texto": "Las participaciones nuevas conservan el coste y la fecha de las traspasadas.", "fuente": "Art. 94.1.a Ley 35/2006 (IRPF)"}, {"id": "traslado", "titulo": "Mover tu bitcoin entre sitios tuyos no es vender", "texto": "Los traslados entre lugares, y los traspasos entre activos de la misma moneda, no son transmisiones.", "fuente": ""}, {"id": "comision", "titulo": "La comisión de red es una pequeña transmisión", "texto": "Lo que se paga de comisión en la propia moneda se transmite a su valor de mercado de ese día. " "Es el criterio prudente; el efecto suele ser de céntimos.", "fuente": "Criterio prudente (sin pronunciamiento expreso)"}, {"id": "sin_desembolso", "titulo": "Lo minado o recibido entra por su valor de ese día", "texto": "Ese valor es el ingreso que se declara al recibirlo y pasa a ser su coste; al venderlo solo " "tributa lo que haya subido desde entonces.", "fuente": ""}, {"id": "recompra", "titulo": "Una pérdida no se resta si recompras el mismo valor", "texto": "En acciones, ETF y fondos, si compraste el mismo valor dentro del plazo anterior o posterior a " "la venta, la pérdida queda aplazada hasta que vendas lo recomprado. No se aplica a las criptomonedas.", "fuente": "Art. 33.5.f y g Ley 35/2006 (IRPF)"}, ] # Lo que se puede elegir en pantalla. La primera opción de cada una es la de partida. OPCIONES = { "territorio": { "titulo": "Dónde declaras", "valores": [["comun", "Territorio común (toda España salvo el País Vasco y Navarra; también Canarias, Ceuta y Melilla)"], ["alava", "Álava"], ["bizkaia", "Bizkaia"], ["gipuzkoa", "Gipuzkoa"], ["navarra", "Navarra"]]}, "comision": { "titulo": "Comisión de red pagada en bitcoin", "valores": [["transmision", "Es una pequeña venta a valor de mercado (prudente)"], ["coste", "No tributa: su coste se reparte entre lo que queda"]]}, "sin_desembolso": { "titulo": "Lo minado o recibido sin pagar", "valores": [["mercado", "Lo declaré como ingreso al recibirlo: su coste es el valor de ese día"], ["cero", "No lo declaré al recibirlo: coste cero, tributa entero al venderlo"]]}, "plazo_fondos": { "titulo": "Plazo de la recompra en fondos de inversión", "valores": [["2", "Dos meses (lo habitual)"], ["12", "Un año (lectura literal: los fondos no cotizan)"]]}, "amortizacion": { "titulo": "Cómo se amortizan los aparatos de la minería", "valores": [["informatica", "Como equipos para procesos de información"], ["maquinaria", "Como maquinaria (más despacio)"]]}, } PLAZO_COTIZADOS = 2 # meses, acciones y ETF # Cómo se cuenta el criterio cuando se elige la opción que no es la de partida ALTERNATIVOS = { ("comision", "coste"): { "titulo": "La comisión de red no tributa", "texto": "Lo que se paga de comisión en la propia moneda no se trata como una venta: su coste se reparte " "entre lo que queda. Es el criterio menos prudente de los dos.", "fuente": "Elegido por ti"}, ("sin_desembolso", "cero"): { "titulo": "Lo minado o recibido entra a coste cero", "texto": "No se declaró como ingreso al recibirlo, así que no tiene coste: al venderlo tributa por todo lo " "cobrado. Lo que correspondía era declararlo al recibirlo.", "fuente": "Elegido por ti"}, ("plazo_fondos", "12"): { "id": "recompra", "titulo": "Una pérdida no se resta si recompras el mismo valor", "texto": "En acciones y ETF, dentro de los dos meses anteriores o posteriores a la venta; en fondos de " "inversión, dentro de un año (has elegido la lectura literal: no cotizan). La pérdida queda aplazada " "hasta que vendas lo recomprado. No se aplica a las criptomonedas.", "fuente": "Art. 33.5.f y g Ley 35/2006 (IRPF)"}, } def recompra_cripto_en(regla: dict | None, ejercicio: int) -> dict | None: """La regla de la recompra para criptomonedas del territorio, si rige ese año.""" return regla if regla and ejercicio >= regla["desde"] else None def criterios_de(opciones: dict, citas: dict | None = None, cripto: dict | None = None) -> list[dict]: """Los criterios tal como se están aplicando con esas opciones. «citas» son las del territorio (id del criterio → artículo de su norma), que se añaden a la de territorio común. «cripto» es la regla de la recompra para criptomonedas, si rige en el territorio ese año.""" cambios = {} for (clave, valor), nuevo in ALTERNATIVOS.items(): if opciones.get(clave) == valor: cambios[nuevo.get("id", clave)] = nuevo salida = [] for c in CRITERIOS: c = {**c, **cambios.get(c["id"], {})} if c["id"] == "recompra" and cripto: c["texto"] = c["texto"].replace( "No se aplica a las criptomonedas.", f"En las criptomonedas también, con {cripto['meses']} meses antes o después ({cripto['cita']}).") propia = (citas or {}).get(c["id"]) if propia: c["fuente"] = f"{propia} · {c['fuente']}" if c["fuente"] else propia salida.append(c) return salida def opciones_validas(opciones: dict | None) -> dict: """Las opciones conocidas, con su valor de partida donde falte o no valga.""" opciones = opciones or {} return {k: (str(opciones.get(k)) if str(opciones.get(k)) in [v for v, _ in o["valores"]] else o["valores"][0][0]) for k, o in OPCIONES.items()} def _mas_meses(fecha: str, meses: int) -> str: d = dt.date.fromisoformat(fecha) total = d.year * 12 + d.month - 1 + meses año, mes = divmod(total, 12) for dia in (d.day, 30, 29, 28): try: return dt.date(año, mes + 1, dia).isoformat() except ValueError: continue return fecha SIN_CANTIDAD = 1e-9 ENTRA = {"Compra", "Mineria", "Recompensa"} TRANSMITE = {"Venta", "Reembolso"} EXCLUIDOS = {"PlanPensiones": "al rescatarlo es un rendimiento del trabajo, no una ganancia patrimonial", "CuentaTrading": "las ganancias están dentro de la cuenta: copia el resultado del informe del bróker en «Cuentas de bróker o trading»", "Cuenta": None, "Deuda": None} CLASES = {"Fondo": "Fondos de inversión", "ETF": "Acciones y ETF", "Accion": "Acciones y ETF", "Cripto": "Criptomonedas", "Metal": "Metales", "Tierra": "Inmuebles y tierra", "Prestamo": "Préstamos"} CONCEPTO_RENDIMIENTO = {"Cuenta": "Intereses de cuentas", "Prestamo": "Intereses de préstamos", "RentaFija": "Cupones e intereses de renta fija", "Tierra": "Arrendamientos", "Fondo": "Dividendos", "ETF": "Dividendos", "Accion": "Dividendos"} def _f(v) -> float: return float(v) if v else 0.0 def _importe(m: dict) -> float: return _f(m.get("importe")) or _f(m.get("cantidad")) * _f(m.get("precio")) def _bolsa(a: dict) -> str: """Los valores homogéneos se agrupan: cada criptomoneda es una sola bolsa.""" if a["tipo"] == "Cripto": return "cripto:" + ((a.get("codigo") or "").strip().lower() or a["id"]) return a["id"] def ejercicios(movimientos: list[dict]) -> list[int]: return sorted({int(m["fecha"][:4]) for m in movimientos}, reverse=True) class _Motor: def __init__(self, activos, movimientos, precios, opciones, recompra_cripto=None, coeficientes=None): self.activos = {a["id"]: a for a in activos} self.coeficientes = coeficientes # País Vasco: el coste se actualiza según el año en que se pagó self.recompra_cripto = recompra_cripto # la regla del territorio para criptomonedas, si la tiene self.precios = Precios(precios) self.opciones = opciones_validas(opciones) self.aplazadas: list[dict] = [] # pérdidas bloqueadas por una recompra, aún sin integrar self.integradas: list[dict] = [] # pérdidas de antes que se liberan al vender lo recomprado self.bloqueando: dict[str, float] = {} # compra → cantidad suya que ya bloquea una pérdida self.compras: dict[str, list[dict]] = {} # bolsa → sus adquisiciones, para mirar hacia delante self.foto: list[dict] | None = None # existencias el último día del ejercicio self.lotes: dict[str, list[dict]] = {} # bolsa → lotes, del más antiguo al más nuevo self.transmisiones: list[dict] = [] self.rendimientos: list[dict] = [] self.recibido: list[dict] = [] # minado y recibido sin pagar self.gastos: list[dict] = [] self.tareas: list[dict] = [] self.fuera: dict[str, dict] = {} # activos que no entran, con el motivo self.movs = [m for m in movimientos if m["activo"] in self.activos] # ── precios de mercado ── def mercado(self, a: dict, fecha: str) -> float | None: if a["tipo"] == "Cripto" and (a.get("codigo") or "").lower() == "bitcoin": return spot_btc.precio(fecha, self.precios.de(a["id"]))[0] r = self.precios.ultimo(a["id"], dt.date.fromisoformat(fecha)) return r.get("precio") if r else None # ── lotes ── def abrir(self, bolsa, fecha, cantidad, coste, m, **extra): if cantidad > SIN_CANTIDAD: self.lotes.setdefault(bolsa, []).append( {"fecha": fecha, "cantidad": cantidad, "coste": coste, "origen": m["id"], "raiz": m["id"], "estimado": bool(m.get("estimado") or m.get("pendiente")), **extra}) def consumir(self, bolsa, cantidad) -> tuple[list[dict], float]: """Saca «cantidad» de los lotes más antiguos. Devuelve (trozos, lo que no se pudo cubrir).""" trozos, falta = [], cantidad cola = self.lotes.get(bolsa, []) while falta > SIN_CANTIDAD and cola: lote = cola[0] q = min(lote["cantidad"], falta) parte = lote["coste"] * q / lote["cantidad"] trozos.append({**lote, "cantidad": q, "coste": parte}) lote["cantidad"] -= q lote["coste"] -= parte falta -= q if lote["cantidad"] <= SIN_CANTIDAD: cola.pop(0) return trozos, max(falta, 0.0) def tarea(self, m, texto, clase="aviso"): self.tareas.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": m["activo"], "texto": texto, "clase": clase}) # ── cada operación ── def transmitir(self, m, a, bolsa, cantidad, valor, concepto): trozos, falta = self.consumir(bolsa, cantidad) coste = sum(t["coste"] for t in trozos) avisos = [] if falta > max(cantidad * 1e-6, SIN_CANTIDAD): avisos.append("sin_cubrir") self.tarea(m, f"{concepto} de {cantidad:g} sin compras anteriores suficientes: faltan {falta:g} " "por apuntar (su coste cuenta como cero).", "error") if any(t.get("estimado") for t in trozos) or m.get("estimado"): avisos.append("estimado") self.tarea(m, f"{concepto} que se apoya en un importe estimado: confirma lo que pagaste o cobraste de verdad.") if any(t.get("sin_fecha") for t in trozos): avisos.append("sin_fecha") actualizacion = 0.0 if self.coeficientes: tabla = self.coeficientes["tablas"].get(m["fecha"][:4]) if tabla: primero = min(tabla) actualizacion = round(sum(t["coste"] * (tabla.get(max(t["fecha"][:4], primero)) or 1.0) for t in trozos) - coste, 2) coste += actualizacion else: avisos.append("sin_coeficientes") self.tarea(m, f"No hay tabla de coeficientes de actualización para {m['fecha'][:4]}: el coste va sin actualizar.") ganancia = round(valor - coste, 2) self.liberar(m, a, trozos) recompras, aplazada = self.recompra(m, a, bolsa, cantidad, ganancia) if ganancia < 0 else ([], 0.0) self.transmisiones.append({ "movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"], "clase": CLASES.get(a["tipo"], "Otros"), "concepto": concepto, "cantidad": cantidad, "valor_transmision": round(valor, 2), "valor_adquisicion": round(coste, 2), "actualizacion": actualizacion, "ganancia": ganancia, "aplazada": aplazada, "computable": round(ganancia + aplazada, 2), "recompras": recompras, "avisos": avisos, "lotes": [{"fecha": t["fecha"], "cantidad": t["cantidad"], "coste": round(t["coste"], 2)} for t in trozos]}) # ── la regla de la recompra ── def plazo(self, a: dict, fecha: str) -> int | None: """Meses del plazo de recompra para ese activo vendido ese día; None si no le toca la regla.""" if a["tipo"] == "Cripto": regla = recompra_cripto_en(self.recompra_cripto, int(fecha[:4])) return regla["meses"] if regla else None if a["tipo"] in ("Accion", "ETF"): return PLAZO_COTIZADOS if a["tipo"] == "Fondo": return int(self.opciones["plazo_fondos"]) return None def recompra(self, m, a, bolsa, cantidad, perdida) -> tuple[list[dict], float]: """Cuánto de esa pérdida queda aplazado por haber comprado el mismo valor cerca de la venta. Devuelve (las compras que la bloquean, importe aplazado > 0).""" meses = self.plazo(a, m["fecha"]) if not meses or cantidad <= SIN_CANTIDAD: return [], 0.0 desde, hasta = _mas_meses(m["fecha"], -meses), _mas_meses(m["fecha"], meses) candidatas = [] # (id de la compra, fecha, cantidad que puede bloquear) # Compradas antes: solo lo que sigue en cartera después de esta venta vivas: dict[str, float] = {} for lote in self.lotes.get(bolsa, []): clave = lote.get("entrada") or lote["raiz"] vivas[clave] = vivas.get(clave, 0.0) + lote["cantidad"] for c in self.compras.get(bolsa, []): if desde <= c["fecha"] <= m["fecha"] and c["id"] in vivas: candidatas.append((c["id"], c["fecha"], vivas[c["id"]])) elif m["fecha"] < c["fecha"] <= hasta: candidatas.append((c["id"], c["fecha"], c["cantidad"])) falta, recompras = cantidad, [] for cid, fecha, disponible in sorted(candidatas, key=lambda x: x[1]): libre = disponible - self.bloqueando.get(cid, 0.0) q = min(libre, falta) if q <= SIN_CANTIDAD: continue self.bloqueando[cid] = self.bloqueando.get(cid, 0.0) + q importe = round(-perdida * q / cantidad, 2) self.aplazadas.append({"venta": m["id"], "fecha_venta": m["fecha"], "activo": a["id"], "nombre": a["nombre"], "compra": cid, "fecha_compra": fecha, "cantidad": q, "pendiente": importe}) recompras.append({"fecha": fecha, "cantidad": q, "importe": importe}) falta -= q if falta <= SIN_CANTIDAD: break return recompras, round(sum(r["importe"] for r in recompras), 2) def mudar(self, trozo, entrada: str, escala: float): """Un traspaso no libera la pérdida aplazada: lo que bloqueaban esas participaciones pasa a bloquearlo lo recibido en el fondo de destino.""" clave = trozo.get("entrada") or trozo["raiz"] falta = trozo["cantidad"] for x in list(self.aplazadas): if x["compra"] != clave or x["cantidad"] <= SIN_CANTIDAD or falta <= SIN_CANTIDAD: continue q = min(falta, x["cantidad"]) importe = round(x["pendiente"] * q / x["cantidad"], 2) x["cantidad"] -= q x["pendiente"] = round(x["pendiente"] - importe, 2) self.bloqueando[clave] = max(self.bloqueando.get(clave, 0.0) - q, 0.0) self.bloqueando[entrada] = self.bloqueando.get(entrada, 0.0) + q * escala self.aplazadas.append({**x, "compra": entrada, "cantidad": q * escala, "pendiente": importe}) falta -= q def liberar(self, m, a, trozos): """Al vender lo recomprado, la pérdida que bloqueaba se integra, en proporción.""" for t in trozos: clave = t.get("entrada") or t["raiz"] for x in self.aplazadas: if x["compra"] != clave or x["cantidad"] <= SIN_CANTIDAD or x["pendiente"] <= 0: continue q = min(t["cantidad"], x["cantidad"]) importe = round(x["pendiente"] * q / x["cantidad"], 2) x["cantidad"] -= q x["pendiente"] = round(x["pendiente"] - importe, 2) self.bloqueando[clave] = max(self.bloqueando.get(clave, 0.0) - q, 0.0) if importe: self.integradas.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"], "importe": -importe, "fecha_venta": x["fecha_venta"], "nombre_venta": x["nombre"]}) def comision(self, m, a, bolsa): cantidad = _f(m.get("cantidad")) if cantidad <= SIN_CANTIDAD: return precio = self.mercado(a, m["fecha"]) if a["tipo"] == "Cripto" else None if self.opciones["comision"] == "transmision" and precio: self.transmitir(m, a, bolsa, cantidad, cantidad * precio, "Comisión pagada en la propia moneda") return # Sin efecto: lo que costó lo que se pierde se reparte entre lo que queda trozos, _ = self.consumir(bolsa, cantidad) perdido = sum(t["coste"] for t in trozos) resto = self.lotes.get(bolsa, []) total = sum(x["cantidad"] for x in resto) for x in resto: x["coste"] += perdido * x["cantidad"] / total if total else 0.0 def entrada_gratis(self, m, a, bolsa): cantidad = _f(m.get("cantidad")) valor = _f(m.get("valor_recibido")) if not valor: precio = self.mercado(a, m["fecha"]) valor = cantidad * precio if precio else 0.0 if not precio: self.tarea(m, "No sé cuánto valía ese día lo recibido: pon el valor en la operación.") self.recibido.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"], "tipo": m["tipo"], "actividad": m.get("actividad") or "", "cantidad": cantidad, "valor": round(valor, 2)}) coste = valor if self.opciones["sin_desembolso"] == "mercado" else 0.0 self.abrir(bolsa, m["fecha"], cantidad, coste, m) def traspaso(self, sal, ent): """Diferimiento: los lotes pasan del fondo de origen al de destino con su coste y su fecha.""" a, b = self.activos[sal["activo"]], self.activos[ent["activo"]] origen, destino = _bolsa(a), _bolsa(b) q_sal, q_ent = _f(sal.get("cantidad")), _f(ent.get("cantidad")) if origen == destino: # La misma moneda en dos activos: no pasa nada; lo que no llega es la comisión if q_sal - q_ent > SIN_CANTIDAD: self.comision({**sal, "cantidad": q_sal - q_ent}, a, origen) return trozos, falta = self.consumir(origen, q_sal) if falta > max(q_sal * 1e-6, SIN_CANTIDAD): self.tarea(sal, f"Traspaso de {q_sal:g} sin compras anteriores suficientes en «{a['nombre']}».", "error") coste = sum(t["coste"] for t in trozos) declarado = _f(ent.get("coste_traspaso")) # El coste fiscal que dio la gestora en la orden del traspaso manda sobre el reconstruido factor = declarado / coste if declarado > 0 and coste > 0 else 1.0 cubierto = sum(t["cantidad"] for t in trozos) or 1.0 for t in trozos: self.abrir(destino, t["fecha"], q_ent * t["cantidad"] / cubierto, t["coste"] * factor, ent, traspasado=True, sin_fecha=t.get("sin_fecha", False)) nuevo = self.lotes[destino][-1] nuevo["raiz"], nuevo["entrada"] = t["raiz"], ent["id"] self.mudar(t, ent["id"], q_ent / q_sal if q_sal else 1.0) if not trozos: self.abrir(destino, ent["fecha"], q_ent, declarado or _importe(ent), ent, traspasado=True, sin_fecha=True) def procesar(self, hasta: str): rango = {"Compra": 0, "Mineria": 0, "Recompensa": 0, "Traspaso_Entrada": 0, "Traspaso_Salida": 1} # Se procesa TODO, también lo posterior al ejercicio: una compra de enero bloquea una pérdida de diciembre movs = sorted(self.movs, key=lambda m: (m["fecha"], rango.get(m["tipo"], 2), m.get("orden") or 0, m["id"])) for m in movs: a = self.activos[m["activo"]] # En criptomonedas cuenta toda adquisición (también lo minado) y no los traslados entre lugares tuyos # (una compra de diciembre puede bloquear una venta de enero del primer año de la regla) if a["tipo"] == "Cripto": cuenta = m["tipo"] in ENTRA and self.recompra_cripto is not None else: cuenta = m["tipo"] in ("Compra", "Traspaso_Entrada") and self.plazo(a, m["fecha"]) is not None if cuenta and _f(m.get("cantidad")) > SIN_CANTIDAD: self.compras.setdefault(_bolsa(a), []).append( {"id": m["id"], "fecha": m["fecha"], "cantidad": _f(m.get("cantidad"))}) # Cada traspaso de salida, con su entrada del mismo día y el mismo importe entradas = [m for m in movs if m["tipo"] == "Traspaso_Entrada"] pareja, usadas = {}, set() for s in (m for m in movs if m["tipo"] == "Traspaso_Salida"): val = _importe(s) candidatas = [e for e in entradas if e["fecha"] == s["fecha"] and e["id"] not in usadas and e["activo"] != s["activo"] and abs(_importe(e) - val) <= max(val * 0.01, 1.0)] if candidatas: e = min(candidatas, key=lambda e: abs(_importe(e) - val)) pareja[s["id"]] = e usadas.add(e["id"]) for m in movs: if self.foto is None and m["fecha"] > hasta: self.foto = self.existencias(hasta) a = self.activos[m["activo"]] if a["tipo"] == "RentaFija": # Cupones, intereses y lo que se gana o pierde al devolverse o venderse: rendimientos del capital mobiliario if m["tipo"] == "Dividendo": self.rendimiento(m, a) elif m["tipo"] == "Reembolso": suyos = [x for x in self.movs if x["activo"] == a["id"]] bruto = _rentafija.rendimiento_reembolso(m, suyos, _f((a.get("renta_fija") or {}).get("nominal"))) self.rendimientos.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"], "concepto": "Renta fija: devolución o venta", "bruto": bruto, "retencion": round(_f(m.get("retencion")), 2), "neto": round(bruto - _f(m.get("retencion")), 2)}) continue if a["tipo"] == "Inmueble": continue # alquiler y venta tienen su cálculo propio (alquiler.py) if a["tipo"] in EXCLUIDOS or por_valor(a): motivo = EXCLUIDOS.get(a["tipo"], "solo se apunta su valor total: sin participaciones no hay lotes") if motivo and m["tipo"] in TRANSMITE | {"Compra"}: self.fuera.setdefault(a["id"], {"activo": a["id"], "nombre": a["nombre"], "motivo": motivo, "cobrado": {}}) if m["tipo"] in TRANSMITE: año = m["fecha"][:4] self.fuera[a["id"]]["cobrado"][año] = round( self.fuera[a["id"]]["cobrado"].get(año, 0.0) + _importe(m), 2) if m["tipo"] == "Dividendo": self.rendimiento(m, a) continue bolsa, t = _bolsa(a), m["tipo"] if t == "Compra": self.abrir(bolsa, m["fecha"], _f(m.get("cantidad")), _importe(m), m) elif t in ("Mineria", "Recompensa"): self.entrada_gratis(m, a, bolsa) elif t in TRANSMITE: self.transmitir(m, a, bolsa, _f(m.get("cantidad")), _importe(m), "Venta" if t == "Venta" else "Reembolso") elif t == "Comision": self.comision(m, a, bolsa) elif t == "Traspaso_Salida": if m["id"] in pareja: self.traspaso(m, pareja[m["id"]]) else: self.consumir(bolsa, _f(m.get("cantidad"))) self.tarea(m, "Traspaso de salida sin su entrada: no sé a qué fondo fue.") elif t == "Traspaso_Entrada": if m["id"] not in usadas: # Viene de un fondo que no está en la cartera: se sabe el coste, no las fechas self.abrir(bolsa, m["fecha"], _f(m.get("cantidad")), _f(m.get("coste_traspaso")) or _importe(m), m, traspasado=True, sin_fecha=True) elif t == "Dividendo": self.rendimiento(m, a) elif t in ("Gasto", "Devolucion"): signo = 1 if t == "Gasto" else -1 self.gastos.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"], "actividad": m.get("actividad") or "", "importe": round(signo * _f(m.get("importe")), 2), "notas": m.get("notas") or "", "amortizable": bool(m.get("amortizable")), "estimado": bool(m.get("estimado") or m.get("pendiente"))}) def cerrar(self, hasta: str): if self.foto is None: self.foto = self.existencias(hasta) def rendimiento(self, m, a): if m.get("impagado"): return neto, retencion = _f(m.get("importe")), _f(m.get("retencion")) self.rendimientos.append({"movimiento": m["id"], "fecha": m["fecha"], "activo": a["id"], "nombre": a["nombre"], "concepto": CONCEPTO_RENDIMIENTO.get(a["tipo"], "Otros rendimientos"), "bruto": round(neto + retencion, 2), "retencion": round(retencion, 2), "neto": round(neto, 2)}) def existencias(self, fecha: str) -> list[dict]: """Lo que queda sin vender ese día, bolsa a bolsa, con su coste y su valor.""" salida = [] for bolsa, lotes in self.lotes.items(): cantidad = sum(x["cantidad"] for x in lotes) if cantidad <= SIN_CANTIDAD: continue suyos = [a for a in self.activos.values() if _bolsa(a) == bolsa and a["tipo"] not in EXCLUIDOS] a = suyos[0] precio = self.mercado(a, fecha) salida.append({"bolsa": bolsa, "nombre": a["nombre"] if len(suyos) == 1 else a["nombre"].split(" en ")[0], "clase": CLASES.get(a["tipo"], "Otros"), "cantidad": cantidad, "coste": round(sum(x["coste"] for x in lotes), 2), "valor": round(cantidad * precio, 2) if precio else None, "lotes": len(lotes), "primer_lote": lotes[0]["fecha"]}) return sorted(salida, key=lambda x: (x["clase"], x["nombre"])) def informe(activos: list[dict], movimientos: list[dict], precios: list[dict], ejercicio: int, opciones: dict | None = None, citas: dict | None = None, actividad: dict | None = None, recompra_cripto: dict | None = None, coeficientes: dict | None = None) -> dict: """Lo que hay que mirar para declarar ese ejercicio. «actividad» son las reglas del territorio para las actividades económicas (ver actividad.py); «recompra_cripto», su regla de la recompra para criptomonedas, si la tiene.""" motor = _Motor(activos, movimientos, precios, opciones, recompra_cripto, coeficientes) fin = f"{ejercicio}-12-31" motor.procesar(fin) motor.cerrar(fin) año = str(ejercicio) del_año = lambda lista: [x for x in lista if x["fecha"].startswith(año)] # noqa: E731 transmisiones = del_año(motor.transmisiones) por_clase: dict[str, dict] = {} for t in transmisiones: c = por_clase.setdefault(t["clase"], {"clase": t["clase"], "transmisiones": 0, "valor_transmision": 0.0, "valor_adquisicion": 0.0, "ganancias": 0.0, "perdidas": 0.0}) c["transmisiones"] += 1 c["valor_transmision"] += t["valor_transmision"] c["valor_adquisicion"] += t["valor_adquisicion"] c["ganancias" if t["ganancia"] >= 0 else "perdidas"] += t["ganancia"] c["aplazadas"] = c.get("aplazadas", 0.0) + t["aplazada"] integradas = del_año(motor.integradas) for c in por_clase.values(): for k in ("valor_transmision", "valor_adquisicion", "ganancias", "perdidas", "aplazadas"): c[k] = round(c[k], 2) c["neto"] = round(c["ganancias"] + c["perdidas"], 2) aplazado = round(sum(t["aplazada"] for t in transmisiones), 2) integrado = round(sum(x["importe"] for x in integradas), 2) rendimientos = del_año(motor.rendimientos) por_concepto: dict[str, dict] = {} for r in rendimientos: c = por_concepto.setdefault(r["concepto"], {"concepto": r["concepto"], "bruto": 0.0, "retencion": 0.0, "neto": 0.0}) for k in ("bruto", "retencion", "neto"): c[k] = round(c[k] + r[k], 2) recibido = del_año(motor.recibido) # Lo pendiente que afecta a ESTE ejercicio: sus propias operaciones y lo estimado que sigue sin confirmar tareas = del_año(motor.tareas) tareas += [{"movimiento": m["id"], "fecha": m["fecha"], "activo": m["activo"], "clase": "aviso", "texto": "Operación con cifras estimadas o sin completar."} for m in motor.movs if m["fecha"].startswith(año) and (m.get("estimado") or m.get("pendiente")) and m["id"] not in {t["movimiento"] for t in tareas}] return { "ejercicio": ejercicio, "ejercicios": ejercicios(motor.movs), "transmisiones": transmisiones, "por_clase": sorted(por_clase.values(), key=lambda c: c["clase"]), "total": {"ganancias": round(sum(c["ganancias"] for c in por_clase.values()), 2), "perdidas": round(sum(c["perdidas"] for c in por_clase.values()), 2), "neto": round(sum(c["neto"] for c in por_clase.values()), 2), # Lo que de verdad se resta o suma este año, con la regla de la recompra "aplazadas": aplazado, "integradas": integrado, "computable": round(sum(c["neto"] for c in por_clase.values()) + aplazado + integrado, 2)}, "integradas": integradas, # Pérdidas aplazadas que siguen esperando a que se venda lo recomprado (de ventas hasta este ejercicio) "pendientes": [{k: x[k] for k in ("fecha_venta", "nombre", "fecha_compra", "pendiente")} for x in motor.aplazadas if x["pendiente"] > 0 and x["fecha_venta"] <= fin], "rendimientos": rendimientos, "por_concepto": sorted(por_concepto.values(), key=lambda c: c["concepto"]), "recibido": recibido, "total_recibido": round(sum(r["valor"] for r in recibido), 2), "gastos": del_año(motor.gastos), # El rendimiento de cada actividad económica (la minería): va a la parte general "actividades": _actividad.rendimientos(motor.gastos, motor.recibido, ejercicio, motor.opciones, actividad) if actividad else [], "existencias": motor.foto, "fuera": [{**f, "cobrado": f["cobrado"].get(año, 0.0)} for f in motor.fuera.values()], "tareas": sorted(tareas, key=lambda t: (t["clase"] != "error", t["fecha"])), "opciones": motor.opciones, "opciones_posibles": OPCIONES, "criterios": criterios_de(motor.opciones, citas, recompra_cripto_en(recompra_cripto, ejercicio)) + ([{"id": "actualizacion", "titulo": "El coste se actualiza con coeficientes", "texto": coeficientes["texto"] + ("" if coeficientes["verificado"] else " " + coeficientes["nota"]), "fuente": coeficientes["cita"]}] if coeficientes else []), }