Stonks/overview/tests.py

1185 lines
45 KiB
Python

from datetime import datetime, timedelta, timezone
from io import StringIO
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from django.utils import timezone as django_timezone
from .brokerage_types import (
BrokerageAccountPayload,
BrokerageAssetPayload,
BrokerageHoldingPayload,
BrokerageSnapshot,
BrokerageTransactionPayload,
)
from .brokerages.base import BaseBrokerageAdapter, BrokerageAdapterError
from .brokerages.charles_schwab import CharlesSchwabBrokerageAdapter
from .brokerages.random import RandomBrokerageAdapter
from .brokerages.registry import register_brokerage_adapter
from .models import (
Account,
AccountHolding,
AccountSnapshot,
AccountTransaction,
Asset,
AssetPriceSnapshot,
BrokerageConnection,
HoldingSnapshot,
IngestionRun,
)
from .services import BrokerageIngestionService
class FakeBrokerageAdapter(BaseBrokerageAdapter):
provider = "fake-broker"
def __init__(self, connection, snapshot=None, error=None):
super().__init__(connection)
self.snapshot = snapshot
self.error = error
def fetch_snapshot(self):
if self.error:
raise self.error
return self.snapshot
class AlternateFakeBrokerageAdapter(FakeBrokerageAdapter):
provider = "alternate-fake-broker"
class ExplodingBrokerageAdapter(BaseBrokerageAdapter):
provider = "error-broker"
def fetch_snapshot(self):
raise RuntimeError("brokerage unavailable")
class FakeCharlesSchwabBrokerageAdapter(CharlesSchwabBrokerageAdapter):
def __init__(self, connection, responses):
super().__init__(connection)
self.responses = responses
self.requests = []
def _request_json(self, endpoint: str, params: dict | None = None):
self.requests.append((endpoint, params or {}))
if endpoint.startswith("/accounts/") and endpoint.endswith("/transactions"):
return self.responses["transactions"]
return self.responses[endpoint]
class BrokerageIngestionServiceTests(TestCase):
def setUp(self):
register_brokerage_adapter(FakeBrokerageAdapter.provider, FakeBrokerageAdapter)
register_brokerage_adapter(
AlternateFakeBrokerageAdapter.provider,
AlternateFakeBrokerageAdapter,
)
register_brokerage_adapter(
ExplodingBrokerageAdapter.provider,
ExplodingBrokerageAdapter,
)
self.connection = BrokerageConnection.objects.create(
provider=FakeBrokerageAdapter.provider,
name="Primary",
base_url="https://broker.example/api/",
credentials={"access_token": "token-1"},
settings={"account_endpoint": "/accounts"},
)
def test_ingest_creates_and_updates_portfolio_records(self):
initial_snapshot = BrokerageSnapshot(
accounts=[
BrokerageAccountPayload(
external_id="acct-1",
name="Brokerage Account",
number="1234",
balance=10000,
)
],
assets=[
BrokerageAssetPayload(
external_id="asset-1",
ticker="AAPL",
price=195.12,
)
],
holdings=[
BrokerageHoldingPayload(
external_id="holding-1",
account_external_id="acct-1",
asset_external_id="asset-1",
ticker="AAPL",
amount=10,
cost_basis=150.25,
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(
accounts=[
BrokerageAccountPayload(
external_id="acct-1",
name="Brokerage Account",
number="1234",
balance=12500,
)
],
assets=[
BrokerageAssetPayload(
external_id="asset-1",
ticker="AAPL",
price=205.55,
)
],
holdings=[
BrokerageHoldingPayload(
external_id="holding-1",
account_external_id="acct-1",
asset_external_id="asset-1",
ticker="AAPL",
amount=12,
cost_basis=150.25,
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(
self.connection,
adapter=FakeBrokerageAdapter(self.connection, snapshot=initial_snapshot),
).ingest()
second_run = BrokerageIngestionService(
self.connection,
adapter=FakeBrokerageAdapter(self.connection, snapshot=updated_snapshot),
).ingest()
self.assertEqual(first_run.status, IngestionRun.STATUS_SUCCEEDED)
self.assertEqual(second_run.status, IngestionRun.STATUS_SUCCEEDED)
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)
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()
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(
provider=ExplodingBrokerageAdapter.provider,
name="Failing",
base_url="https://broker.example/api/",
)
with self.assertRaises(RuntimeError):
BrokerageIngestionService(failing_connection).ingest()
run = IngestionRun.objects.get()
self.assertEqual(run.status, IngestionRun.STATUS_FAILED)
self.assertIn("brokerage unavailable", run.error_message)
def test_multiple_brokerages_can_ingest_same_external_ids(self):
alternate_connection = BrokerageConnection.objects.create(
provider=AlternateFakeBrokerageAdapter.provider,
name="Secondary",
base_url="https://broker-two.example/api/",
)
snapshot = BrokerageSnapshot(
accounts=[
BrokerageAccountPayload(
external_id="acct-1",
name="Shared External Account",
number="9999",
balance=5000,
)
],
assets=[
BrokerageAssetPayload(
external_id="asset-1",
ticker="MSFT",
price=410.01,
)
],
holdings=[
BrokerageHoldingPayload(
external_id="holding-1",
account_external_id="acct-1",
asset_external_id="asset-1",
ticker="MSFT",
amount=3,
cost_basis=300.00,
purchase_date=datetime(2025, 2, 1, tzinfo=timezone.utc),
)
],
)
BrokerageIngestionService(
self.connection,
adapter=FakeBrokerageAdapter(self.connection, snapshot=snapshot),
).ingest()
BrokerageIngestionService(
alternate_connection,
adapter=AlternateFakeBrokerageAdapter(
alternate_connection,
snapshot=snapshot,
),
).ingest()
self.assertEqual(Account.objects.count(), 2)
self.assertEqual(Asset.objects.count(), 2)
self.assertEqual(AccountHolding.objects.count(), 2)
self.assertEqual(
set(Account.objects.values_list("provider", flat=True)),
{FakeBrokerageAdapter.provider, AlternateFakeBrokerageAdapter.provider},
)
def test_missing_adapter_raises_clear_error(self):
unknown_connection = BrokerageConnection.objects.create(
provider="unknown-broker",
name="Unknown",
base_url="https://unknown.example/api/",
)
with self.assertRaises(BrokerageAdapterError):
BrokerageIngestionService(unknown_connection).ingest()
def test_random_adapter_generates_seeded_snapshot(self):
random_connection = BrokerageConnection.objects.create(
provider=RandomBrokerageAdapter.provider,
name="Randomized",
base_url="https://local.random/",
settings={
"seed": 17,
"account_count": 2,
"asset_count": 5,
"holdings_per_account": 3,
"tickers": ["AAPL", "MSFT", "TSLA", "NVDA", "AMD"],
},
)
first_snapshot = RandomBrokerageAdapter(random_connection).fetch_snapshot()
second_snapshot = RandomBrokerageAdapter(random_connection).fetch_snapshot()
self.assertEqual(first_snapshot.accounts, second_snapshot.accounts)
self.assertEqual(first_snapshot.assets, second_snapshot.assets)
self.assertEqual(first_snapshot.holdings, second_snapshot.holdings)
self.assertEqual(len(first_snapshot.accounts), 2)
self.assertEqual(len(first_snapshot.assets), 5)
self.assertEqual(len(first_snapshot.holdings), 6)
def test_random_adapter_ingests_generated_data(self):
random_connection = BrokerageConnection.objects.create(
provider=RandomBrokerageAdapter.provider,
name="Random Import",
base_url="https://local.random/",
settings={
"seed": 99,
"account_count": 3,
"asset_count": 6,
"holdings_per_account": 2,
},
)
run = BrokerageIngestionService(random_connection).ingest()
self.assertEqual(run.status, IngestionRun.STATUS_SUCCEEDED)
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,
)
self.assertEqual(
Asset.objects.filter(provider=RandomBrokerageAdapter.provider).count(),
6,
)
self.assertEqual(
AccountHolding.objects.filter(
provider=RandomBrokerageAdapter.provider
).count(),
6,
)
self.assertEqual(
AccountSnapshot.objects.filter(ingestion_run=run).count(),
3,
)
self.assertEqual(
AssetPriceSnapshot.objects.filter(ingestion_run=run).count(),
6,
)
self.assertEqual(
HoldingSnapshot.objects.filter(ingestion_run=run).count(),
6,
)
self.assertEqual(
AccountTransaction.objects.filter(
provider=RandomBrokerageAdapter.provider
).count(),
18,
)
def test_charles_schwab_adapter_maps_accounts_positions_and_transactions(self):
connection = BrokerageConnection.objects.create(
provider=CharlesSchwabBrokerageAdapter.provider,
name="Schwab",
base_url="https://api.schwabapi.com/trader/v1/",
credentials={"access_token": "token-1"},
settings={
"transaction_start_date": "2026-01-01T00:00:00+00:00",
"transaction_end_date": "2026-01-31T00:00:00+00:00",
"transaction_types": ["TRADE"],
},
)
adapter = FakeCharlesSchwabBrokerageAdapter(
connection,
responses={
"/accounts/accountNumbers": [
{
"accountNumber": "123456789",
"hashValue": "encrypted-account-1",
}
],
"/accounts": [
{
"securitiesAccount": {
"type": "MARGIN",
"accountNumber": "123456789",
"currentBalances": {
"liquidationValue": 25000.49,
},
"positions": [
{
"longQuantity": 10,
"averagePrice": 150.25,
"marketValue": 2000,
"instrument": {
"symbol": "AAPL",
"cusip": "037833100",
},
}
],
}
}
],
"transactions": [
{
"activityId": 123,
"type": "TRADE",
"description": "SELL AAPL",
"time": "2026-01-15T15:30:00+00:00",
"netAmount": -2100,
"realizedGain": 300.5,
"transferItems": [
{
"quantity": 10,
"amount": -2100,
"instruction": "SELL",
"instrument": {
"symbol": "AAPL",
},
}
],
}
],
},
)
snapshot = adapter.fetch_snapshot()
self.assertEqual(snapshot.accounts[0].external_id, "123456789")
self.assertEqual(snapshot.accounts[0].balance, 25000)
self.assertEqual(snapshot.assets[0].ticker, "AAPL")
self.assertEqual(snapshot.assets[0].price, 200)
self.assertEqual(snapshot.holdings[0].amount, 10)
self.assertEqual(snapshot.transactions[0].transaction_type, "sell")
self.assertEqual(snapshot.transactions[0].realized_gain, 300.5)
self.assertEqual(snapshot.transactions[0].quantity, 10)
self.assertIn(
(
"/accounts/encrypted-account-1/transactions",
{
"startDate": "2026-01-01T00:00:00.000+00:00",
"endDate": "2026-01-31T00:00:00.000+00:00",
"types": "TRADE",
},
),
adapter.requests,
)
def test_sync_command_accepts_provider_identifier(self):
random_connection = BrokerageConnection.objects.create(
provider=RandomBrokerageAdapter.provider,
name="Demo Random",
base_url="https://local.random/",
settings={
"seed": 123,
"account_count": 1,
"asset_count": 2,
"holdings_per_account": 1,
},
)
output = StringIO()
call_command(
"sync_brokerage",
connection=random_connection.provider,
stdout=output,
)
self.assertIn("Sync finished", output.getvalue())
self.assertEqual(
Account.objects.filter(provider=RandomBrokerageAdapter.provider).count(),
1,
)
def test_sync_command_requires_name_when_provider_has_multiple_connections(self):
BrokerageConnection.objects.create(
provider=RandomBrokerageAdapter.provider,
name="Random One",
base_url="https://local.random/",
)
BrokerageConnection.objects.create(
provider=RandomBrokerageAdapter.provider,
name="Random Two",
base_url="https://local.random/",
)
with self.assertRaises(CommandError) as exc:
call_command(
"sync_brokerage",
connection=RandomBrokerageAdapter.provider,
)
self.assertIn("matches multiple active connections", str(exc.exception))
def test_overview_page_renders_portfolio_history(self):
BrokerageIngestionService(
self.connection,
adapter=FakeBrokerageAdapter(
self.connection,
snapshot=BrokerageSnapshot(
accounts=[
BrokerageAccountPayload(
external_id="acct-1",
name="Brokerage Account",
number="1234",
balance=10000,
)
],
assets=[
BrokerageAssetPayload(
external_id="asset-1",
ticker="AAPL",
price=200.00,
)
],
holdings=[
BrokerageHoldingPayload(
external_id="holding-1",
account_external_id="acct-1",
asset_external_id="asset-1",
ticker="AAPL",
amount=10,
cost_basis=150.00,
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
)
],
),
),
).ingest()
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Portfolio Value Over Time")
self.assertContains(response, "Recent Sync Points")
def test_account_page_renders_account_history(self):
run = BrokerageIngestionService(
self.connection,
adapter=FakeBrokerageAdapter(
self.connection,
snapshot=BrokerageSnapshot(
accounts=[
BrokerageAccountPayload(
external_id="acct-1",
name="Brokerage Account",
number="1234",
balance=10000,
)
],
assets=[
BrokerageAssetPayload(
external_id="asset-1",
ticker="AAPL",
price=200.00,
)
],
holdings=[
BrokerageHoldingPayload(
external_id="holding-1",
account_external_id="acct-1",
asset_external_id="asset-1",
ticker="AAPL",
amount=10,
cost_basis=150.00,
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
)
],
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()
account = AccountSnapshot.objects.get(ingestion_run=run).account
response = self.client.get(f"/portfolio/{account.id}")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Account Value Over Time")
self.assertContains(response, "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(
self.connection,
adapter=FakeBrokerageAdapter(
self.connection,
snapshot=BrokerageSnapshot(
accounts=[
BrokerageAccountPayload(
external_id="acct-1",
name="Brokerage Account",
number="1234",
balance=8000,
)
],
assets=[
BrokerageAssetPayload(
external_id="asset-1",
ticker="AAPL",
price=180.00,
)
],
holdings=[
BrokerageHoldingPayload(
external_id="holding-1",
account_external_id="acct-1",
asset_external_id="asset-1",
ticker="AAPL",
amount=10,
cost_basis=150.00,
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
)
],
),
),
).ingest()
second_run = BrokerageIngestionService(
self.connection,
adapter=FakeBrokerageAdapter(
self.connection,
snapshot=BrokerageSnapshot(
accounts=[
BrokerageAccountPayload(
external_id="acct-1",
name="Brokerage Account",
number="1234",
balance=12000,
)
],
assets=[
BrokerageAssetPayload(
external_id="asset-1",
ticker="AAPL",
price=210.00,
)
],
holdings=[
BrokerageHoldingPayload(
external_id="holding-1",
account_external_id="acct-1",
asset_external_id="asset-1",
ticker="AAPL",
amount=10,
cost_basis=150.00,
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
)
],
),
),
).ingest()
old_timestamp = django_timezone.now() - timedelta(days=40)
recent_timestamp = django_timezone.now() - timedelta(days=2)
IngestionRun.objects.filter(pk=first_run.pk).update(started_at=old_timestamp)
IngestionRun.objects.filter(pk=second_run.pk).update(started_at=recent_timestamp)
AccountSnapshot.objects.filter(ingestion_run=first_run).update(snapshot_at=old_timestamp)
AccountSnapshot.objects.filter(ingestion_run=second_run).update(snapshot_at=recent_timestamp)
response = self.client.get("/", {"range": "7d"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "value=\"7d\" selected")
self.assertNotContains(response, old_timestamp.strftime("%Y-%m-%d %H:%M"))
self.assertContains(response, recent_timestamp.strftime("%Y-%m-%d %H:%M"))
def test_account_range_selector_filters_account_history(self):
first_run = BrokerageIngestionService(
self.connection,
adapter=FakeBrokerageAdapter(
self.connection,
snapshot=BrokerageSnapshot(
accounts=[
BrokerageAccountPayload(
external_id="acct-1",
name="Brokerage Account",
number="1234",
balance=9000,
)
],
assets=[
BrokerageAssetPayload(
external_id="asset-1",
ticker="AAPL",
price=180.00,
)
],
holdings=[
BrokerageHoldingPayload(
external_id="holding-1",
account_external_id="acct-1",
asset_external_id="asset-1",
ticker="AAPL",
amount=10,
cost_basis=150.00,
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
)
],
),
),
).ingest()
second_run = BrokerageIngestionService(
self.connection,
adapter=FakeBrokerageAdapter(
self.connection,
snapshot=BrokerageSnapshot(
accounts=[
BrokerageAccountPayload(
external_id="acct-1",
name="Brokerage Account",
number="1234",
balance=11000,
)
],
assets=[
BrokerageAssetPayload(
external_id="asset-1",
ticker="AAPL",
price=205.00,
)
],
holdings=[
BrokerageHoldingPayload(
external_id="holding-1",
account_external_id="acct-1",
asset_external_id="asset-1",
ticker="AAPL",
amount=10,
cost_basis=150.00,
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
)
],
),
),
).ingest()
account = Account.objects.get()
old_timestamp = django_timezone.now() - timedelta(days=500)
recent_timestamp = django_timezone.now() - timedelta(days=5)
IngestionRun.objects.filter(pk=first_run.pk).update(started_at=old_timestamp)
IngestionRun.objects.filter(pk=second_run.pk).update(started_at=recent_timestamp)
AccountSnapshot.objects.filter(ingestion_run=first_run).update(snapshot_at=old_timestamp)
AccountSnapshot.objects.filter(ingestion_run=second_run).update(snapshot_at=recent_timestamp)
response = self.client.get(f"/portfolio/{account.id}", {"range": "30d"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "value=\"30d\" selected")
self.assertNotContains(response, old_timestamp.strftime("%Y-%m-%d %H:%M"))
self.assertContains(response, recent_timestamp.strftime("%Y-%m-%d %H:%M"))