rebalance 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,6 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ spec/spec.watchr
6
+ tags
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in rebalance.gemspec
4
+ gemspec
@@ -0,0 +1,100 @@
1
+ Rebalance
2
+ ====
3
+
4
+ I don't fancy myself a savvy investor. Instead, I like to stick to basic rules
5
+ and let the magic of time and markets slowly grow my investments. One way to do
6
+ this is investing in passive index funds and sticking to a pre-defined asset allocation.
7
+ Then, it's just a matter of rebalancing every once in a while to make sure the
8
+ allocations are in line with the goal. Sounds pretty simple, right?
9
+
10
+ Well, it gets slightly more complex you have multiple accounts with multiple funds
11
+ in each. Trying to figure out the best way rebalance across all of these accounts
12
+ can be frustrating and eventually led me to write this gem. You feed it all of your
13
+ investment information (one account or many) and it will tell you exactly what to
14
+ buy and sell in order to bring your asset allocation back into line.
15
+
16
+ ## Install ####################################################################
17
+
18
+ $ gem install rebalance
19
+
20
+ ## Usage ######################################################################
21
+
22
+ Here's a basic example of a script using rebalance:
23
+
24
+ require 'rubygems'
25
+ require 'rebalance'
26
+
27
+ target = Rebalance::Target.new do
28
+ asset_class 35, 'US Total Market'
29
+ asset_class 18, 'Pacific'
30
+ asset_class 18, 'Europe'
31
+ asset_class 8, 'Real Estate'
32
+ asset_class 8, 'Total Bond Market'
33
+ asset_class 8, 'Inflation-Protected Bonds'
34
+ asset_class 5, 'US Small Cap Value'
35
+ end
36
+
37
+ wifes_roth = Rebalance::Account.new "Wife's Roth" do
38
+ fund 'VIPSX', 'Inflation-Protected Bonds', 200, 14.36
39
+ fund 'VBMFX', 'Total Bond Market', 500, 11.03
40
+ end
41
+
42
+ my_roth = Rebalance::Account.new 'My Roth' do
43
+ fund 'VISVX', 'US Small Cap Value', 200, 13.96
44
+ fund 'VGSIX', 'Real Estate', 100, 17.30
45
+ fund 'VTSAX', 'US Total Market', 300, 29.02
46
+ fund 'VPACX', 'Pacific', 300, 8.96
47
+ fund 'VEURX', 'Europe', 50, 21.46
48
+ end
49
+
50
+ my_traditional_ira = Rebalance::Account.new 'My Traditional IRA' do
51
+ fund 'VTSAX', 'US Total Market', 500, 29.02
52
+ fund 'VMMXX', 'Cash', 2500, 1.00
53
+ end
54
+
55
+ accounts_to_rebalance = Rebalance::Rebalancer.new(target, wifes_roth, my_roth, my_traditional_ira)
56
+ accounts_to_rebalance.rebalance
57
+ accounts_to_rebalance.print_results
58
+
59
+ This will output:
60
+
61
+ +--------------------------------------------------------------------------------------------------+
62
+ | Account | Fund | Asset Class | Price | Amount To Buy | Amount To Sell |
63
+ +--------------------------------------------------------------------------------------------------+
64
+ | Wife's Roth | VIPSX | Inflation-Protected Bonds | $14.36 | $461.05 | $0.00 |
65
+ | Wife's Roth | VBMFX | Total Bond Market | $11.03 | $0.00 | $4,044.40 |
66
+ | Wife's Roth | VEURX | Europe | $21.46 | $3,583.35 | $0.00 |
67
+ | My Roth | VISVX | US Small Cap Value | $13.96 | $0.00 | $646.31 |
68
+ | My Roth | VGSIX | Real Estate | $17.30 | $1,699.74 | $0.00 |
69
+ | My Roth | VTSAX | US Total Market | $29.02 | $0.00 | $10,880.91 |
70
+ | My Roth | VPACX | Pacific | $8.96 | $4,962.98 | $0.00 |
71
+ | My Roth | VEURX | Europe | $21.46 | $2,973.13 | $0.00 |
72
+ | My Roth | VBMFX | Total Bond Market | $11.03 | $1,891.37 | $0.00 |
73
+ | My Traditional IRA | VTSAX | US Total Market | $29.02 | $2,500.01 | $0.00 |
74
+ | My Traditional IRA | VMMXX | Cash | $1.00 | $0.00 | $2,500.00 |
75
+ +--------------------------------------------------------------------------------------------------+
76
+
77
+ ## License ###################################################################
78
+
79
+ (The MIT License)
80
+
81
+ Copyright (c) 2011 Bryce Thornton
82
+
83
+ Permission is hereby granted, free of charge, to any person obtaining
84
+ a copy of this software and associated documentation files (the
85
+ 'Software'), to deal in the Software without restriction, including
86
+ without limitation the rights to use, copy, modify, merge, publish,
87
+ distribute, sublicense, and/or sell copies of the Software, and to
88
+ permit persons to whom the Software is furnished to do so, subject to
89
+ the following conditions:
90
+
91
+ The above copyright notice and this permission notice shall be
92
+ included in all copies or substantial portions of the Software.
93
+
94
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
95
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
96
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
97
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
98
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
99
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
100
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,10 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new do |t|
7
+ t.libs.push "lib"
8
+ t.test_files = FileList['spec/rebalance/*_spec.rb']
9
+ t.verbose = true
10
+ end
@@ -0,0 +1,4 @@
1
+ require 'rebalance/target'
2
+ require 'rebalance/account'
3
+ require 'rebalance/fund'
4
+ require 'rebalance/rebalancer'
@@ -0,0 +1,42 @@
1
+ module Rebalance
2
+ class Account
3
+ attr_accessor :name, :funds, :rebalanced_funds
4
+
5
+ def initialize(name, &block)
6
+ self.name = name
7
+ self.funds = {}
8
+ self.rebalanced_funds = {}
9
+
10
+ instance_eval &block
11
+ end
12
+
13
+ def fund(symbol, asset_class, shares, price=nil)
14
+ new_fund = Fund.new(symbol, asset_class, shares, price)
15
+ self.funds[new_fund.symbol] = new_fund
16
+ end
17
+
18
+ def total_value
19
+ total_value = 0
20
+ funds.each do |symbol, fund|
21
+ total_value = total_value + fund.value
22
+ end
23
+ total_value
24
+ end
25
+
26
+ def calculate_percentages
27
+ percentages = {}
28
+ funds.each do |symbol, fund|
29
+ percentages[fund.symbol] = (fund.value / total_value * 100).round(2)
30
+ end
31
+ percentages
32
+ end
33
+
34
+ def find_by_asset_class(asset_class)
35
+ asset_class_funds = []
36
+ funds.each do |symbol, fund|
37
+ asset_class_funds << fund if fund.asset_class == asset_class
38
+ end
39
+ asset_class_funds
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,45 @@
1
+ require 'open-uri'
2
+ require 'json'
3
+
4
+ module Rebalance
5
+ class Fund
6
+ attr_accessor :symbol, :name, :asset_class, :shares, :price
7
+
8
+ def initialize(symbol, asset_class, shares, price=nil)
9
+ self.symbol = symbol
10
+ self.asset_class = asset_class
11
+ self.shares = shares
12
+
13
+ # Lookup value if not passed in
14
+ if price.nil?
15
+ price = get_fund_price(symbol)
16
+ end
17
+
18
+ self.price = price
19
+ end
20
+
21
+ def get_fund_price(symbol)
22
+ yql_url = "http://query.yahooapis.com/v1/public/yql?q=select%20*%20from%20yahoo.finance.quotes%20where%20symbol%20in%20(%22#{symbol}%22)&env=store://datatables.org/alltableswithkeys&format=json"
23
+ response = open(yql_url)
24
+ parsed_response = JSON.parse(response.read)
25
+
26
+ if !parsed_response['query']['results']['quote']['ErrorIndicationreturnedforsymbolchangedinvalid'].nil?
27
+ raise "The symbol #{symbol} can't be found"
28
+ end
29
+
30
+ price = parsed_response['query']['results']['quote']['LastTradePriceOnly']
31
+ price = price.to_f
32
+
33
+ # Cash funds don't always return a price, so just assume $1.00
34
+ if asset_class == "Cash"
35
+ price = 1.00
36
+ end
37
+
38
+ price
39
+ end
40
+
41
+ def value
42
+ (price * shares).round(2)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,306 @@
1
+ require 'ruport'
2
+
3
+ module Rebalance
4
+ class Rebalancer
5
+ attr_accessor :accounts,
6
+ :target,
7
+ :acceptable_asset_class_delta,
8
+ :rebalanced_shares,
9
+ :rebalanced_values,
10
+ :rebalanced_share_difference,
11
+ :rebalanced_value_difference
12
+
13
+ def initialize(target, *accounts)
14
+ @target = target
15
+ # make sure we add empty funds where appropriate before we start rebalancing
16
+ # in order to allow all accounts/funds to balance properly
17
+ @accounts = accounts
18
+
19
+ @acceptable_asset_class_delta = 1.0
20
+
21
+ initialize_result_values
22
+ end
23
+
24
+ def initialize_result_values
25
+ @rebalanced_shares = {}
26
+ @rebalanced_values = {}
27
+ @rebalanced_share_difference = {}
28
+ @rebalanced_value_difference = {}
29
+
30
+ if @accounts.size > 1
31
+ @accounts.each do |account|
32
+ @rebalanced_shares[account.name] = {}
33
+ @rebalanced_values[account.name] = {}
34
+ @rebalanced_share_difference[account.name] = {}
35
+ @rebalanced_value_difference[account.name] = {}
36
+ end
37
+ end
38
+ end
39
+
40
+ def rebalance
41
+ initialize_result_values
42
+
43
+ if @accounts.size == 1
44
+ single_account_rebalance
45
+ elsif @accounts.size > 1
46
+ multiple_account_rebalance
47
+ end
48
+ end
49
+
50
+ def calculate_rebalanced_asset_class_values
51
+ # First, create a hash of symbols and their asset classes
52
+ symbol_asset_class_hash = {}
53
+ @accounts.each do |account|
54
+ account.funds.each do |symbol, fund|
55
+ symbol_asset_class_hash[symbol] = fund.asset_class
56
+ end
57
+ end
58
+
59
+ values = {}
60
+ if @accounts.size > 1
61
+ @rebalanced_values.each do |account, symbol_value_hash|
62
+ symbol_value_hash.each do |symbol, value|
63
+ asset_class = symbol_asset_class_hash[symbol]
64
+ values[asset_class] = 0 if values[asset_class].nil?
65
+ values[asset_class] += value
66
+ end
67
+ end
68
+ else
69
+ @rebalanced_values.each do |symbol, value|
70
+ asset_class = symbol_asset_class_hash[symbol]
71
+ values[asset_class] = 0 if values[asset_class].nil?
72
+ values[asset_class] += value
73
+ end
74
+ end
75
+ values
76
+ end
77
+
78
+ def funds_by_asset_class
79
+ asset_class_hash = {}
80
+ @accounts.each do |account|
81
+ account.funds.each do |symbol, fund|
82
+ asset_class_hash[fund.asset_class] = [] if asset_class_hash[fund.asset_class].nil?
83
+ asset_class_hash[fund.asset_class] << fund if !asset_class_hash[fund.asset_class].include?(fund)
84
+ end
85
+ end
86
+ asset_class_hash
87
+ end
88
+
89
+ # Print a pretty table of our results
90
+ def results
91
+ data = Table('Account','Fund','Asset Class', 'Price','Amount To Buy','Amount To Sell')
92
+
93
+ @rebalanced_value_difference.each do |account, funds|
94
+ funds.each do |symbol, val_diff|
95
+ price = fund_hash[symbol].price
96
+ asset_class = fund_hash[symbol].asset_class
97
+ amount_to_buy = 0
98
+ amount_to_buy = val_diff.abs if val_diff > 0
99
+ amount_to_sell = 0
100
+ amount_to_sell = val_diff.abs if val_diff < 0
101
+
102
+ data_array = [account, symbol, asset_class, format_currency(price), format_currency(amount_to_buy), format_currency(amount_to_sell)]
103
+ data << data_array
104
+ end
105
+ end
106
+
107
+ data
108
+ end
109
+
110
+ def print_results
111
+ p results
112
+ end
113
+
114
+ private
115
+ def single_account_rebalance
116
+ account = @accounts.first
117
+ target_asset_class_values = @target.calculate_target_asset_class_values(account)
118
+
119
+ target_asset_class_values.each do |asset_class, target_value|
120
+ rebalanced_asset_class = rebalance_asset_class_within_account(account, asset_class, target_value)
121
+
122
+ @rebalanced_shares.merge!(rebalanced_asset_class['rebalanced_shares'])
123
+ @rebalanced_share_difference.merge!(rebalanced_asset_class['rebalanced_share_difference'])
124
+ @rebalanced_values.merge!(rebalanced_asset_class['rebalanced_values'])
125
+ @rebalanced_value_difference.merge!(rebalanced_asset_class['rebalanced_value_difference'])
126
+ end
127
+ end
128
+
129
+ def multiple_account_rebalance
130
+ # Rebalance accounts by asset class percentage
131
+ account_percentages = rebalance_account_percentages
132
+
133
+ # Try to rebalance up to 10 times
134
+ i = 0
135
+ while i < 10 && unbalanced_asset_classes = find_unbalanced_asset_classes(account_percentages)
136
+ i += 1
137
+ @accounts = add_empty_funds(unbalanced_asset_classes)
138
+ account_percentages = rebalance_account_percentages
139
+ end
140
+
141
+ total_value = @target.total_value_of_all_accounts(*@accounts)
142
+
143
+ # Now, turn those asset class percentages back into balanced funds
144
+ @accounts.each do |account|
145
+ target_asset_class_percentages = account_percentages[account.name]
146
+
147
+ # Convert the percentages into values
148
+ target_asset_class_values = {}
149
+ target_asset_class_percentages.each do |asset_class, percentage|
150
+ target_value = total_value * (percentage * 0.01)
151
+ target_asset_class_values[asset_class] = target_value
152
+ end
153
+
154
+ target_asset_class_values.each do |asset_class, target_value|
155
+ rebalanced_asset_class = rebalance_asset_class_within_account(account, asset_class, target_value)
156
+
157
+ @rebalanced_shares[account.name].merge!(rebalanced_asset_class['rebalanced_shares'])
158
+ @rebalanced_share_difference[account.name].merge!(rebalanced_asset_class['rebalanced_share_difference'])
159
+ @rebalanced_values[account.name].merge!(rebalanced_asset_class['rebalanced_values'])
160
+ @rebalanced_value_difference[account.name].merge!(rebalanced_asset_class['rebalanced_value_difference'])
161
+ end
162
+ end
163
+ end
164
+
165
+ def find_unbalanced_asset_classes(account_percentages)
166
+ target_percentages = target.calculate_target_asset_class_percentages(*@accounts)
167
+
168
+ rebalanced_asset_classes = {}
169
+
170
+ account_percentages.each do |account_name, asset_class_hash|
171
+ asset_class_hash.each do |asset_class, rebalanced_percentage|
172
+ rebalanced_asset_classes[asset_class] = 0 if rebalanced_asset_classes[asset_class].nil?
173
+ rebalanced_asset_classes[asset_class] += rebalanced_percentage
174
+ end
175
+ end
176
+
177
+ unbalanced_asset_classes = []
178
+
179
+ rebalanced_asset_classes.each do |asset_class, rebalanced_asset_class_percentage|
180
+ if !target_percentages[asset_class].nil?
181
+ if (rebalanced_asset_class_percentage - target_percentages[asset_class]).abs > @acceptable_asset_class_delta
182
+ unbalanced_asset_classes << asset_class
183
+ end
184
+ end
185
+ end
186
+
187
+ if !unbalanced_asset_classes.empty?
188
+ unbalanced_asset_classes
189
+ else
190
+ false
191
+ end
192
+ end
193
+
194
+ def add_empty_funds(unbalanced_asset_classes)
195
+ # Loop through each account and add an empty fund from
196
+ # a missing asset class to an account that doesn't currently
197
+ # carry it.
198
+ temp_accounts = []
199
+ added_asset_classes = []
200
+ @accounts.each do |account|
201
+ unbalanced_asset_classes.each do |unbalanced_asset_class|
202
+ # If we can't find this asset class in this account
203
+ # then add it
204
+ if account.find_by_asset_class(unbalanced_asset_class).empty? && !added_asset_classes.include?(unbalanced_asset_class)
205
+ asset_class_funds = funds_by_asset_class
206
+ fund_to_add = asset_class_funds[unbalanced_asset_class].first
207
+ added_asset_classes << unbalanced_asset_class
208
+ account.fund(fund_to_add.symbol, unbalanced_asset_class, 0, fund_to_add.price)
209
+ end
210
+ end
211
+ temp_accounts << account
212
+ end
213
+
214
+ temp_accounts
215
+ end
216
+
217
+ # Helper that simply pulls the funds out of each account into a hash
218
+ def fund_hash
219
+ funds = {}
220
+ @accounts.each do |account|
221
+ funds.merge!(account.funds)
222
+ end
223
+ funds
224
+ end
225
+
226
+ def rebalance_asset_class_within_account(account, class_name, target_value)
227
+ return_values = {
228
+ 'rebalanced_shares' => {},
229
+ 'rebalanced_share_difference' => {},
230
+ 'rebalanced_values' => {},
231
+ 'rebalanced_value_difference' => {}
232
+ }
233
+
234
+ funds = account.find_by_asset_class(class_name)
235
+ per_fund_target_value = target_value / funds.size
236
+
237
+ funds.each do |fund|
238
+ amount_difference = (fund.value - per_fund_target_value)
239
+
240
+ new_shares = ((fund.value - amount_difference)/fund.price)
241
+ share_difference = (new_shares - fund.shares).round(2)
242
+
243
+ symbol = fund.symbol
244
+
245
+ return_values['rebalanced_shares'][symbol] = new_shares.round(4)
246
+ return_values['rebalanced_share_difference'][symbol] = share_difference
247
+ return_values['rebalanced_values'][symbol] = (new_shares * fund.price).round(2)
248
+ return_values['rebalanced_value_difference'][symbol] = (return_values['rebalanced_values'][symbol] - fund.value).round(2)
249
+ end
250
+
251
+ return_values
252
+ end
253
+
254
+ def rebalance_account_percentages
255
+ working_account_percentages = @target.asset_class_percentages_across_all_accounts(*@accounts)
256
+ working_asset_class_percentages = @target.calculate_current_asset_class_percentages(*@accounts)
257
+
258
+ 100.times do |num|
259
+ working_account_percentages.each do |account_name, a_classes|
260
+ # We need to deal with single fund accounts seperately
261
+ if a_classes.size > 1
262
+ a_classes.each do |class_name, percentage|
263
+ target_value = @target.asset_classes[class_name] || 0
264
+ diff = target_value - working_asset_class_percentages[class_name]
265
+
266
+ # See if the target asset class % is greater than our overall asset class %
267
+ if diff
268
+ if diff > 0
269
+ diff > 1 ? adjustment_size = 1 : adjustment_size = diff
270
+ else
271
+ diff < -1 ? adjustment_size = -1 : adjustment_size = diff
272
+ end
273
+
274
+ if adjustment_size > 0.1 or adjustment_size < -0.1
275
+ working_account_percentages[account_name][class_name] += adjustment_size
276
+ working_asset_class_percentages[class_name] += adjustment_size
277
+
278
+ # Now, go through the rest of the asset classes here and decrement
279
+ # evenly to keep this account's overall percentage the same
280
+ percent_to_change = adjustment_size.quo(a_classes.size-1).to_f * -1
281
+ a_classes.each do |temp_class_name, temp_percentage|
282
+ next if temp_class_name == class_name
283
+ working_account_percentages[account_name][temp_class_name] += percent_to_change
284
+ working_asset_class_percentages[temp_class_name] += percent_to_change
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
292
+
293
+ working_account_percentages
294
+ end
295
+
296
+ # takes a number and outputs a string in any currency format
297
+ # adapted from http://codesnippets.joyent.com/posts/show/1812
298
+ def format_currency(number)
299
+ # split integer and fractional parts
300
+ int, frac = ("%.2f" % number).split('.')
301
+ # insert the delimiters
302
+ int.gsub!(/(\d)(?=(\d\d\d)+(?!\d))/, "\\1,")
303
+ '$' + int + '.' + frac
304
+ end
305
+ end
306
+ end