From 78cb61b0b63393f7e1f344e4528f4d606c889783 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sun, 8 Nov 2026 17:17:16 -0300 Subject: [PATCH] feat(demo): add investment examples to the demo data Investments landed without demo data. The ETF, Bitcoin and savings accounts become investments, and new accounts cover the rest of the tracker: Apple shares (dividends paid into checking, a partial sale, gifted shares with an estimated cost), a retirement fund tracked by value (quarterly gains and a loss, a yearly fee) and gold. Asset classes have targets that add up to 100%, plus one without a target to show how rebalancing leaves it out. The investment examples use their own random seed, so the rest of the demo is unchanged. The demo test now renders the investment pages and checks each kind of holding has figures. --- .../management/commands/load_demo_data.py | 98 +++++++++++++++++-- app/apps/common/tests/test_demo_data.py | 27 +++++ 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/app/apps/common/management/commands/load_demo_data.py b/app/apps/common/management/commands/load_demo_data.py index 09ec4d3d..8455e0c1 100644 --- a/app/apps/common/management/commands/load_demo_data.py +++ b/app/apps/common/management/commands/load_demo_data.py @@ -4,7 +4,9 @@ Run on an empty database (the demo reset flushes first). Covers every feature: multi-currency accounts, exchange rates, an ETF and Bitcoin tracked as currencies with DCA strategies, transfers, a credit card using reference dates, installment plans, recurring transactions, quick transactions, rules, -filter presets, muted/unpaid/archived/deactivated examples. +filter presets, muted/unpaid/archived/deactivated examples, and investments: +unit-priced (ETF, crypto, a stock with dividends, a sale and gifted shares, gold) +and value-tracked (savings, a retirement fund) holdings in asset classes. """ import calendar @@ -19,7 +21,7 @@ from django.db import transaction as db_transaction from django.db.models import Q from django.utils import timezone -from apps.accounts.models import Account, AccountGroup +from apps.accounts.models import Account, AccountGroup, InvestmentClass from apps.common.middleware.thread_local import delete_current_user, write_current_user from apps.currencies.models import Currency, ExchangeRate from apps.dca.models import DCAEntry, DCAStrategy @@ -141,12 +143,24 @@ class Command(BaseCommand): code="MILES", name="Airline Miles", decimal_places=0, suffix=" mi", exchange_currency=usd, ) + aapl = Currency.objects.create( + code="AAPL", name="Apple Inc. shares", decimal_places=4, suffix=" AAPL", + exchange_currency=usd, + ) + gold = Currency.objects.create( + code="XAU", name="Gold (grams)", decimal_places=2, suffix=" g", + exchange_currency=usd, + ) # Monthly rate history so net worth charts move; index 0 = oldest month n = HISTORY_MONTHS + 1 eur_usd = [q(1.06 + 0.04 * i / n + rng.uniform(-0.01, 0.01), 4) for i in range(n)] btc_usd = [q(58000 + 40000 * i / n + rng.uniform(-4000, 4000)) for i in range(n)] vwce_eur = [q(112 + 20 * i / n + rng.uniform(-2, 2)) for i in range(n)] + # Own generator, so the investment examples don't shift the rest of the demo + inv = random.Random(7) + aapl_usd = [q(185 + 45 * i / n + inv.uniform(-9, 9)) for i in range(n)] + gold_usd = [q(76 + 18 * i / n + inv.uniform(-2, 2)) for i in range(n)] for i in range(n): months = i - HISTORY_MONTHS first = self.day(months, 1) @@ -154,7 +168,8 @@ class Command(BaseCommand): if months == 0: when = timezone.now() # latest rate is "today" for src, dst, series in [ - (eur, usd, eur_usd), (btc, usd, btc_usd), (vwce, eur, vwce_eur) + (eur, usd, eur_usd), (btc, usd, btc_usd), (vwce, eur, vwce_eur), + (aapl, usd, aapl_usd), (gold, usd, gold_usd), ]: ExchangeRate.objects.create( from_currency=src, to_currency=dst, rate=series[i], date=when @@ -164,6 +179,13 @@ class Command(BaseCommand): date=timezone.make_aware(datetime.combine(self.day(-HISTORY_MONTHS, 1), time(12))), ) + # Asset classes: targets add up to 100%; Commodities has none, so the + # rebalance view leaves it out + stocks = InvestmentClass.objects.create(name="Stocks", target=Decimal("60")) + fixed_income = InvestmentClass.objects.create(name="Fixed income", target=Decimal("25")) + crypto = InvestmentClass.objects.create(name="Crypto", target=Decimal("15")) + commodities = InvestmentClass.objects.create(name="Commodities") + # Accounts everyday = AccountGroup.objects.create(name="Everyday") investing = AccountGroup.objects.create(name="Savings & Investing") @@ -172,17 +194,34 @@ class Command(BaseCommand): checking = Account.objects.create(name="Checking", group=everyday, currency=usd) card = Account.objects.create(name="Credit Card", group=everyday, currency=usd) cash = Account.objects.create(name="Cash Wallet", group=everyday, currency=usd) + # No exchange currency on an investment: tracked by value savings = Account.objects.create( - name="High-Yield Savings", group=investing, currency=usd, is_asset=True + name="High-Yield Savings", group=investing, currency=usd, is_asset=True, + is_investment=True, investment_class=fixed_income, ) euro = Account.objects.create( name="Euro Account", group=investing, currency=eur, exchange_currency=usd ) + # An exchange currency on an investment: priced by units brokerage = Account.objects.create( - name="Brokerage (VWCE)", group=investing, currency=vwce, is_asset=True + name="Brokerage (VWCE)", group=investing, currency=vwce, exchange_currency=eur, + is_asset=True, is_investment=True, investment_class=stocks, ) btc_wallet = Account.objects.create( - name="Bitcoin Wallet", group=investing, currency=btc, is_asset=True + name="Bitcoin Wallet", group=investing, currency=btc, exchange_currency=usd, + is_asset=True, is_investment=True, investment_class=crypto, + ) + stock_acc = Account.objects.create( + name="Brokerage (Apple shares)", group=investing, currency=aapl, + exchange_currency=usd, is_asset=True, is_investment=True, investment_class=stocks, + ) + retirement = Account.objects.create( + name="Retirement Fund", group=investing, currency=usd, is_asset=True, + is_investment=True, investment_class=stocks, + ) + gold_acc = Account.objects.create( + name="Gold Bars", group=investing, currency=gold, exchange_currency=usd, + is_asset=True, is_investment=True, investment_class=commodities, ) miles_acc = Account.objects.create(name="Airline Miles", group=rewards, currency=miles) old_bank = Account.objects.create( @@ -195,6 +234,7 @@ class Command(BaseCommand): "Salary", "Freelance", "Interest", "Rent", "Groceries", "Dining", "Transport", "Utilities", "Subscriptions", "Health", "Travel", "Shopping", "Investments", "Fees", "Rewards", "Gifts", "Transfers", + "Dividends", "Market result", ] } self.cats["Cable TV"] = TransactionCategory.objects.create( @@ -210,10 +250,16 @@ class Command(BaseCommand): "Acme Corp", "Northwind (client)", "Landlord", "Power & Light Co.", "FiberNet", "Fresh Market", "Corner Bistro", "Sushi House", "Uber", "City Transit", "StreamFlix", "Iron Gym", "Pharma Plus", "BuyMore", - "Alex", "Sky Airlines", + "Alex", "Sky Airlines", "Apple Inc.", ] } + # What counts as an investment's result rather than money in or out + savings.income_categories.set([self.cats["Interest"]]) + retirement.income_categories.set([self.cats["Market result"], self.cats["Fees"]]) + # Dividends paid into checking count towards the Apple shares' return + stock_acc.income_entities.set([self.ents["Apple Inc."]]) + btc_dca = DCAStrategy.objects.create( name="Bitcoin monthly", target_currency=btc, payment_currency=usd, notes="Buy $100 of Bitcoin every month, no matter the price.", @@ -362,6 +408,30 @@ class Command(BaseCommand): income_transaction=inc) self.tx(euro, EX, Decimal("1.50"), d, "Broker fee", "Fees") + # Apple shares every other month; quarterly dividends land in checking + d = self.day(m, 18) + if m % 2 == 0 and d <= today: + self.transfer(checking, stock_acc, Decimal("500.00"), d, "Buy AAPL", + to_amount=q(Decimal(500) / self.rate(aapl_usd, m), 4), + mute=False, category="Investments") + d = self.day(m, 14) + if m % 3 == 1 and d <= today: + self.tx(checking, IN, q(inv.uniform(4, 7)), d, "AAPL dividend", "Dividends", + entities=["Apple Inc."]) + + # Retirement fund, tracked by value: contributions plus the quarterly + # statement's gain or loss + d = self.day(m, 6) + if d <= today: + self.transfer(checking, retirement, Decimal("300.00"), d, + "Retirement contribution", category="Investments") + d = self.day(m, 28) + result = {-12: 310, -9: -180, -6: 420, -3: 265, 0: 190}.get(m) + if result and d <= today: + self.tx(retirement, IN if result > 0 else EX, Decimal(abs(result)), d, + "Quarterly statement result", "Market result", + notes="The fund's gain or loss from its statement.") + # Every 3 months: convert dollars to euros at that month's rate if m % 3 == 0 and self.day(m, 7) <= today: usd_amount = Decimal("1000.00") @@ -381,6 +451,20 @@ class Command(BaseCommand): "ATM withdrawal", category="Transfers") # ---- one-offs ---- + # Investments: gifted shares (no known cost, so estimated), a partial sale, + # a yearly fund fee, and gold bought twice + self.tx(stock_acc, IN, Decimal("5"), self.day(-7, 9), "Shares gifted by grandma", + "Gifts", notes="No cost is known, so it's valued at that day's price.") + self.transfer(stock_acc, checking, Decimal("3"), self.day(-2, 21), "Sell AAPL", + to_amount=q(3 * self.rate(aapl_usd, -2)), mute=False, + category="Investments") + self.tx(retirement, EX, Decimal("45.00"), self.day(-6, 30), "Annual management fee", + "Fees") + for m, spent in ((-9, 1500), (-3, 800)): + self.transfer(checking, gold_acc, Decimal(spent), self.day(m, 12), "Buy gold", + to_amount=q(Decimal(spent) / self.rate(gold_usd, m)), mute=False, + category="Investments") + self.tx(miles_acc, IN, Decimal("30000"), self.day(-HISTORY_MONTHS, 4), "Credit card sign-up bonus", "Rewards", entities=["Sky Airlines"]) trip = -5 diff --git a/app/apps/common/tests/test_demo_data.py b/app/apps/common/tests/test_demo_data.py index 8dc96cc1..54eeb246 100644 --- a/app/apps/common/tests/test_demo_data.py +++ b/app/apps/common/tests/test_demo_data.py @@ -6,8 +6,10 @@ from django.core.management import call_command from django.test import TestCase, override_settings from django.urls import reverse +from apps.accounts.models import Account from apps.common.middleware.thread_local import delete_current_user, write_current_user from apps.dca.models import DCAStrategy +from apps.investments.services import get_holding_performance from apps.transactions.models import Transaction @@ -53,6 +55,24 @@ class LoadDemoDataTests(TestCase): ) self.assertTrue(user.check_password("wygiwyhdemo")) + def test_investments_cover_each_kind(self): + self.load(date.today()) # the tracker values holdings as of the real today + perfs = { + a.name: get_holding_performance(a) + for a in Account.objects.filter(is_investment=True) + } + self.assertTrue(any(p.priced for p in perfs.values())) + self.assertTrue(any(not p.priced for p in perfs.values())) + for name, perf in perfs.items(): + with self.subTest(name=name): + self.assertGreater(perf.quantity, 0) + self.assertIsNotNone(perf.market_value) + apple = perfs["Brokerage (Apple shares)"] + self.assertTrue(apple.estimated) # gifted shares + self.assertGreater(apple.income, 0) # dividends paid into checking + self.assertNotEqual(apple.realized, 0) # partial sale + self.assertTrue(perfs["Retirement Fund"].results) # gains and losses + def test_running_twice_is_a_no_op(self): self.load(date(2031, 7, 14)) count = Transaction.all_objects.count() @@ -79,9 +99,16 @@ class LoadDemoDataTests(TestCase): reverse("insights_emergency_fund"), reverse("quick_transactions_index"), reverse("rules_index"), + reverse("investments_list"), + reverse("investments_charts"), + reverse("investments_rebalance"), + reverse("investment_classes_list"), ] + [ reverse("dca_strategy_detail", kwargs={"strategy_id": s.id}) for s in DCAStrategy.objects.all() + ] + [ + reverse("investment_detail", kwargs={"account_id": a.id}) + for a in Account.objects.filter(is_investment=True) ] for url in urls: with self.subTest(url=url):