AI: Portfolio and Account snapshot pages
This commit is contained in:
parent
618072069e
commit
69067e1fd8
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,117 @@
|
||||||
{% block title %}Overview{% endblock %}
|
{% block title %}Overview{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="section-panel">
|
||||||
<h1>This is cool!!!</h1>
|
<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>
|
||||||
<!-- Accounts list -->
|
<p class="text-secondary mb-0">Latest synced account balances and historical portfolio value.</p>
|
||||||
<div class="list-group">
|
</div>
|
||||||
{% for account in accounts %}
|
<div class="text-lg-end">
|
||||||
<a href="portfolio/{{ account.id }}" class="list-group-item list-group-item-action">
|
<div class="metric-label">Accounts</div>
|
||||||
<div class="row align-items-start">
|
<div class="metric-value">{{ accounts|length }}</div>
|
||||||
<div class="col">
|
</div>
|
||||||
<h2>{{ account.name }}</h2>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<p>${{ account.balance }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
{% endblock %}
|
{% 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>
|
||||||
|
|
||||||
|
<section class="section-panel">
|
||||||
|
<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 %}
|
||||||
|
<a href="portfolio/{{ account.id }}" class="list-group-item list-group-item-action">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-12 col-md-8">
|
||||||
|
<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 class="col-12 col-md-4 text-md-end mt-2 mt-md-0">
|
||||||
|
<strong>${{ account.balance }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -3,46 +3,131 @@
|
||||||
{% block title %}{{ account.name }}{% endblock %}
|
{% block title %}{{ account.name }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="section-panel">
|
||||||
<h1>This is cool!!!</h1>
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3">
|
||||||
|
<div>
|
||||||
<!-- Holdings list -->
|
<h1 class="h2 mb-2">{{ account.name }}</h1>
|
||||||
<div class="list-group">
|
<p class="text-secondary mb-0">{{ account.provider|default:"manual" }}{% if account.number %} • {{ account.number }}{% endif %}</p>
|
||||||
<div class="list-group-item">
|
</div>
|
||||||
<div class="row align-items-start">
|
<div class="text-lg-end">
|
||||||
<div class="col">
|
<div class="metric-label">Current Balance</div>
|
||||||
<h3>Ticker</h2>
|
<div class="metric-value">${{ account.balance }}</div>
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<h3>Value</h2>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<h3>Amount</h2>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<h3>Cost Basis</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% for holding in holdings %}
|
|
||||||
<a href="asset/{{ holding.asset.ticker }}" class="list-group-item list-group-item-action">
|
|
||||||
<div class="row align-items-start">
|
|
||||||
<div class="col">
|
|
||||||
<h2>{{ holding.asset.ticker }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<p>${{ holding.holding_value }} ( ${{ holding.holding_gain }} )</p>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<p>{{ holding.ammount }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<p>${{ holding.cost_basis }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
{% endblock %}
|
{% if account_timeline %}
|
||||||
|
<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="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 snapshot points available yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</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>
|
||||||
|
{% for holding in holdings %}
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-3">
|
||||||
|
<strong>{{ holding.asset.ticker }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
${{ holding.holding_value|floatformat:2 }}
|
||||||
|
<div class="small text-secondary">${{ holding.holding_gain|floatformat:2 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
{{ holding.ammount|floatformat:4 }}
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
${{ holding.cost_basis|floatformat:2 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="list-group-item">
|
||||||
|
<p class="text-secondary mb-0">No holdings available for this account.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,9 +137,9 @@
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container page-shell">
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue