Initial code import

master
Ondřej Hruška 6 years ago
commit fd6b8df18a
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 110
      .gitignore
  2. 55
      README.md
  3. 53
      example_betty.py
  4. 57
      example_bob.py
  5. 373
      profin/__init__.py

110
.gitignore vendored

@ -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

@ -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.

@ -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)

@ -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)

@ -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
Loading…
Cancel
Save