services.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. from decimal import Decimal
  2. from django.utils import timezone
  3. from .models import Dividendo, Operacion, Valor
  4. def actualizar_precios():
  5. """Obtiene la última cotización de cada valor desde Yahoo Finance.
  6. Devuelve (correctos, errores) donde errores es una lista de
  7. (ticker, motivo).
  8. """
  9. import yfinance as yf
  10. correctos = 0
  11. errores = []
  12. for valor in Valor.objects.all():
  13. try:
  14. cierre = yf.Ticker(valor.ticker).history(period='5d')['Close']
  15. precio = Decimal(str(cierre.iloc[-1])).quantize(Decimal('0.0001'))
  16. except Exception as e:
  17. errores.append((valor.ticker, str(e)[:100]))
  18. continue
  19. valor.precio_actual = precio
  20. valor.actualizado_en = timezone.now()
  21. valor.save(update_fields=['precio_actual', 'actualizado_en'])
  22. correctos += 1
  23. return correctos, errores
  24. def posiciones():
  25. """Calcula la situación de cada valor con operaciones registradas.
  26. Devuelve una lista de diccionarios ordenada por ticker con:
  27. - acciones en cartera, precio medio ponderado e importe invertido
  28. - plusvalías realizadas (ventas) según precio medio ponderado
  29. - dividendos netos cobrados
  30. """
  31. resultado = []
  32. for valor in Valor.objects.all():
  33. ops = valor.operaciones.order_by('fecha', 'pk')
  34. if not ops and not valor.dividendos.exists():
  35. continue
  36. acciones = Decimal('0')
  37. coste = Decimal('0') # coste de las acciones aún en cartera
  38. precio_medio = Decimal('0')
  39. plusvalias = Decimal('0') # realizadas en ventas
  40. comisiones = Decimal('0')
  41. for op in ops:
  42. comisiones += op.comisiones
  43. if op.tipo == Operacion.COMPRA:
  44. acciones += op.cantidad
  45. coste += op.cantidad * op.precio + op.comisiones
  46. if acciones > 0:
  47. precio_medio = (coste / acciones).quantize(Decimal('0.0001'))
  48. else:
  49. if acciones <= 0:
  50. continue
  51. pm = coste / acciones
  52. vendidas = min(op.cantidad, acciones)
  53. ingresos = op.cantidad * op.precio - op.comisiones
  54. plusvalias += ingresos - (vendidas * pm)
  55. acciones -= vendidas
  56. coste -= vendidas * pm
  57. if acciones > 0:
  58. precio_medio = (coste / acciones).quantize(Decimal('0.0001'))
  59. else:
  60. precio_medio = Decimal('0')
  61. dividendos = sum((d.neto for d in valor.dividendos.all()), Decimal('0'))
  62. valoracion = None
  63. latente = None
  64. if acciones > 0 and valor.precio_actual is not None:
  65. valoracion = (acciones * valor.precio_actual).quantize(Decimal('0.01'))
  66. latente = (valoracion - coste).quantize(Decimal('0.01'))
  67. resultado.append({
  68. 'valor': valor,
  69. 'acciones': acciones,
  70. 'precio_medio': precio_medio,
  71. 'invertido': coste.quantize(Decimal('0.01')),
  72. 'plusvalias': plusvalias.quantize(Decimal('0.01')),
  73. 'comisiones': comisiones,
  74. 'dividendos': dividendos,
  75. 'valoracion': valoracion,
  76. 'latente': latente,
  77. })
  78. return sorted(resultado, key=lambda p: p['valor'].ticker)
  79. def totales(posiciones_lista=None):
  80. """Agrega los importes globales de la cartera."""
  81. posiciones_lista = posiciones_lista if posiciones_lista is not None else posiciones()
  82. abiertas = [p for p in posiciones_lista if p['acciones'] > 0]
  83. con_precio = [p for p in abiertas if p['valoracion'] is not None]
  84. invertido_con_precio = sum((p['invertido'] for p in con_precio), Decimal('0'))
  85. return {
  86. 'invertido': sum((p['invertido'] for p in abiertas), Decimal('0')),
  87. 'plusvalias': sum((p['plusvalias'] for p in posiciones_lista), Decimal('0')),
  88. 'dividendos': sum((p['dividendos'] for p in posiciones_lista), Decimal('0')),
  89. 'num_abiertas': len(abiertas),
  90. 'valoracion': sum((p['valoracion'] for p in con_precio), Decimal('0')),
  91. 'latente': (
  92. sum((p['latente'] for p in con_precio), Decimal('0'))
  93. if len(con_precio) == len(abiertas) else None
  94. ),
  95. }
  96. def desglose_fifo():
  97. """Desglosa cada venta con su coste de adquisición según FIFO.
  98. Devuelve una lista de filas (una por lote consumido) ordenada por fecha,
  99. con la plusvalía o pérdida realizada en cada una.
  100. """
  101. from collections import deque
  102. filas = []
  103. for valor in Valor.objects.all():
  104. lotes = deque() # pares [cantidad disponible, coste unitario efectivo]
  105. for op in valor.operaciones.order_by('fecha', 'pk'):
  106. if op.tipo == Operacion.COMPRA:
  107. coste_unitario = (op.cantidad * op.precio + op.comisiones) / op.cantidad
  108. lotes.append([op.cantidad, coste_unitario])
  109. elif op.tipo == Operacion.VENTA:
  110. por_vender = op.cantidad
  111. comision_unitaria = op.comisiones / op.cantidad
  112. while por_vender > 0 and lotes:
  113. disponibles, coste_lote = lotes[0]
  114. usadas = min(por_vender, disponibles)
  115. importe = usadas * (op.precio - comision_unitaria)
  116. coste = usadas * coste_lote
  117. filas.append({
  118. 'fecha': op.fecha,
  119. 'valor': valor,
  120. 'cantidad': usadas,
  121. 'precio_venta': op.precio,
  122. 'importe': importe.quantize(Decimal('0.01')),
  123. 'coste': coste.quantize(Decimal('0.01')),
  124. 'resultado': (importe - coste).quantize(Decimal('0.01')),
  125. })
  126. por_vender -= usadas
  127. if disponibles > usadas:
  128. lotes[0][0] = disponibles - usadas
  129. else:
  130. lotes.popleft()
  131. if por_vender > 0:
  132. # venta sin acciones suficientes registradas (dato inconsistente)
  133. filas.append({
  134. 'fecha': op.fecha,
  135. 'valor': valor,
  136. 'cantidad': por_vender,
  137. 'precio_venta': op.precio,
  138. 'importe': Decimal('0'),
  139. 'coste': Decimal('0'),
  140. 'resultado': None,
  141. })
  142. filas.sort(key=lambda f: f['fecha'])
  143. return filas
  144. def resumen_fiscal(filas=None):
  145. """Agrupa el desglose FIFO por año natural."""
  146. from collections import defaultdict
  147. filas = filas if filas is not None else desglose_fifo()
  148. por_anyo = defaultdict(lambda: Decimal('0'))
  149. for f in filas:
  150. if f['resultado'] is not None:
  151. por_anyo[f['fecha'].year] += f['resultado']
  152. return {anyo: imp.quantize(Decimal('0.01')) for anyo, imp in sorted(por_anyo.items())}