from decimal import Decimal from django.utils import timezone from .models import Dividendo, Divisa, Operacion, Valor CENT = Decimal('0.01') def actualizar_cambios(): """Actualiza el cambio a euros de cada divisa desde Yahoo Finance. Devuelve (correctos, errores) donde errores es una lista de (par, motivo). """ import yfinance as yf correctos = 0 errores = [] for divisa in Divisa.objects.exclude(codigo='EUR'): par = f'{divisa.codigo}EUR=X' try: cierre = yf.Ticker(par).history(period='5d')['Close'] cambio = Decimal(str(cierre.iloc[-1])).quantize(Decimal('0.000001')) except Exception as e: errores.append((par, str(e)[:100])) continue divisa.cambio_eur = cambio divisa.actualizado_en = timezone.now() divisa.save(update_fields=['cambio_eur', 'actualizado_en']) correctos += 1 return correctos, errores def actualizar_precios(): """Obtiene la última cotización de cada valor desde Yahoo Finance. Devuelve (correctos, errores) donde errores es una lista de (ticker, motivo). """ import yfinance as yf correctos = 0 errores = [] for valor in Valor.objects.all(): try: cierre = yf.Ticker(valor.ticker).history(period='5d')['Close'] precio = Decimal(str(cierre.iloc[-1])).quantize(Decimal('0.0001')) except Exception as e: errores.append((valor.ticker, str(e)[:100])) continue valor.precio_actual = precio valor.actualizado_en = timezone.now() valor.save(update_fields=['precio_actual', 'actualizado_en']) correctos += 1 return correctos, errores def posiciones(): """Calcula la situación de cada valor con operaciones registradas. Devuelve una lista de diccionarios ordenada por ticker con: - acciones en cartera, precio medio ponderado e importe invertido - plusvalías realizadas (ventas) según FIFO - dividendos netos cobrados Las posiciones cerradas usan el cambio congelado en cada registro: no varían al actualizar cotizaciones ni tipos de cambio. """ resultado = [] for valor in Valor.objects.select_related('divisa'): ops = valor.operaciones.order_by('fecha', 'pk') if not ops and not valor.dividendos.exists(): continue acciones = Decimal('0') coste = Decimal('0') # coste de las acciones aún en cartera precio_medio = Decimal('0') comisiones = Decimal('0') for op in ops: comisiones += op.comisiones if op.tipo == Operacion.COMPRA: acciones += op.cantidad coste += op.cantidad * op.precio + op.comisiones if acciones > 0: precio_medio = (coste / acciones).quantize(Decimal('0.0001')) else: if acciones <= 0: continue pm = coste / acciones vendidas = min(op.cantidad, acciones) acciones -= vendidas coste -= vendidas * pm if acciones > 0: precio_medio = (coste / acciones).quantize(Decimal('0.0001')) else: precio_medio = Decimal('0') filas_fifo = _fifo_de_operaciones(valor, ops) plusvalias = sum( (f['resultado'] for f in filas_fifo if f['resultado'] is not None), Decimal('0'), ) plusvalias_eur = sum( (f['resultado_eur'] for f in filas_fifo if f['resultado_eur'] is not None), Decimal('0'), ) cambio = valor.divisa.cambio_eur dividendos = sum((d.neto for d in valor.dividendos.all()), Decimal('0')) dividendos_eur = sum( (d.neto * (d.cambio_eur or cambio) for d in valor.dividendos.all()), Decimal('0'), ) valoracion = None latente = None if acciones > 0 and valor.precio_actual is not None: valoracion = (acciones * valor.precio_actual).quantize(CENT) latente = (valoracion - coste).quantize(CENT) def a_eur(importe): return None if importe is None else (importe * cambio).quantize(CENT) resultado.append({ 'valor': valor, 'divisa': valor.divisa.codigo, 'cambio': cambio, 'acciones': acciones, 'precio_medio': precio_medio, 'invertido': coste.quantize(CENT), 'plusvalias': plusvalias.quantize(CENT), 'comisiones': comisiones, 'dividendos': dividendos, 'valoracion': valoracion, 'latente': latente, # abiertas: cambio actual; cerradas: congelado en cada registro 'invertido_eur': a_eur(coste), 'plusvalias_eur': plusvalias_eur.quantize(CENT), 'dividendos_eur': dividendos_eur.quantize(CENT), 'valoracion_eur': a_eur(valoracion), 'latente_eur': a_eur(latente), }) return sorted(resultado, key=lambda p: p['valor'].ticker) def totales(posiciones_lista=None): """Agrega los importes globales de la cartera, en euros.""" posiciones_lista = posiciones_lista if posiciones_lista is not None else posiciones() abiertas = [p for p in posiciones_lista if p['acciones'] > 0] con_precio = [p for p in abiertas if p['valoracion'] is not None] def suma(clave, lista): return sum((p[clave] for p in lista), Decimal('0')) return { 'invertido': suma('invertido_eur', abiertas), 'plusvalias': suma('plusvalias_eur', posiciones_lista), 'dividendos': suma('dividendos_eur', posiciones_lista), 'num_abiertas': len(abiertas), 'valoracion': suma('valoracion_eur', con_precio), 'latente': ( suma('latente_eur', con_precio) if len(con_precio) == len(abiertas) else None ), } def _fifo_de_operaciones(valor, ops): """Desglose FIFO de un valor concreto. Cada venta convierte su resultado a euros con el cambio congelado en la propia operación (o el vigente si es antigua y no lo tiene). """ from collections import deque lotes = deque() # pares [cantidad disponible, coste unitario efectivo] filas = [] for op in ops: if op.tipo == Operacion.COMPRA: coste_unitario = (op.cantidad * op.precio + op.comisiones) / op.cantidad lotes.append([op.cantidad, coste_unitario]) elif op.tipo == Operacion.VENTA: por_vender = op.cantidad comision_unitaria = op.comisiones / op.cantidad cambio_venta = op.cambio_eur or valor.divisa.cambio_eur while por_vender > 0 and lotes: disponibles, coste_lote = lotes[0] usadas = min(por_vender, disponibles) importe = usadas * (op.precio - comision_unitaria) coste = usadas * coste_lote resultado = (importe - coste).quantize(CENT) filas.append({ 'fecha': op.fecha, 'valor': valor, 'divisa': valor.divisa.codigo, 'cantidad': usadas, 'precio_venta': op.precio, 'importe': importe.quantize(CENT), 'coste': coste.quantize(CENT), 'resultado': resultado, 'resultado_eur': ( None if resultado is None else (resultado * cambio_venta).quantize(CENT) ), }) por_vender -= usadas if disponibles > usadas: lotes[0][0] = disponibles - usadas else: lotes.popleft() if por_vender > 0: # venta sin acciones suficientes registradas (dato inconsistente) filas.append({ 'fecha': op.fecha, 'valor': valor, 'divisa': valor.divisa.codigo, 'cantidad': por_vender, 'precio_venta': op.precio, 'importe': Decimal('0'), 'coste': Decimal('0'), 'resultado': None, 'resultado_eur': None, }) return filas def desglose_fifo(): """Desglosa cada venta con su coste de adquisición según FIFO. Devuelve una lista de filas (una por lote consumido) ordenada por fecha, con la plusvalía o pérdida realizada en cada una. """ filas = [] for valor in Valor.objects.select_related('divisa'): filas += _fifo_de_operaciones(valor, valor.operaciones.order_by('fecha', 'pk')) filas.sort(key=lambda f: f['fecha']) return filas def resumen_fiscal(filas=None): """Agrupa el desglose FIFO por año natural, en euros (cambio de cada venta).""" from collections import defaultdict filas = filas if filas is not None else desglose_fifo() por_anyo = defaultdict(lambda: Decimal('0')) for f in filas: if f['resultado_eur'] is not None: por_anyo[f['fecha'].year] += f['resultado_eur'] return {anyo: imp.quantize(CENT) for anyo, imp in sorted(por_anyo.items())}