From 463700e3f5e2af6ab2d1d4bae2088472f6578cb8 Mon Sep 17 00:00:00 2001 From: Wayne Scott Date: Sat, 27 Jan 2024 15:59:48 -0500 Subject: [PATCH 1/2] Validate input file and complain about any unused data items --- src/fplan/fplan.py | 220 +++++++++++++++++++++++++++------------------ 1 file changed, 133 insertions(+), 87 deletions(-) diff --git a/src/fplan/fplan.py b/src/fplan/fplan.py index 44ad0ca..d224de0 100755 --- a/src/fplan/fplan.py +++ b/src/fplan/fplan.py @@ -38,65 +38,92 @@ class Data: def load_file(self, file): with open(file) as conffile: d = tomllib.loads(conffile.read()) - self.i_rate = 1 + d.get('inflation', 0) / 100 # inflation rate: 2.5 -> 1.025 - self.r_rate = 1 + d.get('returns', 6) / 100 # invest rate: 6 -> 1.06 - - self.startage = d['startage'] - self.endage = d.get('endage', max(96, self.startage+5)) - - # 2023 tax table (could predict it moves with inflation?) - # married joint at the moment, can override in config file - default_taxrates = [[0, 10], - [22000, 12], - [89450 , 22], - [190750, 24], - [364200, 32], - [462500, 35], - [693750, 37]] - default_stded = 27700 - tmp_taxrates = default_taxrates - if 'taxes' in d: - tmp_taxrates = d['taxes'].get('taxrates', default_taxrates) - self.stded = d['taxes'].get('stded', default_stded) - self.state_tax = d['taxes'].get('state_rate', 0) - self.state_cg_tax = d['taxes'].get('state_cg_rate', self.state_tax) - else: - self.stded = default_stded - self.state_tax = 0 - self.state_cg_tax = 0 - # add fake level and switch to decimals - tmp_taxrates[:0] = [[0, 0]] - self.taxrates = [[x,y/100.0] for (x,y) in tmp_taxrates] - self.state_tax = self.state_tax / 100.0 - self.state_cg_tax = self.state_cg_tax / 100.0 - - if 'prep' in d: - self.workyr = d['prep']['workyears'] - self.maxsave = d['prep']['maxsave'] - self.maxsave_inflation = d['prep'].get('inflation', True) - self.worktax = 1 + d['prep'].get('tax_rate', 25)/100 - else: - self.workyr = 0 - self.retireage = self.startage + self.workyr - self.numyr = self.endage - self.retireage - - self.aftertax = d.get('aftertax', {'bal': 0}) - if 'basis' not in self.aftertax: - self.aftertax['basis'] = 0 - - self.IRA = d.get('IRA', {'bal': 0}) - if 'maxcontrib' not in self.IRA: - self.IRA['maxcontrib'] = 19500 + 7000*2 + try: + self.i_rate = 1 + d.pop('inflation', 0) / 100 # inflation rate: 2.5 -> 1.025 + self.r_rate = 1 + d.pop('returns', 6) / 100 # invest rate: 6 -> 1.06 + + self.startage = d.pop('startage') + self.endage = d.pop('endage', max(96, self.startage+5)) + + # 2023 tax table (could predict it moves with inflation?) + # married joint at the moment, can override in config file + default_taxrates = [[0, 10], + [22000, 12], + [89450 , 22], + [190750, 24], + [364200, 32], + [462500, 35], + [693750, 37]] + default_stded = 27700 + tmp_taxrates = default_taxrates + taxes = d.pop('taxes', {}) + tmp_taxrates = taxes.pop('taxrates', default_taxrates) + self.stded = taxes.pop('stded', default_stded) + self.state_tax = taxes.pop('state_rate', 0) + self.state_cg_tax = taxes.pop('state_cg_rate', self.state_tax) + for k,v in taxes.items(): + print("unknown config taxes.{} = {}".format(k, v)) + + # add fake level and switch to decimals + tmp_taxrates[:0] = [[0, 0]] + self.taxrates = [[x,y/100.0] for (x,y) in tmp_taxrates] + self.state_tax = self.state_tax / 100.0 + self.state_cg_tax = self.state_cg_tax / 100.0 + + prep = d.pop('prep', {}) + self.workyr = prep.pop('workyears', 0) + self.maxsave = prep.pop('maxsave', 0) + self.maxsave_inflation = prep.pop('inflation', True) + self.worktax = 1 + prep.pop('tax_rate', 25)/100 + for k,v in prep.items(): + print("unknown config prep.{} = {}".format(k, v)) + + self.retireage = self.startage + self.workyr + self.numyr = self.endage - self.retireage + + self.aftertax = {} + tmp = d.pop('aftertax', {'bal': 0}) + try: + self.aftertax['bal'] = tmp.pop('bal') + self.aftertax['basis'] = tmp.pop('basis', 0) + except KeyError as bad: + print("missing key {} in aftertax = {}".format(bad, tmp)) + else: + for k,v in tmp.items(): + print("unknown config aftertax.{} = {}".format(k, v)) + + self.IRA = {} + tmp = d.pop('IRA', {'bal': 0}) + try: + self.IRA['bal'] = tmp.pop('bal') + self.IRA['maxcontrib'] = tmp.pop('maxcontrib', 19500 + 7000*2) + except KeyError as bad: + print("missing key {} in IRA = {}".format(bad, tmp)) + else: + for k,v in tmp.items(): + print("unknown config IRA.{} = {}".format(k, v)) + + self.roth = {} + tmp = d.pop('roth', {'bal': 0}) + try: + self.roth['bal'] = tmp.pop('bal') + self.roth['maxcontrib'] = tmp.pop('maxcontrib', 7000*2) + self.roth['contributions'] = tmp.pop('contributions', []) + except KeyError as bad: + print("missing key {} in roth = {}".format(bad, tmp)) + else: + for k,v in tmp.items(): + print("unknown config roth.{} = {}".format(k, v)) - self.roth = d.get('roth', {'bal': 0}) - if 'maxcontrib' not in self.roth: - self.roth['maxcontrib'] = 7000*2 - if 'contributions' not in self.roth: - self.roth['contributions'] = [] + self.parse_expenses(d) + self.sepp_end = max(5, 59-self.retireage) # first year you can spend IRA reserved for SEPP + self.sepp_ratio = 25 # money per-year from SEPP (bal/ratio) + except KeyError as bad: + print("missing {} in config file".format(bad)) + raise - self.parse_expenses(d) - self.sepp_end = max(5, 59-self.retireage) # first year you can spend IRA reserved for SEPP - self.sepp_ratio = 25 # money per-year from SEPP (bal/ratio) + for k,v in d.items(): + print("unknown config {} = {}".format(k, v)) def parse_expenses(self, S): """ Return array of income/expense per year """ @@ -104,40 +131,59 @@ def parse_expenses(self, S): EXP = [0] * self.numyr TAX = [0] * self.numyr - for k,v in S.get('expense', {}).items(): - for age in agelist(v['age']): - year = age - self.retireage - if year < 0: - continue - elif year >= self.numyr: - break - else: - amount = v['amount'] - if v.get('inflation'): - amount *= self.i_rate ** (year + self.workyr) - EXP[year] += amount - - for k,v in S.get('income', {}).items(): - for age in agelist(v['age']): - year = age - self.retireage - if year < 0: - continue - elif year >= self.numyr: - break - else: - amount = v['amount'] - if v.get('inflation'): - amount *= self.i_rate ** (year + self.workyr) - INC[year] += amount - if v.get('tax'): - TAX[year] += amount + for k,v in S.pop('expense', {}).items(): + try: + amount = v.pop('amount') + inflation = v.pop('inflation', False) + + for age in agelist(v.pop('age')): + year = age - self.retireage + if year < 0: + continue + elif year >= self.numyr: + break + else: + val = amount + if inflation: + val *= self.i_rate ** (year + self.workyr) + EXP[year] += val + except KeyError as bad: + print("Missing key {} from expense.{}".format(bad, k)) + else: + for k2,v2 in v.items(): + print("unknown config expense.{}.{} = {}".format(k, k2, v2)) + + for k,v in S.pop('income', {}).items(): + try: + amount = v.pop('amount') + inflation = v.pop('inflation', False) + tax = v.pop('tax', False) + + for age in agelist(v.pop('age')): + year = age - self.retireage + if year < 0: + continue + elif year >= self.numyr: + break + else: + val = amount + if inflation: + val *= self.i_rate ** (year + self.workyr) + INC[year] += val + if tax: + TAX[year] += val; + except KeyError as bad: + print("Missing key {} from income.{}".format(bad, k)) + else: + for k2,v2 in v.items(): + print("unknown config income.{}.{} = {}".format(k, k2, v2)) self.income = INC self.expenses = EXP self.taxed = TAX # Minimize: c^T * x # Subject to: A_ub * x <= b_ub -#vars: money, per year(savings, ira, roth, ira2roth) (193 vars) +#vars: money, sepp, per year(savings, ira, roth, ira2roth) #all vars positive def solve(args): # optimize this poly (we want to maximize the money we can spend) @@ -198,7 +244,7 @@ def solve(args): (taxbase, last_cut, last_rate) = (0, 0, 0) for (cut, rate) in S.taxrates: if rate > 0: # if below fed std_ded, assumes tax 0% - rate += S.state_tax + rate += S.state_tax taxbase += (cut - last_cut) * last_rate * i_mul (last_cut, last_rate) = (cut, rate) base = taxbase @@ -221,7 +267,7 @@ def solve(args): row[n0+vper*year+2] = -1 # Roth row[n0+vper*year+3] = rate + 0.0001 # tax on Roth conversion - # + 0.0001 hack so that conversions + # + 0.0001 hack so that conversions # look slightly inferior to withdrawals A += [row] From e68f2e32cabc03cd45c92424f90dcefc613150f2 Mon Sep 17 00:00:00 2001 From: Wayne Scott Date: Sun, 28 Jan 2024 17:01:32 -0500 Subject: [PATCH 2/2] add helper function --- src/fplan/fplan.py | 95 ++++++++++++++++++++-------------------------- 1 file changed, 42 insertions(+), 53 deletions(-) diff --git a/src/fplan/fplan.py b/src/fplan/fplan.py index d224de0..43b1d40 100755 --- a/src/fplan/fplan.py +++ b/src/fplan/fplan.py @@ -34,6 +34,15 @@ def agelist(str): else: raise Exception("Bad age " + str) +def subtable(data, table, keys): + tmp = data.pop(table, {}) + ret = {} + for k,defval in keys.items(): + ret[k] = tmp.pop(k, defval) + for k,v in tmp.items(): + print("unknown config {}.{} = {}".format(table, k, v)) + return ret + class Data: def load_file(self, file): with open(file) as conffile: @@ -54,66 +63,46 @@ def load_file(self, file): [364200, 32], [462500, 35], [693750, 37]] - default_stded = 27700 - tmp_taxrates = default_taxrates - taxes = d.pop('taxes', {}) - tmp_taxrates = taxes.pop('taxrates', default_taxrates) - self.stded = taxes.pop('stded', default_stded) - self.state_tax = taxes.pop('state_rate', 0) - self.state_cg_tax = taxes.pop('state_cg_rate', self.state_tax) - for k,v in taxes.items(): - print("unknown config taxes.{} = {}".format(k, v)) - + taxes = subtable(d, 'taxes', + { 'taxrates': default_taxrates, + 'stded': 27700, + 'state_rate': 0, + 'state_cg_rate': -1, + }) + self.stded = taxes['stded'] + tmp_taxrates = taxes['taxrates'] # add fake level and switch to decimals tmp_taxrates[:0] = [[0, 0]] self.taxrates = [[x,y/100.0] for (x,y) in tmp_taxrates] - self.state_tax = self.state_tax / 100.0 - self.state_cg_tax = self.state_cg_tax / 100.0 - - prep = d.pop('prep', {}) - self.workyr = prep.pop('workyears', 0) - self.maxsave = prep.pop('maxsave', 0) - self.maxsave_inflation = prep.pop('inflation', True) - self.worktax = 1 + prep.pop('tax_rate', 25)/100 - for k,v in prep.items(): - print("unknown config prep.{} = {}".format(k, v)) + self.stded = taxes['stded'] + self.state_tax = taxes['state_rate'] / 100.0 + self.state_cg_tax = taxes['state_cg_rate'] / 100.0 + if self.state_cg_tax == -1: + self.state_cg_tax = self.state_tax + + prep = subtable(d, 'prep', + { 'workyears': 0, + 'maxsave': 0, + 'inflation': True, + 'tax_rate': 25 }) + self.workyr = prep['workyears'] + self.maxsave = prep['maxsave'] + self.maxsave_inflation = prep['inflation'] + self.worktax = 1 + prep['tax_rate'] / 100.0 self.retireage = self.startage + self.workyr self.numyr = self.endage - self.retireage - self.aftertax = {} - tmp = d.pop('aftertax', {'bal': 0}) - try: - self.aftertax['bal'] = tmp.pop('bal') - self.aftertax['basis'] = tmp.pop('basis', 0) - except KeyError as bad: - print("missing key {} in aftertax = {}".format(bad, tmp)) - else: - for k,v in tmp.items(): - print("unknown config aftertax.{} = {}".format(k, v)) - - self.IRA = {} - tmp = d.pop('IRA', {'bal': 0}) - try: - self.IRA['bal'] = tmp.pop('bal') - self.IRA['maxcontrib'] = tmp.pop('maxcontrib', 19500 + 7000*2) - except KeyError as bad: - print("missing key {} in IRA = {}".format(bad, tmp)) - else: - for k,v in tmp.items(): - print("unknown config IRA.{} = {}".format(k, v)) - - self.roth = {} - tmp = d.pop('roth', {'bal': 0}) - try: - self.roth['bal'] = tmp.pop('bal') - self.roth['maxcontrib'] = tmp.pop('maxcontrib', 7000*2) - self.roth['contributions'] = tmp.pop('contributions', []) - except KeyError as bad: - print("missing key {} in roth = {}".format(bad, tmp)) - else: - for k,v in tmp.items(): - print("unknown config roth.{} = {}".format(k, v)) + self.aftertax = subtable(d, 'aftertax', + { 'bal': 0, + 'basis': 0 }) + self.IRA = subtable(d, 'IRA', + { 'bal': 0, + 'maxcontrib': 19500 + 7000*2 }) + self.roth = subtable(d, 'roth', + { 'bal': 0, + 'maxcontrib': 7000*2, + 'contributions': []}) self.parse_expenses(d) self.sepp_end = max(5, 59-self.retireage) # first year you can spend IRA reserved for SEPP