commit
fd6b8df18a
@ -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…
Reference in new issue