AI: Account income, transaction and gain/loss tabs
This commit is contained in:
parent
69067e1fd8
commit
ccee2a00af
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -91,42 +91,252 @@
|
|||
|
||||
<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>
|
||||
<h2 class="h5 mb-0">Account Activity</h2>
|
||||
<span class="text-secondary small">{{ holdings|length }} holding{{ holdings|length|pluralize }} • {{ transactions|length }} transaction{{ transactions|length|pluralize }}</span>
|
||||
</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="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 class="tab-content">
|
||||
<div class="tab-pane fade{% if active_tab == 'holdings' %} show active{% endif %}">
|
||||
<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>
|
||||
{% empty %}
|
||||
<div class="list-group-item">
|
||||
<p class="text-secondary mb-0">No holdings available for this account.</p>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue