Merge pull request #655 from eitchtee/feat/demo-investments

feat(demo): add investment examples to the demo data
This commit is contained in:
Herculino Trotta authored and GitHub committed 2026-11-08 17:20:04 -03:00
commit bdbcf3ea70
2 files changed
+118 -7

No files matched your search

@@ -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
+27
View File
@@ -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):