Adds graph subcommand
Moves all graph and matplotlib dependencies to graph.py. Moves Transactions and Categories methods outside the class for better visibility. Variable expenses renamed to required. transactions.py now has by_year, by_month, by_categories in global scope and slightly improved perfomance.
This commit is contained in:
parent
9971e6214f
commit
fe840b622c
132
main.py
132
main.py
@ -1,12 +1,9 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import argparse
|
import argparse
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import pickle
|
from pfbudget.graph import monthly
|
||||||
import sys
|
|
||||||
from pfbudget.categories import Categories
|
|
||||||
from pfbudget.transactions import load_transactions, save_transactions
|
from pfbudget.transactions import load_transactions, save_transactions
|
||||||
from pfbudget.parsers import Parser
|
|
||||||
import pfbudget.tools as tools
|
import pfbudget.tools as tools
|
||||||
|
|
||||||
|
|
||||||
@ -21,11 +18,15 @@ class PfBudgetNotInitialized(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DataFileMissing(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def init(state, args):
|
def init(state, args):
|
||||||
"""init function
|
"""init function
|
||||||
|
|
||||||
Creates .pfbudget.pickle which stores the internal state of the program for later use. Parses all raw directory
|
Creates state file which stores the internal state of the program for later use.
|
||||||
into data directory.
|
Calls parse, that parses all raw directory into data directory.
|
||||||
|
|
||||||
args.raw -- raw dir
|
args.raw -- raw dir
|
||||||
args.data -- data dir
|
args.data -- data dir
|
||||||
@ -50,8 +51,8 @@ def init(state, args):
|
|||||||
def restart(state, args):
|
def restart(state, args):
|
||||||
"""restart function
|
"""restart function
|
||||||
|
|
||||||
Deletes .pfbudget.pickle and creates new one. Parses all raw directory into data directory. New dirs can be passed
|
Deletes state and creates new one. Parses all raw directory into data directory.
|
||||||
as arguments, otherwise uses previous values
|
New dirs can be passed as arguments, otherwise uses previous values.
|
||||||
|
|
||||||
args.raw -- raw dir
|
args.raw -- raw dir
|
||||||
args.data -- data dir
|
args.data -- data dir
|
||||||
@ -61,8 +62,7 @@ def restart(state, args):
|
|||||||
try:
|
try:
|
||||||
(Path(state.data_dir) / fn).unlink()
|
(Path(state.data_dir) / fn).unlink()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print("missing {}".format(Path(state.data_dir) / fn))
|
raise DataFileMissing("missing {}".format(Path(state.data_dir) / fn))
|
||||||
sys.exit(-1)
|
|
||||||
|
|
||||||
if args.raw:
|
if args.raw:
|
||||||
state.raw_dir = args.raw
|
state.raw_dir = args.raw
|
||||||
@ -91,8 +91,8 @@ def backup(state, args):
|
|||||||
def parse(state, args):
|
def parse(state, args):
|
||||||
"""parse function
|
"""parse function
|
||||||
|
|
||||||
Extracts from .pfbudget.pickle the already read files and parses the remaining. args will be None if called from
|
Extracts from .pfbudget.pickle the already read files and parses the remaining.
|
||||||
command line and gathered from the pickle.
|
args will be None if called from command line and gathered from the pickle.
|
||||||
|
|
||||||
args.raw -- raw dir
|
args.raw -- raw dir
|
||||||
args.data -- data dir
|
args.data -- data dir
|
||||||
@ -141,7 +141,10 @@ def vacation(state, args):
|
|||||||
|
|
||||||
def status(state, args):
|
def status(state, args):
|
||||||
print(state)
|
print(state)
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
def graph(state, args):
|
||||||
|
monthly(state, start=dt.date(2020, 1, 1), end=dt.date(2020, 12, 31))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@ -197,105 +200,20 @@ if __name__ == "__main__":
|
|||||||
p_backup.set_defaults(func=backup)
|
p_backup.set_defaults(func=backup)
|
||||||
p_parse.set_defaults(func=parse)
|
p_parse.set_defaults(func=parse)
|
||||||
p_vacation.set_defaults(func=vacation)
|
p_vacation.set_defaults(func=vacation)
|
||||||
p_report.set_defaults(func=categorize)
|
|
||||||
p_status.set_defaults(func=status)
|
p_status.set_defaults(func=status)
|
||||||
|
p_graph.set_defaults(func=graph)
|
||||||
|
|
||||||
state = tools.pfstate(p)
|
state = tools.pfstate(p)
|
||||||
state.filename = p
|
state.filename = p
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
args.func(state, args)
|
args.func(state, args)
|
||||||
|
|
||||||
transactions = load_transactions(state.data_dir)
|
# income = [sum(t.value for cat, transactions in months.items() for t in transactions
|
||||||
|
# if cat in get_income_categories()) for months in monthly_transactions_by_cat]
|
||||||
|
|
||||||
# reprocess = [Education().name]
|
# expenses = []
|
||||||
# for i, transaction in enumerate(transactions):
|
# for category in expense_categories:
|
||||||
# for category in Categories.get_categories():
|
# expense_value = [-sum(t.value for t in month[category]) for month in monthly_transactions_by_cat]
|
||||||
# if transaction.category in reprocess:
|
# expenses.extend(expense_value)
|
||||||
# transaction.category = ''
|
|
||||||
|
|
||||||
monthly_transactions = transactions.get_transactions_by_month(
|
# print("Income: {}, Expenses: {}, Net = {}"".format(sum(income), sum(expenses), sum(income) - sum(expenses)))
|
||||||
start=dt.date(2020, 1, 1), end=dt.date(2020, 12, 31)
|
|
||||||
)
|
|
||||||
monthly_transactions_by_cat = []
|
|
||||||
for month_transactions in monthly_transactions.values():
|
|
||||||
cat = month_transactions.get_transactions_by_category()
|
|
||||||
monthly_transactions_by_cat.append(cat)
|
|
||||||
|
|
||||||
for month, month_transactions in zip(
|
|
||||||
monthly_transactions.keys(), monthly_transactions_by_cat
|
|
||||||
):
|
|
||||||
nulls = sum(t.value for t in month_transactions["Null"])
|
|
||||||
if nulls != 0:
|
|
||||||
print(f"{month} {nulls}")
|
|
||||||
|
|
||||||
expense_categories = [
|
|
||||||
*Categories.get_fixed_expenses(),
|
|
||||||
*Categories.get_variable_expenses(),
|
|
||||||
*Categories.get_discretionary_expenses(),
|
|
||||||
]
|
|
||||||
|
|
||||||
if True:
|
|
||||||
t = list(monthly_transactions.keys())
|
|
||||||
income = [
|
|
||||||
float(
|
|
||||||
sum(
|
|
||||||
t.value
|
|
||||||
for cat, transactions in months.items()
|
|
||||||
for t in transactions
|
|
||||||
if cat in Categories.get_income_categories()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for months in monthly_transactions_by_cat
|
|
||||||
]
|
|
||||||
# income = []
|
|
||||||
# for months in monthly_transactions_by_cat:
|
|
||||||
# for cat, transactions in months.items():
|
|
||||||
# if cat in Categories.get_income_categories():
|
|
||||||
# income.append(sum(transactions))
|
|
||||||
|
|
||||||
expenses = []
|
|
||||||
for category in expense_categories:
|
|
||||||
expense_value = [
|
|
||||||
-float(sum(t.value for t in month[category]))
|
|
||||||
for month in monthly_transactions_by_cat
|
|
||||||
]
|
|
||||||
expenses.append(expense_value)
|
|
||||||
# expenses = [transactions for months in monthly_transactions_by_cat for cat, transactions in months.items()
|
|
||||||
# if cat not in Categories.get_income_categories() and transactions]
|
|
||||||
for expense in expenses:
|
|
||||||
for i, month in reversed(list(enumerate(t))):
|
|
||||||
if expense[i] < 0:
|
|
||||||
if i - 1 < 0:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
expense[i - 1] += expense[i]
|
|
||||||
expense[i] = 0
|
|
||||||
|
|
||||||
plt.plot(t, income, label="Income")
|
|
||||||
plt.stackplot(t, expenses, labels=expense_categories)
|
|
||||||
plt.legend(bbox_to_anchor=(1, 1), loc="upper left")
|
|
||||||
plt.show()
|
|
||||||
|
|
||||||
income = [
|
|
||||||
sum(
|
|
||||||
t.value
|
|
||||||
for cat, transactions in months.items()
|
|
||||||
for t in transactions
|
|
||||||
if cat in Categories.get_income_categories()
|
|
||||||
)
|
|
||||||
for months in monthly_transactions_by_cat
|
|
||||||
]
|
|
||||||
|
|
||||||
expenses = []
|
|
||||||
for category in expense_categories:
|
|
||||||
expense_value = [
|
|
||||||
-sum(t.value for t in month[category])
|
|
||||||
for month in monthly_transactions_by_cat
|
|
||||||
]
|
|
||||||
expenses.extend(expense_value)
|
|
||||||
|
|
||||||
print(
|
|
||||||
"Income: {}, Expenses: {}, Net = {}".format(
|
|
||||||
sum(income), sum(expenses), sum(income) - sum(expenses)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|||||||
@ -38,35 +38,32 @@ class Categories:
|
|||||||
def get_categories(cls):
|
def get_categories(cls):
|
||||||
return cls.__subclasses__()
|
return cls.__subclasses__()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_categories_names(cls):
|
|
||||||
return [cat.name for cat in cls.get_categories()]
|
|
||||||
|
|
||||||
@classmethod
|
def get_categories():
|
||||||
def get_income_categories(cls):
|
return [cat.name for cat in Categories.get_categories()]
|
||||||
return [cat.name for cat in cls.get_categories() if "Income" in cat.name]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_fixed_expenses(cls):
|
|
||||||
return [
|
|
||||||
Utilities.name,
|
|
||||||
Commute.name,
|
|
||||||
]
|
|
||||||
|
|
||||||
@classmethod
|
def get_income_categories():
|
||||||
def get_variable_expenses(cls):
|
return [cat for cat in get_categories() if "Income" in cat]
|
||||||
|
|
||||||
|
|
||||||
|
def get_fixed_expenses():
|
||||||
|
return [Utilities.name, Commute.name]
|
||||||
|
|
||||||
|
|
||||||
|
def get_required_expenses():
|
||||||
return [Groceries.name]
|
return [Groceries.name]
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_discretionary_expenses(cls):
|
def get_discretionary_expenses():
|
||||||
return [
|
return [
|
||||||
cat.name
|
cat
|
||||||
for cat in cls.get_categories()
|
for cat in get_categories()
|
||||||
if cat.name
|
if cat
|
||||||
not in [
|
not in [
|
||||||
*cls.get_income_categories(),
|
*get_income_categories(),
|
||||||
*cls.get_fixed_expenses(),
|
*get_fixed_expenses(),
|
||||||
*cls.get_variable_expenses(),
|
*get_required_expenses(),
|
||||||
Investment.name,
|
Investment.name,
|
||||||
Null.name,
|
Null.name,
|
||||||
]
|
]
|
||||||
@ -193,7 +190,7 @@ class Travel(Categories):
|
|||||||
name = "Travel"
|
name = "Travel"
|
||||||
regex = [c("ryanair"), c("easyjet"), c("airbnb")]
|
regex = [c("ryanair"), c("easyjet"), c("airbnb")]
|
||||||
not_in_travel = [
|
not_in_travel = [
|
||||||
*Categories.get_income_categories(),
|
*get_income_categories(),
|
||||||
Utilities.name,
|
Utilities.name,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
72
pfbudget/graph.py
Normal file
72
pfbudget/graph.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from dateutil.rrule import MONTHLY, YEARLY
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
from .categories import (
|
||||||
|
get_income_categories,
|
||||||
|
get_fixed_expenses,
|
||||||
|
get_required_expenses,
|
||||||
|
get_discretionary_expenses,
|
||||||
|
)
|
||||||
|
from .transactions import load_transactions, daterange, by_category, by_month
|
||||||
|
|
||||||
|
|
||||||
|
def monthly(state, start, end):
|
||||||
|
transactions = load_transactions(state.data_dir)
|
||||||
|
|
||||||
|
monthly_transactions = by_month(transactions, start, end)
|
||||||
|
monthly_transactions_by_categories = {}
|
||||||
|
income, fixed, required, discretionary = [], [], [], []
|
||||||
|
|
||||||
|
for month, transactions in monthly_transactions.items():
|
||||||
|
monthly_transactions_by_categories[month] = by_category(transactions)
|
||||||
|
income.append(
|
||||||
|
sum(
|
||||||
|
float(t.value)
|
||||||
|
for category, transactions in monthly_transactions_by_categories[
|
||||||
|
month
|
||||||
|
].items()
|
||||||
|
if transactions and category in get_income_categories()
|
||||||
|
for t in transactions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fixed.append(
|
||||||
|
sum(
|
||||||
|
-float(t.value)
|
||||||
|
for category, transactions in monthly_transactions_by_categories[
|
||||||
|
month
|
||||||
|
].items()
|
||||||
|
if transactions and category in get_fixed_expenses()
|
||||||
|
for t in transactions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
required.append(
|
||||||
|
sum(
|
||||||
|
-float(t.value)
|
||||||
|
for category, transactions in monthly_transactions_by_categories[
|
||||||
|
month
|
||||||
|
].items()
|
||||||
|
if transactions and category in get_required_expenses()
|
||||||
|
for t in transactions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
discretionary.append(
|
||||||
|
sum(
|
||||||
|
-float(t.value)
|
||||||
|
for category, transactions in monthly_transactions_by_categories[
|
||||||
|
month
|
||||||
|
].items()
|
||||||
|
if transactions and category in get_discretionary_expenses()
|
||||||
|
for t in transactions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
plt.plot(daterange(start, end, "month"), income, label="Income")
|
||||||
|
plt.stackplot(
|
||||||
|
daterange(start, end, "month"),
|
||||||
|
fixed,
|
||||||
|
required,
|
||||||
|
discretionary,
|
||||||
|
labels=["Fixed", "Required", "Discretionary"],
|
||||||
|
)
|
||||||
|
plt.legend(loc="upper left")
|
||||||
|
plt.show()
|
||||||
@ -4,7 +4,7 @@ import datetime as dt
|
|||||||
import pickle
|
import pickle
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from .categories import Categories, Null, Travel
|
from .categories import Categories, Null, Travel, get_categories
|
||||||
from .transactions import (
|
from .transactions import (
|
||||||
Transaction,
|
Transaction,
|
||||||
load_transactions,
|
load_transactions,
|
||||||
@ -255,7 +255,7 @@ def manual_categorization(state: PFState, transactions: list):
|
|||||||
category = input(f"{transaction.desc()} category: ")
|
category = input(f"{transaction.desc()} category: ")
|
||||||
if category == "quit":
|
if category == "quit":
|
||||||
return
|
return
|
||||||
if category not in Categories.get_categories_names():
|
if category not in get_categories():
|
||||||
print("category doesn't exist")
|
print("category doesn't exist")
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
from csv import reader, writer
|
from csv import reader, writer
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from dateutil.rrule import rrule, MONTHLY, YEARLY
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .categories import Categories
|
from .categories import get_categories
|
||||||
|
|
||||||
COMMENT_TOKEN = "#"
|
COMMENT_TOKEN = "#"
|
||||||
|
|
||||||
@ -124,42 +125,58 @@ class Transactions(list):
|
|||||||
|
|
||||||
return years
|
return years
|
||||||
|
|
||||||
def get_transactions_by_month(self, start=None, end=None):
|
|
||||||
if not start:
|
|
||||||
start = self[0].date
|
|
||||||
if not end:
|
|
||||||
end = self[-1].date
|
|
||||||
|
|
||||||
months = dict()
|
def daterange(start, end, period):
|
||||||
for year, year_transactions in self.get_transactions_by_year(
|
if period == "year":
|
||||||
start, end
|
r = [d.strftime("%Y") for d in rrule(YEARLY, dtstart=start, until=end)]
|
||||||
).items():
|
elif period == "month":
|
||||||
for month in range(1, 13):
|
r = [d.strftime("%b %Y") for d in rrule(MONTHLY, dtstart=start, until=end)]
|
||||||
key = "_".join([str(year), str(month)])
|
|
||||||
months[key] = Transactions(
|
|
||||||
t for t in year_transactions if t.date.month == month
|
|
||||||
)
|
|
||||||
|
|
||||||
# trims last unused months
|
|
||||||
trim = 1
|
|
||||||
for transactions in reversed(months.values()):
|
|
||||||
if transactions:
|
|
||||||
break
|
|
||||||
else:
|
else:
|
||||||
trim += 1
|
raise TransactionError("wrong time period")
|
||||||
while trim := trim - 1:
|
return r
|
||||||
months.popitem()
|
|
||||||
|
|
||||||
return months
|
|
||||||
|
|
||||||
def get_transactions_by_category(self):
|
def by_year(transactions, start=None, end=None) -> dict:
|
||||||
categories = {cat: [] for cat in Categories.get_categories_names()}
|
start = start if start else transactions[0].date
|
||||||
for transaction in self:
|
end = end if end else transactions[-1].date
|
||||||
|
|
||||||
|
yearly_transactions = dict.fromkeys(daterange(start, end, "year"), None)
|
||||||
|
for t in [t for t in transactions if t.date >= start and t.date <= end]:
|
||||||
try:
|
try:
|
||||||
categories[transaction.category].append(transaction)
|
yearly_transactions[t.date.strftime("%Y")].append(t)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
categories[transaction.category] = [transaction]
|
yearly_transactions[t.date.strftime("%Y")] = [t]
|
||||||
return categories
|
except KeyError:
|
||||||
|
raise TransactionError("date invalid")
|
||||||
|
|
||||||
|
return yearly_transactions
|
||||||
|
|
||||||
|
|
||||||
|
def by_month(transactions, start=None, end=None) -> dict:
|
||||||
|
start = start if start else transactions[0].date
|
||||||
|
end = end if end else transactions[-1].date
|
||||||
|
|
||||||
|
monthly_transactions = dict.fromkeys(daterange(start, end, "month"), None)
|
||||||
|
for t in [t for t in transactions if t.date >= start and t.date <= end]:
|
||||||
|
try:
|
||||||
|
monthly_transactions[t.date.strftime("%b %Y")].append(t)
|
||||||
|
except AttributeError:
|
||||||
|
monthly_transactions[t.date.strftime("%b %Y")] = [t]
|
||||||
|
except KeyError:
|
||||||
|
raise TransactionError("date invalid")
|
||||||
|
|
||||||
|
return monthly_transactions
|
||||||
|
|
||||||
|
|
||||||
|
def by_category(transactions) -> dict:
|
||||||
|
transactions_by_category = dict.fromkeys(get_categories(), None)
|
||||||
|
for transaction in transactions:
|
||||||
|
try:
|
||||||
|
transactions_by_category[transaction.category].append(transaction)
|
||||||
|
except AttributeError:
|
||||||
|
transactions_by_category[transaction.category] = [transaction]
|
||||||
|
|
||||||
|
return transactions_by_category
|
||||||
|
|
||||||
|
|
||||||
def load_transactions(data_dir) -> Transactions:
|
def load_transactions(data_dir) -> Transactions:
|
||||||
@ -184,7 +201,7 @@ def save_transactions(data_dir, transactions):
|
|||||||
write_transactions(f, trs)
|
write_transactions(f, trs)
|
||||||
|
|
||||||
|
|
||||||
def read_transactions(filename, encoding="utf-8"):
|
def read_transactions(filename, encoding="utf-8") -> list:
|
||||||
try:
|
try:
|
||||||
with open(filename, newline="", encoding=encoding) as f:
|
with open(filename, newline="", encoding=encoding) as f:
|
||||||
r = reader(f, delimiter="\t")
|
r = reader(f, delimiter="\t")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user