Tino 1 mese fa
parent
commit
6199da6f9c

+ 9 - 0
.dockerignore

@@ -0,0 +1,9 @@
+venv/
+__pycache__/
+*.pyc
+db.sqlite3
+.git/
+k8s/
+Dockerfile
+README.md
+run.sh

+ 19 - 0
Dockerfile

@@ -0,0 +1,19 @@
+FROM python:3.12-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY manage.py .
+COPY bolsa/ bolsa/
+COPY cartera/ cartera/
+
+RUN DEBUG=False python manage.py collectstatic --noinput
+
+EXPOSE 8000
+
+CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn bolsa.wsgi:application --bind 0.0.0.0:8000 --workers 2"]

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


+ 4 - 17
bolsa/urls.py

@@ -1,22 +1,9 @@
-"""
-URL configuration for bolsa project.
-
-The `urlpatterns` list routes URLs to views. For more information please see:
-    https://docs.djangoproject.com/en/4.2/topics/http/urls/
-Examples:
-Function views
-    1. Add an import:  from my_app import views
-    2. Add a URL to urlpatterns:  path('', views.home, name='home')
-Class-based views
-    1. Add an import:  from other_app.views import Home
-    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
-Including another URLconf
-    1. Import the include() function: from django.urls import include, path
-    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
-"""
 from django.contrib import admin
-from django.urls import path
+from django.urls import include, path
+from django.views.generic import RedirectView
 
 urlpatterns = [
     path('admin/', admin.site.urls),
+    path('cartera/', include('cartera.urls')),
+    path('', RedirectView.as_view(pattern_name='cartera:dashboard', permanent=False)),
 ]

BIN
cartera/__pycache__/admin.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


+ 21 - 1
cartera/admin.py

@@ -1,3 +1,23 @@
 from django.contrib import admin
 
-# Register your models here.
+from .models import Dividendo, Operacion, Valor
+
+
+@admin.register(Valor)
+class ValorAdmin(admin.ModelAdmin):
+    list_display = ('ticker', 'nombre', 'mercado', 'isin')
+    search_fields = ('ticker', 'nombre', 'isin')
+
+
+@admin.register(Operacion)
+class OperacionAdmin(admin.ModelAdmin):
+    list_display = ('fecha', 'valor', 'tipo', 'cantidad', 'precio', 'comisiones', 'importe')
+    list_filter = ('tipo', 'valor')
+    date_hierarchy = 'fecha'
+
+
+@admin.register(Dividendo)
+class DividendoAdmin(admin.ModelAdmin):
+    list_display = ('fecha', 'valor', 'bruto', 'retencion', 'neto')
+    list_filter = ('valor',)
+    date_hierarchy = 'fecha'

+ 60 - 0
cartera/migrations/0001_initial.py

@@ -0,0 +1,60 @@
+# Generated by Django 4.2.30 on 2026-08-24 11:30
+
+from decimal import Decimal
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Valor',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('ticker', models.CharField(max_length=20, unique=True)),
+                ('nombre', models.CharField(max_length=100)),
+                ('mercado', models.CharField(blank=True, help_text='p. ej. SIBE, NASDAQ', max_length=30)),
+                ('isin', models.CharField(blank=True, max_length=12)),
+            ],
+            options={
+                'verbose_name': 'valor',
+                'verbose_name_plural': 'valores',
+                'ordering': ['ticker'],
+            },
+        ),
+        migrations.CreateModel(
+            name='Operacion',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('tipo', models.CharField(choices=[('C', 'Compra'), ('V', 'Venta')], max_length=1, verbose_name='tipo de operación')),
+                ('fecha', models.DateField()),
+                ('cantidad', models.DecimalField(decimal_places=4, max_digits=14)),
+                ('precio', models.DecimalField(decimal_places=4, help_text='Precio unitario por acción', max_digits=12)),
+                ('comisiones', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)),
+                ('valor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operaciones', to='cartera.valor')),
+            ],
+            options={
+                'verbose_name': 'operación',
+                'ordering': ['-fecha', '-pk'],
+            },
+        ),
+        migrations.CreateModel(
+            name='Dividendo',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('fecha', models.DateField()),
+                ('bruto', models.DecimalField(decimal_places=2, max_digits=10)),
+                ('retencion', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=10)),
+                ('valor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dividendos', to='cartera.valor')),
+            ],
+            options={
+                'ordering': ['-fecha', '-pk'],
+            },
+        ),
+    ]

+ 28 - 0
cartera/migrations/0002_valor_actualizado_en_valor_precio_actual_and_more.py

@@ -0,0 +1,28 @@
+# Generated by Django 4.2.30 on 2026-08-24 11:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('cartera', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='valor',
+            name='actualizado_en',
+            field=models.DateTimeField(blank=True, editable=False, null=True),
+        ),
+        migrations.AddField(
+            model_name='valor',
+            name='precio_actual',
+            field=models.DecimalField(blank=True, decimal_places=4, editable=False, max_digits=12, null=True),
+        ),
+        migrations.AlterField(
+            model_name='valor',
+            name='ticker',
+            field=models.CharField(help_text='Ticker según Yahoo Finance; en Bolsa española con sufijo .MC', max_length=20, unique=True),
+        ),
+    ]

BIN
cartera/migrations/__pycache__/0001_initial.cpython-312.pyc


BIN
cartera/migrations/__pycache__/0002_valor_actualizado_en_valor_precio_actual_and_more.cpython-312.pyc


+ 74 - 1
cartera/models.py

@@ -1,3 +1,76 @@
+from decimal import Decimal
+
 from django.db import models
 
-# Create your models here.
+
+class Valor(models.Model):
+    """Un valor bursátil (acción, ETF...) de la cartera."""
+
+    ticker = models.CharField(
+        max_length=20, unique=True,
+        help_text='Ticker según Yahoo Finance; en Bolsa española con sufijo .MC',
+    )
+    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)
+    precio_actual = models.DecimalField(
+        max_digits=12, decimal_places=4, null=True, blank=True,
+        editable=False,
+    )
+    actualizado_en = models.DateTimeField(null=True, blank=True, editable=False)
+
+    class Meta:
+        verbose_name = 'valor'
+        verbose_name_plural = 'valores'
+        ordering = ['ticker']
+
+    def __str__(self):
+        return f'{self.ticker} - {self.nombre}'
+
+
+class Operacion(models.Model):
+    COMPRA = 'C'
+    VENTA = 'V'
+    TIPOS = [
+        (COMPRA, 'Compra'),
+        (VENTA, 'Venta'),
+    ]
+
+    valor = models.ForeignKey(Valor, on_delete=models.CASCADE, related_name='operaciones')
+    tipo = models.CharField('tipo de operación', max_length=1, choices=TIPOS)
+    fecha = models.DateField()
+    cantidad = models.DecimalField(max_digits=14, decimal_places=4)
+    precio = models.DecimalField(
+        max_digits=12, decimal_places=4,
+        help_text='Precio unitario por acción',
+    )
+    comisiones = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0'))
+
+    class Meta:
+        ordering = ['-fecha', '-pk']
+        verbose_name = 'operación'
+
+    def __str__(self):
+        return f'{self.get_tipo_display()} {self.cantidad} {self.valor.ticker} ({self.fecha})'
+
+    @property
+    def importe(self):
+        """Importe total de la operación, comisiones incluidas."""
+        return (self.cantidad * self.precio).quantize(Decimal('0.01')) + self.comisiones
+
+
+class Dividendo(models.Model):
+    valor = models.ForeignKey(Valor, on_delete=models.CASCADE, related_name='dividendos')
+    fecha = models.DateField()
+    bruto = models.DecimalField(max_digits=10, decimal_places=2)
+    retencion = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0'))
+
+    class Meta:
+        ordering = ['-fecha', '-pk']
+
+    def __str__(self):
+        return f'Dividendo {self.bruto} € {self.valor.ticker} ({self.fecha})'
+
+    @property
+    def neto(self):
+        return self.bruto - self.retencion

+ 176 - 0
cartera/services.py

@@ -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())}

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

@@ -0,0 +1,42 @@
+{% load static %}
+<!DOCTYPE html>
+<html lang="es">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{% block title %}Bolsa{% endblock %}</title>
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
+    <style>
+        body { background-color: #f8f9fa; }
+        .card { box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,.075); border: 1px solid rgba(0,0,0,.08); }
+    </style>
+</head>
+<body>
+    <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
+        <div class="container">
+            <a class="navbar-brand fw-bold" href="{% url 'cartera:dashboard' %}">
+                📈 Bolsa - Cartera
+            </a>
+            <div class="navbar-nav ms-auto">
+                <a class="nav-link" href="{% url 'cartera:fifo' %}">FIFO</a>
+                <a class="nav-link" href="/admin">Admin</a>
+            </div>
+        </div>
+    </nav>
+
+    <div class="container">
+        {% if messages %}
+        {% for message in messages %}
+        <div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
+            {{ message }}
+            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+        </div>
+        {% endfor %}
+        {% endif %}
+
+        {% block content %}{% endblock %}
+    </div>
+
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+</body>
+</html>

+ 128 - 0
cartera/templates/cartera/dashboard.html

@@ -0,0 +1,128 @@
+{% extends "cartera/base.html" %}
+
+{% block title %}Cartera - Bolsa{% endblock %}
+
+{% block content %}
+<h2 class="mb-4">📊 Mi cartera</h2>
+
+<form method="post" action="{% url 'cartera:actualizar' %}" class="mb-3">
+    {% csrf_token %}
+    <button type="submit" class="btn btn-outline-primary btn-sm">
+        🔄 Actualizar precios
+    </button>
+</form>
+
+<div class="row mb-4">
+    <div class="col-md-4">
+        <div class="card text-center">
+            <div class="card-body">
+                <h6 class="text-muted">Valoración cartera</h6>
+                <h3>{{ totales.valoracion }} €</h3>
+                <small class="{% if totales.latente >= 0 %}text-success{% else %}text-danger{% endif %}">
+                    {% if totales.latente >= 0 %}+{% endif %}{{ totales.latente }} € latente
+                </small>
+            </div>
+        </div>
+    </div>
+    <div class="col-md-4">
+        <div class="card text-center">
+            <div class="card-body">
+                <h6 class="text-muted">Plusvalías realizadas</h6>
+                <h3 class="{% if totales.plusvalias >= 0 %}text-success{% else %}text-danger{% endif %}">
+                    {{ totales.plusvalias }} €
+                </h3>
+            </div>
+        </div>
+    </div>
+    <div class="col-md-4">
+        <div class="card text-center">
+            <div class="card-body">
+                <h6 class="text-muted">Dividendos netos</h6>
+                <h3 class="text-success">{{ totales.dividendos }} €</h3>
+            </div>
+        </div>
+    </div>
+</div>
+
+<h4>Posiciones abiertas</h4>
+{% if abiertas %}
+<div class="card mb-4">
+    <table class="table table-hover mb-0">
+        <thead class="table-light">
+            <tr>
+                <th>Ticker</th>
+                <th>Nombre</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>
+            </tr>
+        </thead>
+        <tbody>
+            {% for p in abiertas %}
+            <tr>
+                <td><strong>{{ p.valor.ticker }}</strong></td>
+                <td>{{ p.valor.nombre }}</td>
+                <td class="text-end">{{ p.acciones|floatformat:0 }}</td>
+                <td class="text-end">{{ p.precio_medio|floatformat:4 }} €</td>
+                <td class="text-end">
+                    {% if p.valor.precio_actual %}{{ p.valor.precio_actual|floatformat:4 }} €{% else %}—{% endif %}
+                </td>
+                <td class="text-end">{{ p.invertido }} €</td>
+                <td class="text-end">
+                    {% if p.valoracion %}{{ p.valoracion }} €{% 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 %}
+                </td>
+                <td class="text-end">{{ p.dividendos }} €</td>
+            </tr>
+            {% endfor %}
+        </tbody>
+        </tbody>
+        <tfoot class="table-light">
+            <tr>
+                <th colspan="4">Total</th>
+                <th class="text-end">{{ totales.invertido }} €</th>
+                <th class="text-end"></th>
+            </tr>
+        </tfoot>
+    </table>
+</div>
+{% else %}
+<p class="text-muted">No hay posiciones abiertas.</p>
+{% endif %}
+
+<h4>Posiciones cerradas</h4>
+{% if cerradas %}
+<div class="card">
+    <table class="table table-hover mb-0">
+        <thead class="table-light">
+            <tr>
+                <th>Ticker</th>
+                <th>Nombre</th>
+                <th class="text-end">Plusvalías realizadas</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-end {% if p.plusvalias >= 0 %}text-success{% else %}text-danger{% endif %}">
+                    {{ p.plusvalias }} €
+                </td>
+                <td class="text-end">{{ p.dividendos }} €</td>
+            </tr>
+            {% endfor %}
+        </tbody>
+    </table>
+</div>
+{% else %}
+<p class="text-muted">Todavía no hay posiciones cerradas.</p>
+{% endif %}
+{% endblock %}

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

@@ -0,0 +1,57 @@
+{% extends "cartera/base.html" %}
+
+{% block title %}Desglose FIFO - Bolsa{% endblock %}
+
+{% block content %}
+<h2 class="mb-4">🧮 Plusvalías por FIFO</h2>
+
+<div class="row mb-4">
+    {% for anyo, importe in fiscal.items %}
+    <div class="col-md-3">
+        <div class="card text-center mb-2">
+            <div class="card-body py-2">
+                <h6 class="text-muted mb-1">{{ anyo }}</h6>
+                <h5 class="{% if importe >= 0 %}text-success{% else %}text-danger{% endif %} mb-0">
+                    {{ importe }} €
+                </h5>
+            </div>
+        </div>
+    </div>
+    {% endfor %}
+</div>
+
+{% if filas %}
+<div class="card">
+    <table class="table table-hover mb-0">
+        <thead class="table-light">
+            <tr>
+                <th>Fecha venta</th>
+                <th>Ticker</th>
+                <th class="text-end">Acciones</th>
+                <th class="text-end">Precio venta</th>
+                <th class="text-end">Importe</th>
+                <th class="text-end">Coste (FIFO)</th>
+                <th class="text-end">Resultado</th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for f in filas %}
+            <tr>
+                <td>{{ f.fecha }}</td>
+                <td><strong>{{ f.valor.ticker }}</strong></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 {% if f.resultado >= 0 %}text-success{% else %}text-danger{% endif %}">
+                    {% if f.resultado is None %}?{% else %}{{ f.resultado }} €{% endif %}
+                </td>
+            </tr>
+            {% endfor %}
+        </tbody>
+    </table>
+</div>
+{% else %}
+<p class="text-muted">No hay ventas registradas todavía.</p>
+{% endif %}
+{% endblock %}

+ 11 - 0
cartera/urls.py

@@ -0,0 +1,11 @@
+from django.urls import path
+
+from . import views
+
+app_name = 'cartera'
+
+urlpatterns = [
+    path('', views.dashboard, name='dashboard'),
+    path('fifo/', views.fifo, name='fifo'),
+    path('actualizar/', views.actualizar, name='actualizar'),
+]

+ 34 - 2
cartera/views.py

@@ -1,3 +1,35 @@
-from django.shortcuts import render
+from django.contrib import messages
+from django.shortcuts import redirect, render
 
-# Create your views here.
+from .services import actualizar_precios, desglose_fifo, posiciones, resumen_fiscal, totales
+
+
+def dashboard(request):
+    pos = posiciones()
+    context = {
+        'posiciones': pos,
+        'abiertas': [p for p in pos if p['acciones'] > 0],
+        'cerradas': [p for p in pos if p['acciones'] == 0],
+        'totales': totales(pos),
+    }
+    return render(request, 'cartera/dashboard.html', context)
+
+
+def actualizar(request):
+    if request.method == 'POST':
+        correctos, errores = actualizar_precios()
+        if correctos:
+            messages.success(request, f'Precios actualizados: {correctos}')
+        for ticker, motivo in errores:
+            messages.warning(request, f'{ticker}: {motivo}')
+    return redirect('cartera:dashboard')
+
+
+def fifo(request):
+    filas = desglose_fifo()
+    context = {
+        'filas': filas,
+        'fiscal': resumen_fiscal(filas),
+        'total': sum((f['resultado'] or 0 for f in filas), start=0),
+    }
+    return render(request, 'cartera/fifo.html', context)

BIN
db.sqlite3


+ 70 - 0
k8s/Makefile

@@ -0,0 +1,70 @@
+# Bolsa - tareas de construcción y despliegue en Kubernetes
+#
+# Uso:   make -C k8s <objetivo>
+# Ej.:   make -C k8s deploy
+
+IMAGE     := harbor.reymota.es/reymota/bolsa:latest
+NAMESPACE := bolsa
+DEPLOY    := bolsa
+HARBOR    := harbor.reymota.es
+
+.DEFAULT_GOAL := help
+.PHONY: help build push deploy robot-secret db-secret restart status logs uninstall purge
+
+help: ## Muestra esta ayuda
+	@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
+	awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-12s\033[0m %s\n", $$1, $$2}'
+
+build: ## Construye la imagen Docker desde la raíz del proyecto
+	docker build -t $(IMAGE) ..
+
+push: build ## Sube la imagen a Harbor
+	docker push $(IMAGE)
+
+robot-secret: ## Crea/renueva el pull secret del robot account. Uso: make -C k8s robot-secret TOKEN='<token>'
+	@test -n "$(TOKEN)" || { echo "Error: falta TOKEN. Uso: make -C k8s robot-secret TOKEN='<token>'"; exit 1; }
+	kubectl create secret docker-registry myregistrykey \
+		--docker-server=$(HARBOR) \
+		--docker-username='robot$$reymota+regbrowser-pull' \
+		--docker-password='$(TOKEN)' \
+		-n $(NAMESPACE) --dry-run=client -o yaml | kubectl apply -f -
+	kubectl rollout restart deploy/$(DEPLOY) -n $(NAMESPACE)
+	kubectl rollout status deploy/$(DEPLOY) -n $(NAMESPACE) --timeout=180s
+
+db-secret: ## Crea/renueva el secret de conexión a Postgres (copia credenciales de reymota-pg-cluster)
+	kubectl get secret postgres-app-user -n reymota-pg-cluster -o json | \
+	python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps({'apiVersion':'v1','kind':'Secret','metadata':{'name':'bolsa-db','namespace':'$(NAMESPACE)'},'type':'Opaque','data':{k:d['data'][k] for k in ('username','password')}}))" | \
+	kubectl apply -f -
+
+deploy: ## Aplica los manifiestos y espera a que esté disponible
+	kubectl apply -f namespace.yaml
+	kubectl apply -f database.yaml -n reymota-pg-cluster
+	@if ! kubectl get secret myregistrykey -n $(NAMESPACE) >/dev/null 2>&1; then \
+		echo ">> Falta el pull secret; cópialo con:"; \
+		echo ">>   kubectl get secret myregistrykey -n regbrowser -o json | python3 -c \"import json,sys; d=json.load(sys.stdin); d['metadata']={'name':'myregistrykey','namespace':'$(NAMESPACE)'}; print(json.dumps(d))\" | kubectl apply -f -"; \
+		exit 1; \
+	fi
+	@if ! kubectl get secret bolsa-db -n $(NAMESPACE) >/dev/null 2>&1; then \
+		echo ">> Falta el secret de BD; ejecutando 'make db-secret'..."; \
+		$(MAKE) --no-print-directory db-secret; \
+	fi
+	kubectl apply -f secrets.yaml -f deployment.yaml -f service-ingress.yaml
+	kubectl rollout status deploy/$(DEPLOY) -n $(NAMESPACE) --timeout=180s
+
+restart: ## Reinicia el deployment (para hacer pull de la última imagen)
+	kubectl rollout restart deploy/$(DEPLOY) -n $(NAMESPACE)
+	kubectl rollout status deploy/$(DEPLOY) -n $(NAMESPACE) --timeout=180s
+
+status: ## Estado de pods, service, ingress
+	kubectl get pods,svc,ingress -n $(NAMESPACE)
+
+logs: ## Sigue los logs del pod
+	kubectl logs -f deploy/$(DEPLOY) -n $(NAMESPACE)
+
+uninstall: ## Elimina la app (deployment, service, ingress, secrets) conservando namespace y BD
+	kubectl delete deployment/$(DEPLOY) service/bolsa ingress/bolsa \
+		secret/myregistrykey secret/bolsa-db secret/bolsa-secrets \
+		-n $(NAMESPACE) --ignore-not-found
+
+purge: ## Elimina TODO incluida la BD (borra el namespace completo). ¡Los datos se pierden!
+	kubectl delete namespace $(NAMESPACE) --ignore-not-found

+ 12 - 0
k8s/database.yaml

@@ -0,0 +1,12 @@
+apiVersion: postgresql.cnpg.io/v1
+kind: Database
+metadata:
+  name: bolsa-db
+  namespace: reymota-pg-cluster
+spec:
+  cluster:
+    name: postgres-cluster
+  name: bolsa
+  owner: creylopez
+  databaseReclaimPolicy: retain
+  ensure: present

+ 53 - 0
k8s/deployment.yaml

@@ -0,0 +1,53 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: bolsa
+  namespace: bolsa
+  labels:
+    app: bolsa
+spec:
+  replicas: 1
+  strategy:
+    type: Recreate
+  selector:
+    matchLabels:
+      app: bolsa
+  template:
+    metadata:
+      labels:
+        app: bolsa
+    spec:
+      imagePullSecrets:
+        - name: myregistrykey
+      containers:
+        - name: bolsa
+          image: harbor.reymota.es/reymota/bolsa:latest
+          imagePullPolicy: Always
+          ports:
+            - containerPort: 8000
+          env:
+            - name: DEBUG
+              value: "False"
+            - name: ALLOWED_HOSTS
+              value: "bolsa.reymota.lab,ser7.reymota.lab"
+            - name: SECRET_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: bolsa-secrets
+                  key: SECRET_KEY
+            - name: POSTGRES_HOST
+              value: postgres-cluster-rw.reymota-pg-cluster.svc.cluster.local
+            - name: POSTGRES_PORT
+              value: "5432"
+            - name: POSTGRES_DB
+              value: bolsa
+            - name: POSTGRES_USER
+              valueFrom:
+                secretKeyRef:
+                  name: bolsa-db
+                  key: username
+            - name: POSTGRES_PASSWORD
+              valueFrom:
+                secretKeyRef:
+                  name: bolsa-db
+                  key: password

+ 4 - 0
k8s/namespace.yaml

@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: bolsa

+ 8 - 0
k8s/secrets.yaml

@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: bolsa-secrets
+  namespace: bolsa
+type: Opaque
+stringData:
+  SECRET_KEY: "qD91qunFJcqYxJ2DFCyTsXdIxn1zuE5YkkYgkh0TattORbkJkOfy6nXbTMzKvaMolGk"

+ 35 - 0
k8s/service-ingress.yaml

@@ -0,0 +1,35 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: bolsa
+  namespace: bolsa
+  labels:
+    app: bolsa
+spec:
+  type: ClusterIP
+  clusterIP: 10.43.57.233
+  selector:
+    app: bolsa
+  ports:
+    - name: http
+      port: 80
+      targetPort: 8000
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: bolsa
+  namespace: bolsa
+spec:
+  ingressClassName: nginx
+  rules:
+    - host: bolsa.reymota.lab
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: bolsa
+                port:
+                  number: 80

+ 1 - 0
requirements.txt

@@ -2,3 +2,4 @@ Django>=4.2,<5.0
 psycopg2-binary>=2.9.9
 gunicorn>=22.0.0
 whitenoise>=6.6.0
+yfinance>=0.2.40