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

646 lines
37 KiB
Python

"""
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/<territorio>.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 []),
}