fortuneteller 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +130 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/fortuneteller.gemspec +26 -0
- data/lib/fortuneteller.rb +29 -0
- data/lib/fortuneteller/account.rb +51 -0
- data/lib/fortuneteller/cashflow.rb +18 -0
- data/lib/fortuneteller/inflating_int.rb +13 -0
- data/lib/fortuneteller/job.rb +78 -0
- data/lib/fortuneteller/moment_struct.rb +122 -0
- data/lib/fortuneteller/person.rb +11 -0
- data/lib/fortuneteller/savings_plans.rb +13 -0
- data/lib/fortuneteller/simulator.rb +114 -0
- data/lib/fortuneteller/social_security.rb +71 -0
- data/lib/fortuneteller/spending_strategy.rb +70 -0
- data/lib/fortuneteller/state.rb +121 -0
- data/lib/fortuneteller/transform_base.rb +14 -0
- data/lib/fortuneteller/transform_generator.rb +59 -0
- data/lib/fortuneteller/utils/social_security.rb +261 -0
- data/lib/fortuneteller/version.rb +3 -0
- metadata +112 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module FortuneTeller
|
|
2
|
+
# Representation of a job being simulated with FortuneTeller
|
|
3
|
+
class Job < TransformGenerator
|
|
4
|
+
def initialize(**args)
|
|
5
|
+
super(**args)
|
|
6
|
+
@data.savings_plans = []
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def salary
|
|
10
|
+
@data.salary
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def calculate_take_home_pay
|
|
14
|
+
salary = @data.salary
|
|
15
|
+
plan = @data.savings_plans[0]
|
|
16
|
+
pretax_gross = (salary / 12.0).floor
|
|
17
|
+
pretax_savings = ((plan.percent / 100.0) * pretax_gross).floor
|
|
18
|
+
pretax_adjusted = pretax_gross - pretax_savings
|
|
19
|
+
tax_withholding = (pretax_adjusted * 0.3).floor
|
|
20
|
+
(pretax_adjusted - tax_withholding)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def add_savings_plan(savings_plan)
|
|
24
|
+
@data.savings_plans << savings_plan
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def gen_transforms(from:, to:, plan:)
|
|
30
|
+
fields = gen_transform_fields(@data)
|
|
31
|
+
transforms = []
|
|
32
|
+
transforms.push gen_transform(from, fields) if from.day == 1
|
|
33
|
+
current = from.next_month.at_beginning_of_month
|
|
34
|
+
while current < to
|
|
35
|
+
transforms.push gen_transform(current, fields)
|
|
36
|
+
current = current.next_month.at_beginning_of_month
|
|
37
|
+
end
|
|
38
|
+
transforms
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def gen_transform(date, fields)
|
|
42
|
+
self.class::Transform.new(date: date, holder: holder, **fields)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def gen_transform_fields(data)
|
|
46
|
+
wages = (data.salary / 12.0).floor
|
|
47
|
+
account_credits = {}
|
|
48
|
+
income = { wages: wages, saved: 0, matched: 0, pay_period: :monthly }
|
|
49
|
+
data.savings_plans.each do |p|
|
|
50
|
+
s = (wages * p.percent / 100.0).floor
|
|
51
|
+
income[:saved] += s
|
|
52
|
+
m = (wages * p.match / 100.0).floor
|
|
53
|
+
income[:matched] += m
|
|
54
|
+
account_credits[p.account_id] = s + m
|
|
55
|
+
end
|
|
56
|
+
{ account_credits: account_credits, income: income }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The transforms generated by job
|
|
60
|
+
class Transform < FortuneTeller::TransformBase
|
|
61
|
+
|
|
62
|
+
def initialize(income:, account_credits:, **base)
|
|
63
|
+
@income = income
|
|
64
|
+
@account_credits = account_credits
|
|
65
|
+
super(**base)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def apply_to(state)
|
|
69
|
+
state.apply_w2_income(
|
|
70
|
+
date: date,
|
|
71
|
+
holder: holder,
|
|
72
|
+
income: @income,
|
|
73
|
+
account_credits: @account_credits
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# rubocop:disable Metrics/AbcSize
|
|
2
|
+
module FortuneTeller
|
|
3
|
+
# We use MomentStruct for objects with scheduled changes
|
|
4
|
+
class MomentStruct
|
|
5
|
+
attr_reader :start, :future
|
|
6
|
+
def initialize(**args)
|
|
7
|
+
@start = FortuneTeller::MomentStructBase.new args
|
|
8
|
+
@future = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_reader
|
|
12
|
+
FortuneTeller::MomentStructReader.new(self)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def read(date)
|
|
16
|
+
return @start if @future[0][:date] > date
|
|
17
|
+
future_ct = @future.length
|
|
18
|
+
@future.each_with_index do |f, i|
|
|
19
|
+
if (i == (future_ct - 1)) || (@future[(i + 1)][:date] > date)
|
|
20
|
+
return f[:struct]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read_for_writing(date)
|
|
26
|
+
i = 0
|
|
27
|
+
while i < @future.length
|
|
28
|
+
future = @future[i]
|
|
29
|
+
return future[:struct] if future[:date] == date
|
|
30
|
+
break if future[:date] > date
|
|
31
|
+
i += 1
|
|
32
|
+
end
|
|
33
|
+
current = (i.zero? ? @start : @future[(i - 1)][:struct])
|
|
34
|
+
@future.insert(i, date: date, struct: current.clone)
|
|
35
|
+
@future[i][:struct]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def on(date)
|
|
39
|
+
FortuneTeller::MomentStructMoment.new(self, date)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def insert_at(index, date); end
|
|
45
|
+
|
|
46
|
+
def method_missing(name, *args)
|
|
47
|
+
if name.to_s.end_with?('=') || @start.respond_to?(name)
|
|
48
|
+
@start.send(name, *args)
|
|
49
|
+
else
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def respond_to_missing?(name, include_private = false)
|
|
55
|
+
@start.respond_to?(name) || super
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An enumerator-like reader for efficient ordered reading
|
|
60
|
+
class MomentStructReader
|
|
61
|
+
def initialize(struct)
|
|
62
|
+
@struct = struct
|
|
63
|
+
@struct_ct = @struct.future.length
|
|
64
|
+
@date = nil
|
|
65
|
+
@next_date = (@struct.future.empty? ? nil : @struct.future[0][:date])
|
|
66
|
+
@i = -1
|
|
67
|
+
@object = @struct.start
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def read(date)
|
|
71
|
+
validate_read(date)
|
|
72
|
+
@date = date
|
|
73
|
+
return @object if @next_date.nil? || (date < @next_date)
|
|
74
|
+
((@i + 1)..(@struct_ct - 1)).each do |i|
|
|
75
|
+
next_obj = @struct.future[i + 1]
|
|
76
|
+
next unless next_obj.nil? || (next_obj[:date] > @date)
|
|
77
|
+
@next_date = (next_obj.nil? ? nil : next_obj[:date])
|
|
78
|
+
@i = i
|
|
79
|
+
return @object = @struct.future[i][:struct]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def validate_read(date)
|
|
86
|
+
throw 'Reading backwards' if !@date.nil? && (date < @date)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Utility class returned while scheduling with `on`
|
|
91
|
+
class MomentStructMoment
|
|
92
|
+
DESTRUCTIVE = %i[delete_field update].freeze
|
|
93
|
+
def initialize(struct, date)
|
|
94
|
+
@struct = struct
|
|
95
|
+
@date = date
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def method_missing(name, *args)
|
|
99
|
+
if name.to_s.end_with?('=') || DESTRUCTIVE.include?(name)
|
|
100
|
+
@struct.read_for_writing(@date).send(name, *args)
|
|
101
|
+
else
|
|
102
|
+
obj = @struct.read(@date)
|
|
103
|
+
if name.to_s.end_with?('=') || obj.respond_to?(name)
|
|
104
|
+
@struct.read(@date).send(name, *args)
|
|
105
|
+
else
|
|
106
|
+
super
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def respond_to_missing?(name, include_private = false)
|
|
112
|
+
@struct.start.respond_to?(name) || super
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Underlying structure for method accessible hash
|
|
117
|
+
class MomentStructBase < OpenStruct
|
|
118
|
+
def update(**kargs)
|
|
119
|
+
kargs.each { |k, v| self[k] = v }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module FortuneTeller
|
|
2
|
+
# Representation of a person being simulated with FortuneTeller
|
|
3
|
+
class Person
|
|
4
|
+
attr_reader :gender, :birthday, :filing_status
|
|
5
|
+
def initialize(gender:, birthday:, filing_status:)
|
|
6
|
+
@gender = gender
|
|
7
|
+
@birthday = birthday
|
|
8
|
+
@filing_status = filing_status
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module FortuneTeller
|
|
2
|
+
module SavingsPlans
|
|
3
|
+
# Represents a savings plan based on percent of salary
|
|
4
|
+
class Percent
|
|
5
|
+
attr_reader :account_id, :percent, :match
|
|
6
|
+
def initialize(account_id:, percent:, match: 0)
|
|
7
|
+
@account_id = account_id
|
|
8
|
+
@percent = percent
|
|
9
|
+
@match = match
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module FortuneTeller
|
|
2
|
+
# Simulates personal finances.
|
|
3
|
+
class Simulator
|
|
4
|
+
attr_accessor :primary, :partner, :spending_strategy
|
|
5
|
+
|
|
6
|
+
def initialize
|
|
7
|
+
@objects = {}
|
|
8
|
+
@available_keys = ('AA'..'ZZ').to_a
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
%i[account job social_security].each do |object_type|
|
|
12
|
+
define_method :"add_#{object_type}" do |object|
|
|
13
|
+
add_object type: object_type, object: object
|
|
14
|
+
end
|
|
15
|
+
define_method :"#{object_type.to_s.pluralize}" do
|
|
16
|
+
retrieve_objects type: object_type
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def calculate_take_home_pay(_date)
|
|
21
|
+
# TODO: make this date dependant
|
|
22
|
+
@objects[:job].each_value.map(&:calculate_take_home_pay).sum
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def simulate
|
|
26
|
+
validate_plan!
|
|
27
|
+
end_date = first_day_of_year((youngest_birthday.year + 101))
|
|
28
|
+
states = [initial_state]
|
|
29
|
+
while states.last.date != end_date
|
|
30
|
+
states << simulate_next_state(states.last)
|
|
31
|
+
end
|
|
32
|
+
puts states.as_json
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inflating_int(int, start_date = nil)
|
|
36
|
+
FortuneTeller::InflatingInt.new(
|
|
37
|
+
int: int,
|
|
38
|
+
start_date: (start_date.nil? ? Date.today : start_date)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def simulate_next_state(last)
|
|
45
|
+
end_date = first_day_of_year((last.date.year + 1))
|
|
46
|
+
transforms = static_transforms(from: last.date, to: end_date)
|
|
47
|
+
state = evolve_state(last, transforms, end_date)
|
|
48
|
+
extra = spending_strategy.resolution_transforms(state: state)
|
|
49
|
+
unless extra.empty?
|
|
50
|
+
transforms.concat(extra).sort!
|
|
51
|
+
state = evolve_state(last, transforms, end_date)
|
|
52
|
+
end
|
|
53
|
+
state
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def first_day_of_year(year)
|
|
57
|
+
Date.new(year, 1, 1)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def evolve_state(state, transforms, to)
|
|
61
|
+
state = state.init_next
|
|
62
|
+
transforms.each { |t| t.apply_to(state) }
|
|
63
|
+
state.pass_time(to: to)
|
|
64
|
+
state
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def static_transforms(from:, to:)
|
|
68
|
+
transforms = []
|
|
69
|
+
%i[job social_security].each do |object_type|
|
|
70
|
+
@objects[object_type].each_value { |o| transforms.push(o) }
|
|
71
|
+
end
|
|
72
|
+
transforms = transforms.map do |x|
|
|
73
|
+
x.bounded_gen_transforms(from: from, to: to, plan: self)
|
|
74
|
+
end
|
|
75
|
+
transforms.reduce([], :concat).sort
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def youngest_birthday
|
|
79
|
+
return @primary.birthday if no_partner?
|
|
80
|
+
[@primary.birthday, @partner.birthday].min
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def no_partner?
|
|
84
|
+
@partner.nil?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def no_primary?
|
|
88
|
+
@primary.nil?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def initial_state
|
|
92
|
+
s = FortuneTeller::State.new(start_date: Date.today)
|
|
93
|
+
@objects[:account].each { |k, a| s.add_account(key: k, account: a) }
|
|
94
|
+
s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_plan!
|
|
98
|
+
throw 'Please assign primary' if no_primary?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def add_object(type:, object:)
|
|
102
|
+
key = @available_keys.shift
|
|
103
|
+
@objects[type] ||= {}
|
|
104
|
+
@objects[type][key] = object
|
|
105
|
+
key
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def retrieve_objects(type:)
|
|
109
|
+
@objects[type]
|
|
110
|
+
rescue NoMethodError
|
|
111
|
+
{}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module FortuneTeller
|
|
2
|
+
# Represents a persons social security strategy
|
|
3
|
+
class SocialSecurity < TransformGenerator
|
|
4
|
+
attr_reader :pia
|
|
5
|
+
def initialize(fra_pia: nil, **base)
|
|
6
|
+
@fra_pia = fra_pia
|
|
7
|
+
super(**base)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def gen_transforms(from:, to:, plan:)
|
|
13
|
+
benefit = get_benefit_amount(plan: plan).on(from)
|
|
14
|
+
transforms = []
|
|
15
|
+
transforms.push gen_transform(from, benefit) if from.day == 1
|
|
16
|
+
current = from.next_month.at_beginning_of_month
|
|
17
|
+
while current < to
|
|
18
|
+
transforms.push gen_transform(current, benefit)
|
|
19
|
+
current = current.next_month.at_beginning_of_month
|
|
20
|
+
end
|
|
21
|
+
transforms
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def gen_transform(date, benefit)
|
|
25
|
+
self.class::Transform.new(date: date, holder: holder, benefit: benefit)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def get_benefit_amount(plan:)
|
|
29
|
+
return @benefit unless @benefit.nil?
|
|
30
|
+
|
|
31
|
+
if @start_date.day == 1
|
|
32
|
+
start_month = @start_date
|
|
33
|
+
else
|
|
34
|
+
start_month = @start_date.next_month.at_beginning_of_month
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
calc = FortuneTeller::Utils::SocialSecurity.new(
|
|
38
|
+
dob: plan.send(@holder).birthday,
|
|
39
|
+
start_month: start_month
|
|
40
|
+
)
|
|
41
|
+
if not @fra_pia.nil?
|
|
42
|
+
calc.fra_pia = @fra_pia
|
|
43
|
+
else
|
|
44
|
+
current_salary = plan.jobs.values.keep_if { |j| j.holder==@holder }.map(&:salary).sum
|
|
45
|
+
puts "CURRENT SAL #{@holder} #{current_salary}"
|
|
46
|
+
calc.estimate_pia(current_salary: current_salary, annual_raise: 0.98)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
benefit = calc.calculate_benefit
|
|
50
|
+
puts "BENEFIT #{benefit}"
|
|
51
|
+
@benefit = plan.inflating_int(benefit, start_month)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The transforms generated by social security
|
|
55
|
+
class Transform < FortuneTeller::TransformBase
|
|
56
|
+
|
|
57
|
+
def initialize(benefit:, **base)
|
|
58
|
+
@benefit = benefit
|
|
59
|
+
super(**base)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def apply_to(state)
|
|
63
|
+
state.apply_ss_income(
|
|
64
|
+
date: date,
|
|
65
|
+
holder: holder,
|
|
66
|
+
income: {ss: @benefit},
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module FortuneTeller
|
|
2
|
+
# The spending strategy being used when simulating with FortuneTeller
|
|
3
|
+
class SpendingStrategy < TransformGenerator
|
|
4
|
+
def resolution_transforms(state:)
|
|
5
|
+
transforms = []
|
|
6
|
+
reader = state_reader
|
|
7
|
+
12.times do |i|
|
|
8
|
+
date = determine_transform_date(state, i)
|
|
9
|
+
data = reader.read(date)
|
|
10
|
+
next unless data.strategy == :exact
|
|
11
|
+
amount = data.amount.on(date) - determine_take_home_pay(state, i)
|
|
12
|
+
transforms.push(gen_transform(date, amount)) if amount.positive?
|
|
13
|
+
end
|
|
14
|
+
transforms
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def gen_transform(date, amount)
|
|
20
|
+
self.class::Transform.new(
|
|
21
|
+
holder: :joint,
|
|
22
|
+
date: date,
|
|
23
|
+
withdrawal: amount
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def determine_transform_date(state, index)
|
|
28
|
+
Date.new(state.from.year, (index + 1), 1)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def determine_take_home_pay(state, index)
|
|
32
|
+
c = state.cashflow
|
|
33
|
+
merged = state.class.cashflow_base
|
|
34
|
+
merged.merge!(c[:primary][index]).merge!(c[:partner][index])
|
|
35
|
+
merged.line_items[:take_home_pay]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The transforms generated by job
|
|
39
|
+
class Transform < FortuneTeller::TransformBase
|
|
40
|
+
attr_reader :withdrawal
|
|
41
|
+
def initialize(holder:, date:, withdrawal:)
|
|
42
|
+
@withdrawal = withdrawal
|
|
43
|
+
super(holder: holder, date: date)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def apply_to(state)
|
|
47
|
+
withdrawn = 0
|
|
48
|
+
state.accounts.reject { |_k, a| a.balance.zero? }.each do |k, a|
|
|
49
|
+
a.pass_time(to: @date)
|
|
50
|
+
withdrawal = [a.balance, (@withdrawal - withdrawn)].min
|
|
51
|
+
next if withdrawal.zero?
|
|
52
|
+
make_withdrawal(state, a.account_ref.holder, k, withdrawal)
|
|
53
|
+
withdrawn += withdrawal
|
|
54
|
+
break if withdrawn == @withdrawal
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def make_withdrawal(state, holder, account, amount)
|
|
61
|
+
state.apply_pretax_savings_withdrawal(
|
|
62
|
+
date: @date,
|
|
63
|
+
holder: holder,
|
|
64
|
+
source: account,
|
|
65
|
+
amount: amount
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|