rebalance 0.0.1

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,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