1
1

add whole new app for balance prediction, replace uwsgi, update everything

This commit is contained in:
2021-01-04 14:45:43 +01:00
parent 5e2881af2a
commit 37f698d82b
20 changed files with 448 additions and 191 deletions

0
core/__init__.py Normal file
View File

15
core/admin.py Normal file
View File

@@ -0,0 +1,15 @@
from django.contrib import admin
from core.models import Subject, Transaction
from financeplanner.admin import admin_site
@admin.register(Subject, site=admin_site)
class SubjectAdmin(admin.ModelAdmin):
pass
@admin.register(Transaction, site=admin_site)
class TransactionAdmin(admin.ModelAdmin):
list_display = ("subject", "amount", "booking_date",)
ordering = ("-booking_date",)

5
core/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'core'

View File

View File

View File

@@ -0,0 +1,74 @@
from datetime import datetime
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from django.core.management import BaseCommand
from core.models import Subject
from core.prediction import predict_transactions
past_lookup_delta = relativedelta(weeks=2)
future_lookup_delta = relativedelta(months=2)
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--start", default="0", help="the current balance to use as a starting point for prediction",
)
def handle(self, *args, **options):
start_balance = Decimal(options["start"])
today = datetime.now().date()
past_lookup_bound = today - past_lookup_delta
future_lookup_bound = today + future_lookup_delta
transaction_dict = {}
for subject in Subject.objects.all():
transactions = []
for tr in subject.transactions.order_by("booking_date"):
if past_lookup_bound <= tr.booking_date <= future_lookup_bound:
transactions.append(tr)
predicted_transaction, prediction_info = predict_transactions(subject)
if predicted_transaction:
first_predicted_date = predicted_transaction[0].booking_date
if first_predicted_date >= past_lookup_bound:
# if two weeks after the first predicted transaction have passed, the subject is considered done
for tr in predicted_transaction:
if past_lookup_bound <= tr.booking_date <= future_lookup_bound:
transactions.append(tr)
transaction_dict[subject] = (transactions, prediction_info)
future_transactions = []
for subject, prediction in transaction_dict.items():
transactions, info = prediction[0], prediction[1]
print(f">>> {subject}")
if info:
rec_days, rec_months, day_of_month = \
info["recurring_days"], info["recurring_months"], info["day_of_month"]
if rec_months and day_of_month:
print(f"~~~ predicted transaction on day-of-month {day_of_month} every {rec_months} month(s)")
elif rec_months:
print(f"~~~ predicted transaction every {rec_months} month(s)")
elif rec_days:
print(f"~~~ predicted transaction every {rec_days} day(s)")
else:
print("~~~ no prediction possible")
for tr in transactions:
print(f" {tr.booking_date:%d.%m.%Y} | {tr.amount} € | {'stored' if tr.pk else 'predicted'}")
if tr.booking_date > today:
future_transactions.append(tr)
print()
current_balance = start_balance
print(f"starting calculation with amount {current_balance}")
for tr in sorted(future_transactions, key=lambda t: t.booking_date):
current_balance += tr.amount
print(
f"{tr.booking_date:%d.%m.%Y} | "
f"{tr.subject.name:<18} | "
f"{tr.amount:>8} € ==> "
f"{current_balance:>8}"
)

View File

@@ -0,0 +1,48 @@
# Generated by Django 3.1.5 on 2021-01-04 13:41
import core.models
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Balance',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', core.models.PriceField(decimal_places=2, max_digits=7)),
('date', models.DateField()),
],
options={
'get_latest_by': 'date',
},
),
migrations.CreateModel(
name='Subject',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=64)),
('created_time', models.DateTimeField(default=django.utils.timezone.now)),
],
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', core.models.PriceField(decimal_places=2, max_digits=7)),
('booking_date', models.DateField()),
('subject', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', related_query_name='transaction', to='core.subject')),
],
options={
'get_latest_by': 'booking_date',
},
),
]

View File

49
core/models.py Normal file
View File

@@ -0,0 +1,49 @@
from decimal import Decimal
from django.db import models
from django.utils import timezone
class PriceField(models.DecimalField):
# allowing numbers until 99,999.99
max_digits = 7
decimal_places = 2
def to_python(self, value):
value = super().to_python(value)
if isinstance(value, Decimal):
return value.quantize(Decimal(10) ** -self.decimal_places)
return value
def __init__(self, *args, **kwargs):
kwargs['max_digits'] = self.max_digits
kwargs['decimal_places'] = self.decimal_places
super().__init__(*args, **kwargs)
class Subject(models.Model):
name = models.CharField(max_length=64)
created_time = models.DateTimeField(default=timezone.now)
def __str__(self):
return self.name
class Transaction(models.Model):
class Meta:
get_latest_by = "booking_date"
amount = PriceField()
booking_date = models.DateField()
subject = models.ForeignKey(
to=Subject, on_delete=models.CASCADE,
related_name="transactions", related_query_name="transaction",
)
class Balance(models.Model):
class Meta:
get_latest_by = "date"
amount = PriceField()
date = models.DateField()

87
core/prediction.py Normal file
View File

@@ -0,0 +1,87 @@
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from core.models import Subject, Transaction
days_per_year = Decimal("365.25")
days_per_month = days_per_year / Decimal("12")
monthly_distance_threshold = 3
certain_day_of_month_threshold = 3
def _group_follow_up_objects(objects):
"""
Creates a list of each follow-up pair of objects within a given list, like this:
[1, 2, 3] -> [(1, 2), (2, 3)]
"""
tuples = []
if len(objects) >= 2:
for idx in range(len(objects) - 1):
tuples.append((objects[idx], objects[idx + 1]))
return tuples
def predict_amount(subject: Subject):
if subject.transactions.exists():
return subject.transactions.latest().amount
return None
def predict_booking_dates(subject: Subject):
dates, recurring_days, recurring_months, day_of_month = [], None, None, None
if subject.transactions.count() < 2:
return dates, recurring_days, recurring_months, day_of_month
existing_transactions = subject.transactions.order_by("booking_date")
last_date = existing_transactions.latest().booking_date
one_year_later = last_date + relativedelta(years=1)
transaction_tuples = _group_follow_up_objects(existing_transactions)
date_deltas = [second.booking_date - first.booking_date for first, second in transaction_tuples]
average_delta_in_days = Decimal(sum(delta.days for delta in date_deltas)) / Decimal(len(date_deltas))
average_delta_in_months = average_delta_in_days / days_per_month
days_per_month_mod = average_delta_in_days % days_per_month
distance_from_days_per_month = min(days_per_month_mod, days_per_month - days_per_month_mod)
if distance_from_days_per_month <= monthly_distance_threshold:
# transactions can be considered to happen every n months
recurring_months = round(average_delta_in_months)
while last_date < one_year_later and len(dates) < 10:
last_date += relativedelta(months=recurring_months)
dates.append(last_date)
days_of_month = [t.booking_date.day for t in existing_transactions]
if max(days_of_month) - min(days_of_month) <= certain_day_of_month_threshold:
# since transactions occurred in a close range around a certain day of month, we can
# improve our prediction
day_of_month = round(sum(days_of_month) / len(days_of_month))
for idx in range(len(dates)):
dates[idx] = dates[idx].replace(day=day_of_month)
else:
# there is no monthly pattern, just add the average delta to determine new dates
recurring_days = round(average_delta_in_days)
while last_date < one_year_later and len(dates) < 10:
last_date += relativedelta(days=recurring_days)
dates.append(last_date)
return dates, recurring_days, recurring_months, day_of_month
def predict_transactions(subject: Subject):
"""
Analyze existing transactions of a given subject and predict future transactions, up to one year in advance.
"""
transactions, prediction_info = [], None
amount = predict_amount(subject)
dates, rec_days, rec_months, day_of_month = predict_booking_dates(subject)
if amount and dates:
transactions = [Transaction(amount=amount, booking_date=date, subject=subject) for date in dates]
prediction_info = {
"recurring_days": rec_days,
"recurring_months": rec_months,
"day_of_month": day_of_month,
}
return transactions, prediction_info

0
core/tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,114 @@
from datetime import date
from django.test import TestCase
from core.models import Subject, Transaction
from core.prediction import predict_transactions
class PredictionTestCase(TestCase):
def test_not_enough_data(self):
subject = Subject.objects.create(name="rent")
predicted_transactions, prediction_info = predict_transactions(subject)
self.assertEqual(predicted_transactions, [])
self.assertIsNone(prediction_info)
Transaction.objects.create(amount=-333, booking_date=date(2020, 6, 2), subject=subject)
predicted_transactions, prediction_info = predict_transactions(subject)
self.assertEqual(predicted_transactions, [])
self.assertIsNone(prediction_info)
Transaction.objects.create(amount=-333, booking_date=date(2020, 7, 2), subject=subject)
predicted_transactions, prediction_info = predict_transactions(subject)
self.assertNotEqual(predicted_transactions, [])
self.assertIsNotNone(prediction_info)
def test_every_month(self):
subject = Subject.objects.create(name="rent")
Transaction.objects.create(amount=-333, booking_date=date(2020, 6, 2), subject=subject)
Transaction.objects.create(amount=-333, booking_date=date(2020, 7, 2), subject=subject)
predicted_transactions, prediction_info = predict_transactions(subject)
first, second, last = predicted_transactions[0], predicted_transactions[1], predicted_transactions[-1]
self.assertEqual(first.booking_date, date(2020, 8, 2))
self.assertEqual(second.booking_date, date(2020, 9, 2))
self.assertEqual(last.booking_date, date(2021, 5, 2))
for tr in predicted_transactions:
self.assertEqual(tr.amount, -333)
self.assertDictEqual(
prediction_info,
{"recurring_days": None, "recurring_months": 1, "day_of_month": 2},
)
def test_every_3_months(self):
subject = Subject.objects.create(name="rent")
Transaction.objects.create(amount=-333, booking_date=date(2020, 6, 2), subject=subject)
Transaction.objects.create(amount=-333, booking_date=date(2020, 9, 2), subject=subject)
predicted_transactions, prediction_info = predict_transactions(subject)
first, second, last = predicted_transactions[0], predicted_transactions[1], predicted_transactions[-1]
self.assertEqual(first.booking_date, date(2020, 12, 2))
self.assertEqual(second.booking_date, date(2021, 3, 2))
self.assertEqual(last.booking_date, date(2021, 9, 2))
for tr in predicted_transactions:
self.assertEqual(tr.amount, -333)
self.assertDictEqual(
prediction_info,
{"recurring_days": None, "recurring_months": 3, "day_of_month": 2},
)
def test_every_year(self):
subject = Subject.objects.create(name="rent")
Transaction.objects.create(amount=-333, booking_date=date(2019, 6, 2), subject=subject)
Transaction.objects.create(amount=-333, booking_date=date(2020, 6, 2), subject=subject)
predicted_transactions, prediction_info = predict_transactions(subject)
self.assertEqual(len(predicted_transactions), 1)
trans = predicted_transactions[0]
self.assertEqual(trans.booking_date, date(2021, 6, 2))
self.assertEqual(trans.amount, -333)
self.assertDictEqual(
prediction_info,
{"recurring_days": None, "recurring_months": 12, "day_of_month": 2},
)
def test_monthly_varying_begin_end(self):
subject = Subject.objects.create(name="rent")
Transaction.objects.create(amount=-333, booking_date=date(2020, 1, 31), subject=subject)
Transaction.objects.create(amount=-333, booking_date=date(2020, 3, 1), subject=subject)
Transaction.objects.create(amount=-333, booking_date=date(2020, 4, 1), subject=subject)
Transaction.objects.create(amount=-333, booking_date=date(2020, 4, 30), subject=subject)
predicted_transactions, prediction_info = predict_transactions(subject)
first, second, last = predicted_transactions[0], predicted_transactions[1], predicted_transactions[-1]
self.assertEqual(first.booking_date, date(2020, 5, 30))
self.assertEqual(second.booking_date, date(2020, 6, 30))
self.assertEqual(last.booking_date, date(2021, 2, 28))
for tr in predicted_transactions:
self.assertEqual(tr.amount, -333)
self.assertDictEqual(
prediction_info,
{"recurring_days": None, "recurring_months": 1, "day_of_month": None},
)
def test_no_monthly_pattern(self):
subject = Subject.objects.create(name="rent")
Transaction.objects.create(amount=-333, booking_date=date(2020, 1, 15), subject=subject)
Transaction.objects.create(amount=-333, booking_date=date(2020, 2, 10), subject=subject)
Transaction.objects.create(amount=-333, booking_date=date(2020, 4, 25), subject=subject)
Transaction.objects.create(amount=-333, booking_date=date(2020, 5, 5), subject=subject)
predicted_transactions, prediction_info = predict_transactions(subject)
first, second, last = predicted_transactions[0], predicted_transactions[1], predicted_transactions[-1]
self.assertEqual(first.booking_date, date(2020, 6, 11))
self.assertEqual(second.booking_date, date(2020, 7, 18))
self.assertEqual(last.booking_date, date(2021, 5, 10))
for tr in predicted_transactions:
self.assertEqual(tr.amount, -333)
self.assertDictEqual(
prediction_info,
{"recurring_days": 37, "recurring_months": None, "day_of_month": None},
)
def test_amount_change(self):
subject = Subject.objects.create(name="rent")
Transaction.objects.create(amount=1337, booking_date=date(2020, 6, 2), subject=subject)
Transaction.objects.create(amount=1312, booking_date=date(2020, 7, 2), subject=subject)
Transaction.objects.create(amount=404, booking_date=date(2020, 8, 2), subject=subject)
predicted_transactions, prediction_info = predict_transactions(subject)
for tr in predicted_transactions:
self.assertEqual(tr.amount, 404)

View File

@@ -0,0 +1,47 @@
from decimal import Decimal, InvalidOperation
from django.test import TestCase
from django.utils import timezone
from core.models import Balance
def _create_and_reload_balance(amount):
bal = Balance.objects.create(amount=amount, date=timezone.now().date())
bal.refresh_from_db()
return bal
class PriceFieldTestCase(TestCase):
def test_normal_decimal(self):
bal = _create_and_reload_balance(Decimal("13.37"))
self.assertEqual(bal.amount, Decimal("13.37"))
def test_too_big_decimal(self):
with self.assertRaises(InvalidOperation):
_create_and_reload_balance(Decimal("13371337.00"))
def test_rounding_decimal(self):
bal = _create_and_reload_balance(Decimal("1.337"))
self.assertEqual(bal.amount, Decimal("1.34"))
def test_normal_int(self):
bal = _create_and_reload_balance(1337)
self.assertEqual(bal.amount, Decimal("1337.00"))
def test_too_big_int(self):
with self.assertRaises(InvalidOperation):
_create_and_reload_balance(13371337)
def test_normal_float(self):
bal = _create_and_reload_balance(13.37)
self.assertEqual(bal.amount, Decimal("13.37"))
def test_too_big_float(self):
with self.assertRaises(InvalidOperation):
_create_and_reload_balance(13371337.00)
def test_rounding_float(self):
bal = _create_and_reload_balance(1.337)
self.assertEqual(bal.amount, Decimal("1.34"))

3
core/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.