diff --git a/overview/admin.py b/overview/admin.py index 3610578..0dc5bba 100644 --- a/overview/admin.py +++ b/overview/admin.py @@ -1,10 +1,22 @@ from django.contrib import admin -from .models import Account, AccountHolding, Asset, BrokerageConnection, IngestionRun +from .models import ( + Account, + AccountHolding, + AccountSnapshot, + Asset, + AssetPriceSnapshot, + BrokerageConnection, + HoldingSnapshot, + IngestionRun, +) # Register your models here. admin.site.register(Account) admin.site.register(AccountHolding) +admin.site.register(AccountSnapshot) admin.site.register(Asset) +admin.site.register(AssetPriceSnapshot) admin.site.register(BrokerageConnection) +admin.site.register(HoldingSnapshot) admin.site.register(IngestionRun) diff --git a/overview/migrations/0006_accountsnapshot_assetpricesnapshot_holdingsnapshot.py b/overview/migrations/0006_accountsnapshot_assetpricesnapshot_holdingsnapshot.py new file mode 100644 index 0000000..968113c --- /dev/null +++ b/overview/migrations/0006_accountsnapshot_assetpricesnapshot_holdingsnapshot.py @@ -0,0 +1,58 @@ +# Generated by Django 6.0.5 on 2026-05-24 01:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('overview', '0005_remove_brokerageconnection_access_token_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='AccountSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('balance', models.IntegerField()), + ('snapshot_at', models.DateTimeField()), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.account')), + ('ingestion_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.ingestionrun')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('ingestion_run', 'account'), name='unique_account_snapshot_per_run')], + }, + ), + migrations.CreateModel( + name='AssetPriceSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('price', models.FloatField()), + ('snapshot_at', models.DateTimeField()), + ('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.asset')), + ('ingestion_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.ingestionrun')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('ingestion_run', 'asset'), name='unique_asset_snapshot_per_run')], + }, + ), + migrations.CreateModel( + name='HoldingSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.FloatField()), + ('cost_basis', models.FloatField()), + ('market_price', models.FloatField()), + ('value', models.FloatField()), + ('snapshot_at', models.DateTimeField()), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.account')), + ('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.asset')), + ('holding', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.accountholding')), + ('ingestion_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.ingestionrun')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('ingestion_run', 'holding'), name='unique_holding_snapshot_per_run')], + }, + ), + ] diff --git a/overview/models.py b/overview/models.py index b090461..82b92d2 100644 --- a/overview/models.py +++ b/overview/models.py @@ -124,3 +124,53 @@ class IngestionRun(models.Model): def __str__(self): return f"{self.connection} [{self.status}]" + + +class AccountSnapshot(models.Model): + ingestion_run = models.ForeignKey(IngestionRun, on_delete=models.CASCADE) + account = models.ForeignKey(Account, on_delete=models.CASCADE) + balance = models.IntegerField() + snapshot_at = models.DateTimeField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["ingestion_run", "account"], + name="unique_account_snapshot_per_run", + ), + ] + + +class AssetPriceSnapshot(models.Model): + ingestion_run = models.ForeignKey(IngestionRun, on_delete=models.CASCADE) + asset = models.ForeignKey(Asset, on_delete=models.CASCADE) + price = models.FloatField() + snapshot_at = models.DateTimeField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["ingestion_run", "asset"], + name="unique_asset_snapshot_per_run", + ), + ] + + +class HoldingSnapshot(models.Model): + ingestion_run = models.ForeignKey(IngestionRun, on_delete=models.CASCADE) + holding = models.ForeignKey(AccountHolding, on_delete=models.CASCADE) + account = models.ForeignKey(Account, on_delete=models.CASCADE) + asset = models.ForeignKey(Asset, on_delete=models.CASCADE) + amount = models.FloatField() + cost_basis = models.FloatField() + market_price = models.FloatField() + value = models.FloatField() + snapshot_at = models.DateTimeField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["ingestion_run", "holding"], + name="unique_holding_snapshot_per_run", + ), + ] diff --git a/overview/services.py b/overview/services.py index 9d9970b..b24fff2 100644 --- a/overview/services.py +++ b/overview/services.py @@ -6,7 +6,16 @@ from django.utils import timezone from .brokerage_types import BrokerageSnapshot from .brokerages.base import BaseBrokerageAdapter from .brokerages.registry import get_brokerage_adapter -from .models import Account, AccountHolding, Asset, BrokerageConnection, IngestionRun +from .models import ( + Account, + AccountHolding, + AccountSnapshot, + Asset, + AssetPriceSnapshot, + BrokerageConnection, + HoldingSnapshot, + IngestionRun, +) @dataclass(frozen=True) @@ -34,7 +43,7 @@ class BrokerageIngestionService: try: snapshot = self.adapter.fetch_snapshot() - result = self._persist_snapshot(snapshot) + result = self._persist_snapshot(snapshot, run) except Exception as exc: run.status = IngestionRun.STATUS_FAILED run.error_message = str(exc) @@ -61,9 +70,14 @@ class BrokerageIngestionService: return run @transaction.atomic - def _persist_snapshot(self, snapshot: BrokerageSnapshot) -> IngestionResult: + def _persist_snapshot( + self, + snapshot: BrokerageSnapshot, + run: IngestionRun, + ) -> IngestionResult: accounts_by_external_id = {} assets_by_external_id = {} + snapshot_at = run.started_at or timezone.now() for payload in snapshot.accounts: account, _ = Account.objects.update_or_create( @@ -76,6 +90,14 @@ class BrokerageIngestionService: }, ) accounts_by_external_id[payload.external_id] = account + AccountSnapshot.objects.update_or_create( + ingestion_run=run, + account=account, + defaults={ + "balance": payload.balance, + "snapshot_at": snapshot_at, + }, + ) for payload in snapshot.assets: asset, _ = Asset.objects.update_or_create( @@ -87,11 +109,19 @@ class BrokerageIngestionService: }, ) assets_by_external_id[payload.external_id] = asset + AssetPriceSnapshot.objects.update_or_create( + ingestion_run=run, + asset=asset, + defaults={ + "price": payload.price, + "snapshot_at": snapshot_at, + }, + ) for payload in snapshot.holdings: account = accounts_by_external_id[payload.account_external_id] asset = assets_by_external_id[payload.asset_external_id] - AccountHolding.objects.update_or_create( + holding, _ = AccountHolding.objects.update_or_create( provider=self.connection.provider, external_id=payload.external_id, defaults={ @@ -103,6 +133,19 @@ class BrokerageIngestionService: "purchase_date": payload.purchase_date, }, ) + HoldingSnapshot.objects.update_or_create( + ingestion_run=run, + holding=holding, + defaults={ + "account": account, + "asset": asset, + "amount": payload.amount, + "cost_basis": payload.cost_basis, + "market_price": asset.price, + "value": payload.amount * asset.price, + "snapshot_at": snapshot_at, + }, + ) return IngestionResult( accounts_processed=len(snapshot.accounts), diff --git a/overview/templates/overviewbase.html b/overview/templates/overviewbase.html index 3d7d8b3..c0f0207 100644 --- a/overview/templates/overviewbase.html +++ b/overview/templates/overviewbase.html @@ -3,25 +3,117 @@ {% block title %}Overview{% endblock %} {% block content %} - -
${{ account.balance }}
-Latest synced account balances and historical portfolio value.
+Running total based on the latest known balance for each account at every sync.
+Run a brokerage sync to start building a portfolio history.
+ {% endif %} +No historical snapshots yet.
+ {% endif %} +No accounts yet. Run a brokerage sync to populate this view.
+{{ account.provider|default:"manual" }}{% if account.number %} • {{ account.number }}{% endif %}
+${{ holding.holding_value }} ( ${{ holding.holding_gain }} )
-{{ holding.ammount }}
-${{ holding.cost_basis }}
-Historical balance snapshots recorded each time this account is synced.
+No account history yet. Run another sync to capture snapshots.
+ {% endif %} +No snapshot points available yet.
+ {% endif %} +No holdings available for this account.
+