From ccee2a00af2646b5cd19fa2bb8eab9237b8a4340 Mon Sep 17 00:00:00 2001 From: Tyler Wilcox Date: Sat, 23 May 2026 22:22:36 -0400 Subject: [PATCH] AI: Account income, transaction and gain/loss tabs --- overview/admin.py | 2 + overview/brokerage_types.py | 14 + overview/brokerages/random.py | 83 ++++ overview/brokerages/rest.py | 66 +++ .../management/commands/sync_brokerage.py | 3 +- ...tionrun_transactions_processed_and_more.py | 37 ++ ...08_accounttransaction_quantity_and_more.py | 23 + overview/models.py | 26 + overview/services.py | 26 + overview/templates/portfolio.html | 270 ++++++++-- overview/tests.py | 468 +++++++++++++++++- overview/views.py | 191 ++++++- templates/base.html | 8 + 13 files changed, 1184 insertions(+), 33 deletions(-) create mode 100644 overview/migrations/0007_ingestionrun_transactions_processed_and_more.py create mode 100644 overview/migrations/0008_accounttransaction_quantity_and_more.py diff --git a/overview/admin.py b/overview/admin.py index 0dc5bba..58f6348 100644 --- a/overview/admin.py +++ b/overview/admin.py @@ -4,6 +4,7 @@ from .models import ( Account, AccountHolding, AccountSnapshot, + AccountTransaction, Asset, AssetPriceSnapshot, BrokerageConnection, @@ -15,6 +16,7 @@ from .models import ( admin.site.register(Account) admin.site.register(AccountHolding) admin.site.register(AccountSnapshot) +admin.site.register(AccountTransaction) admin.site.register(Asset) admin.site.register(AssetPriceSnapshot) admin.site.register(BrokerageConnection) diff --git a/overview/brokerage_types.py b/overview/brokerage_types.py index 68cfe9e..92cf496 100644 --- a/overview/brokerage_types.py +++ b/overview/brokerage_types.py @@ -28,8 +28,22 @@ class BrokerageHoldingPayload: 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) class BrokerageSnapshot: accounts: list[BrokerageAccountPayload] = field(default_factory=list) assets: list[BrokerageAssetPayload] = field(default_factory=list) holdings: list[BrokerageHoldingPayload] = field(default_factory=list) + transactions: list[BrokerageTransactionPayload] = field(default_factory=list) diff --git a/overview/brokerages/random.py b/overview/brokerages/random.py index c02509b..0e9ea44 100644 --- a/overview/brokerages/random.py +++ b/overview/brokerages/random.py @@ -6,6 +6,7 @@ from overview.brokerage_types import ( BrokerageAssetPayload, BrokerageHoldingPayload, BrokerageSnapshot, + BrokerageTransactionPayload, ) from .base import BaseBrokerageAdapter @@ -58,11 +59,18 @@ class RandomBrokerageAdapter(BaseBrokerageAdapter): holdings_per_account=holdings_per_account, reference_time=reference_time, ) + transactions = self._build_transactions( + generator, + accounts=accounts, + assets=assets, + reference_time=reference_time, + ) return BrokerageSnapshot( accounts=accounts, assets=assets, holdings=holdings, + transactions=transactions, ) def _build_accounts( @@ -170,3 +178,78 @@ class RandomBrokerageAdapter(BaseBrokerageAdapter): accounts[:] = refreshed_accounts 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 diff --git a/overview/brokerages/rest.py b/overview/brokerages/rest.py index fa58545..b342f16 100644 --- a/overview/brokerages/rest.py +++ b/overview/brokerages/rest.py @@ -8,6 +8,7 @@ from overview.brokerage_types import ( BrokerageAssetPayload, BrokerageHoldingPayload, BrokerageSnapshot, + BrokerageTransactionPayload, ) from .base import BaseBrokerageAdapter, BrokerageAdapterError @@ -26,11 +27,15 @@ class GenericRestBrokerageAdapter(BaseBrokerageAdapter): holdings = self._fetch_collection( self.connection.get_setting("holding_endpoint", "/holdings") ) + transactions = self._fetch_collection( + self.connection.get_setting("transaction_endpoint", "/transactions") + ) return BrokerageSnapshot( accounts=[self._build_account(item) for item in accounts], assets=[self._build_asset(item) for item in assets], 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]: @@ -129,3 +134,64 @@ class GenericRestBrokerageAdapter(BaseBrokerageAdapter): cost_basis=float(item.get(holding_cost_basis_field, 0)), 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, + ) diff --git a/overview/management/commands/sync_brokerage.py b/overview/management/commands/sync_brokerage.py index f588b78..8e9abac 100644 --- a/overview/management/commands/sync_brokerage.py +++ b/overview/management/commands/sync_brokerage.py @@ -24,7 +24,8 @@ class Command(BaseCommand): "Sync finished: " f"{run.accounts_processed} account(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)." ) ) diff --git a/overview/migrations/0007_ingestionrun_transactions_processed_and_more.py b/overview/migrations/0007_ingestionrun_transactions_processed_and_more.py new file mode 100644 index 0000000..50dea7b --- /dev/null +++ b/overview/migrations/0007_ingestionrun_transactions_processed_and_more.py @@ -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')], + }, + ), + ] diff --git a/overview/migrations/0008_accounttransaction_quantity_and_more.py b/overview/migrations/0008_accounttransaction_quantity_and_more.py new file mode 100644 index 0000000..baa5f5e --- /dev/null +++ b/overview/migrations/0008_accounttransaction_quantity_and_more.py @@ -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), + ), + ] diff --git a/overview/models.py b/overview/models.py index 82b92d2..1b0ef6d 100644 --- a/overview/models.py +++ b/overview/models.py @@ -67,6 +67,31 @@ class AccountHolding(models.Model): 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): provider = models.CharField(max_length=64) name = models.CharField(max_length=128) @@ -119,6 +144,7 @@ class IngestionRun(models.Model): accounts_processed = models.PositiveIntegerField(default=0) assets_processed = models.PositiveIntegerField(default=0) holdings_processed = models.PositiveIntegerField(default=0) + transactions_processed = models.PositiveIntegerField(default=0) error_message = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/overview/services.py b/overview/services.py index b24fff2..96a2f27 100644 --- a/overview/services.py +++ b/overview/services.py @@ -10,6 +10,7 @@ from .models import ( Account, AccountHolding, AccountSnapshot, + AccountTransaction, Asset, AssetPriceSnapshot, BrokerageConnection, @@ -23,6 +24,7 @@ class IngestionResult: accounts_processed: int assets_processed: int holdings_processed: int + transactions_processed: int class BrokerageIngestionService: @@ -58,6 +60,7 @@ class BrokerageIngestionService: run.accounts_processed = result.accounts_processed run.assets_processed = result.assets_processed run.holdings_processed = result.holdings_processed + run.transactions_processed = result.transactions_processed run.save( update_fields=[ "status", @@ -65,6 +68,7 @@ class BrokerageIngestionService: "accounts_processed", "assets_processed", "holdings_processed", + "transactions_processed", ] ) 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( accounts_processed=len(snapshot.accounts), assets_processed=len(snapshot.assets), holdings_processed=len(snapshot.holdings), + transactions_processed=len(snapshot.transactions), ) diff --git a/overview/templates/portfolio.html b/overview/templates/portfolio.html index 92fa11b..b77b91e 100644 --- a/overview/templates/portfolio.html +++ b/overview/templates/portfolio.html @@ -91,42 +91,252 @@
-

Current Holdings

- {{ holdings|length }} position{{ holdings|length|pluralize }} +

Account Activity

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

No holdings available for this account.

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

No holdings available for this account.

+
+ +
+
+
+
+
Date
+
Type
+
Name
+
Asset
+
Value
+
+
+ {% for transaction in transactions %} +
+
+
+ {{ transaction.date|date:"Y-m-d" }} +
+
+ {{ transaction.transaction_type|title }} +
+
+ {{ transaction.name }} +
+
+ {% if transaction.asset %} + {{ transaction.asset.ticker }} + {% else %} + Cash + {% endif %} +
+
+ ${{ transaction.value|floatformat:2 }} +
+
+
+ {% empty %} +
+

No transactions available for this account.

+
+ {% endfor %}
- {% endfor %} +
+ +
+ {% if income_chart.bars %} +
+
+ ${{ income_chart.max_label }} + Monthly income from dividends and other income transactions +
+ + + {% for bar in income_chart.bars %} + + {{ bar.label }} | ${{ bar.display_value }} + + {% endfor %} + +
+ {% for bar in income_chart.bars %} + {{ bar.short_label }} + {% endfor %} +
+
+ +
+ {% for month in income_chart.months %} +
+
+ {{ month.label }} + ${{ month.display_value }} +
+
+ {% endfor %} +
+ {% else %} +

No dividend or asset income transactions are available for this account in the selected range.

+ {% endif %} +
+ +
+
+
+
+
{{ realized_gain_summary.label }}
+
+ ${{ realized_gain_summary.total_display }} +
+
+ {{ realized_gain_summary.sales_count }} sale{{ realized_gain_summary.sales_count|pluralize }} in {{ realized_gain_summary.selected_year }} +
+
+
+ + + + +
+
+
+ +
+
+
+
Date
+
Name
+
Asset
+
Qty
+
Proceeds
+
Gain/Loss
+
+
+ {% for sale in realized_gain_summary.sales %} +
+
+
+ {{ sale.date|date:"Y-m-d" }} +
+
{{ sale.name }}
+
+ {% if sale.asset %} + {{ sale.asset.ticker }} + {% else %} + Cash + {% endif %} +
+
+ {% if sale.quantity %} + {{ sale.quantity|floatformat:4 }} + {% else %} + N/A + {% endif %} +
+
${{ sale.proceeds_display }}
+
+ {% if sale.realized_gain_display %} + ${{ sale.realized_gain_display }} + {% else %} + N/A + {% endif %} +
+
+
+ {% empty %} +
+

No realized sale transactions are available for this account.

+
+ {% endfor %} +
+
diff --git a/overview/tests.py b/overview/tests.py index b2e0fb0..97f7057 100644 --- a/overview/tests.py +++ b/overview/tests.py @@ -11,6 +11,7 @@ from .brokerage_types import ( BrokerageAssetPayload, BrokerageHoldingPayload, BrokerageSnapshot, + BrokerageTransactionPayload, ) from .brokerages.base import BaseBrokerageAdapter, BrokerageAdapterError from .brokerages.random import RandomBrokerageAdapter @@ -19,6 +20,7 @@ from .models import ( Account, AccountHolding, AccountSnapshot, + AccountTransaction, Asset, AssetPriceSnapshot, BrokerageConnection, @@ -101,6 +103,18 @@ class BrokerageIngestionServiceTests(TestCase): 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( @@ -130,6 +144,28 @@ class BrokerageIngestionServiceTests(TestCase): 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( @@ -146,6 +182,7 @@ class BrokerageIngestionServiceTests(TestCase): self.assertEqual(Account.objects.count(), 1) self.assertEqual(Asset.objects.count(), 1) self.assertEqual(AccountHolding.objects.count(), 1) + self.assertEqual(AccountTransaction.objects.count(), 2) self.assertEqual(AccountSnapshot.objects.count(), 2) self.assertEqual(AssetPriceSnapshot.objects.count(), 2) self.assertEqual(HoldingSnapshot.objects.count(), 2) @@ -153,6 +190,7 @@ class BrokerageIngestionServiceTests(TestCase): account = Account.objects.get() asset = Asset.objects.get() holding = AccountHolding.objects.get() + transactions = list(AccountTransaction.objects.order_by("external_id")) latest_holding_snapshot = HoldingSnapshot.objects.filter( ingestion_run=second_run ).get() @@ -160,10 +198,16 @@ class BrokerageIngestionServiceTests(TestCase): self.assertEqual(account.balance, 12500) self.assertEqual(asset.price, 205.55) 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(second_run.accounts_processed, 1) self.assertEqual(second_run.assets_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): failing_connection = BrokerageConnection.objects.create( @@ -288,6 +332,7 @@ class BrokerageIngestionServiceTests(TestCase): self.assertEqual(run.accounts_processed, 3) self.assertEqual(run.assets_processed, 6) self.assertEqual(run.holdings_processed, 6) + self.assertEqual(run.transactions_processed, 18) self.assertEqual( Account.objects.filter(provider=RandomBrokerageAdapter.provider).count(), 3, @@ -314,6 +359,12 @@ class BrokerageIngestionServiceTests(TestCase): HoldingSnapshot.objects.filter(ingestion_run=run).count(), 6, ) + self.assertEqual( + AccountTransaction.objects.filter( + provider=RandomBrokerageAdapter.provider + ).count(), + 18, + ) def test_sync_command_accepts_provider_identifier(self): random_connection = BrokerageConnection.objects.create( @@ -435,6 +486,17 @@ class BrokerageIngestionServiceTests(TestCase): 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() @@ -444,7 +506,411 @@ class BrokerageIngestionServiceTests(TestCase): self.assertEqual(response.status_code, 200) 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): first_run = BrokerageIngestionService( diff --git a/overview/views.py b/overview/views.py index 4b5080a..84c58b5 100644 --- a/overview/views.py +++ b/overview/views.py @@ -5,7 +5,16 @@ 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 +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): @@ -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(): latest_balances = {} timeline = [] @@ -189,19 +355,42 @@ def index(request): def portfolio(request, account_id): account = get_object_or_404(Account, pk=account_id) holdings = AccountHolding.objects.filter(account=account).select_related("asset") + transactions = AccountTransaction.objects.filter(account=account).select_related( + "asset" + ) 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( _build_account_timeline(account), selected_range, ) latest_account_point = account_timeline[-1] if account_timeline else None 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 = { "account": account, "date_range_options": DATE_RANGE_OPTIONS, "selected_range": selected_range, + "active_tab": active_tab, "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, "latest_account_point": latest_account_point, "account_chart": chart_model, diff --git a/templates/base.html b/templates/base.html index 7ee32d5..3b8e9d6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -68,6 +68,14 @@ stroke-width: 1.5; } + .chart-bar { + fill: #1f9d55; + } + + .chart-bar:hover { + fill: #16803d; + } + .chart-axis { color: #52606d; font-size: 0.85rem;