from datetime import timedelta from decimal import Decimal from typing import Iterable from dateutil.relativedelta import relativedelta from django.utils import timezone 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 def predict_all(subjects: Iterable[Subject], past_days=30, future_days=60): today = timezone.now().date() past_lookup_bound = today - timedelta(days=past_days) future_lookup_bound = today + timedelta(days=future_days) lookup_bounds = (past_lookup_bound, future_lookup_bound) prediction_list = [] for subject in subjects: transactions = list( subject.transactions.filter(booking_date__range=lookup_bounds).order_by("booking_date")) predicted_transactions, prediction_info = predict_transactions(subject) if predicted_transactions: first_predicted_date = predicted_transactions[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 transaction in predicted_transactions: if past_lookup_bound <= transaction.booking_date <= future_lookup_bound: transactions.append(transaction) prediction_list.append((subject, transactions, prediction_info)) return prediction_list min_pos_color = (Decimal("50"), Decimal("150"), Decimal("220")) max_pos_color = (Decimal("50"), Decimal("170"), Decimal("20")) min_neg_color = (Decimal("220"), Decimal("70"), Decimal("30")) max_neg_color = (Decimal("180"), Decimal("160"), Decimal("30")) global_minimum = Decimal("-300") global_maximum = Decimal("300") def limit(amount: Decimal, first_bound: Decimal, second_bound: Decimal): lower = min(first_bound, second_bound) upper = max(first_bound, second_bound) return max(min(amount, upper), lower) def get_color_for_amount(amount: Decimal, minimum: Decimal, maximum: Decimal): if amount >= Decimal(): minimum = Decimal() maximum = min(maximum, global_maximum) rmin, gmin, bmin = min_pos_color rmax, gmax, bmax = max_pos_color else: minimum = max(minimum, global_minimum) maximum = Decimal() rmin, gmin, bmin = min_neg_color rmax, gmax, bmax = max_neg_color assert minimum < maximum percentage = (amount - minimum) / (maximum - minimum) return "#%02x%02x%02x" % ( round(limit(rmin + (percentage * (rmax - rmin)), rmin, rmax)), round(limit(gmin + (percentage * (gmax - gmin)), gmin, gmax)), round(limit(bmin + (percentage * (bmax - bmin)), bmin, bmax)), ) def predict_balance(subjects: Iterable[Subject], start_balance=Decimal("0")): prediction_list = predict_all(subjects) today = timezone.now().date() future_transactions = [] minimum, maximum = Decimal(), Decimal() for _, transactions, _ in prediction_list: for transaction in transactions: if transaction.booking_date > today: future_transactions.append(transaction) minimum = min(minimum, transaction.amount) maximum = max(maximum, transaction.amount) formatted_transactions = [] current_balance = start_balance for transaction in sorted(future_transactions, key=lambda t: t.booking_date): current_balance += transaction.amount formatted_transactions.append({ "date": transaction.booking_date, "subject": transaction.subject, "amount": transaction.amount, "balance": current_balance, "predicted": transaction.pk is None, "color": get_color_for_amount(transaction.amount, minimum, maximum), }) return formatted_transactions