From 69067e1fd87b3bdcd6d272acc9a8be38e774b11a Mon Sep 17 00:00:00 2001 From: Tyler Wilcox Date: Sat, 23 May 2026 21:37:33 -0400 Subject: [PATCH] AI: Portfolio and Account snapshot pages --- overview/admin.py | 14 +- ...shot_assetpricesnapshot_holdingsnapshot.py | 58 ++++ overview/models.py | 50 +++ overview/services.py | 51 +++- overview/templates/overviewbase.html | 130 ++++++-- overview/templates/portfolio.html | 161 +++++++--- overview/tests.py | 287 +++++++++++++++++- overview/views.py | 211 ++++++++++++- templates/base.html | 112 ++++++- 9 files changed, 998 insertions(+), 76 deletions(-) create mode 100644 overview/migrations/0006_accountsnapshot_assetpricesnapshot_holdingsnapshot.py 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 %} - -

This is cool!!!

- - - -
- {% for account in accounts %} - -
-
-

{{ account.name }}

-
-
-

${{ account.balance }}

-
-
-
- {% endfor %} +
+
+
+

Portfolio Overview

+

Latest synced account balances and historical portfolio value.

+
+
+
Accounts
+
{{ accounts|length }}
+
+
+
+
+
+
+
+

Portfolio Value Over Time

+

Running total based on the latest known balance for each account at every sync.

+
+
+
+ {% if latest_portfolio_point %} +
+
Latest Total
+
${{ latest_portfolio_point.display_value }}
+
+ {% endif %} +
+ + +
+
-{% endblock %} \ No newline at end of file + {% if portfolio_timeline %} +
+
+ ${{ portfolio_chart.max_label }} + Change shown on hover +
+ + + + {% for point in portfolio_chart.points %} + + {{ point.label }} | ${{ point.display_value }} | {{ point.change_display }} + + {% endfor %} + +
+ {{ portfolio_chart.start_label }} + ${{ portfolio_chart.min_label }} + {{ portfolio_chart.end_label }} +
+
+ {% else %} +

Run a brokerage sync to start building a portfolio history.

+ {% endif %} +
+
+
+
+

Recent Sync Points

+ {% if portfolio_timeline %} +
+ {% for point in portfolio_timeline %} +
+
+ {{ point.label }} + ${{ point.display_value }} +
+
+ {% endfor %} +
+ {% else %} +

No historical snapshots yet.

+ {% endif %} +
+
+
+ +
+
+

Accounts

+ Click an account to view holdings and its value history. +
+ +
+ {% for account in accounts %} + +
+
+

{{ account.name }}

+
{{ account.provider|default:"manual" }}{% if account.number %} • {{ account.number }}{% endif %}
+
+
+ ${{ account.balance }} +
+
+
+ {% empty %} +
+

No accounts yet. Run a brokerage sync to populate this view.

+
+ {% endfor %} +
+
+ +{% endblock %} diff --git a/overview/templates/portfolio.html b/overview/templates/portfolio.html index 1fcbfe5..92fa11b 100644 --- a/overview/templates/portfolio.html +++ b/overview/templates/portfolio.html @@ -3,46 +3,131 @@ {% block title %}{{ account.name }}{% endblock %} {% block content %} - -

This is cool!!!

- - -
-
-
-
-

Ticker

-
-
-

Value

-
-
-

Amount

-
-
-

Cost Basis

-
+
+
+
+

{{ account.name }}

+

{{ account.provider|default:"manual" }}{% if account.number %} • {{ account.number }}{% endif %}

+
+
+
Current Balance
+
${{ account.balance }}
- {% for holding in holdings %} - -
-
-

{{ holding.asset.ticker }}

-
-
-

${{ holding.holding_value }} ( ${{ holding.holding_gain }} )

-
-
-

{{ holding.ammount }}

-
-
-

${{ holding.cost_basis }}

-
-
-
- {% endfor %}
+
+
+
+
+
+

Account Value Over Time

+

Historical balance snapshots recorded each time this account is synced.

+
+
+
+ {% if latest_account_point %} +
+
Latest Snapshot
+
${{ latest_account_point.display_value }}
+
+ {% endif %} +
+ + +
+
-{% endblock %} \ No newline at end of file + {% if account_timeline %} +
+
+ ${{ account_chart.max_label }} + Change shown on hover +
+ + + + {% for point in account_chart.points %} + + {{ point.label }} | ${{ point.display_value }} | {{ point.change_display }} + + {% endfor %} + +
+ {{ account_chart.start_label }} + ${{ account_chart.min_label }} + {{ account_chart.end_label }} +
+
+ {% else %} +

No account history yet. Run another sync to capture snapshots.

+ {% endif %} +
+
+
+
+

Snapshot Timeline

+ {% if account_timeline %} +
+ {% for point in account_timeline %} +
+
+ {{ point.label }} + ${{ point.display_value }} +
+
+ {% endfor %} +
+ {% else %} +

No snapshot points available yet.

+ {% endif %} +
+
+
+ +
+
+

Current Holdings

+ {{ holdings|length }} position{{ holdings|length|pluralize }} +
+ +
+
+
+
Ticker
+
Value
+
Amount
+
Cost Basis
+
+
+ {% for holding in holdings %} +
+
+
+ {{ holding.asset.ticker }} +
+
+ ${{ holding.holding_value|floatformat:2 }} +
${{ holding.holding_gain|floatformat:2 }}
+
+
+ {{ holding.ammount|floatformat:4 }} +
+
+ ${{ holding.cost_basis|floatformat:2 }} +
+
+
+ {% empty %} +
+

No holdings available for this account.

+
+ {% endfor %} +
+
+ +{% endblock %} diff --git a/overview/tests.py b/overview/tests.py index 2d3c8a3..b2e0fb0 100644 --- a/overview/tests.py +++ b/overview/tests.py @@ -1,9 +1,10 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from io import StringIO from django.core.management import call_command from django.core.management.base import CommandError from django.test import TestCase +from django.utils import timezone as django_timezone from .brokerage_types import ( BrokerageAccountPayload, @@ -14,7 +15,16 @@ from .brokerage_types import ( from .brokerages.base import BaseBrokerageAdapter, BrokerageAdapterError from .brokerages.random import RandomBrokerageAdapter from .brokerages.registry import register_brokerage_adapter -from .models import Account, AccountHolding, Asset, BrokerageConnection, IngestionRun +from .models import ( + Account, + AccountHolding, + AccountSnapshot, + Asset, + AssetPriceSnapshot, + BrokerageConnection, + HoldingSnapshot, + IngestionRun, +) from .services import BrokerageIngestionService @@ -136,14 +146,21 @@ class BrokerageIngestionServiceTests(TestCase): self.assertEqual(Account.objects.count(), 1) self.assertEqual(Asset.objects.count(), 1) self.assertEqual(AccountHolding.objects.count(), 1) + self.assertEqual(AccountSnapshot.objects.count(), 2) + self.assertEqual(AssetPriceSnapshot.objects.count(), 2) + self.assertEqual(HoldingSnapshot.objects.count(), 2) account = Account.objects.get() asset = Asset.objects.get() holding = AccountHolding.objects.get() + latest_holding_snapshot = HoldingSnapshot.objects.filter( + ingestion_run=second_run + ).get() self.assertEqual(account.balance, 12500) self.assertEqual(asset.price, 205.55) self.assertEqual(holding.ammount, 12) + self.assertEqual(latest_holding_snapshot.value, 12 * 205.55) self.assertEqual(second_run.accounts_processed, 1) self.assertEqual(second_run.assets_processed, 1) self.assertEqual(second_run.holdings_processed, 1) @@ -285,6 +302,18 @@ class BrokerageIngestionServiceTests(TestCase): ).count(), 6, ) + self.assertEqual( + AccountSnapshot.objects.filter(ingestion_run=run).count(), + 3, + ) + self.assertEqual( + AssetPriceSnapshot.objects.filter(ingestion_run=run).count(), + 6, + ) + self.assertEqual( + HoldingSnapshot.objects.filter(ingestion_run=run).count(), + 6, + ) def test_sync_command_accepts_provider_identifier(self): random_connection = BrokerageConnection.objects.create( @@ -331,3 +360,257 @@ class BrokerageIngestionServiceTests(TestCase): ) self.assertIn("matches multiple active connections", str(exc.exception)) + + def test_overview_page_renders_portfolio_history(self): + BrokerageIngestionService( + self.connection, + adapter=FakeBrokerageAdapter( + self.connection, + snapshot=BrokerageSnapshot( + accounts=[ + BrokerageAccountPayload( + external_id="acct-1", + name="Brokerage Account", + number="1234", + balance=10000, + ) + ], + assets=[ + BrokerageAssetPayload( + external_id="asset-1", + ticker="AAPL", + price=200.00, + ) + ], + holdings=[ + BrokerageHoldingPayload( + external_id="holding-1", + account_external_id="acct-1", + asset_external_id="asset-1", + ticker="AAPL", + amount=10, + cost_basis=150.00, + purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc), + ) + ], + ), + ), + ).ingest() + + response = self.client.get("/") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Portfolio Value Over Time") + self.assertContains(response, "Recent Sync Points") + + def test_account_page_renders_account_history(self): + run = BrokerageIngestionService( + self.connection, + adapter=FakeBrokerageAdapter( + self.connection, + snapshot=BrokerageSnapshot( + accounts=[ + BrokerageAccountPayload( + external_id="acct-1", + name="Brokerage Account", + number="1234", + balance=10000, + ) + ], + assets=[ + BrokerageAssetPayload( + external_id="asset-1", + ticker="AAPL", + price=200.00, + ) + ], + holdings=[ + BrokerageHoldingPayload( + external_id="holding-1", + account_external_id="acct-1", + asset_external_id="asset-1", + ticker="AAPL", + amount=10, + cost_basis=150.00, + purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc), + ) + ], + ), + ), + ).ingest() + account = AccountSnapshot.objects.get(ingestion_run=run).account + + response = self.client.get(f"/portfolio/{account.id}") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Account Value Over Time") + self.assertContains(response, "Current Holdings") + + def test_overview_range_selector_filters_to_recent_history(self): + first_run = BrokerageIngestionService( + self.connection, + adapter=FakeBrokerageAdapter( + self.connection, + snapshot=BrokerageSnapshot( + accounts=[ + BrokerageAccountPayload( + external_id="acct-1", + name="Brokerage Account", + number="1234", + balance=8000, + ) + ], + assets=[ + BrokerageAssetPayload( + external_id="asset-1", + ticker="AAPL", + price=180.00, + ) + ], + holdings=[ + BrokerageHoldingPayload( + external_id="holding-1", + account_external_id="acct-1", + asset_external_id="asset-1", + ticker="AAPL", + amount=10, + cost_basis=150.00, + purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc), + ) + ], + ), + ), + ).ingest() + second_run = BrokerageIngestionService( + self.connection, + adapter=FakeBrokerageAdapter( + self.connection, + snapshot=BrokerageSnapshot( + accounts=[ + BrokerageAccountPayload( + external_id="acct-1", + name="Brokerage Account", + number="1234", + balance=12000, + ) + ], + assets=[ + BrokerageAssetPayload( + external_id="asset-1", + ticker="AAPL", + price=210.00, + ) + ], + holdings=[ + BrokerageHoldingPayload( + external_id="holding-1", + account_external_id="acct-1", + asset_external_id="asset-1", + ticker="AAPL", + amount=10, + cost_basis=150.00, + purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc), + ) + ], + ), + ), + ).ingest() + + old_timestamp = django_timezone.now() - timedelta(days=40) + recent_timestamp = django_timezone.now() - timedelta(days=2) + IngestionRun.objects.filter(pk=first_run.pk).update(started_at=old_timestamp) + IngestionRun.objects.filter(pk=second_run.pk).update(started_at=recent_timestamp) + AccountSnapshot.objects.filter(ingestion_run=first_run).update(snapshot_at=old_timestamp) + AccountSnapshot.objects.filter(ingestion_run=second_run).update(snapshot_at=recent_timestamp) + + response = self.client.get("/", {"range": "7d"}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "value=\"7d\" selected") + self.assertNotContains(response, old_timestamp.strftime("%Y-%m-%d %H:%M")) + self.assertContains(response, recent_timestamp.strftime("%Y-%m-%d %H:%M")) + + def test_account_range_selector_filters_account_history(self): + first_run = BrokerageIngestionService( + self.connection, + adapter=FakeBrokerageAdapter( + self.connection, + snapshot=BrokerageSnapshot( + accounts=[ + BrokerageAccountPayload( + external_id="acct-1", + name="Brokerage Account", + number="1234", + balance=9000, + ) + ], + assets=[ + BrokerageAssetPayload( + external_id="asset-1", + ticker="AAPL", + price=180.00, + ) + ], + holdings=[ + BrokerageHoldingPayload( + external_id="holding-1", + account_external_id="acct-1", + asset_external_id="asset-1", + ticker="AAPL", + amount=10, + cost_basis=150.00, + purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc), + ) + ], + ), + ), + ).ingest() + second_run = BrokerageIngestionService( + self.connection, + adapter=FakeBrokerageAdapter( + self.connection, + snapshot=BrokerageSnapshot( + accounts=[ + BrokerageAccountPayload( + external_id="acct-1", + name="Brokerage Account", + number="1234", + balance=11000, + ) + ], + assets=[ + BrokerageAssetPayload( + external_id="asset-1", + ticker="AAPL", + price=205.00, + ) + ], + holdings=[ + BrokerageHoldingPayload( + external_id="holding-1", + account_external_id="acct-1", + asset_external_id="asset-1", + ticker="AAPL", + amount=10, + cost_basis=150.00, + purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc), + ) + ], + ), + ), + ).ingest() + account = Account.objects.get() + + old_timestamp = django_timezone.now() - timedelta(days=500) + recent_timestamp = django_timezone.now() - timedelta(days=5) + IngestionRun.objects.filter(pk=first_run.pk).update(started_at=old_timestamp) + IngestionRun.objects.filter(pk=second_run.pk).update(started_at=recent_timestamp) + AccountSnapshot.objects.filter(ingestion_run=first_run).update(snapshot_at=old_timestamp) + AccountSnapshot.objects.filter(ingestion_run=second_run).update(snapshot_at=recent_timestamp) + + response = self.client.get(f"/portfolio/{account.id}", {"range": "30d"}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "value=\"30d\" selected") + self.assertNotContains(response, old_timestamp.strftime("%Y-%m-%d %H:%M")) + self.assertContains(response, recent_timestamp.strftime("%Y-%m-%d %H:%M")) diff --git a/overview/views.py b/overview/views.py index 7f16fde..4b5080a 100644 --- a/overview/views.py +++ b/overview/views.py @@ -1,18 +1,209 @@ -from django.http import HttpResponse -from django.shortcuts import render -from .models import Account, AccountHolding +from datetime import timedelta +from decimal import Decimal + +from django.http import HttpRequest +from django.shortcuts import get_object_or_404, render +from django.utils import timezone + +from .models import Account, AccountHolding, AccountSnapshot, IngestionRun + + +def _format_currency(value): + return f"{value:,.2f}" + + +DATE_RANGE_OPTIONS = [ + ("7d", "7D"), + ("30d", "30D"), + ("90d", "90D"), + ("1y", "1Y"), + ("all", "All"), +] + + +def _get_selected_range(request: HttpRequest): + allowed_values = {value for value, _ in DATE_RANGE_OPTIONS} + selected_range = request.GET.get("range", "all") + if selected_range not in allowed_values: + return "all" + return selected_range + + +def _get_range_cutoff(selected_range: str): + now = timezone.now() + if selected_range == "7d": + return now - timedelta(days=7) + if selected_range == "30d": + return now - timedelta(days=30) + if selected_range == "90d": + return now - timedelta(days=90) + if selected_range == "1y": + return now - timedelta(days=365) + return None + + +def _filter_timeline_by_range(timeline, selected_range: str): + cutoff = _get_range_cutoff(selected_range) + if cutoff is None: + return timeline + + filtered = [point for point in timeline if point["timestamp"] >= cutoff] + if filtered: + return filtered + return timeline[-1:] if timeline else [] + + +def _build_chart_model(timeline, width=100, height=100): + if not timeline: + return { + "path": "", + "points": [], + "min_label": _format_currency(0), + "max_label": _format_currency(0), + "start_label": "", + "end_label": "", + } + + values = [point["value"] for point in timeline] + min_value = min(values) + max_value = max(values) + span = max_value - min_value + if span == 0: + span = 1 + + chart_points = [] + coordinates = [] + point_count = len(timeline) + for index, point in enumerate(timeline): + if point_count == 1: + x = width / 2 + y = height / 2 + else: + x = (index / (point_count - 1)) * width + normalized = (point["value"] - min_value) / span + y = height - (normalized * height) + + change = None + change_display = "N/A" + if index > 0: + change = point["value"] - timeline[index - 1]["value"] + sign = "+" if change >= 0 else "-" + change_display = f"{sign}${_format_currency(abs(change))}" + + chart_points.append( + { + "x": f"{x:.2f}", + "y": f"{y:.2f}", + "label": point["label"], + "display_value": point["display_value"], + "change_display": change_display, + } + ) + normalized = (point["value"] - min_value) / span + coordinates.append(f"{x:.2f},{y:.2f}") + + if len(coordinates) == 1: + path = f"M 0 {height / 2:.2f} L {width:.2f} {height / 2:.2f}" + else: + path = "M " + " L ".join(coordinates) + + return { + "path": path, + "points": chart_points, + "min_label": _format_currency(min_value), + "max_label": _format_currency(max_value), + "start_label": timeline[0]["short_label"], + "end_label": timeline[-1]["short_label"], + } + + +def _build_portfolio_timeline(): + latest_balances = {} + timeline = [] + runs = IngestionRun.objects.order_by("started_at", "created_at").prefetch_related( + "accountsnapshot_set__account" + ) + + for run in runs: + snapshots = list(run.accountsnapshot_set.all()) + if not snapshots: + continue + + for snapshot in snapshots: + latest_balances[snapshot.account_id] = Decimal(snapshot.balance) + + total_value = sum(latest_balances.values(), Decimal("0")) + timestamp = run.started_at or run.created_at + timeline.append( + { + "timestamp": timestamp, + "label": timestamp.strftime("%Y-%m-%d %H:%M"), + "short_label": timestamp.strftime("%b %d"), + "value": float(total_value), + "display_value": _format_currency(total_value), + } + ) + + return timeline + + +def _build_account_timeline(account): + snapshots = AccountSnapshot.objects.filter(account=account).order_by("snapshot_at") + timeline = [] + + for snapshot in snapshots: + timeline.append( + { + "timestamp": snapshot.snapshot_at, + "label": snapshot.snapshot_at.strftime("%Y-%m-%d %H:%M"), + "short_label": snapshot.snapshot_at.strftime("%b %d"), + "value": float(snapshot.balance), + "display_value": _format_currency(snapshot.balance), + } + ) + + return timeline -# Create your views here. def index(request): + accounts = Account.objects.all().order_by("name") + selected_range = _get_selected_range(request) + portfolio_timeline = _filter_timeline_by_range( + _build_portfolio_timeline(), + selected_range, + ) + latest_portfolio_point = portfolio_timeline[-1] if portfolio_timeline else None + chart_model = _build_chart_model(portfolio_timeline) - accounts = Account.objects.all() + context = { + "accounts": accounts, + "date_range_options": DATE_RANGE_OPTIONS, + "selected_range": selected_range, + "portfolio_timeline": portfolio_timeline, + "latest_portfolio_point": latest_portfolio_point, + "portfolio_chart": chart_model, + } + return render(request, "overviewbase.html", context) - return render(request, "overviewbase.html", {"accounts": accounts}) def portfolio(request, account_id): + account = get_object_or_404(Account, pk=account_id) + holdings = AccountHolding.objects.filter(account=account).select_related("asset") + selected_range = _get_selected_range(request) + account_timeline = _filter_timeline_by_range( + _build_account_timeline(account), + selected_range, + ) + latest_account_point = account_timeline[-1] if account_timeline else None + chart_model = _build_chart_model(account_timeline) - account = Account.objects.get(pk=account_id) - holdings = AccountHolding.objects.filter(account = account) - - return render(request, "portfolio.html", {"account": account, "holdings": holdings}) \ No newline at end of file + context = { + "account": account, + "date_range_options": DATE_RANGE_OPTIONS, + "selected_range": selected_range, + "holdings": holdings, + "account_timeline": account_timeline, + "latest_account_point": latest_account_point, + "account_chart": chart_model, + } + return render(request, "portfolio.html", context) diff --git a/templates/base.html b/templates/base.html index 508fdd2..7ee32d5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,6 +4,114 @@ {% block title %}{% endblock %} - Stonks + @@ -29,9 +137,9 @@
-
+
{% block content %} {% endblock %}
- \ No newline at end of file +