|
|
@@ -0,0 +1,176 @@
|
|
|
+from decimal import Decimal
|
|
|
+
|
|
|
+from django.utils import timezone
|
|
|
+
|
|
|
+from .models import Dividendo, Operacion, Valor
|
|
|
+
|
|
|
+
|
|
|
+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 precio medio ponderado
|
|
|
+ - dividendos netos cobrados
|
|
|
+ """
|
|
|
+ resultado = []
|
|
|
+ for valor in Valor.objects.all():
|
|
|
+ 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')
|
|
|
+ plusvalias = Decimal('0') # realizadas en ventas
|
|
|
+ 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)
|
|
|
+ ingresos = op.cantidad * op.precio - op.comisiones
|
|
|
+ plusvalias += ingresos - (vendidas * pm)
|
|
|
+ acciones -= vendidas
|
|
|
+ coste -= vendidas * pm
|
|
|
+ if acciones > 0:
|
|
|
+ precio_medio = (coste / acciones).quantize(Decimal('0.0001'))
|
|
|
+ else:
|
|
|
+ precio_medio = Decimal('0')
|
|
|
+
|
|
|
+ dividendos = sum((d.neto 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(Decimal('0.01'))
|
|
|
+ latente = (valoracion - coste).quantize(Decimal('0.01'))
|
|
|
+
|
|
|
+ resultado.append({
|
|
|
+ 'valor': valor,
|
|
|
+ 'acciones': acciones,
|
|
|
+ 'precio_medio': precio_medio,
|
|
|
+ 'invertido': coste.quantize(Decimal('0.01')),
|
|
|
+ 'plusvalias': plusvalias.quantize(Decimal('0.01')),
|
|
|
+ 'comisiones': comisiones,
|
|
|
+ 'dividendos': dividendos,
|
|
|
+ 'valoracion': valoracion,
|
|
|
+ 'latente': latente,
|
|
|
+ })
|
|
|
+
|
|
|
+ return sorted(resultado, key=lambda p: p['valor'].ticker)
|
|
|
+
|
|
|
+
|
|
|
+def totales(posiciones_lista=None):
|
|
|
+ """Agrega los importes globales de la cartera."""
|
|
|
+ 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]
|
|
|
+ invertido_con_precio = sum((p['invertido'] for p in con_precio), Decimal('0'))
|
|
|
+ return {
|
|
|
+ 'invertido': sum((p['invertido'] for p in abiertas), Decimal('0')),
|
|
|
+ 'plusvalias': sum((p['plusvalias'] for p in posiciones_lista), Decimal('0')),
|
|
|
+ 'dividendos': sum((p['dividendos'] for p in posiciones_lista), Decimal('0')),
|
|
|
+ 'num_abiertas': len(abiertas),
|
|
|
+ 'valoracion': sum((p['valoracion'] for p in con_precio), Decimal('0')),
|
|
|
+ 'latente': (
|
|
|
+ sum((p['latente'] for p in con_precio), Decimal('0'))
|
|
|
+ if len(con_precio) == len(abiertas) else None
|
|
|
+ ),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+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.
|
|
|
+ """
|
|
|
+ from collections import deque
|
|
|
+
|
|
|
+ filas = []
|
|
|
+ for valor in Valor.objects.all():
|
|
|
+ lotes = deque() # pares [cantidad disponible, coste unitario efectivo]
|
|
|
+ for op in valor.operaciones.order_by('fecha', 'pk'):
|
|
|
+ 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
|
|
|
+ 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
|
|
|
+ filas.append({
|
|
|
+ 'fecha': op.fecha,
|
|
|
+ 'valor': valor,
|
|
|
+ 'cantidad': usadas,
|
|
|
+ 'precio_venta': op.precio,
|
|
|
+ 'importe': importe.quantize(Decimal('0.01')),
|
|
|
+ 'coste': coste.quantize(Decimal('0.01')),
|
|
|
+ 'resultado': (importe - coste).quantize(Decimal('0.01')),
|
|
|
+ })
|
|
|
+ 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,
|
|
|
+ 'cantidad': por_vender,
|
|
|
+ 'precio_venta': op.precio,
|
|
|
+ 'importe': Decimal('0'),
|
|
|
+ 'coste': Decimal('0'),
|
|
|
+ 'resultado': None,
|
|
|
+ })
|
|
|
+ filas.sort(key=lambda f: f['fecha'])
|
|
|
+ return filas
|
|
|
+
|
|
|
+
|
|
|
+def resumen_fiscal(filas=None):
|
|
|
+ """Agrupa el desglose FIFO por año natural."""
|
|
|
+ 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'] is not None:
|
|
|
+ por_anyo[f['fecha'].year] += f['resultado']
|
|
|
+ return {anyo: imp.quantize(Decimal('0.01')) for anyo, imp in sorted(por_anyo.items())}
|