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,
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue