AI: Portfolio and Account snapshot pages

This commit is contained in:
Tyler Wilcox 2026-05-23 21:37:33 -04:00
parent 618072069e
commit 69067e1fd8
9 changed files with 998 additions and 76 deletions

View File

@ -1,10 +1,22 @@
from django.contrib import admin 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. # Register your models here.
admin.site.register(Account) admin.site.register(Account)
admin.site.register(AccountHolding) admin.site.register(AccountHolding)
admin.site.register(AccountSnapshot)
admin.site.register(Asset) admin.site.register(Asset)
admin.site.register(AssetPriceSnapshot)
admin.site.register(BrokerageConnection) admin.site.register(BrokerageConnection)
admin.site.register(HoldingSnapshot)
admin.site.register(IngestionRun) admin.site.register(IngestionRun)

View File

@ -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')],
},
),
]

View File

@ -124,3 +124,53 @@ class IngestionRun(models.Model):
def __str__(self): def __str__(self):
return f"{self.connection} [{self.status}]" 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",
),
]

View File

@ -6,7 +6,16 @@ from django.utils import timezone
from .brokerage_types import BrokerageSnapshot from .brokerage_types import BrokerageSnapshot
from .brokerages.base import BaseBrokerageAdapter from .brokerages.base import BaseBrokerageAdapter
from .brokerages.registry import get_brokerage_adapter 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) @dataclass(frozen=True)
@ -34,7 +43,7 @@ class BrokerageIngestionService:
try: try:
snapshot = self.adapter.fetch_snapshot() snapshot = self.adapter.fetch_snapshot()
result = self._persist_snapshot(snapshot) result = self._persist_snapshot(snapshot, run)
except Exception as exc: except Exception as exc:
run.status = IngestionRun.STATUS_FAILED run.status = IngestionRun.STATUS_FAILED
run.error_message = str(exc) run.error_message = str(exc)
@ -61,9 +70,14 @@ class BrokerageIngestionService:
return run return run
@transaction.atomic @transaction.atomic
def _persist_snapshot(self, snapshot: BrokerageSnapshot) -> IngestionResult: def _persist_snapshot(
self,
snapshot: BrokerageSnapshot,
run: IngestionRun,
) -> IngestionResult:
accounts_by_external_id = {} accounts_by_external_id = {}
assets_by_external_id = {} assets_by_external_id = {}
snapshot_at = run.started_at or timezone.now()
for payload in snapshot.accounts: for payload in snapshot.accounts:
account, _ = Account.objects.update_or_create( account, _ = Account.objects.update_or_create(
@ -76,6 +90,14 @@ class BrokerageIngestionService:
}, },
) )
accounts_by_external_id[payload.external_id] = account 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: for payload in snapshot.assets:
asset, _ = Asset.objects.update_or_create( asset, _ = Asset.objects.update_or_create(
@ -87,11 +109,19 @@ class BrokerageIngestionService:
}, },
) )
assets_by_external_id[payload.external_id] = asset 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: for payload in snapshot.holdings:
account = accounts_by_external_id[payload.account_external_id] account = accounts_by_external_id[payload.account_external_id]
asset = assets_by_external_id[payload.asset_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, provider=self.connection.provider,
external_id=payload.external_id, external_id=payload.external_id,
defaults={ defaults={
@ -103,6 +133,19 @@ class BrokerageIngestionService:
"purchase_date": payload.purchase_date, "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( return IngestionResult(
accounts_processed=len(snapshot.accounts), accounts_processed=len(snapshot.accounts),

View File

@ -3,25 +3,117 @@
{% block title %}Overview{% endblock %} {% block title %}Overview{% endblock %}
{% block content %} {% block content %}
<div class="section-panel">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3">
<div>
<h1 class="h2 mb-2">Portfolio Overview</h1>
<p class="text-secondary mb-0">Latest synced account balances and historical portfolio value.</p>
</div>
<div class="text-lg-end">
<div class="metric-label">Accounts</div>
<div class="metric-value">{{ accounts|length }}</div>
</div>
</div>
</div>
<h1>This is cool!!!</h1> <div class="row g-4 mb-4">
<div class="col-12 col-lg-8">
<section class="section-panel h-100">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="h5 mb-1">Portfolio Value Over Time</h2>
<p class="text-secondary mb-0">Running total based on the latest known balance for each account at every sync.</p>
</div>
</div>
<div class="d-flex justify-content-between align-items-end gap-3 flex-wrap mb-3">
{% if latest_portfolio_point %}
<div class="text-end">
<div class="metric-label">Latest Total</div>
<div class="fw-semibold fs-5">${{ latest_portfolio_point.display_value }}</div>
</div>
{% endif %}
<form method="get" class="range-form">
<label for="portfolio-range" class="metric-label mb-0">Range</label>
<select id="portfolio-range" name="range" class="form-select form-select-sm range-select" onchange="this.form.submit()">
{% for value, label in date_range_options %}
<option value="{{ value }}"{% if selected_range == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</form>
</div>
{% if portfolio_timeline %}
<div class="chart-frame">
<div class="chart-axis chart-axis-top">
<span>${{ portfolio_chart.max_label }}</span>
<span>Change shown on hover</span>
</div>
<svg class="chart-svg" viewBox="0 0 100 100" preserveAspectRatio="none" role="img" aria-label="Portfolio value over time">
<line class="chart-baseline" x1="0" y1="100" x2="100" y2="100"></line>
<path class="chart-line" d="{{ portfolio_chart.path }}"></path>
{% for point in portfolio_chart.points %}
<circle class="chart-point" cx="{{ point.x }}" cy="{{ point.y }}" r="2.2">
<title>{{ point.label }} | ${{ point.display_value }} | {{ point.change_display }}</title>
</circle>
{% endfor %}
</svg>
<div class="chart-axis chart-axis-bottom">
<span>{{ portfolio_chart.start_label }}</span>
<span>${{ portfolio_chart.min_label }}</span>
<span>{{ portfolio_chart.end_label }}</span>
</div>
</div>
{% else %}
<p class="text-secondary mb-0">Run a brokerage sync to start building a portfolio history.</p>
{% endif %}
</section>
</div>
<div class="col-12 col-lg-4">
<section class="section-panel h-100">
<h2 class="h5 mb-3">Recent Sync Points</h2>
{% if portfolio_timeline %}
<div class="list-group list-group-flush timeline-list">
{% for point in portfolio_timeline %}
<div class="list-group-item">
<div class="d-flex justify-content-between gap-3">
<span class="text-secondary">{{ point.label }}</span>
<strong>${{ point.display_value }}</strong>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-secondary mb-0">No historical snapshots yet.</p>
{% endif %}
</section>
</div>
</div>
<!-- Accounts list --> <section class="section-panel">
<div class="list-group"> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">Accounts</h2>
<span class="text-secondary small">Click an account to view holdings and its value history.</span>
</div>
<div class="list-group">
{% for account in accounts %} {% for account in accounts %}
<a href="portfolio/{{ account.id }}" class="list-group-item list-group-item-action"> <a href="portfolio/{{ account.id }}" class="list-group-item list-group-item-action">
<div class="row align-items-start"> <div class="row align-items-center">
<div class="col"> <div class="col-12 col-md-8">
<h2>{{ account.name }}</h2> <h3 class="h6 mb-1">{{ account.name }}</h3>
<div class="text-secondary small">{{ account.provider|default:"manual" }}{% if account.number %} • {{ account.number }}{% endif %}</div>
</div> </div>
<div class="col"> <div class="col-12 col-md-4 text-md-end mt-2 mt-md-0">
<p>${{ account.balance }}</p> <strong>${{ account.balance }}</strong>
</div> </div>
</div> </div>
</a> </a>
{% empty %}
<div class="list-group-item">
<p class="text-secondary mb-0">No accounts yet. Run a brokerage sync to populate this view.</p>
</div>
{% endfor %} {% endfor %}
</div> </div>
</section>
{% endblock %} {% endblock %}

View File

@ -3,46 +3,131 @@
{% block title %}{{ account.name }}{% endblock %} {% block title %}{{ account.name }}{% endblock %}
{% block content %} {% block content %}
<div class="section-panel">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3">
<div>
<h1 class="h2 mb-2">{{ account.name }}</h1>
<p class="text-secondary mb-0">{{ account.provider|default:"manual" }}{% if account.number %} • {{ account.number }}{% endif %}</p>
</div>
<div class="text-lg-end">
<div class="metric-label">Current Balance</div>
<div class="metric-value">${{ account.balance }}</div>
</div>
</div>
</div>
<h1>This is cool!!!</h1> <div class="row g-4 mb-4">
<div class="col-12 col-lg-8">
<section class="section-panel h-100">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="h5 mb-1">Account Value Over Time</h2>
<p class="text-secondary mb-0">Historical balance snapshots recorded each time this account is synced.</p>
</div>
</div>
<div class="d-flex justify-content-between align-items-end gap-3 flex-wrap mb-3">
{% if latest_account_point %}
<div class="text-end">
<div class="metric-label">Latest Snapshot</div>
<div class="fw-semibold fs-5">${{ latest_account_point.display_value }}</div>
</div>
{% endif %}
<form method="get" class="range-form">
<label for="account-range" class="metric-label mb-0">Range</label>
<select id="account-range" name="range" class="form-select form-select-sm range-select" onchange="this.form.submit()">
{% for value, label in date_range_options %}
<option value="{{ value }}"{% if selected_range == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</form>
</div>
<!-- Holdings list --> {% if account_timeline %}
<div class="list-group"> <div class="chart-frame">
<div class="chart-axis chart-axis-top">
<span>${{ account_chart.max_label }}</span>
<span>Change shown on hover</span>
</div>
<svg class="chart-svg" viewBox="0 0 100 100" preserveAspectRatio="none" role="img" aria-label="Account value over time">
<line class="chart-baseline" x1="0" y1="100" x2="100" y2="100"></line>
<path class="chart-line" d="{{ account_chart.path }}"></path>
{% for point in account_chart.points %}
<circle class="chart-point" cx="{{ point.x }}" cy="{{ point.y }}" r="2.2">
<title>{{ point.label }} | ${{ point.display_value }} | {{ point.change_display }}</title>
</circle>
{% endfor %}
</svg>
<div class="chart-axis chart-axis-bottom">
<span>{{ account_chart.start_label }}</span>
<span>${{ account_chart.min_label }}</span>
<span>{{ account_chart.end_label }}</span>
</div>
</div>
{% else %}
<p class="text-secondary mb-0">No account history yet. Run another sync to capture snapshots.</p>
{% endif %}
</section>
</div>
<div class="col-12 col-lg-4">
<section class="section-panel h-100">
<h2 class="h5 mb-3">Snapshot Timeline</h2>
{% if account_timeline %}
<div class="list-group list-group-flush timeline-list">
{% for point in account_timeline %}
<div class="list-group-item"> <div class="list-group-item">
<div class="row align-items-start"> <div class="d-flex justify-content-between gap-3">
<div class="col"> <span class="text-secondary">{{ point.label }}</span>
<h3>Ticker</h2> <strong>${{ point.display_value }}</strong>
</div> </div>
<div class="col">
<h3>Value</h2>
</div> </div>
<div class="col"> {% endfor %}
<h3>Amount</h2>
</div> </div>
<div class="col"> {% else %}
<h3>Cost Basis</h2> <p class="text-secondary mb-0">No snapshot points available yet.</p>
{% endif %}
</section>
</div> </div>
</div>
<section class="section-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">Current Holdings</h2>
<span class="text-secondary small">{{ holdings|length }} position{{ holdings|length|pluralize }}</span>
</div>
<div class="list-group">
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-3"><strong>Ticker</strong></div>
<div class="col-3"><strong>Value</strong></div>
<div class="col-3"><strong>Amount</strong></div>
<div class="col-3"><strong>Cost Basis</strong></div>
</div> </div>
</div> </div>
{% for holding in holdings %} {% for holding in holdings %}
<a href="asset/{{ holding.asset.ticker }}" class="list-group-item list-group-item-action"> <div class="list-group-item">
<div class="row align-items-start"> <div class="row align-items-center">
<div class="col"> <div class="col-3">
<h2>{{ holding.asset.ticker }}</h2> <strong>{{ holding.asset.ticker }}</strong>
</div> </div>
<div class="col"> <div class="col-3">
<p>${{ holding.holding_value }} ( ${{ holding.holding_gain }} )</p> ${{ holding.holding_value|floatformat:2 }}
<div class="small text-secondary">${{ holding.holding_gain|floatformat:2 }}</div>
</div> </div>
<div class="col"> <div class="col-3">
<p>{{ holding.ammount }}</p> {{ holding.ammount|floatformat:4 }}
</div> </div>
<div class="col"> <div class="col-3">
<p>${{ holding.cost_basis }}</p> ${{ holding.cost_basis|floatformat:2 }}
</div> </div>
</div> </div>
</a> </div>
{% empty %}
<div class="list-group-item">
<p class="text-secondary mb-0">No holdings available for this account.</p>
</div>
{% endfor %} {% endfor %}
</div> </div>
</section>
{% endblock %} {% endblock %}

View File

@ -1,9 +1,10 @@
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from io import StringIO from io import StringIO
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django.test import TestCase from django.test import TestCase
from django.utils import timezone as django_timezone
from .brokerage_types import ( from .brokerage_types import (
BrokerageAccountPayload, BrokerageAccountPayload,
@ -14,7 +15,16 @@ from .brokerage_types import (
from .brokerages.base import BaseBrokerageAdapter, BrokerageAdapterError from .brokerages.base import BaseBrokerageAdapter, BrokerageAdapterError
from .brokerages.random import RandomBrokerageAdapter from .brokerages.random import RandomBrokerageAdapter
from .brokerages.registry import register_brokerage_adapter 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 from .services import BrokerageIngestionService
@ -136,14 +146,21 @@ class BrokerageIngestionServiceTests(TestCase):
self.assertEqual(Account.objects.count(), 1) self.assertEqual(Account.objects.count(), 1)
self.assertEqual(Asset.objects.count(), 1) self.assertEqual(Asset.objects.count(), 1)
self.assertEqual(AccountHolding.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() account = Account.objects.get()
asset = Asset.objects.get() asset = Asset.objects.get()
holding = AccountHolding.objects.get() holding = AccountHolding.objects.get()
latest_holding_snapshot = HoldingSnapshot.objects.filter(
ingestion_run=second_run
).get()
self.assertEqual(account.balance, 12500) self.assertEqual(account.balance, 12500)
self.assertEqual(asset.price, 205.55) self.assertEqual(asset.price, 205.55)
self.assertEqual(holding.ammount, 12) 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.accounts_processed, 1)
self.assertEqual(second_run.assets_processed, 1) self.assertEqual(second_run.assets_processed, 1)
self.assertEqual(second_run.holdings_processed, 1) self.assertEqual(second_run.holdings_processed, 1)
@ -285,6 +302,18 @@ class BrokerageIngestionServiceTests(TestCase):
).count(), ).count(),
6, 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): def test_sync_command_accepts_provider_identifier(self):
random_connection = BrokerageConnection.objects.create( random_connection = BrokerageConnection.objects.create(
@ -331,3 +360,257 @@ class BrokerageIngestionServiceTests(TestCase):
) )
self.assertIn("matches multiple active connections", str(exc.exception)) 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"))

View File

@ -1,18 +1,209 @@
from django.http import HttpResponse from datetime import timedelta
from django.shortcuts import render from decimal import Decimal
from .models import Account, AccountHolding
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): 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): 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) context = {
holdings = AccountHolding.objects.filter(account = account) "account": account,
"date_range_options": DATE_RANGE_OPTIONS,
return render(request, "portfolio.html", {"account": account, "holdings": holdings}) "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)

View File

@ -4,6 +4,114 @@
<meta name="viewport" content="width=device-width, intitial-scale=1"> <meta name="viewport" content="width=device-width, intitial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<title>{% block title %}{% endblock %} - Stonks</title> <title>{% block title %}{% endblock %} - Stonks</title>
<style>
body {
background: #f5f7fb;
}
.page-shell {
padding-top: 5.5rem;
padding-bottom: 3rem;
}
.section-panel {
background: #ffffff;
border: 1px solid #d8e0ea;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
color: #102a43;
}
.metric-label {
color: #52606d;
font-size: 0.95rem;
margin-bottom: 0.5rem;
}
.chart-frame {
width: 100%;
height: 280px;
border-radius: 8px;
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
border: 1px solid #d8e0ea;
padding: 1rem 1rem 0.75rem;
}
.chart-svg {
width: 100%;
height: 220px;
display: block;
}
.chart-line {
fill: none;
stroke: #2f6fed;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.chart-baseline {
stroke: #cbd2d9;
stroke-width: 1;
}
.chart-point {
fill: #2f6fed;
stroke: #ffffff;
stroke-width: 1.5;
}
.chart-axis {
color: #52606d;
font-size: 0.85rem;
}
.chart-axis-top,
.chart-axis-bottom {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.chart-axis-top {
margin-bottom: 0.35rem;
}
.chart-axis-bottom {
margin-top: 0.5rem;
}
.range-form {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.range-select {
width: auto;
min-width: 90px;
}
.timeline-list {
max-height: 280px;
overflow-y: auto;
}
.timeline-list .list-group-item {
padding-left: 0;
padding-right: 0;
border-left: 0;
border-right: 0;
}
</style>
</head> </head>
<body> <body>
@ -29,7 +137,7 @@
</nav> </nav>
</div> </div>
<div class="container"> <div class="container page-shell">
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>