Initial code import
This commit is contained in:
+110
@@ -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
|
||||
Reference in New Issue
Block a user