AI: Account income, transaction and gain/loss tabs

This commit is contained in:
Tyler Wilcox 2026-05-23 22:22:36 -04:00
parent 69067e1fd8
commit ccee2a00af
13 changed files with 1184 additions and 33 deletions

View File

@ -4,6 +4,7 @@ from .models import (
Account, Account,
AccountHolding, AccountHolding,
AccountSnapshot, AccountSnapshot,
AccountTransaction,
Asset, Asset,
AssetPriceSnapshot, AssetPriceSnapshot,
BrokerageConnection, BrokerageConnection,
@ -15,6 +16,7 @@ from .models import (
admin.site.register(Account) admin.site.register(Account)
admin.site.register(AccountHolding) admin.site.register(AccountHolding)
admin.site.register(AccountSnapshot) admin.site.register(AccountSnapshot)
admin.site.register(AccountTransaction)
admin.site.register(Asset) admin.site.register(Asset)
admin.site.register(AssetPriceSnapshot) admin.site.register(AssetPriceSnapshot)
admin.site.register(BrokerageConnection) admin.site.register(BrokerageConnection)

View File

@ -28,8 +28,22 @@ class BrokerageHoldingPayload:
purchase_date: datetime purchase_date: datetime
@dataclass(frozen=True)
class BrokerageTransactionPayload:
external_id: str
account_external_id: str
asset_external_id: str | None
transaction_type: str
date: datetime
value: float
name: str
quantity: float | None = None
realized_gain: float | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
class BrokerageSnapshot: class BrokerageSnapshot:
accounts: list[BrokerageAccountPayload] = field(default_factory=list) accounts: list[BrokerageAccountPayload] = field(default_factory=list)
assets: list[BrokerageAssetPayload] = field(default_factory=list) assets: list[BrokerageAssetPayload] = field(default_factory=list)
holdings: list[BrokerageHoldingPayload] = field(default_factory=list) holdings: list[BrokerageHoldingPayload] = field(default_factory=list)
transactions: list[BrokerageTransactionPayload] = field(default_factory=list)

View File

@ -6,6 +6,7 @@ from overview.brokerage_types import (
BrokerageAssetPayload, BrokerageAssetPayload,
BrokerageHoldingPayload, BrokerageHoldingPayload,
BrokerageSnapshot, BrokerageSnapshot,
BrokerageTransactionPayload,
) )
from .base import BaseBrokerageAdapter from .base import BaseBrokerageAdapter
@ -58,11 +59,18 @@ class RandomBrokerageAdapter(BaseBrokerageAdapter):
holdings_per_account=holdings_per_account, holdings_per_account=holdings_per_account,
reference_time=reference_time, reference_time=reference_time,
) )
transactions = self._build_transactions(
generator,
accounts=accounts,
assets=assets,
reference_time=reference_time,
)
return BrokerageSnapshot( return BrokerageSnapshot(
accounts=accounts, accounts=accounts,
assets=assets, assets=assets,
holdings=holdings, holdings=holdings,
transactions=transactions,
) )
def _build_accounts( def _build_accounts(
@ -170,3 +178,78 @@ class RandomBrokerageAdapter(BaseBrokerageAdapter):
accounts[:] = refreshed_accounts accounts[:] = refreshed_accounts
return holdings return holdings
def _build_transactions(
self,
generator: random.Random,
accounts: list[BrokerageAccountPayload],
assets: list[BrokerageAssetPayload],
reference_time: datetime,
) -> list[BrokerageTransactionPayload]:
transactions_per_account = max(
1,
int(self.connection.get_setting("transactions_per_account", 6)),
)
days_back = int(self.connection.get_setting("max_purchase_age_days", 3650))
transaction_types = [
"buy",
"sell",
"dividend",
"deposit",
"fee",
]
transactions = []
next_transaction_id = 1
for account in accounts:
for _ in range(transactions_per_account):
transaction_type = generator.choice(transaction_types)
asset = generator.choice(assets)
value = round(generator.uniform(25, 2500), 2)
asset_external_id = asset.external_id
name = f"{transaction_type.title()} {asset.ticker}"
if transaction_type == "deposit":
asset_external_id = None
name = "Cash Deposit"
quantity = None
realized_gain = None
elif transaction_type == "fee":
asset_external_id = None
value = round(generator.uniform(-25, -3), 2)
name = "Account Fee"
quantity = None
realized_gain = None
elif transaction_type == "sell":
quantity = round(generator.uniform(1, 20), 4)
value *= -1
realized_gain = round(generator.uniform(-500, 1200), 2)
elif transaction_type == "dividend":
value = round(generator.uniform(5, 150), 2)
name = f"Dividend {asset.ticker}"
quantity = None
realized_gain = None
else:
quantity = round(generator.uniform(1, 20), 4)
realized_gain = None
transaction_date = reference_time - timedelta(
days=generator.randint(1, max(1, days_back))
)
transactions.append(
BrokerageTransactionPayload(
external_id=f"txn-{next_transaction_id}",
account_external_id=account.external_id,
asset_external_id=asset_external_id,
transaction_type=transaction_type,
date=transaction_date,
value=value,
name=name,
quantity=quantity,
realized_gain=realized_gain,
)
)
next_transaction_id += 1
return transactions

View File

@ -8,6 +8,7 @@ from overview.brokerage_types import (
BrokerageAssetPayload, BrokerageAssetPayload,
BrokerageHoldingPayload, BrokerageHoldingPayload,
BrokerageSnapshot, BrokerageSnapshot,
BrokerageTransactionPayload,
) )
from .base import BaseBrokerageAdapter, BrokerageAdapterError from .base import BaseBrokerageAdapter, BrokerageAdapterError
@ -26,11 +27,15 @@ class GenericRestBrokerageAdapter(BaseBrokerageAdapter):
holdings = self._fetch_collection( holdings = self._fetch_collection(
self.connection.get_setting("holding_endpoint", "/holdings") self.connection.get_setting("holding_endpoint", "/holdings")
) )
transactions = self._fetch_collection(
self.connection.get_setting("transaction_endpoint", "/transactions")
)
return BrokerageSnapshot( return BrokerageSnapshot(
accounts=[self._build_account(item) for item in accounts], accounts=[self._build_account(item) for item in accounts],
assets=[self._build_asset(item) for item in assets], assets=[self._build_asset(item) for item in assets],
holdings=[self._build_holding(item) for item in holdings], holdings=[self._build_holding(item) for item in holdings],
transactions=[self._build_transaction(item) for item in transactions],
) )
def _fetch_collection(self, endpoint: str) -> list[dict]: def _fetch_collection(self, endpoint: str) -> list[dict]:
@ -129,3 +134,64 @@ class GenericRestBrokerageAdapter(BaseBrokerageAdapter):
cost_basis=float(item.get(holding_cost_basis_field, 0)), cost_basis=float(item.get(holding_cost_basis_field, 0)),
purchase_date=purchase_date, purchase_date=purchase_date,
) )
def _build_transaction(self, item: dict) -> BrokerageTransactionPayload:
transaction_id_field = self.connection.get_setting("transaction_id_field", "id")
transaction_account_field = self.connection.get_setting(
"transaction_account_field",
"account_id",
)
transaction_asset_field = self.connection.get_setting(
"transaction_asset_field",
"asset_id",
)
transaction_type_field = self.connection.get_setting(
"transaction_type_field",
"transaction_type",
)
transaction_date_field = self.connection.get_setting(
"transaction_date_field",
"date",
)
transaction_value_field = self.connection.get_setting(
"transaction_value_field",
"value",
)
transaction_name_field = self.connection.get_setting(
"transaction_name_field",
"name",
)
transaction_quantity_field = self.connection.get_setting(
"transaction_quantity_field",
"quantity",
)
transaction_realized_gain_field = self.connection.get_setting(
"transaction_realized_gain_field",
"realized_gain",
)
transaction_date = item.get(transaction_date_field)
if transaction_date:
transaction_date = datetime.fromisoformat(
str(transaction_date).replace("Z", "+00:00")
)
else:
transaction_date = datetime.now(timezone.utc)
asset_external_id = item.get(transaction_asset_field)
if asset_external_id is not None:
asset_external_id = str(asset_external_id)
quantity = item.get(transaction_quantity_field)
realized_gain = item.get(transaction_realized_gain_field)
return BrokerageTransactionPayload(
external_id=str(item[transaction_id_field]),
account_external_id=str(item[transaction_account_field]),
asset_external_id=asset_external_id,
transaction_type=str(item[transaction_type_field]),
date=transaction_date,
value=float(item.get(transaction_value_field, 0)),
name=str(item.get(transaction_name_field, "")),
quantity=float(quantity) if quantity is not None else None,
realized_gain=float(realized_gain) if realized_gain is not None else None,
)

View File

@ -24,7 +24,8 @@ class Command(BaseCommand):
"Sync finished: " "Sync finished: "
f"{run.accounts_processed} account(s), " f"{run.accounts_processed} account(s), "
f"{run.assets_processed} asset(s), " f"{run.assets_processed} asset(s), "
f"{run.holdings_processed} holding(s)." f"{run.holdings_processed} holding(s), "
f"{run.transactions_processed} transaction(s)."
) )
) )

View File

@ -0,0 +1,37 @@
# Generated by Django 6.0.5 on 2026-05-24 01:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('overview', '0006_accountsnapshot_assetpricesnapshot_holdingsnapshot'),
]
operations = [
migrations.AddField(
model_name='ingestionrun',
name='transactions_processed',
field=models.PositiveIntegerField(default=0),
),
migrations.CreateModel(
name='AccountTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('transaction_type', models.CharField(max_length=64)),
('date', models.DateTimeField()),
('value', models.FloatField()),
('name', models.CharField(max_length=255)),
('provider', models.CharField(blank=True, max_length=64)),
('external_id', models.CharField(blank=True, max_length=255)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.account')),
('asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='overview.asset')),
],
options={
'ordering': ['-date', '-id'],
'constraints': [models.UniqueConstraint(fields=('provider', 'external_id'), name='unique_transaction_provider_external_id')],
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 6.0.5 on 2026-05-24 02:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('overview', '0007_ingestionrun_transactions_processed_and_more'),
]
operations = [
migrations.AddField(
model_name='accounttransaction',
name='quantity',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='accounttransaction',
name='realized_gain',
field=models.FloatField(blank=True, null=True),
),
]

View File

@ -67,6 +67,31 @@ class AccountHolding(models.Model):
return f"{self.account.name}: {self.ticker}" return f"{self.account.name}: {self.ticker}"
class AccountTransaction(models.Model):
account = models.ForeignKey(Account, on_delete=models.CASCADE)
asset = models.ForeignKey(Asset, on_delete=models.SET_NULL, null=True, blank=True)
transaction_type = models.CharField(max_length=64)
date = models.DateTimeField()
value = models.FloatField()
name = models.CharField(max_length=255)
quantity = models.FloatField(null=True, blank=True)
realized_gain = models.FloatField(null=True, blank=True)
provider = models.CharField(max_length=64, blank=True)
external_id = models.CharField(max_length=255, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["provider", "external_id"],
name="unique_transaction_provider_external_id",
),
]
ordering = ["-date", "-id"]
def __str__(self):
return f"{self.account.name}: {self.name}"
class BrokerageConnection(models.Model): class BrokerageConnection(models.Model):
provider = models.CharField(max_length=64) provider = models.CharField(max_length=64)
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
@ -119,6 +144,7 @@ class IngestionRun(models.Model):
accounts_processed = models.PositiveIntegerField(default=0) accounts_processed = models.PositiveIntegerField(default=0)
assets_processed = models.PositiveIntegerField(default=0) assets_processed = models.PositiveIntegerField(default=0)
holdings_processed = models.PositiveIntegerField(default=0) holdings_processed = models.PositiveIntegerField(default=0)
transactions_processed = models.PositiveIntegerField(default=0)
error_message = models.TextField(blank=True) error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@ -10,6 +10,7 @@ from .models import (
Account, Account,
AccountHolding, AccountHolding,
AccountSnapshot, AccountSnapshot,
AccountTransaction,
Asset, Asset,
AssetPriceSnapshot, AssetPriceSnapshot,
BrokerageConnection, BrokerageConnection,
@ -23,6 +24,7 @@ class IngestionResult:
accounts_processed: int accounts_processed: int
assets_processed: int assets_processed: int
holdings_processed: int holdings_processed: int
transactions_processed: int
class BrokerageIngestionService: class BrokerageIngestionService:
@ -58,6 +60,7 @@ class BrokerageIngestionService:
run.accounts_processed = result.accounts_processed run.accounts_processed = result.accounts_processed
run.assets_processed = result.assets_processed run.assets_processed = result.assets_processed
run.holdings_processed = result.holdings_processed run.holdings_processed = result.holdings_processed
run.transactions_processed = result.transactions_processed
run.save( run.save(
update_fields=[ update_fields=[
"status", "status",
@ -65,6 +68,7 @@ class BrokerageIngestionService:
"accounts_processed", "accounts_processed",
"assets_processed", "assets_processed",
"holdings_processed", "holdings_processed",
"transactions_processed",
] ]
) )
return run return run
@ -147,8 +151,30 @@ class BrokerageIngestionService:
}, },
) )
for payload in snapshot.transactions:
account = accounts_by_external_id[payload.account_external_id]
asset = None
if payload.asset_external_id:
asset = assets_by_external_id[payload.asset_external_id]
AccountTransaction.objects.update_or_create(
provider=self.connection.provider,
external_id=payload.external_id,
defaults={
"account": account,
"asset": asset,
"transaction_type": payload.transaction_type,
"date": payload.date,
"value": payload.value,
"name": payload.name,
"quantity": payload.quantity,
"realized_gain": payload.realized_gain,
},
)
return IngestionResult( return IngestionResult(
accounts_processed=len(snapshot.accounts), accounts_processed=len(snapshot.accounts),
assets_processed=len(snapshot.assets), assets_processed=len(snapshot.assets),
holdings_processed=len(snapshot.holdings), holdings_processed=len(snapshot.holdings),
transactions_processed=len(snapshot.transactions),
) )

View File

@ -91,10 +91,54 @@
<section class="section-panel"> <section class="section-panel">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">Current Holdings</h2> <h2 class="h5 mb-0">Account Activity</h2>
<span class="text-secondary small">{{ holdings|length }} position{{ holdings|length|pluralize }}</span> <span class="text-secondary small">{{ holdings|length }} holding{{ holdings|length|pluralize }} • {{ transactions|length }} transaction{{ transactions|length|pluralize }}</span>
</div> </div>
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<a
class="nav-link{% if active_tab == 'holdings' %} active{% endif %}"
href="?range={{ selected_range }}&tab=holdings"
role="tab"
aria-selected="{% if active_tab == 'holdings' %}true{% else %}false{% endif %}"
>
Holdings
</a>
</li>
<li class="nav-item" role="presentation">
<a
class="nav-link{% if active_tab == 'transactions' %} active{% endif %}"
href="?range={{ selected_range }}&tab=transactions"
role="tab"
aria-selected="{% if active_tab == 'transactions' %}true{% else %}false{% endif %}"
>
Transactions
</a>
</li>
<li class="nav-item" role="presentation">
<a
class="nav-link{% if active_tab == 'income' %} active{% endif %}"
href="?range={{ selected_range }}&tab=income"
role="tab"
aria-selected="{% if active_tab == 'income' %}true{% else %}false{% endif %}"
>
Income
</a>
</li>
<li class="nav-item" role="presentation">
<a
class="nav-link{% if active_tab == 'realized_gain' %} active{% endif %}"
href="?range={{ selected_range }}&tab=realized_gain"
role="tab"
aria-selected="{% if active_tab == 'realized_gain' %}true{% else %}false{% endif %}"
>
Realized Gain/Loss
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade{% if active_tab == 'holdings' %} show active{% endif %}">
<div class="list-group"> <div class="list-group">
<div class="list-group-item"> <div class="list-group-item">
<div class="row align-items-center"> <div class="row align-items-center">
@ -128,6 +172,172 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
<div class="tab-pane fade{% if active_tab == 'transactions' %} show active{% endif %}">
<div class="list-group">
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-2"><strong>Date</strong></div>
<div class="col-2"><strong>Type</strong></div>
<div class="col-3"><strong>Name</strong></div>
<div class="col-2"><strong>Asset</strong></div>
<div class="col-3 text-end"><strong>Value</strong></div>
</div>
</div>
{% for transaction in transactions %}
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-2">
<span class="small text-secondary">{{ transaction.date|date:"Y-m-d" }}</span>
</div>
<div class="col-2">
<strong>{{ transaction.transaction_type|title }}</strong>
</div>
<div class="col-3">
{{ transaction.name }}
</div>
<div class="col-2">
{% if transaction.asset %}
{{ transaction.asset.ticker }}
{% else %}
<span class="text-secondary">Cash</span>
{% endif %}
</div>
<div class="col-3 text-end">
<strong>${{ transaction.value|floatformat:2 }}</strong>
</div>
</div>
</div>
{% empty %}
<div class="list-group-item">
<p class="text-secondary mb-0">No transactions available for this account.</p>
</div>
{% endfor %}
</div>
</div>
<div class="tab-pane fade{% if active_tab == 'income' %} show active{% endif %}">
{% if income_chart.bars %}
<div class="chart-frame mb-3">
<div class="chart-axis chart-axis-top">
<span>${{ income_chart.max_label }}</span>
<span>Monthly income from dividends and other income transactions</span>
</div>
<svg class="chart-svg" viewBox="0 0 100 100" preserveAspectRatio="none" role="img" aria-label="Monthly income bar chart">
<line class="chart-baseline" x1="0" y1="100" x2="100" y2="100"></line>
{% for bar in income_chart.bars %}
<rect
class="chart-bar"
x="{{ bar.x }}"
y="{{ bar.y }}"
width="{{ bar.width }}"
height="{{ bar.height }}"
rx="1.5"
>
<title>{{ bar.label }} | ${{ bar.display_value }}</title>
</rect>
{% endfor %}
</svg>
<div class="chart-axis chart-axis-bottom">
{% for bar in income_chart.bars %}
<span>{{ bar.short_label }}</span>
{% endfor %}
</div>
</div>
<div class="list-group">
{% for month in income_chart.months %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center gap-3">
<span>{{ month.label }}</span>
<strong>${{ month.display_value }}</strong>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-secondary mb-0">No dividend or asset income transactions are available for this account in the selected range.</p>
{% endif %}
</div>
<div class="tab-pane fade{% if active_tab == 'realized_gain' %} show active{% endif %}">
<div class="section-panel p-3 mb-3">
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<div class="metric-label">{{ realized_gain_summary.label }}</div>
<div class="fw-semibold fs-4 {% if realized_gain_summary.is_gain %}text-success{% else %}text-danger{% endif %}">
${{ realized_gain_summary.total_display }}
</div>
<div class="text-secondary small">
{{ realized_gain_summary.sales_count }} sale{{ realized_gain_summary.sales_count|pluralize }} in {{ realized_gain_summary.selected_year }}
</div>
</div>
<form method="get" class="range-form">
<input type="hidden" name="range" value="{{ selected_range }}">
<input type="hidden" name="tab" value="realized_gain">
<label for="realized-gain-year" class="metric-label mb-0">Year</label>
<select id="realized-gain-year" name="realized_year" class="form-select form-select-sm range-select" onchange="this.form.submit()">
{% for year in realized_gain_years %}
<option value="{{ year }}"{% if selected_realized_gain_year == year %} selected{% endif %}>{{ year }}</option>
{% empty %}
<option value="{{ selected_realized_gain_year }}" selected>{{ selected_realized_gain_year }}</option>
{% endfor %}
</select>
</form>
</div>
</div>
<div class="list-group">
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-2"><strong>Date</strong></div>
<div class="col-3"><strong>Name</strong></div>
<div class="col-2"><strong>Asset</strong></div>
<div class="col-1 text-end"><strong>Qty</strong></div>
<div class="col-2 text-end"><strong>Proceeds</strong></div>
<div class="col-2 text-end"><strong>Gain/Loss</strong></div>
</div>
</div>
{% for sale in realized_gain_summary.sales %}
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-2">
<span class="small text-secondary">{{ sale.date|date:"Y-m-d" }}</span>
</div>
<div class="col-3">{{ sale.name }}</div>
<div class="col-2">
{% if sale.asset %}
{{ sale.asset.ticker }}
{% else %}
<span class="text-secondary">Cash</span>
{% endif %}
</div>
<div class="col-1 text-end">
{% if sale.quantity %}
{{ sale.quantity|floatformat:4 }}
{% else %}
<span class="text-secondary">N/A</span>
{% endif %}
</div>
<div class="col-2 text-end">${{ sale.proceeds_display }}</div>
<div class="col-2 text-end">
{% if sale.realized_gain_display %}
<strong class="{% if sale.is_gain %}text-success{% else %}text-danger{% endif %}">${{ sale.realized_gain_display }}</strong>
{% else %}
<span class="text-secondary">N/A</span>
{% endif %}
</div>
</div>
</div>
{% empty %}
<div class="list-group-item">
<p class="text-secondary mb-0">No realized sale transactions are available for this account.</p>
</div>
{% endfor %}
</div>
</div>
</div>
</section> </section>
{% endblock %} {% endblock %}

View File

@ -11,6 +11,7 @@ from .brokerage_types import (
BrokerageAssetPayload, BrokerageAssetPayload,
BrokerageHoldingPayload, BrokerageHoldingPayload,
BrokerageSnapshot, BrokerageSnapshot,
BrokerageTransactionPayload,
) )
from .brokerages.base import BaseBrokerageAdapter, BrokerageAdapterError from .brokerages.base import BaseBrokerageAdapter, BrokerageAdapterError
from .brokerages.random import RandomBrokerageAdapter from .brokerages.random import RandomBrokerageAdapter
@ -19,6 +20,7 @@ from .models import (
Account, Account,
AccountHolding, AccountHolding,
AccountSnapshot, AccountSnapshot,
AccountTransaction,
Asset, Asset,
AssetPriceSnapshot, AssetPriceSnapshot,
BrokerageConnection, BrokerageConnection,
@ -101,6 +103,18 @@ class BrokerageIngestionServiceTests(TestCase):
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc), purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
) )
], ],
transactions=[
BrokerageTransactionPayload(
external_id="txn-1",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="buy",
date=datetime(2025, 1, 15, tzinfo=timezone.utc),
value=1502.50,
name="Buy AAPL",
quantity=10,
)
],
) )
updated_snapshot = BrokerageSnapshot( updated_snapshot = BrokerageSnapshot(
@ -130,6 +144,28 @@ class BrokerageIngestionServiceTests(TestCase):
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc), purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
) )
], ],
transactions=[
BrokerageTransactionPayload(
external_id="txn-1",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="sell",
date=datetime(2025, 1, 15, tzinfo=timezone.utc),
value=-1803.00,
name="Sell AAPL Updated",
quantity=6,
realized_gain=300.50,
),
BrokerageTransactionPayload(
external_id="txn-2",
account_external_id="acct-1",
asset_external_id=None,
transaction_type="deposit",
date=datetime(2025, 1, 16, tzinfo=timezone.utc),
value=2500.00,
name="Cash Deposit",
),
],
) )
first_run = BrokerageIngestionService( first_run = BrokerageIngestionService(
@ -146,6 +182,7 @@ 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(AccountTransaction.objects.count(), 2)
self.assertEqual(AccountSnapshot.objects.count(), 2) self.assertEqual(AccountSnapshot.objects.count(), 2)
self.assertEqual(AssetPriceSnapshot.objects.count(), 2) self.assertEqual(AssetPriceSnapshot.objects.count(), 2)
self.assertEqual(HoldingSnapshot.objects.count(), 2) self.assertEqual(HoldingSnapshot.objects.count(), 2)
@ -153,6 +190,7 @@ class BrokerageIngestionServiceTests(TestCase):
account = Account.objects.get() account = Account.objects.get()
asset = Asset.objects.get() asset = Asset.objects.get()
holding = AccountHolding.objects.get() holding = AccountHolding.objects.get()
transactions = list(AccountTransaction.objects.order_by("external_id"))
latest_holding_snapshot = HoldingSnapshot.objects.filter( latest_holding_snapshot = HoldingSnapshot.objects.filter(
ingestion_run=second_run ingestion_run=second_run
).get() ).get()
@ -160,10 +198,16 @@ class BrokerageIngestionServiceTests(TestCase):
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(transactions[0].name, "Sell AAPL Updated")
self.assertEqual(transactions[0].asset, asset)
self.assertEqual(transactions[0].quantity, 6)
self.assertEqual(transactions[0].realized_gain, 300.50)
self.assertIsNone(transactions[1].asset)
self.assertEqual(latest_holding_snapshot.value, 12 * 205.55) 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)
self.assertEqual(second_run.transactions_processed, 2)
def test_ingest_marks_run_failed_when_client_errors(self): def test_ingest_marks_run_failed_when_client_errors(self):
failing_connection = BrokerageConnection.objects.create( failing_connection = BrokerageConnection.objects.create(
@ -288,6 +332,7 @@ class BrokerageIngestionServiceTests(TestCase):
self.assertEqual(run.accounts_processed, 3) self.assertEqual(run.accounts_processed, 3)
self.assertEqual(run.assets_processed, 6) self.assertEqual(run.assets_processed, 6)
self.assertEqual(run.holdings_processed, 6) self.assertEqual(run.holdings_processed, 6)
self.assertEqual(run.transactions_processed, 18)
self.assertEqual( self.assertEqual(
Account.objects.filter(provider=RandomBrokerageAdapter.provider).count(), Account.objects.filter(provider=RandomBrokerageAdapter.provider).count(),
3, 3,
@ -314,6 +359,12 @@ class BrokerageIngestionServiceTests(TestCase):
HoldingSnapshot.objects.filter(ingestion_run=run).count(), HoldingSnapshot.objects.filter(ingestion_run=run).count(),
6, 6,
) )
self.assertEqual(
AccountTransaction.objects.filter(
provider=RandomBrokerageAdapter.provider
).count(),
18,
)
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(
@ -435,6 +486,17 @@ class BrokerageIngestionServiceTests(TestCase):
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc), purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
) )
], ],
transactions=[
BrokerageTransactionPayload(
external_id="txn-1",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="buy",
date=datetime(2025, 1, 15, tzinfo=timezone.utc),
value=1500.00,
name="Buy AAPL",
)
],
), ),
), ),
).ingest() ).ingest()
@ -444,7 +506,411 @@ class BrokerageIngestionServiceTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Account Value Over Time") self.assertContains(response, "Account Value Over Time")
self.assertContains(response, "Current Holdings") self.assertContains(response, "Account Activity")
def test_account_page_renders_transaction_tab(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=[],
transactions=[
BrokerageTransactionPayload(
external_id="txn-1",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="buy",
date=datetime(2025, 1, 15, tzinfo=timezone.utc),
value=1500.00,
name="Buy AAPL",
),
BrokerageTransactionPayload(
external_id="txn-2",
account_external_id="acct-1",
asset_external_id=None,
transaction_type="deposit",
date=datetime(2025, 1, 16, tzinfo=timezone.utc),
value=2500.00,
name="Cash Deposit",
),
],
),
),
).ingest()
account = AccountSnapshot.objects.get(ingestion_run=run).account
response = self.client.get(
f"/portfolio/{account.id}",
{"tab": "transactions"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Transactions")
self.assertContains(response, "Buy AAPL")
self.assertContains(response, "Cash Deposit")
self.assertContains(response, "value=\"all\" selected")
def test_account_page_renders_income_tab(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=[],
transactions=[
BrokerageTransactionPayload(
external_id="txn-1",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="dividend",
date=datetime(2025, 1, 15, tzinfo=timezone.utc),
value=42.50,
name="Dividend AAPL",
),
BrokerageTransactionPayload(
external_id="txn-2",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="interest",
date=datetime(2025, 1, 20, tzinfo=timezone.utc),
value=10.00,
name="Interest Sweep",
),
BrokerageTransactionPayload(
external_id="txn-3",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="buy",
date=datetime(2025, 1, 25, tzinfo=timezone.utc),
value=-1000.00,
name="Buy AAPL",
),
],
),
),
).ingest()
account = AccountSnapshot.objects.get(ingestion_run=run).account
response = self.client.get(
f"/portfolio/{account.id}",
{"tab": "income"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Monthly income from dividends and other income transactions")
self.assertContains(response, "Jan 2025")
self.assertContains(response, "52.50")
self.assertNotContains(response, "1,000.00")
def test_account_page_renders_realized_gain_tab(self):
current_year = django_timezone.now().year
last_year = current_year - 1
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=[],
transactions=[
BrokerageTransactionPayload(
external_id="txn-1",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="sell",
date=datetime(current_year, 3, 10, tzinfo=timezone.utc),
value=-2500.00,
name="Sell AAPL Lot 1",
quantity=5,
realized_gain=450.25,
),
BrokerageTransactionPayload(
external_id="txn-2",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="sell",
date=datetime(current_year, 5, 8, tzinfo=timezone.utc),
value=-1200.00,
name="Sell AAPL Lot 2",
quantity=3,
realized_gain=-75.25,
),
BrokerageTransactionPayload(
external_id="txn-3",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="sell",
date=datetime(last_year, 9, 1, tzinfo=timezone.utc),
value=-900.00,
name="Prior Year Sale",
quantity=2,
realized_gain=999.00,
),
],
),
),
).ingest()
account = AccountSnapshot.objects.get(ingestion_run=run).account
response = self.client.get(
f"/portfolio/{account.id}",
{"tab": "realized_gain"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"{current_year} Realized Gain")
self.assertContains(response, "Sell AAPL Lot 1")
self.assertContains(response, "Sell AAPL Lot 2")
self.assertContains(response, "375.00")
self.assertEqual(
[
sale["name"]
for sale in response.context["realized_gain_summary"]["sales"]
],
["Sell AAPL Lot 2", "Sell AAPL Lot 1"],
)
prior_year_response = self.client.get(
f"/portfolio/{account.id}",
{
"tab": "realized_gain",
"realized_year": last_year,
},
)
self.assertEqual(prior_year_response.status_code, 200)
self.assertContains(prior_year_response, f"{last_year} Realized Gain")
self.assertContains(prior_year_response, "Prior Year Sale")
self.assertContains(prior_year_response, "999.00")
self.assertEqual(
[
sale["name"]
for sale in prior_year_response.context["realized_gain_summary"]["sales"]
],
["Prior Year Sale"],
)
def test_account_page_labels_ytd_realized_loss(self):
current_year = django_timezone.now().year
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=[],
transactions=[
BrokerageTransactionPayload(
external_id="txn-1",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="sell",
date=datetime(current_year, 3, 10, tzinfo=timezone.utc),
value=-2500.00,
name="Sell AAPL Loss",
quantity=5,
realized_gain=-450.25,
),
],
),
),
).ingest()
account = AccountSnapshot.objects.get(ingestion_run=run).account
response = self.client.get(
f"/portfolio/{account.id}",
{"tab": "realized_gain"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"{current_year} Realized Loss")
self.assertContains(response, "-450.25")
def test_account_page_defaults_realized_gain_year_to_current_year(self):
current_year = django_timezone.now().year
last_year = current_year - 1
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=[],
transactions=[
BrokerageTransactionPayload(
external_id="txn-1",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="sell",
date=datetime(last_year, 3, 10, tzinfo=timezone.utc),
value=-2500.00,
name="Prior Year Sale",
quantity=5,
realized_gain=-450.25,
),
],
),
),
).ingest()
account = AccountSnapshot.objects.get(ingestion_run=run).account
response = self.client.get(
f"/portfolio/{account.id}",
{"tab": "realized_gain"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"{current_year} Realized Gain/Loss")
self.assertContains(response, "0 sales")
self.assertContains(
response,
f'value="{current_year}" selected',
)
prior_year_response = self.client.get(
f"/portfolio/{account.id}",
{
"tab": "realized_gain",
"realized_year": last_year,
},
)
self.assertEqual(prior_year_response.status_code, 200)
self.assertContains(prior_year_response, f"{last_year} Realized Loss")
self.assertContains(prior_year_response, "1 sale")
self.assertContains(prior_year_response, "Prior Year Sale")
self.assertContains(
prior_year_response,
f'value="{current_year}"',
)
def test_account_page_can_select_current_year_without_realized_sales(self):
current_year = django_timezone.now().year
last_year = current_year - 1
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=[],
transactions=[
BrokerageTransactionPayload(
external_id="txn-1",
account_external_id="acct-1",
asset_external_id="asset-1",
transaction_type="sell",
date=datetime(last_year, 3, 10, tzinfo=timezone.utc),
value=-2500.00,
name="Prior Year Sale",
quantity=5,
realized_gain=-450.25,
),
],
),
),
).ingest()
account = AccountSnapshot.objects.get(ingestion_run=run).account
response = self.client.get(
f"/portfolio/{account.id}",
{
"tab": "realized_gain",
"realized_year": current_year,
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"{current_year} Realized Gain/Loss")
self.assertContains(response, "0 sales")
self.assertEqual(response.context["realized_gain_summary"]["sales"], [])
def test_overview_range_selector_filters_to_recent_history(self): def test_overview_range_selector_filters_to_recent_history(self):
first_run = BrokerageIngestionService( first_run = BrokerageIngestionService(

View File

@ -5,7 +5,16 @@ from django.http import HttpRequest
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.utils import timezone from django.utils import timezone
from .models import Account, AccountHolding, AccountSnapshot, IngestionRun from .models import (
Account,
AccountHolding,
AccountSnapshot,
AccountTransaction,
IngestionRun,
)
INCOME_TRANSACTION_TYPES = {"dividend", "interest", "income", "distribution"}
REALIZED_SALE_TRANSACTION_TYPES = {"sell", "sale"}
def _format_currency(value): def _format_currency(value):
@ -117,6 +126,163 @@ def _build_chart_model(timeline, width=100, height=100):
} }
def _filter_transactions_by_range(transactions, selected_range: str):
cutoff = _get_range_cutoff(selected_range)
if cutoff is None:
return transactions
return transactions.filter(date__gte=cutoff)
def _build_income_chart(transactions, width=100, height=100):
monthly_totals = {}
for transaction in transactions:
if transaction.transaction_type.lower() not in INCOME_TRANSACTION_TYPES:
continue
if transaction.value <= 0:
continue
month_key = transaction.date.strftime("%Y-%m")
if month_key not in monthly_totals:
monthly_totals[month_key] = {
"label": transaction.date.strftime("%b %Y"),
"short_label": transaction.date.strftime("%b"),
"value": 0.0,
}
monthly_totals[month_key]["value"] += float(transaction.value)
months = [
{
"label": value["label"],
"short_label": value["short_label"],
"value": round(value["value"], 2),
"display_value": _format_currency(value["value"]),
}
for _, value in sorted(monthly_totals.items())
]
if not months:
return {
"bars": [],
"max_label": _format_currency(0),
"min_label": _format_currency(0),
}
max_value = max(month["value"] for month in months)
if max_value == 0:
max_value = 1
bar_width = width / max(len(months), 1)
bars = []
for index, month in enumerate(months):
bar_height = (month["value"] / max_value) * height
x = index * bar_width + (bar_width * 0.15)
y = height - bar_height
bars.append(
{
"x": f"{x:.2f}",
"y": f"{y:.2f}",
"width": f"{bar_width * 0.7:.2f}",
"height": f"{bar_height:.2f}",
"label": month["label"],
"short_label": month["short_label"],
"display_value": month["display_value"],
}
)
return {
"bars": bars,
"months": months,
"max_label": _format_currency(max(month["value"] for month in months)),
"min_label": _format_currency(0),
}
def _get_realized_gain_years(transactions):
current_year = timezone.now().year
years = [current_year]
for transaction in transactions:
if transaction.transaction_type.lower() not in REALIZED_SALE_TRANSACTION_TYPES:
continue
year = transaction.date.year
if year not in years:
years.append(year)
return sorted(years, reverse=True)
def _get_selected_realized_gain_year(request: HttpRequest, available_years):
requested_year = request.GET.get("realized_year")
if requested_year:
try:
requested_year = int(requested_year)
except ValueError:
requested_year = None
if requested_year in available_years:
return requested_year
if available_years:
return available_years[0]
return timezone.now().year
def _build_realized_gain_summary(transactions, selected_year):
now = timezone.now()
realized_sales = []
selected_year_total = 0.0
selected_year_sales_count = 0
for transaction in transactions:
if transaction.transaction_type.lower() not in REALIZED_SALE_TRANSACTION_TYPES:
continue
realized_gain = transaction.realized_gain
is_selected_year = transaction.date.year == selected_year
if is_selected_year:
selected_year_sales_count += 1
if is_selected_year and realized_gain is not None:
selected_year_total += float(realized_gain)
if is_selected_year:
realized_sales.append(
{
"date": transaction.date,
"name": transaction.name,
"asset": transaction.asset,
"quantity": transaction.quantity,
"proceeds": abs(float(transaction.value)),
"proceeds_display": _format_currency(abs(float(transaction.value))),
"realized_gain": realized_gain,
"realized_gain_display": (
_format_currency(realized_gain)
if realized_gain is not None
else None
),
"is_gain": realized_gain is not None and realized_gain >= 0,
}
)
return {
"sales": realized_sales,
"total": selected_year_total,
"total_display": _format_currency(selected_year_total),
"is_gain": selected_year_total >= 0,
"label": _realized_gain_label(selected_year_total, selected_year),
"selected_year": selected_year,
"sales_count": selected_year_sales_count,
"has_sales": selected_year_sales_count > 0,
}
def _realized_gain_label(value, year):
if value > 0:
return f"{year} Realized Gain"
if value < 0:
return f"{year} Realized Loss"
return f"{year} Realized Gain/Loss"
def _build_portfolio_timeline(): def _build_portfolio_timeline():
latest_balances = {} latest_balances = {}
timeline = [] timeline = []
@ -189,19 +355,42 @@ def index(request):
def portfolio(request, account_id): def portfolio(request, account_id):
account = get_object_or_404(Account, pk=account_id) account = get_object_or_404(Account, pk=account_id)
holdings = AccountHolding.objects.filter(account=account).select_related("asset") holdings = AccountHolding.objects.filter(account=account).select_related("asset")
transactions = AccountTransaction.objects.filter(account=account).select_related(
"asset"
)
selected_range = _get_selected_range(request) selected_range = _get_selected_range(request)
active_tab = request.GET.get("tab", "holdings")
if active_tab not in {"holdings", "transactions", "income", "realized_gain"}:
active_tab = "holdings"
account_timeline = _filter_timeline_by_range( account_timeline = _filter_timeline_by_range(
_build_account_timeline(account), _build_account_timeline(account),
selected_range, selected_range,
) )
latest_account_point = account_timeline[-1] if account_timeline else None latest_account_point = account_timeline[-1] if account_timeline else None
chart_model = _build_chart_model(account_timeline) chart_model = _build_chart_model(account_timeline)
ranged_transactions = _filter_transactions_by_range(transactions, selected_range)
income_chart = _build_income_chart(ranged_transactions)
realized_gain_years = _get_realized_gain_years(transactions)
selected_realized_gain_year = _get_selected_realized_gain_year(
request,
realized_gain_years,
)
realized_gain_summary = _build_realized_gain_summary(
transactions,
selected_realized_gain_year,
)
context = { context = {
"account": account, "account": account,
"date_range_options": DATE_RANGE_OPTIONS, "date_range_options": DATE_RANGE_OPTIONS,
"selected_range": selected_range, "selected_range": selected_range,
"active_tab": active_tab,
"holdings": holdings, "holdings": holdings,
"transactions": transactions,
"income_chart": income_chart,
"realized_gain_years": realized_gain_years,
"selected_realized_gain_year": selected_realized_gain_year,
"realized_gain_summary": realized_gain_summary,
"account_timeline": account_timeline, "account_timeline": account_timeline,
"latest_account_point": latest_account_point, "latest_account_point": latest_account_point,
"account_chart": chart_model, "account_chart": chart_model,

View File

@ -68,6 +68,14 @@
stroke-width: 1.5; stroke-width: 1.5;
} }
.chart-bar {
fill: #1f9d55;
}
.chart-bar:hover {
fill: #16803d;
}
.chart-axis { .chart-axis {
color: #52606d; color: #52606d;
font-size: 0.85rem; font-size: 0.85rem;