mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
Merge pull request #655 from eitchtee/feat/demo-investments
feat(demo): add investment examples to the demo data
This commit is contained in:
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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user