commit fd6b8df18a24c1324afb977b0d06d05880d850c4 Author: Ondřej Hruška Date: Wed Apr 25 00:34:11 2018 +0200 Initial code import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79f12aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,110 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +.static_storage/ +.media/ +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +.idea/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +personal.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d70bde --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# ProFin - Personal Finance Projector + +``` + __ + o /' ) + /' ( , + __/' PRO ) .' `; + o _.-~~~~' ``---..__ .' ; + _.--' b) ``--...____.' .' + ( _. )). `-._ < + `vvvvvvv-)-.....___.- `-. __...--'-.'. + `^^^^^'-------.....`-.___.'----... .' `.; + jgs `-` ` +``` + +ProFin is a python module for projecting the balance of a personal savings account +over a future period. + +The output can be used to visualise the balance development when planning some big +purchase or a change of the living situation, such as buying a flat or a car, +getting a new job with possible months of no income, etc. + +Please note the script is intended only for orientation and there may be minor +inaccuracies or bugs (please report!) + +## Supported Features + +### Financial Operations & Schemes + +- Once-off expenses and income +- Recurrent monthly payments / income at a fixed date +- Monthly payments with a total expense cap +- Skipping a month in monthly payments / income +- Spread monthly expenses (e.g. food cost) - distributed over all days in a month +- Simple loan (borrowing money from a friend) with back-payments +- Setting initial or correction balance + +### Functions + +- Setting up a financial situation with changes at arbitrary dates +- Simulating account balance with day granularity +- Plotting results using PyPlot + +## Dependencies + +The script uses NumPy, MatPlotLib and Pandas for plotting. This may be improved in a future version +(in particular Pandas could be replaced). + +If plotting is not needed and the dependencies are not available (e.g. some old Debian), +remove their imports and comment out the graph() function + +## Usage + +See the example files for an example of usage. (The examples are silly and not very representative, but they show the API well). +Additionally the code is documented with doc comments. diff --git a/example_betty.py b/example_betty.py new file mode 100644 index 0000000..41caa7f --- /dev/null +++ b/example_betty.py @@ -0,0 +1,53 @@ +import profin + +pf = profin.Projector() + +# This demo simulates Betty who saved for a car + + +# Start date and balance +pf.date(2018, 'March', 24) +pf.balance(+40_000) # initial savings + +# Recurring payments, paid on 1st of eahc month +rent = pf.monthly('Rent', -8_000) # a pretty high rent... + +# using .spread() to distribute the cost over the whole month +# Set to False for less accurate graph but more readable stdout logs +want_spread = True + +food = pf.monthly('Food', -3500).spread(want_spread) + +# Betty goes to gym and it's very expensive +gym1 = pf.monthly('Gym 1', -500).on(10) +gym2 = pf.monthly('Gym 2', -500).on(20) + +# She is a cashier but the pay isn't that good +job = pf.monthly('Cashier Job', +22_000, day=6) + + +pf.date(2018, 'June') +# Betty is saving for a car, she stopped going to the gym and instead jogs outside. +gym1.end() +gym2.end() + + +# In September, Frank lent Betty some cash for the car +friend_loan = pf.borrow('Frank\'s Loan', +35_000).on(2018, 'Sep')\ + .repay_monthly(-8000, day=7)\ + .begin(2018, 'Dec') # Frank gives Betty 3 months before she has to start paying it back + +# Betty can't decide which car to buy. Finally... +pf.expend("Car Purchase", 120_000).on(2018, 'Sep', 28) + +# whoops that was expensive. But she'll recover ... + +# Now she's paying off the loan. It's done in about 3 months after December + + +# --- We generate the prediction until December 31, 2019 +# You can pass a second argument verbose=False to disable stdout printing +samples = pf.project_to(2019) + +# show in a graph +pf.graph(samples) diff --git a/example_bob.py b/example_bob.py new file mode 100644 index 0000000..bc4c9a4 --- /dev/null +++ b/example_bob.py @@ -0,0 +1,57 @@ +import profin + +pf = profin.Projector() + +# This demo simulates Mr. Bob who gave up one poorly paying job, then had trouble finding a better one, +# had to borrow from a friend, then finally found a good paying job and all was OK + + +# Start date and balance +pf.date(2018, 'April', 24) +pf.balance(+100_000) # initial savings + +# Recurring payments, paid on 1st of eahc month +rent = pf.monthly('Rent', -12_000) # a pretty high rent... +internet = pf.monthly('Internet', -450).on(10) # this is paid on the 10th +spotify = pf.monthly('Spotify', -160) + +# using .spread() to distribute the cost over the whole month +# Set to False for less accurate graph but more readable stdout logs +want_spread = True + +food = pf.monthly('Food', -3500).spread(want_spread) +membership = pf.monthly('Club Member.', -200).spread(want_spread) +misc = pf.monthly('Misc Expenses', -2000).spread(want_spread) + +# A poorly paying job +job1 = pf.monthly('Cleaning Job', +15_000, day=12) + +# Not going to the Club in May +membership.skip_month(2018, 'May') + +# --- we can set a global date instead of specifying it in each start() and end() +# also, if .start() is not called, the global date is used +pf.date(2018, 'June') + +job1.end() # Quit the job in May, last pay received in June + +# cancel non-essential expenses +spotify.end() +membership.end() +misc.end() + +# Looking bad, borrow from a friend +friend_loan = pf.borrow('Loan from Luke', +50_000).on(2018, 'Oct')\ + .repay_monthly(-5000, day=18) + +# A new job with much better pay than before! +pf.date(2018, 'Dec') +job2 = pf.monthly('Sausage Stand Job', +25_000, day=15) + + +# --- We generate the prediction until December 31, 2019 +# You can pass a second argument verbose=False to disable stdout printing +samples = pf.project_to(2019) + +# show in a graph +pf.graph(samples) diff --git a/profin/__init__.py b/profin/__init__.py new file mode 100644 index 0000000..74c9a1e --- /dev/null +++ b/profin/__init__.py @@ -0,0 +1,373 @@ +from calendar import monthrange +import datetime +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import matplotlib.cbook as cbook +import pandas as pd + + +def parse_month(month) -> int: + """ Parse string month to a number 1-12 """ + map = { + 'jan': 1, + 'feb': 2, + 'mar': 3, + 'apr': 4, + 'may': 5, + 'jun': 6, + 'jul': 7, + 'aug': 8, + 'sep': 9, + 'oct': 10, + 'nov': 11, + 'dec': 12, + + 'sept': 9, + + 'january': 1, + 'february': 2, + 'march': 3, + 'april': 4, + 'june': 6, + 'july': 7, + 'august': 8, + 'september': 9, + 'october': 10, + 'november': 11, + 'december': 12 + } + + k = str(month).lower() + if k in map: + return map[k] + + return int(month) # try it as numeric + +class Projector: + def __init__(self): + self.incomes = [] + self.cursor = datetime.datetime.now().date() + self.oldest = None + + def date(self, year:int, month='Jan', day:int=1) -> 'Projector': + self.cursor = datetime.date(year, parse_month(month), day) + + if self.oldest is None: + self.oldest = self.cursor + + return self + + def project_to(self, year, month='Dec', day=31, verbose=True): + records = [] + + end = None + aday = day + while aday >= 28: + try: + end = datetime.date(year, parse_month(month), aday) + break + except ValueError: + aday -= 1 + + if end is None: + raise ValueError("Bad projection end date") + + now = self.oldest + balance = 0 + while now <= end: + buf = "" + any_non_balance = False + any = False + + for inc in self.incomes: + ain = inc.get_absolute_income_on(now) + if ain is not None: + balance = ain + any = True + if verbose: + buf += "\nSet Balance: %d" % balance + else: + inco = inc.get_income_on(now) + if inco is not None and inco != 0: + any = True + any_non_balance = True + balance += inco + if verbose: + buf += "\n| %20s ... %s%d" % (inc.name, '+' if inco>0 else '', inco) + + if any: + if verbose: + print(now) + print(buf.strip()) + if any_non_balance: + print('End Balance: %d' % balance) + print('\n') + + records.append({'date': now, 'balance': balance}) + + now += datetime.timedelta(days=1) + + return records + + def graph(self, samples, currency='CZK'): + """ Show samples from project_to() in a line graph """ + years = mdates.YearLocator() # every year + months = mdates.MonthLocator() # every month + yearsFmt = mdates.DateFormatter('%Y') + + r = pd.DataFrame().from_records(samples) + + fig, ax = plt.subplots() + ax.step(r.date, r.balance, where='post') + + # format the ticks + ax.xaxis.set_major_locator(years) + ax.xaxis.set_major_formatter(yearsFmt) + ax.xaxis.set_minor_locator(months) + + # round to nearest years... + datemin = np.datetime64(r.date[0], 'Y') + datemax = np.datetime64(r.date[len(r.date)-1], 'Y') + np.timedelta64(1, 'Y') + ax.set_xlim(datemin, datemax) + + # format the coords message box + def price(x): + return currency+' %1.0f' % x + + ax.format_xdata = mdates.DateFormatter('%Y-%m-%d') + ax.format_ydata = price + ax.grid(True) + + # rotates and right aligns the x labels, and moves the bottom of the + # axes up to make room for them + fig.autofmt_xdate() + + plt.show() + + def balance(self, balance:int) -> 'Projector': + """ Set known balance at a date cursor (this modifies the total value) """ + self.incomes.append(SetBalance(self, balance)) + return self + + def monthly(self, name: str, income: int, day=1) -> 'MonthlyIncome': + """ Monthly recurrent expense or income """ + m = MonthlyIncome(self, name, per_month=income, payday=day) + self.incomes.append(m) + return m + + def receive(self, name, money) -> 'SingleIncome': + """ Once-off Income """ + return self.single(name, +abs(money)) + + def expend(self, name, money) -> 'SingleIncome': + """ Expense """ + return self.single(name, -abs(money)) + + def single(self, name, money) -> 'SingleIncome': + """ Non-recurrent expense or income """ + m = SingleIncome(self, name, money=money) + self.incomes.append(m) + return m + + def borrow(self, name, money) -> 'SimpleLoan': + """ Borrow money with no interest, to be repaid """ + m = SimpleLoan(self, name, money=money) + self.incomes.append(m) + return m + + +class AIncome: + """ Abstract income record """ + def __init__(self, name:str, pf:Projector): + self.pf = pf + self.name = name + + self.date_start = pf.cursor + self.date_end = None + self.started = False + self.ended = False + + def get_income_on(self, date): + """ Get income on a given date """ + + if self.ended: + return 0 + + if self.date_end is not None and self.date_end <= date: + self.ended = True + + if not self.started and self.date_start <= date: + self.started = True + + if self.started: + return self._day_income(date) + + def _day_income(self, date): + """ Day's income, end dates have been already checked and are OK """ + raise NotImplementedError() + + def get_absolute_income_on(self, date): + """ Get absolute income value (used for SetBalance) """ + return None + + +class MonthlyIncome(AIncome): + """ Periodic income with monthly period """ + def __init__(self, pf: Projector, name:str, per_month:int, payday:int=1): + """ + payday - the day of the month when the money is sent or received + per_month - money sent or received per month + """ + super().__init__(name, pf) + self.skip_dates = [] + self.monthly = per_month + self.payday = payday + self.remains = None + self.spreading = False + + def on(self, day:int) -> 'MonthlyIncome': + """ Set the payday """ + self.payday = day + return self + + def _day_income(self, date:datetime.date) -> int: + if datetime.date(date.year, date.month, 1) in self.skip_dates: + return 0 + + if self.spreading: + if self.remains is not None: + if self.remains > 0: + if abs(self.monthly) > self.remains: + self.ended = True + topay = self.remains + self.remains = 0 + return topay if self.monthly > 0 else -topay + self.remains -= round(abs(self.monthly) / monthrange(date.year, date.month)[1]) + else: + return 0 + + x = round(self.monthly / monthrange(date.year, date.month)[1]) + return x + else: + if date.day == self.payday: + if self.remains is not None: + if self.remains > 0: + if abs(self.monthly) > self.remains: + self.ended = True + topay = self.remains + self.remains = 0 + return topay + self.remains -= abs(self.monthly) + else: + return 0 + return self.monthly + else: + return 0 + + def skip_month(self, year, month) -> 'MonthlyIncome': + """ Skip a month """ + self.skip_dates.append(datetime.date(year, parse_month(month), 1)) + return self + + def start(self, year=None, month=1, day=1) -> 'MonthlyIncome': + """ Set the start date """ + if year is None: + self.date_start = self.pf.cursor + else: + self.date_start = datetime.date(year, parse_month(month), day) + return self + + def end(self, year=None, month=1, day=1) -> 'MonthlyIncome': + """ Set the end date """ + + if year is None: + self.date_end = datetime.date(self.pf.cursor.year, self.pf.cursor.month, + monthrange(self.pf.cursor.year, self.pf.cursor.month)[1]) # last day of the month + else: + self.date_end = datetime.date(year, parse_month(month), day) + return self + + def total(self, total) -> 'MonthlyIncome': + """ Set the total after which the payment is stopped """ + self.remains = total + return self + + def spread(self, doSpread=True) -> 'MonthlyIncome': + """ Spread over the month """ + self.spreading = doSpread + return self + + +class SingleIncome(AIncome): + """ Single-shot income """ + + def __init__(self, pf: Projector, name:str, money:int): + super().__init__(name, pf) + self.money = money + self.start = pf.cursor + self.end = pf.cursor + + def _day_income(self, date): + return self.money + + def on(self, year, month=1, day=1) -> 'SingleIncome': + """ Set the exact date """ + self.date_start = self.date_end = datetime.date(year, parse_month(month), day) + return self + + +class SimpleLoan(AIncome): + """ Simple interest-free loan """ + def __init__(self, pf: Projector, name:str, money:int): + """ + Money is the total borrowed money + """ + super().__init__(name, pf) + self.borrowed = money + self.start = pf.cursor + self.end = pf.cursor + + self.receipt = pf.receive(name, money) + self.repay = None + + def on(self, year, month=1, day=1) -> 'SimpleLoan': + """ Set borrow date """ + self.start = datetime.date(year, parse_month(month), day) + self.receipt.on(year, month, day) + return self + + def repay_monthly(self, payment, day=1) -> 'SimpleLoan': + """ + Configure monthly payments. + payment - how much to pay monthly + day - day of the month to pay + """ + self.repay = self.pf.monthly(self.name, -abs(payment), day=day).total(abs(self.borrowed)) + return self.begin(self.start.year, self.start.month) + + def begin(self, year, month) -> 'SimpleLoan': + """ Set the payments start month """ + self.repay.start(year, month) + return self + + def _day_income(self, date): + return None + + +class SetBalance (AIncome): + """ Set balance to a known value """ + def __init__(self, pf: Projector, balance:int): + super().__init__('Balance', pf) + self.balance = balance + self.start = pf.cursor + self.end = pf.cursor + + def _day_income(self, date): + return 0 + + def get_absolute_income_on(self, date): + if date == self.start: + return self.balance + else: + return None \ No newline at end of file