Ver Fonte

Algunos arreglos

Tino há 1 mês atrás
pai
commit
a10f5c5100

+ 2 - 0
.gitignore

@@ -1 +1,3 @@
 venv
+__pycache__
+cartera/migrations/*

BIN
cartera/__pycache__/__init__.cpython-312.pyc


BIN
cartera/__pycache__/admin.cpython-312.pyc


BIN
cartera/__pycache__/apps.cpython-312.pyc


BIN
cartera/__pycache__/forms.cpython-312.pyc


BIN
cartera/__pycache__/models.cpython-312.pyc


BIN
cartera/__pycache__/services.cpython-312.pyc


BIN
cartera/__pycache__/urls.cpython-312.pyc


BIN
cartera/__pycache__/views.cpython-312.pyc


+ 8 - 2
cartera/admin.py

@@ -1,12 +1,18 @@
 from django.contrib import admin
 
-from .models import Dividendo, Operacion, Valor
+from .models import Dividendo, Divisa, Operacion, Valor
+
+
+@admin.register(Divisa)
+class DivisaAdmin(admin.ModelAdmin):
+    list_display = ('codigo', 'nombre', 'cambio_eur', 'actualizado_en')
 
 
 @admin.register(Valor)
 class ValorAdmin(admin.ModelAdmin):
-    list_display = ('ticker', 'nombre', 'mercado', 'isin')
+    list_display = ('ticker', 'nombre', 'divisa', 'mercado', 'isin')
     search_fields = ('ticker', 'nombre', 'isin')
+    list_filter = ('divisa',)
 
 
 @admin.register(Operacion)

+ 39 - 4
cartera/forms.py

@@ -1,6 +1,6 @@
 from django import forms
 
-from .models import Dividendo, Operacion, Valor
+from .models import Dividendo, Divisa, Operacion, Valor
 
 
 class BootstrapFormMixin:
@@ -13,29 +13,64 @@ class BootstrapFormMixin:
             field.widget.attrs['class'] = f'{css} form-control'.strip()
 
 
+class ValorChoiceField(forms.ModelChoiceField):
+    """Muestra la moneda de cada valor en el desplegable."""
+
+    def label_from_instance(self, obj):
+        return f'{obj.ticker} - {obj.nombre} [{obj.divisa.codigo}]'
+
+
 class ValorForm(BootstrapFormMixin, forms.ModelForm):
     class Meta:
         model = Valor
-        fields = ['ticker', 'nombre', 'mercado', 'isin']
+        fields = ['ticker', 'nombre', 'divisa', 'mercado', 'isin']
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.fields['ticker'].widget.attrs['placeholder'] = 'p. ej. SAN.MC'
+        if not Divisa.objects.exists():
+            self.fields['divisa'].empty_label = None
 
 
 class OperacionForm(BootstrapFormMixin, forms.ModelForm):
+    valor = ValorChoiceField(queryset=Valor.objects.select_related('divisa'))
+
     class Meta:
         model = Operacion
         fields = ['valor', 'tipo', 'fecha', 'cantidad', 'precio', 'comisiones']
         widgets = {
-            'fecha': forms.DateInput(attrs={'type': 'date'}),
+            # formato ISO: imprescindible para que <input type="date">
+            # muestre la fecha existente al editar
+            'fecha': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
+        }
+        help_texts = {
+            'valor': 'El importe se registra en la moneda indicada entre corchetes.',
         }
 
 
 class DividendoForm(BootstrapFormMixin, forms.ModelForm):
+    valor = ValorChoiceField(queryset=Valor.objects.select_related('divisa'))
+
     class Meta:
         model = Dividendo
         fields = ['valor', 'fecha', 'bruto', 'retencion']
         widgets = {
-            'fecha': forms.DateInput(attrs={'type': 'date'}),
+            'fecha': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
+        }
+        help_texts = {
+            'valor': 'Los importes se registran en la moneda indicada entre corchetes.',
         }
+
+
+class DivisaForm(BootstrapFormMixin, forms.ModelForm):
+    class Meta:
+        model = Divisa
+        fields = ['codigo', 'nombre', 'cambio_eur']
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields['codigo'].widget.attrs['placeholder'] = 'p. ej. USD'
+        self.fields['codigo'].widget.attrs['style'] = 'text-transform: uppercase'
+
+    def clean_codigo(self):
+        return self.cleaned_data['codigo'].upper()

+ 49 - 0
cartera/models.py

@@ -3,6 +3,29 @@ from decimal import Decimal
 from django.db import models
 
 
+class Divisa(models.Model):
+    """Moneda de cotización y su tipo de cambio a euros."""
+
+    codigo = models.CharField(
+        max_length=3, unique=True,
+        help_text='Código ISO 4217; p. ej. EUR, USD, CHF',
+    )
+    nombre = models.CharField(max_length=50)
+    cambio_eur = models.DecimalField(
+        max_digits=12, decimal_places=6, default=Decimal('1'),
+        help_text='Euros que vale 1 unidad de esta divisa',
+    )
+    actualizado_en = models.DateTimeField(null=True, blank=True, editable=False)
+
+    class Meta:
+        verbose_name = 'divisa'
+        verbose_name_plural = 'divisas'
+        ordering = ['codigo']
+
+    def __str__(self):
+        return self.codigo
+
+
 class Valor(models.Model):
     """Un valor bursátil (acción, ETF...) de la cartera."""
 
@@ -13,6 +36,10 @@ class Valor(models.Model):
     nombre = models.CharField(max_length=100)
     mercado = models.CharField(max_length=30, blank=True, help_text='p. ej. SIBE, NASDAQ')
     isin = models.CharField(max_length=12, blank=True)
+    divisa = models.ForeignKey(
+        Divisa, on_delete=models.PROTECT, related_name='valores',
+        help_text='Moneda en la que cotiza y se opera este valor',
+    )
     precio_actual = models.DecimalField(
         max_digits=12, decimal_places=4, null=True, blank=True,
         editable=False,
@@ -45,6 +72,11 @@ class Operacion(models.Model):
         help_text='Precio unitario por acción',
     )
     comisiones = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0'))
+    cambio_eur = models.DecimalField(
+        max_digits=12, decimal_places=6, null=True, blank=True,
+        editable=False,
+        help_text='Cambio a euros congelado al registrar la operación',
+    )
 
     class Meta:
         ordering = ['-fecha', '-pk']
@@ -53,6 +85,13 @@ class Operacion(models.Model):
     def __str__(self):
         return f'{self.get_tipo_display()} {self.cantidad} {self.valor.ticker} ({self.fecha})'
 
+    def save(self, *args, **kwargs):
+        # congela el tipo de cambio vigente si aún no tiene uno:
+        # así las posiciones cerradas no se alteran al actualizar cotizaciones
+        if self.cambio_eur is None and self.valor_id:
+            self.cambio_eur = self.valor.divisa.cambio_eur
+        super().save(*args, **kwargs)
+
     @property
     def importe(self):
         """Importe total de la operación, comisiones incluidas."""
@@ -64,6 +103,11 @@ class Dividendo(models.Model):
     fecha = models.DateField()
     bruto = models.DecimalField(max_digits=10, decimal_places=2)
     retencion = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0'))
+    cambio_eur = models.DecimalField(
+        max_digits=12, decimal_places=6, null=True, blank=True,
+        editable=False,
+        help_text='Cambio a euros congelado al registrar el dividendo',
+    )
 
     class Meta:
         ordering = ['-fecha', '-pk']
@@ -71,6 +115,11 @@ class Dividendo(models.Model):
     def __str__(self):
         return f'Dividendo {self.bruto} € {self.valor.ticker} ({self.fecha})'
 
+    def save(self, *args, **kwargs):
+        if self.cambio_eur is None and self.valor_id:
+            self.cambio_eur = self.valor.divisa.cambio_eur
+        super().save(*args, **kwargs)
+
     @property
     def neto(self):
         return self.bruto - self.retencion

+ 138 - 62
cartera/services.py

@@ -2,7 +2,34 @@ from decimal import Decimal
 
 from django.utils import timezone
 
-from .models import Dividendo, Operacion, Valor
+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():
@@ -34,11 +61,14 @@ def posiciones():
 
     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
+    - 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.all():
+    for valor in Valor.objects.select_related('divisa'):
         ops = valor.operaciones.order_by('fecha', 'pk')
         if not ops and not valor.dividendos.exists():
             continue
@@ -46,7 +76,6 @@ def posiciones():
         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:
@@ -61,8 +90,6 @@ def posiciones():
                     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:
@@ -70,107 +97,156 @@ def posiciones():
                 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(Decimal('0.01'))
-            latente = (valoracion - coste).quantize(Decimal('0.01'))
+            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(Decimal('0.01')),
-            'plusvalias': plusvalias.quantize(Decimal('0.01')),
+            '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."""
+    """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]
-    invertido_con_precio = sum((p['invertido'] for p in con_precio), Decimal('0'))
+
+    def suma(clave, lista):
+        return sum((p[clave] for p in lista), 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')),
+        'invertido': suma('invertido_eur', abiertas),
+        'plusvalias': suma('plusvalias_eur', posiciones_lista),
+        'dividendos': suma('dividendos_eur', posiciones_lista),
         'num_abiertas': len(abiertas),
-        'valoracion': sum((p['valoracion'] for p in con_precio), Decimal('0')),
+        'valoracion': suma('valoracion_eur', con_precio),
         'latente': (
-            sum((p['latente'] for p in con_precio), Decimal('0'))
+            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.
     """
-    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,
-                    })
+    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."""
+    """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'] 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())}
+        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())}

+ 1 - 0
cartera/templates/cartera/base.html

@@ -26,6 +26,7 @@
                     <li class="nav-item"><a class="nav-link" href="{% url 'cartera:operaciones' %}">Movimientos</a></li>
                     <li class="nav-item"><a class="nav-link" href="{% url 'cartera:dividendos' %}">Dividendos</a></li>
                     <li class="nav-item"><a class="nav-link" href="{% url 'cartera:valores' %}">Valores</a></li>
+                    <li class="nav-item"><a class="nav-link" href="{% url 'cartera:divisas' %}">Divisas</a></li>
                     <li class="nav-item"><a class="nav-link" href="{% url 'cartera:fifo' %}">FIFO</a></li>
                     <li class="nav-item"><a class="nav-link" href="/admin">Admin</a></li>
                 </ul>

+ 29 - 18
cartera/templates/cartera/dashboard.html

@@ -51,43 +51,50 @@
         <thead class="table-light">
             <tr>
                 <th>Ticker</th>
-                <th>Nombre</th>
+                <th class="text-center">Div.</th>
                 <th class="text-end">Acciones</th>
                 <th class="text-end">Precio medio</th>
                 <th class="text-end">Precio actual</th>
                 <th class="text-end">Invertido</th>
                 <th class="text-end">Valoración</th>
-                <th class="text-end">Latente</th>
-                <th class="text-end">Dividendos netos</th>
+                <th class="text-end">Latente €</th>
+                <th class="text-end">Dividendos €</th>
             </tr>
         </thead>
         <tbody>
             {% for p in abiertas %}
             <tr>
                 <td><strong>{{ p.valor.ticker }}</strong></td>
-                <td>{{ p.valor.nombre }}</td>
+                <td class="text-center"><span class="badge bg-secondary">{{ p.divisa }}</span></td>
                 <td class="text-end">{{ p.acciones|floatformat:0 }}</td>
-                <td class="text-end">{{ p.precio_medio|floatformat:4 }} €</td>
+                <td class="text-end">{{ p.precio_medio|floatformat:4 }} {{ p.divisa }}</td>
                 <td class="text-end">
-                    {% if p.valor.precio_actual %}{{ p.valor.precio_actual|floatformat:4 }} €{% else %}—{% endif %}
+                    {% if p.valor.precio_actual %}{{ p.valor.precio_actual|floatformat:4 }} {{ p.divisa }}{% else %}—{% endif %}
                 </td>
-                <td class="text-end">{{ p.invertido }} €</td>
                 <td class="text-end">
-                    {% if p.valoracion %}{{ p.valoracion }} €{% else %}—{% endif %}
+                    {{ p.invertido_eur }} €
+                    {% if p.divisa != 'EUR' %}<br><small class="text-muted">{{ p.invertido }} {{ p.divisa }}</small>{% endif %}
+                </td>
+                <td class="text-end">
+                    {% if p.valoracion %}
+                        {{ p.valoracion_eur }} €
+                        {% if p.divisa != 'EUR' %}<br><small class="text-muted">{{ p.valoracion }} {{ p.divisa }}</small>{% endif %}
+                    {% else %}—{% endif %}
                 </td>
                 <td class="text-end {% if p.latente >= 0 %}text-success{% else %}text-danger{% endif %}">
-                    {% if p.valoracion %}{% if p.latente >= 0 %}+{% endif %}{{ p.latente }} €{% else %}—{% endif %}
+                    {% if p.valoracion %}{% if p.latente >= 0 %}+{% endif %}{{ p.latente_eur }} €{% else %}—{% endif %}
                 </td>
-                <td class="text-end">{{ p.dividendos }} €</td>
+                <td class="text-end">{{ p.dividendos_eur }} €</td>
             </tr>
             {% endfor %}
         </tbody>
-        </tbody>
         <tfoot class="table-light">
             <tr>
-                <th colspan="4">Total</th>
+                <th colspan="5">Total cartera (EUR)</th>
                 <th class="text-end">{{ totales.invertido }} €</th>
-                <th class="text-end"></th>
+                <th class="text-end">{{ totales.valoracion }} €</th>
+                <th class="text-end">{% if totales.latente is not None %}{{ totales.latente }} €{% else %}—{% endif %}</th>
+                <th class="text-end">{{ totales.dividendos }} €</th>
             </tr>
         </tfoot>
     </table>
@@ -103,20 +110,24 @@
         <thead class="table-light">
             <tr>
                 <th>Ticker</th>
-                <th>Nombre</th>
+                <th class="text-center">Div.</th>
                 <th class="text-end">Plusvalías realizadas</th>
-                <th class="text-end">Dividendos cobrados</th>
+                <th class="text-end">Plusvalías €</th>
+                <th class="text-end">Dividendos cobrados €</th>
             </tr>
         </thead>
         <tbody>
             {% for p in cerradas %}
             <tr>
                 <td><strong>{{ p.valor.ticker }}</strong></td>
-                <td>{{ p.valor.nombre }}</td>
+                <td class="text-center"><span class="badge bg-secondary">{{ p.divisa }}</span></td>
                 <td class="text-end {% if p.plusvalias >= 0 %}text-success{% else %}text-danger{% endif %}">
-                    {{ p.plusvalias }} €
+                    {{ p.plusvalias }} {{ p.divisa }}
+                </td>
+                <td class="text-end {% if p.plusvalias_eur >= 0 %}text-success{% else %}text-danger{% endif %}">
+                    {{ p.plusvalias_eur }} €
                 </td>
-                <td class="text-end">{{ p.dividendos }} €</td>
+                <td class="text-end">{{ p.dividendos_eur }} €</td>
             </tr>
             {% endfor %}
         </tbody>

+ 3 - 3
cartera/templates/cartera/dividendo_list.html

@@ -26,9 +26,9 @@
             <tr>
                 <td>{{ d.fecha }}</td>
                 <td><strong>{{ d.valor.ticker }}</strong></td>
-                <td class="text-end">{{ d.bruto }} €</td>
-                <td class="text-end">{{ d.retencion }} €</td>
-                <td class="text-end text-success">{{ d.neto }} €</td>
+                <td class="text-end">{{ d.bruto }} {{ d.valor.divisa.codigo }}</td>
+                <td class="text-end">{{ d.retencion }} {{ d.valor.divisa.codigo }}</td>
+                <td class="text-end text-success"><strong>{{ d.neto }} {{ d.valor.divisa.codigo }}</strong></td>
                 <td class="text-end text-nowrap">
                     <a class="btn btn-outline-secondary btn-sm" href="{% url 'cartera:dividendo_editar' d.pk %}">✏️</a>
                     <a class="btn btn-outline-danger btn-sm" href="{% url 'cartera:dividendo_borrar' d.pk %}">🗑️</a>

+ 57 - 0
cartera/templates/cartera/divisa_list.html

@@ -0,0 +1,57 @@
+{% extends "cartera/base.html" %}
+
+{% block title %}Divisas - Bolsa{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <h2>💱 Divisas</h2>
+    <div>
+        <form method="post" action="{% url 'cartera:actualizar' %}" class="d-inline">
+            {% csrf_token %}
+            <button type="submit" class="btn btn-outline-primary btn-sm me-1">🔄 Actualizar cambios</button>
+        </form>
+        <a href="{% url 'cartera:divisa_nueva' %}" class="btn btn-primary btn-sm">+ Nueva divisa</a>
+    </div>
+</div>
+
+<p class="text-muted small">
+    Cambio expresado en euros por 1 unidad de la divisa. Se actualiza automáticamente
+    con el botón «Actualizar precios» de la cartera.
+</p>
+
+{% if divisas %}
+<div class="card" style="max-width: 640px;">
+    <table class="table table-hover mb-0">
+        <thead class="table-light">
+            <tr>
+                <th>Código</th>
+                <th>Nombre</th>
+                <th class="text-end">Cambio a EUR</th>
+                <th>Actualizado</th>
+                <th></th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for d in divisas %}
+            <tr>
+                <td><strong>{{ d.codigo }}</strong></td>
+                <td>{{ d.nombre }}</td>
+                <td class="text-end">
+                    {% if d.codigo == 'EUR' %}1,000000{% else %}{{ d.cambio_eur|floatformat:6 }}{% endif %}
+                </td>
+                <td class="text-muted small">{{ d.actualizado_en|date:"d/m/Y H:i"|default:"—" }}</td>
+                <td class="text-end text-nowrap">
+                    <a class="btn btn-outline-secondary btn-sm" href="{% url 'cartera:divisa_editar' d.pk %}">✏️</a>
+                    {% if d.codigo != 'EUR' %}
+                    <a class="btn btn-outline-danger btn-sm" href="{% url 'cartera:divisa_borrar' d.pk %}">🗑️</a>
+                    {% endif %}
+                </td>
+            </tr>
+            {% endfor %}
+        </tbody>
+    </table>
+</div>
+{% else %}
+<p class="text-muted">No hay divisas dadas de alta.</p>
+{% endif %}
+{% endblock %}

+ 8 - 4
cartera/templates/cartera/fifo.html

@@ -20,6 +20,8 @@
     {% endfor %}
 </div>
 
+<p class="text-muted small">Resúmenes anuales en euros, con el cambio de moneda registrado en cada venta.</p>
+
 {% if filas %}
 <div class="card">
     <table class="table table-hover mb-0">
@@ -27,6 +29,7 @@
             <tr>
                 <th>Fecha venta</th>
                 <th>Ticker</th>
+                <th class="text-center">Div.</th>
                 <th class="text-end">Acciones</th>
                 <th class="text-end">Precio venta</th>
                 <th class="text-end">Importe</th>
@@ -39,12 +42,13 @@
             <tr>
                 <td>{{ f.fecha }}</td>
                 <td><strong>{{ f.valor.ticker }}</strong></td>
+                <td class="text-center"><span class="badge bg-secondary">{{ f.divisa }}</span></td>
                 <td class="text-end">{{ f.cantidad|floatformat:0 }}</td>
-                <td class="text-end">{{ f.precio_venta|floatformat:4 }} €</td>
-                <td class="text-end">{{ f.importe }} €</td>
-                <td class="text-end">{{ f.coste }} €</td>
+                <td class="text-end">{{ f.precio_venta|floatformat:4 }} {{ f.divisa }}</td>
+                <td class="text-end">{{ f.importe }} {{ f.divisa }}</td>
+                <td class="text-end">{{ f.coste }} {{ f.divisa }}</td>
                 <td class="text-end {% if f.resultado >= 0 %}text-success{% else %}text-danger{% endif %}">
-                    {% if f.resultado is None %}?{% else %}{{ f.resultado }} €{% endif %}
+                    {% if f.resultado is None %}?{% else %}{{ f.resultado }} {{ f.divisa }}{% endif %}
                 </td>
             </tr>
             {% endfor %}

+ 23 - 3
cartera/templates/cartera/operacion_list.html

@@ -8,6 +8,26 @@
     <a href="{% url 'cartera:operacion_nueva' %}" class="btn btn-primary">+ Nuevo movimiento</a>
 </div>
 
+<form method="get" class="row g-2 align-items-center mb-3">
+    <div class="col-auto">
+        <select name="valor" class="form-select form-select-sm" onchange="this.form.submit()">
+            <option value="">Todos los tickers</option>
+            {% for v in valores %}
+            <option value="{{ v.pk }}" {% if v.pk|stringformat:'s' == valor_filtro %}selected{% endif %}>
+                {{ v.ticker }}{% if v.nombre %} - {{ v.nombre }}{% endif %}
+            </option>
+            {% endfor %}
+        </select>
+    </div>
+    <div class="col-auto">
+        <button type="submit" class="btn btn-primary btn-sm">Filtrar</button>
+        {% if valor_filtro %}<a href="{% url 'cartera:operaciones' %}" class="btn btn-outline-secondary btn-sm">Limpiar</a>{% endif %}
+    </div>
+    <div class="col-auto text-muted small align-self-center">
+        {{ operaciones|length }} movimiento{{ operaciones|length|pluralize }}
+    </div>
+</form>
+
 {% if operaciones %}
 <div class="card">
     <table class="table table-hover mb-0">
@@ -36,9 +56,9 @@
                     {% endif %}
                 </td>
                 <td class="text-end">{{ o.cantidad|floatformat:0 }}</td>
-                <td class="text-end">{{ o.precio|floatformat:4 }} €</td>
-                <td class="text-end">{{ o.comisiones }} €</td>
-                <td class="text-end">{{ o.importe }} €</td>
+                <td class="text-end">{{ o.precio|floatformat:4 }} {{ o.valor.divisa.codigo }}</td>
+                <td class="text-end">{{ o.comisiones }} {{ o.valor.divisa.codigo }}</td>
+                <td class="text-end"><strong>{{ o.importe }} {{ o.valor.divisa.codigo }}</strong></td>
                 <td class="text-end text-nowrap">
                     <a class="btn btn-outline-secondary btn-sm" href="{% url 'cartera:operacion_editar' o.pk %}">✏️</a>
                     <a class="btn btn-outline-danger btn-sm" href="{% url 'cartera:operacion_borrar' o.pk %}">🗑️</a>

+ 3 - 1
cartera/templates/cartera/valor_list.html

@@ -15,6 +15,7 @@
             <tr>
                 <th>Ticker</th>
                 <th>Nombre</th>
+                <th>Divisa</th>
                 <th>Mercado</th>
                 <th>ISIN</th>
                 <th class="text-end">Precio actual</th>
@@ -26,10 +27,11 @@
             <tr>
                 <td><strong>{{ v.ticker }}</strong></td>
                 <td>{{ v.nombre }}</td>
+                <td><span class="badge bg-secondary">{{ v.divisa.codigo }}</span></td>
                 <td>{{ v.mercado }}</td>
                 <td>{{ v.isin }}</td>
                 <td class="text-end">
-                    {% if v.precio_actual %}{{ v.precio_actual|floatformat:4 }} €{% else %}—{% endif %}
+                    {% if v.precio_actual %}{{ v.precio_actual|floatformat:4 }} {{ v.divisa.codigo }}{% else %}—{% endif %}
                 </td>
                 <td class="text-end text-nowrap">
                     <a class="btn btn-outline-secondary btn-sm" href="{% url 'cartera:valor_editar' v.pk %}">✏️</a>

+ 5 - 0
cartera/urls.py

@@ -23,4 +23,9 @@ urlpatterns = [
     path('valores/nuevo/', views.ValorCreateView.as_view(), name='valor_nuevo'),
     path('valores/<int:pk>/editar/', views.ValorUpdateView.as_view(), name='valor_editar'),
     path('valores/<int:pk>/borrar/', views.ValorDeleteView.as_view(), name='valor_borrar'),
+
+    path('divisas/', views.DivisaListView.as_view(), name='divisas'),
+    path('divisas/nueva/', views.DivisaCreateView.as_view(), name='divisa_nueva'),
+    path('divisas/<int:pk>/editar/', views.DivisaUpdateView.as_view(), name='divisa_editar'),
+    path('divisas/<int:pk>/borrar/', views.DivisaDeleteView.as_view(), name='divisa_borrar'),
 ]

+ 80 - 8
cartera/views.py

@@ -1,11 +1,19 @@
 from django.contrib import messages
+from django.db.models.deletion import ProtectedError
 from django.shortcuts import redirect, render
 from django.urls import reverse_lazy
 from django.views.generic import CreateView, DeleteView, ListView, UpdateView
 
-from .forms import DividendoForm, OperacionForm, ValorForm
-from .models import Dividendo, Operacion, Valor
-from .services import actualizar_precios, desglose_fifo, posiciones, resumen_fiscal, totales
+from .forms import DividendoForm, DivisaForm, OperacionForm, ValorForm
+from .models import Dividendo, Divisa, Operacion, Valor
+from .services import (
+    actualizar_cambios,
+    actualizar_precios,
+    desglose_fifo,
+    posiciones,
+    resumen_fiscal,
+    totales,
+)
 
 
 class CancelarUrlMixin:
@@ -30,6 +38,11 @@ def dashboard(request):
 
 def actualizar(request):
     if request.method == 'POST':
+        cambios, errores_cambios = actualizar_cambios()
+        if cambios:
+            messages.success(request, f'Cambios de moneda actualizados: {cambios}')
+        for par, motivo in errores_cambios:
+            messages.warning(request, f'{par}: {motivo}')
         correctos, errores = actualizar_precios()
         if correctos:
             messages.success(request, f'Precios actualizados: {correctos}')
@@ -43,7 +56,7 @@ def fifo(request):
     context = {
         'filas': filas,
         'fiscal': resumen_fiscal(filas),
-        'total': sum((f['resultado'] or 0 for f in filas), start=0),
+        'total': sum((f['resultado_eur'] or 0 for f in filas), start=0),
     }
     return render(request, 'cartera/fifo.html', context)
 
@@ -52,7 +65,19 @@ class OperacionListView(ListView):
     model = Operacion
     template_name = 'cartera/operacion_list.html'
     context_object_name = 'operaciones'
-    queryset = Operacion.objects.select_related('valor')
+
+    def get_queryset(self):
+        qs = Operacion.objects.select_related('valor', 'valor__divisa').order_by('-fecha', '-pk')
+        valor = self.request.GET.get('valor')
+        if valor:
+            qs = qs.filter(valor_id=valor)
+        return qs
+
+    def get_context_data(self, **kwargs):
+        ctx = super().get_context_data(**kwargs)
+        ctx['valores'] = Valor.objects.order_by('ticker')
+        ctx['valor_filtro'] = self.request.GET.get('valor', '')
+        return ctx
 
 
 class OperacionCreateView(CancelarUrlMixin, CreateView):
@@ -79,7 +104,7 @@ class OperacionUpdateView(CancelarUrlMixin, UpdateView):
         return super().form_valid(form)
 
 
-class OperacionDeleteView(DeleteView):
+class OperacionDeleteView(CancelarUrlMixin, DeleteView):
     model = Operacion
     template_name = 'cartera/confirm_delete.html'
     success_url = reverse_lazy('cartera:operaciones')
@@ -121,7 +146,7 @@ class DividendoUpdateView(CancelarUrlMixin, UpdateView):
         return super().form_valid(form)
 
 
-class DividendoDeleteView(DeleteView):
+class DividendoDeleteView(CancelarUrlMixin, DeleteView):
     model = Dividendo
     template_name = 'cartera/confirm_delete.html'
     success_url = reverse_lazy('cartera:dividendos')
@@ -162,7 +187,7 @@ class ValorUpdateView(CancelarUrlMixin, UpdateView):
         return super().form_valid(form)
 
 
-class ValorDeleteView(DeleteView):
+class ValorDeleteView(CancelarUrlMixin, DeleteView):
     model = Valor
     template_name = 'cartera/confirm_delete.html'
     success_url = reverse_lazy('cartera:valores')
@@ -171,3 +196,50 @@ class ValorDeleteView(DeleteView):
     def form_valid(self, form):
         messages.success(self.request, 'Valor eliminado (con todas sus operaciones)')
         return super().form_valid(form)
+
+
+class DivisaListView(ListView):
+    model = Divisa
+    template_name = 'cartera/divisa_list.html'
+    context_object_name = 'divisas'
+
+
+class DivisaCreateView(CancelarUrlMixin, CreateView):
+    model = Divisa
+    form_class = DivisaForm
+    template_name = 'cartera/form.html'
+    success_url = reverse_lazy('cartera:divisas')
+    titulo = 'Nueva divisa'
+
+    def form_valid(self, form):
+        messages.success(self.request, 'Divisa creada')
+        return super().form_valid(form)
+
+
+class DivisaUpdateView(CancelarUrlMixin, UpdateView):
+    model = Divisa
+    form_class = DivisaForm
+    template_name = 'cartera/form.html'
+    success_url = reverse_lazy('cartera:divisas')
+    titulo = 'Editar divisa'
+
+    def form_valid(self, form):
+        messages.success(self.request, 'Divisa actualizada')
+        return super().form_valid(form)
+
+
+class DivisaDeleteView(CancelarUrlMixin, DeleteView):
+    model = Divisa
+    template_name = 'cartera/confirm_delete.html'
+    success_url = reverse_lazy('cartera:divisas')
+    titulo = 'Eliminar divisa'
+
+    def post(self, request, *args, **kwargs):
+        try:
+            return super().post(request, *args, **kwargs)
+        except ProtectedError:
+            messages.error(
+                request,
+                'No se puede eliminar: hay valores que usan esta divisa.',
+            )
+            return redirect('cartera:divisas')

BIN
db.sqlite3


+ 1 - 0
opencode

@@ -0,0 +1 @@
+opencode -s ses_fcc18ffd0ffeDvPicBUZnm19SO