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.
@@ -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