rebalance 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +6 -0
- data/Gemfile +4 -0
- data/README.md +100 -0
- data/Rakefile +10 -0
- data/lib/rebalance.rb +4 -0
- data/lib/rebalance/account.rb +42 -0
- data/lib/rebalance/fund.rb +45 -0
- data/lib/rebalance/rebalancer.rb +306 -0
- data/lib/rebalance/target.rb +84 -0
- data/lib/rebalance/version.rb +3 -0
- data/rebalance.gemspec +26 -0
- data/spec/cassettes/price_lookup_for_98765.yml +40 -0
- data/spec/cassettes/price_lookup_for_VISVX.yml +42 -0
- data/spec/cassettes/price_lookup_for_VMMXX.yml +41 -0
- data/spec/rebalance/account_spec.rb +37 -0
- data/spec/rebalance/fund_spec.rb +28 -0
- data/spec/rebalance/rebalancer_spec.rb +285 -0
- data/spec/rebalance/target_spec.rb +138 -0
- data/spec/spec_helper.rb +46 -0
- metadata +116 -0
@@ -0,0 +1,84 @@
|
|
1
|
+
module Rebalance
|
2
|
+
class Target
|
3
|
+
attr_accessor :asset_classes
|
4
|
+
|
5
|
+
def initialize(&block)
|
6
|
+
self.asset_classes = {}
|
7
|
+
instance_eval &block
|
8
|
+
end
|
9
|
+
|
10
|
+
def asset_class(percentage, asset_class)
|
11
|
+
self.asset_classes[asset_class] = percentage
|
12
|
+
end
|
13
|
+
|
14
|
+
def calculate_target_asset_class_values(*accounts)
|
15
|
+
total_value = total_value_of_all_accounts(*accounts)
|
16
|
+
|
17
|
+
target_values = {}
|
18
|
+
asset_classes.each do |asset_class, percentage|
|
19
|
+
target_values[asset_class] = (total_value * (percentage.to_f/100)).round(2)
|
20
|
+
end
|
21
|
+
target_values
|
22
|
+
end
|
23
|
+
|
24
|
+
def calculate_target_asset_class_percentages(*accounts)
|
25
|
+
target_percentages = {}
|
26
|
+
target_values = calculate_target_asset_class_values(*accounts)
|
27
|
+
total_value = total_value_of_all_accounts(*accounts)
|
28
|
+
|
29
|
+
target_values.each do |asset_class, asset_class_value|
|
30
|
+
target_percentages[asset_class] = ((asset_class_value / total_value)*100).round(4)
|
31
|
+
end
|
32
|
+
target_percentages
|
33
|
+
end
|
34
|
+
|
35
|
+
def calculate_current_asset_class_values(*accounts)
|
36
|
+
current_values = {}
|
37
|
+
accounts.each do |account|
|
38
|
+
account.funds.each do |symbol, fund|
|
39
|
+
current_values[fund.asset_class] = 0 if current_values[fund.asset_class].nil?
|
40
|
+
current_values[fund.asset_class] += fund.value
|
41
|
+
end
|
42
|
+
end
|
43
|
+
current_values
|
44
|
+
end
|
45
|
+
|
46
|
+
def calculate_current_asset_class_percentages(*accounts)
|
47
|
+
current_percentages = {}
|
48
|
+
current_values = calculate_current_asset_class_values(*accounts)
|
49
|
+
total_value = total_value_of_all_accounts(*accounts)
|
50
|
+
|
51
|
+
current_values.each do |asset_class, asset_class_value|
|
52
|
+
current_percentages[asset_class] = ((asset_class_value / total_value)*100).round(4)
|
53
|
+
end
|
54
|
+
current_percentages
|
55
|
+
end
|
56
|
+
|
57
|
+
def total_value_of_all_accounts(*accounts)
|
58
|
+
value = 0
|
59
|
+
accounts.each do |account|
|
60
|
+
value += account.total_value
|
61
|
+
end
|
62
|
+
value
|
63
|
+
end
|
64
|
+
|
65
|
+
# get each account's asset class percentage breakdown in relation
|
66
|
+
# to all the accounts
|
67
|
+
def asset_class_percentages_across_all_accounts(*accounts)
|
68
|
+
total_value_of_all_accounts = total_value_of_all_accounts(*accounts)
|
69
|
+
account_percentages = {}
|
70
|
+
|
71
|
+
accounts.each do |account|
|
72
|
+
account.funds.each do |symbol, fund|
|
73
|
+
asset_class_total = 0
|
74
|
+
account.find_by_asset_class(fund.asset_class).each do |asset_class_fund|
|
75
|
+
asset_class_total += asset_class_fund.value
|
76
|
+
end
|
77
|
+
account_percentages[account.name] = {} if account_percentages[account.name].nil?
|
78
|
+
account_percentages[account.name][fund.asset_class] = ((asset_class_total / total_value_of_all_accounts) * 100).round(4)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
account_percentages
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
data/rebalance.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "rebalance/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "rebalance"
|
7
|
+
s.version = Rebalance::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Bryce Thornton"]
|
10
|
+
s.email = ["brycethornton@gmail.com"]
|
11
|
+
s.homepage = ""
|
12
|
+
s.summary = %q{Target asset allocation rebalancer}
|
13
|
+
s.description = %q{Rebalances mutual fund accounts to match your target asset allocation}
|
14
|
+
|
15
|
+
s.add_dependency "ruport"
|
16
|
+
s.add_dependency "json"
|
17
|
+
s.add_development_dependency "vcr"
|
18
|
+
s.add_development_dependency "webmock"
|
19
|
+
|
20
|
+
s.rubyforge_project = "rebalance"
|
21
|
+
|
22
|
+
s.files = `git ls-files`.split("\n")
|
23
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
24
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
25
|
+
s.require_paths = ["lib"]
|
26
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
---
|
2
|
+
- !ruby/struct:VCR::HTTPInteraction
|
3
|
+
request: !ruby/struct:VCR::Request
|
4
|
+
method: :get
|
5
|
+
uri: http://query.yahooapis.com:80/v1/public/yql?env=store://datatables.org/alltableswithkeys&format=json&q=select%20*%20from%20yahoo.finance.quotes%20where%20symbol%20in%20(%2298765%22)
|
6
|
+
body:
|
7
|
+
headers:
|
8
|
+
response: !ruby/struct:VCR::Response
|
9
|
+
status: !ruby/struct:VCR::ResponseStatus
|
10
|
+
code: 200
|
11
|
+
message: OK
|
12
|
+
headers:
|
13
|
+
access-control-allow-origin:
|
14
|
+
- ! '*'
|
15
|
+
cache-control:
|
16
|
+
- no-cache
|
17
|
+
content-type:
|
18
|
+
- application/json;charset=utf-8
|
19
|
+
vary:
|
20
|
+
- Accept-Encoding
|
21
|
+
date:
|
22
|
+
- Sun, 11 Dec 2011 23:18:49 GMT
|
23
|
+
server:
|
24
|
+
- YTS/1.20.7
|
25
|
+
age:
|
26
|
+
- '1'
|
27
|
+
transfer-encoding:
|
28
|
+
- chunked
|
29
|
+
connection:
|
30
|
+
- keep-alive
|
31
|
+
body: ! '{"query":{"count":1,"created":"2011-12-11T23:18:50Z","lang":"en-US","results":{"quote":{"symbol":"98765","Ask":null,"AverageDailyVolume":"0","Bid":null,"AskRealtime":null,"BidRealtime":null,"BookValue":null,"Change_PercentChange":"N/A
|
32
|
+
- N/A","Change":null,"Commission":null,"ChangeRealtime":"0.00","AfterHoursChangeRealtime":"N/A
|
33
|
+
- N/A","DividendShare":null,"LastTradeDate":null,"TradeDate":null,"EarningsShare":null,"ErrorIndicationreturnedforsymbolchangedinvalid":"No
|
34
|
+
such ticker symbol. <a href=/l>Try Symbol Lookup</a> (Look up: <a href=/l?s=98765>98765</a>)","EPSEstimateCurrentYear":null,"EPSEstimateNextYear":null,"EPSEstimateNextQuarter":null,"DaysLow":null,"DaysHigh":null,"YearLow":null,"YearHigh":null,"HoldingsGainPercent":"-","AnnualizedGain":null,"HoldingsGain":null,"HoldingsGainPercentRealtime":"N/A
|
35
|
+
- N/A","HoldingsGainRealtime":null,"MoreInfo":null,"OrderBookRealtime":null,"MarketCapitalization":null,"MarketCapRealtime":null,"EBITDA":null,"ChangeFromYearLow":null,"PercentChangeFromYearLow":null,"LastTradeRealtimeWithTime":"N/A
|
36
|
+
- <b>0.00</b>","ChangePercentRealtime":"N/A - 0.00%","ChangeFromYearHigh":null,"PercebtChangeFromYearHigh":null,"LastTradeWithTime":"N/A
|
37
|
+
- <b>0.00</b>","LastTradePriceOnly":"0.00","HighLimit":null,"LowLimit":null,"DaysRange":"N/A
|
38
|
+
- N/A","DaysRangeRealtime":"N/A - N/A","FiftydayMovingAverage":null,"TwoHundreddayMovingAverage":null,"ChangeFromTwoHundreddayMovingAverage":null,"PercentChangeFromTwoHundreddayMovingAverage":null,"ChangeFromFiftydayMovingAverage":null,"PercentChangeFromFiftydayMovingAverage":null,"Name":"98765","Notes":null,"Open":null,"PreviousClose":null,"PricePaid":null,"ChangeinPercent":null,"PriceSales":null,"PriceBook":null,"ExDividendDate":null,"PERatio":null,"DividendPayDate":null,"PERatioRealtime":null,"PEGRatio":null,"PriceEPSEstimateCurrentYear":null,"PriceEPSEstimateNextYear":null,"Symbol":"98765","SharesOwned":null,"ShortRatio":null,"LastTradeTime":null,"TickerTrend":null,"OneyrTargetPrice":null,"Volume":null,"HoldingsValue":null,"HoldingsValueRealtime":null,"YearRange":"N/A
|
39
|
+
- N/A","DaysValueChange":"- 0.00%","DaysValueChangeRealtime":"N/A - N/A","StockExchange":null,"DividendYield":null,"PercentChange":"N/A"}}}}'
|
40
|
+
http_version: '1.1'
|
@@ -0,0 +1,42 @@
|
|
1
|
+
---
|
2
|
+
- !ruby/struct:VCR::HTTPInteraction
|
3
|
+
request: !ruby/struct:VCR::Request
|
4
|
+
method: :get
|
5
|
+
uri: http://query.yahooapis.com:80/v1/public/yql?env=store://datatables.org/alltableswithkeys&format=json&q=select%20*%20from%20yahoo.finance.quotes%20where%20symbol%20in%20(%22VISVX%22)
|
6
|
+
body:
|
7
|
+
headers:
|
8
|
+
response: !ruby/struct:VCR::Response
|
9
|
+
status: !ruby/struct:VCR::ResponseStatus
|
10
|
+
code: 200
|
11
|
+
message: OK
|
12
|
+
headers:
|
13
|
+
access-control-allow-origin:
|
14
|
+
- ! '*'
|
15
|
+
cache-control:
|
16
|
+
- no-cache
|
17
|
+
content-type:
|
18
|
+
- application/json;charset=utf-8
|
19
|
+
vary:
|
20
|
+
- Accept-Encoding
|
21
|
+
date:
|
22
|
+
- Sun, 11 Dec 2011 23:18:48 GMT
|
23
|
+
server:
|
24
|
+
- YTS/1.20.7
|
25
|
+
age:
|
26
|
+
- '1'
|
27
|
+
transfer-encoding:
|
28
|
+
- chunked
|
29
|
+
connection:
|
30
|
+
- keep-alive
|
31
|
+
body: ! '{"query":{"count":1,"created":"2011-12-11T23:18:49Z","lang":"en-US","results":{"quote":{"symbol":"VISVX","Ask":null,"AverageDailyVolume":"0","Bid":null,"AskRealtime":null,"BidRealtime":null,"BookValue":null,"Change_PercentChange":"+0.41
|
32
|
+
- +2.75%","Change":"+0.41","Commission":null,"ChangeRealtime":"+0.41","AfterHoursChangeRealtime":"N/A
|
33
|
+
- N/A","DividendShare":null,"LastTradeDate":"12/9/2011","TradeDate":null,"EarningsShare":null,"ErrorIndicationreturnedforsymbolchangedinvalid":null,"EPSEstimateCurrentYear":null,"EPSEstimateNextYear":null,"EPSEstimateNextQuarter":null,"DaysLow":null,"DaysHigh":null,"YearLow":null,"YearHigh":null,"HoldingsGainPercent":"-
|
34
|
+
- -","AnnualizedGain":null,"HoldingsGain":null,"HoldingsGainPercentRealtime":"<i>-</i>
|
35
|
+
- <i>-</i>","HoldingsGainRealtime":"<i>-</i>","MoreInfo":"cnped","OrderBookRealtime":null,"MarketCapitalization":null,"MarketCapRealtime":null,"EBITDA":null,"ChangeFromYearLow":null,"PercentChangeFromYearLow":null,"LastTradeRealtimeWithTime":"<i>Dec 9</i>
|
36
|
+
- <b><i>15.30</i></b>","ChangePercentRealtime":"<i>+0.41</i> - <i>+2.75%</i>","ChangeFromYearHigh":null,"PercebtChangeFromYearHigh":null,"LastTradeWithTime":"Dec 9
|
37
|
+
- <b>15.30</b>","LastTradePriceOnly":"15.30","HighLimit":null,"LowLimit":null,"DaysRange":"N/A
|
38
|
+
- N/A","DaysRangeRealtime":"N/A - N/A","FiftydayMovingAverage":null,"TwoHundreddayMovingAverage":null,"ChangeFromTwoHundreddayMovingAverage":null,"PercentChangeFromTwoHundreddayMovingAverage":null,"ChangeFromFiftydayMovingAverage":null,"PercentChangeFromFiftydayMovingAverage":null,"Name":"VANGUARD
|
39
|
+
INDEX TR","Notes":null,"Open":null,"PreviousClose":"14.89","PricePaid":null,"ChangeinPercent":"+2.75%","PriceSales":null,"PriceBook":null,"ExDividendDate":null,"PERatio":null,"DividendPayDate":null,"PERatioRealtime":null,"PEGRatio":null,"PriceEPSEstimateCurrentYear":null,"PriceEPSEstimateNextYear":null,"Symbol":"VISVX","SharesOwned":null,"ShortRatio":null,"LastTradeTime":"6:25pm","TickerTrend":null,"OneyrTargetPrice":null,"Volume":null,"HoldingsValue":null,"HoldingsValueRealtime":"<i>-</i>","YearRange":"N/A
|
40
|
+
- N/A","DaysValueChange":"- - +2.75%","DaysValueChangeRealtime":"<i>-</i> -
|
41
|
+
<i>+2.75%</i>","StockExchange":"NasdaqSC","DividendYield":null,"PercentChange":"+2.75%"}}}}'
|
42
|
+
http_version: '1.1'
|
@@ -0,0 +1,41 @@
|
|
1
|
+
---
|
2
|
+
- !ruby/struct:VCR::HTTPInteraction
|
3
|
+
request: !ruby/struct:VCR::Request
|
4
|
+
method: :get
|
5
|
+
uri: http://query.yahooapis.com:80/v1/public/yql?env=store://datatables.org/alltableswithkeys&format=json&q=select%20*%20from%20yahoo.finance.quotes%20where%20symbol%20in%20(%22VMMXX%22)
|
6
|
+
body:
|
7
|
+
headers:
|
8
|
+
response: !ruby/struct:VCR::Response
|
9
|
+
status: !ruby/struct:VCR::ResponseStatus
|
10
|
+
code: 200
|
11
|
+
message: OK
|
12
|
+
headers:
|
13
|
+
access-control-allow-origin:
|
14
|
+
- ! '*'
|
15
|
+
cache-control:
|
16
|
+
- no-cache
|
17
|
+
content-type:
|
18
|
+
- application/json;charset=utf-8
|
19
|
+
vary:
|
20
|
+
- Accept-Encoding
|
21
|
+
date:
|
22
|
+
- Sat, 31 Dec 2011 21:14:44 GMT
|
23
|
+
server:
|
24
|
+
- YTS/1.20.7
|
25
|
+
age:
|
26
|
+
- '1'
|
27
|
+
transfer-encoding:
|
28
|
+
- chunked
|
29
|
+
connection:
|
30
|
+
- keep-alive
|
31
|
+
body: ! '{"query":{"count":1,"created":"2011-12-31T21:14:45Z","lang":"en-US","results":{"quote":{"symbol":"VMMXX","Ask":null,"AverageDailyVolume":"0","Bid":null,"AskRealtime":null,"BidRealtime":null,"BookValue":null,"Change_PercentChange":"N/A
|
32
|
+
- N/A","Change":null,"Commission":null,"ChangeRealtime":"0.00","AfterHoursChangeRealtime":"N/A
|
33
|
+
- N/A","DividendShare":null,"LastTradeDate":"12/30/2011","TradeDate":null,"EarningsShare":null,"ErrorIndicationreturnedforsymbolchangedinvalid":null,"EPSEstimateCurrentYear":null,"EPSEstimateNextYear":null,"EPSEstimateNextQuarter":null,"DaysLow":null,"DaysHigh":null,"YearLow":null,"YearHigh":null,"HoldingsGainPercent":"-
|
34
|
+
- -","AnnualizedGain":null,"HoldingsGain":null,"HoldingsGainPercentRealtime":"<i>-</i>
|
35
|
+
- <i>-</i>","HoldingsGainRealtime":"<i>-</i>","MoreInfo":"ned","OrderBookRealtime":null,"MarketCapitalization":null,"MarketCapRealtime":null,"EBITDA":null,"ChangeFromYearLow":null,"PercentChangeFromYearLow":null,"LastTradeRealtimeWithTime":"<i>Dec
|
36
|
+
30</i> - <b><i>0.02%</i></b>","ChangePercentRealtime":"N/A - N/A","ChangeFromYearHigh":null,"PercebtChangeFromYearHigh":null,"LastTradeWithTime":"Dec
|
37
|
+
30 - <b>0.02%</b>","LastTradePriceOnly":"0.02%","HighLimit":null,"LowLimit":null,"DaysRange":"N/A
|
38
|
+
- N/A","DaysRangeRealtime":"N/A - N/A","FiftydayMovingAverage":null,"TwoHundreddayMovingAverage":null,"ChangeFromTwoHundreddayMovingAverage":null,"PercentChangeFromTwoHundreddayMovingAverage":null,"ChangeFromFiftydayMovingAverage":null,"PercentChangeFromFiftydayMovingAverage":null,"Name":"VANGUARD
|
39
|
+
MONEY MA","Notes":null,"Open":null,"PreviousClose":null,"PricePaid":null,"ChangeinPercent":null,"PriceSales":null,"PriceBook":null,"ExDividendDate":null,"PERatio":null,"DividendPayDate":null,"PERatioRealtime":null,"PEGRatio":null,"PriceEPSEstimateCurrentYear":null,"PriceEPSEstimateNextYear":null,"Symbol":"VMMXX","SharesOwned":null,"ShortRatio":null,"LastTradeTime":"6:24pm","TickerTrend":null,"OneyrTargetPrice":null,"Volume":null,"HoldingsValue":null,"HoldingsValueRealtime":"<i>-</i>","YearRange":"N/A
|
40
|
+
- N/A","DaysValueChange":"- - 0.00%","DaysValueChangeRealtime":"<i>-</i> - <i>-</i>","StockExchange":"NasdaqSC","DividendYield":null,"PercentChange":"N/A"}}}}'
|
41
|
+
http_version: '1.1'
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require File.expand_path('../../spec_helper', __FILE__)
|
2
|
+
|
3
|
+
describe Rebalance::Account do
|
4
|
+
before do
|
5
|
+
@account = Rebalance::Account.new 'Test Account' do
|
6
|
+
fund 'ABCDE', 'Bonds', 200, 10.00
|
7
|
+
fund 'FGHIJ', 'Domestic Stocks', 500, 25.19
|
8
|
+
fund 'KLMNO', 'Domestic Stocks', 100, 20.00
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
it "knows the account name" do
|
13
|
+
@account.name.must_equal 'Test Account'
|
14
|
+
end
|
15
|
+
|
16
|
+
it "has the right number of funds" do
|
17
|
+
@account.funds.size.must_equal 3
|
18
|
+
end
|
19
|
+
|
20
|
+
it "remembers the first fund" do
|
21
|
+
@account.funds['ABCDE'].symbol.must_equal 'ABCDE'
|
22
|
+
end
|
23
|
+
|
24
|
+
it "knows the total value of all funds" do
|
25
|
+
@account.total_value.must_equal 16595.00
|
26
|
+
end
|
27
|
+
|
28
|
+
it "knows the percentage of each fund" do
|
29
|
+
percentages = @account.calculate_percentages
|
30
|
+
percentages['ABCDE'].must_equal 12.05
|
31
|
+
end
|
32
|
+
|
33
|
+
it "finds funds by asset class" do
|
34
|
+
@account.find_by_asset_class('Domestic Stocks')[0].symbol.must_equal 'FGHIJ'
|
35
|
+
@account.find_by_asset_class('Domestic Stocks')[1].symbol.must_equal 'KLMNO'
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require File.expand_path('../../spec_helper', __FILE__)
|
2
|
+
|
3
|
+
describe Rebalance::Fund do
|
4
|
+
it "knows it's total value" do
|
5
|
+
fund = Rebalance::Fund.new('ABC', 'Bonds', 20, 4.51)
|
6
|
+
fund.value.must_equal 90.20
|
7
|
+
end
|
8
|
+
|
9
|
+
it "will look up the price if none is specified" do
|
10
|
+
VCR.use_cassette('price_lookup_for_VISVX', :record => :new_episodes) do
|
11
|
+
fund = Rebalance::Fund.new('VISVX', 'US Small Cap Value', 20)
|
12
|
+
fund.price.must_equal 15.30
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
it "will throw an exception if it can't lookup the price" do
|
17
|
+
VCR.use_cassette('price_lookup_for_98765', :record => :new_episodes) do
|
18
|
+
proc { fund = Rebalance::Fund.new('98765', 'Nonexistent Asset Class', 20) }.must_raise RuntimeError
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
it "handles cash appropriately" do
|
23
|
+
VCR.use_cassette('price_lookup_for_VMMXX', :record => :new_episodes) do
|
24
|
+
fund = Rebalance::Fund.new('VMMXX', 'Cash', 500)
|
25
|
+
fund.price.must_equal 1.00
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,285 @@
|
|
1
|
+
require File.expand_path('../../spec_helper', __FILE__)
|
2
|
+
|
3
|
+
describe Rebalance::Rebalancer do
|
4
|
+
describe 'with a single account' do
|
5
|
+
before do
|
6
|
+
@target = Rebalance::Target.new do
|
7
|
+
asset_class 30, 'Some Asset Class'
|
8
|
+
asset_class 20, 'Another Asset Class'
|
9
|
+
asset_class 50, 'Bonds'
|
10
|
+
end
|
11
|
+
|
12
|
+
@account = Rebalance::Account.new 'Test Account' do
|
13
|
+
fund 'ABCDE', 'Some Asset Class', 500, 10.00
|
14
|
+
fund 'FGHIJ', 'Some Asset Class', 300, 25.00
|
15
|
+
fund 'KLMNO', 'Another Asset Class', 75, 300
|
16
|
+
fund 'PQRST', 'Bonds', 35.5, 32.00
|
17
|
+
fund 'UVWXY', 'Bonds', 75, 5.50
|
18
|
+
end
|
19
|
+
|
20
|
+
@rebalance = Rebalance::Rebalancer.new(@target, @account)
|
21
|
+
@rebalance.rebalance
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'creates a hash of funds by asset class' do
|
25
|
+
expected_hash = {
|
26
|
+
'Some Asset Class' => [@account.funds['ABCDE'], @account.funds['FGHIJ']],
|
27
|
+
'Another Asset Class' => [@account.funds['KLMNO']],
|
28
|
+
'Bonds' => [@account.funds['PQRST'], @account.funds['UVWXY']]
|
29
|
+
}
|
30
|
+
|
31
|
+
@rebalance.funds_by_asset_class.must_equal expected_hash
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'provides the new number of shares for each fund' do
|
35
|
+
expected_rebalance = {
|
36
|
+
'ABCDE' => 548.2275,
|
37
|
+
'FGHIJ' => 219.291,
|
38
|
+
'KLMNO' => 24.3657,
|
39
|
+
'PQRST' => 285.5352,
|
40
|
+
'UVWXY' => 1661.2955
|
41
|
+
}
|
42
|
+
|
43
|
+
@rebalance.rebalanced_shares.must_equal expected_rebalance
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'provides the difference in shares for each fund' do
|
47
|
+
expected_difference = {
|
48
|
+
'ABCDE' => 48.23,
|
49
|
+
'FGHIJ' => -80.71,
|
50
|
+
'KLMNO' => -50.63,
|
51
|
+
'PQRST' => 250.04,
|
52
|
+
'UVWXY' => 1586.30
|
53
|
+
}
|
54
|
+
|
55
|
+
@rebalance.rebalanced_share_difference.must_equal expected_difference
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'provides the new value for each fund' do
|
59
|
+
expected_rebalance = {
|
60
|
+
'ABCDE' => 5482.28,
|
61
|
+
'FGHIJ' => 5482.28,
|
62
|
+
'KLMNO' => 7309.70,
|
63
|
+
'PQRST' => 9137.13,
|
64
|
+
'UVWXY' => 9137.13
|
65
|
+
}
|
66
|
+
|
67
|
+
@rebalance.rebalanced_values.must_equal expected_rebalance
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'provides the difference in value for each fund' do
|
71
|
+
expected_difference = {
|
72
|
+
'ABCDE' => 482.28,
|
73
|
+
'FGHIJ' => -2017.72,
|
74
|
+
'KLMNO' => -15190.30,
|
75
|
+
'PQRST' => 8001.13,
|
76
|
+
'UVWXY' => 8724.63
|
77
|
+
}
|
78
|
+
|
79
|
+
@rebalance.rebalanced_value_difference.must_equal expected_difference
|
80
|
+
|
81
|
+
total_value = 0
|
82
|
+
@rebalance.rebalanced_value_difference.values.each { |value| total_value += value }
|
83
|
+
total_value.round(2).must_equal 0.02
|
84
|
+
end
|
85
|
+
|
86
|
+
it 'should be rebalanced' do
|
87
|
+
assert_rebalanced @rebalance
|
88
|
+
assert_accounts_have_same_values_after_rebalance @rebalance
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
describe 'with multiple accounts' do
|
93
|
+
before do
|
94
|
+
@target = Rebalance::Target.new do
|
95
|
+
asset_class 30, 'Some Asset Class'
|
96
|
+
asset_class 20, 'Another Asset Class'
|
97
|
+
asset_class 50, 'Bonds'
|
98
|
+
end
|
99
|
+
|
100
|
+
@wifes_roth = Rebalance::Account.new "Wife's Roth" do
|
101
|
+
fund 'ABCDE', 'Some Asset Class', 500, 10.00 # $5,000
|
102
|
+
fund 'FGHIJ', 'Some Asset Class', 300, 25.00 # $7,500
|
103
|
+
fund 'KLMNO', 'Another Asset Class', 75, 300 # $22,500
|
104
|
+
fund 'PQRST', 'Bonds', 35.5, 32.00 # $1,136
|
105
|
+
fund 'UVWXY', 'Bonds', 75, 5.50 # $412.50
|
106
|
+
end
|
107
|
+
|
108
|
+
@my_roth = Rebalance::Account.new 'My Roth' do
|
109
|
+
fund 'AAAAA', 'Cash', 150, 1.00 # $150
|
110
|
+
fund 'BBBBB', 'Some Asset Class', 10, 23.00 # $230
|
111
|
+
fund 'FGHIJ', 'Some Asset Class', 100, 25.00 # $2,500
|
112
|
+
end
|
113
|
+
|
114
|
+
@my_sep_ira = Rebalance::Account.new 'My SEP IRA' do
|
115
|
+
fund 'ZZZZZ', 'Bonds', 250, 20.25 # $5,062.50
|
116
|
+
end
|
117
|
+
|
118
|
+
@rebalance = Rebalance::Rebalancer.new(@target, @wifes_roth, @my_roth, @my_sep_ira)
|
119
|
+
@rebalance.rebalance
|
120
|
+
end
|
121
|
+
|
122
|
+
it 'provides the new number of shares for each fund' do
|
123
|
+
expected_rebalance = {
|
124
|
+
"Wife's Roth" => {
|
125
|
+
'ABCDE' => 525.2515,
|
126
|
+
'FGHIJ' => 210.1006,
|
127
|
+
'KLMNO' => 29.5349,
|
128
|
+
'PQRST' => 268.4844,
|
129
|
+
'UVWXY' => 1562.0911
|
130
|
+
},
|
131
|
+
"My Roth" => {
|
132
|
+
'AAAAA' => 0.0,
|
133
|
+
'BBBBB' => 62.60850,
|
134
|
+
'FGHIJ' => 57.59980
|
135
|
+
},
|
136
|
+
"My SEP IRA" => {
|
137
|
+
'ZZZZZ' => 249.9999
|
138
|
+
}
|
139
|
+
}
|
140
|
+
|
141
|
+
@rebalance.rebalanced_shares.must_equal expected_rebalance
|
142
|
+
end
|
143
|
+
|
144
|
+
it 'provides the share difference for each fund' do
|
145
|
+
expected_rebalance = {
|
146
|
+
"Wife's Roth" => {
|
147
|
+
'ABCDE' => 25.25,
|
148
|
+
'FGHIJ' => -89.9,
|
149
|
+
'KLMNO' => -45.47,
|
150
|
+
'PQRST' => 232.98,
|
151
|
+
'UVWXY' => 1487.09
|
152
|
+
},
|
153
|
+
"My Roth" => {
|
154
|
+
'AAAAA' => -150.0,
|
155
|
+
'BBBBB' => 52.61,
|
156
|
+
'FGHIJ' => -42.4
|
157
|
+
},
|
158
|
+
"My SEP IRA" => {
|
159
|
+
'ZZZZZ' => 0.0
|
160
|
+
}
|
161
|
+
}
|
162
|
+
|
163
|
+
@rebalance.rebalanced_share_difference.must_equal expected_rebalance
|
164
|
+
end
|
165
|
+
|
166
|
+
it 'provides the rebalanced values for each fund' do
|
167
|
+
expected_rebalance = {
|
168
|
+
"Wife's Roth" => {
|
169
|
+
'ABCDE' => 5252.52,
|
170
|
+
'FGHIJ' => 5252.52,
|
171
|
+
'KLMNO' => 8860.48,
|
172
|
+
'PQRST' => 8591.50,
|
173
|
+
'UVWXY' => 8591.50
|
174
|
+
},
|
175
|
+
"My Roth" => {
|
176
|
+
'AAAAA' => 0,
|
177
|
+
'BBBBB' => 1440.00,
|
178
|
+
'FGHIJ' => 1440.00
|
179
|
+
},
|
180
|
+
"My SEP IRA" => {
|
181
|
+
'ZZZZZ' => 5062.50
|
182
|
+
}
|
183
|
+
}
|
184
|
+
|
185
|
+
@rebalance.rebalanced_values.must_equal expected_rebalance
|
186
|
+
end
|
187
|
+
|
188
|
+
it 'provides the rebalanced value difference for each fund' do
|
189
|
+
expected_rebalance = {
|
190
|
+
"Wife's Roth" => {
|
191
|
+
'ABCDE' => 252.52,
|
192
|
+
'FGHIJ' => -2247.48,
|
193
|
+
'KLMNO' => -13639.52,
|
194
|
+
'PQRST' => 7455.50,
|
195
|
+
'UVWXY' => 8179.00
|
196
|
+
},
|
197
|
+
"My Roth" => {
|
198
|
+
'AAAAA' => -150.00,
|
199
|
+
'BBBBB' => 1210.00,
|
200
|
+
'FGHIJ' => -1060.00
|
201
|
+
},
|
202
|
+
"My SEP IRA" => {
|
203
|
+
'ZZZZZ' => 0.00
|
204
|
+
}
|
205
|
+
}
|
206
|
+
|
207
|
+
@rebalance.rebalanced_value_difference.must_equal expected_rebalance
|
208
|
+
end
|
209
|
+
|
210
|
+
it 'should be rebalanced' do
|
211
|
+
assert_rebalanced @rebalance
|
212
|
+
assert_accounts_have_same_values_after_rebalance @rebalance
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
describe 'without enough value in an account with a single asset class to hit the target' do
|
217
|
+
before do
|
218
|
+
@target = Rebalance::Target.new do
|
219
|
+
asset_class 90, 'Some Asset Class'
|
220
|
+
asset_class 10, 'Another Asset Class'
|
221
|
+
end
|
222
|
+
|
223
|
+
@wifes_roth = Rebalance::Account.new "Wife's Roth" do
|
224
|
+
fund 'BBBBB', 'Some Asset Class', 10, 23.00 # $230
|
225
|
+
end
|
226
|
+
|
227
|
+
@my_roth = Rebalance::Account.new 'My Roth' do
|
228
|
+
fund 'KLMNO', 'Another Asset Class', 75, 300 # $22,500
|
229
|
+
end
|
230
|
+
|
231
|
+
@rebalance = Rebalance::Rebalancer.new(@target, @wifes_roth, @my_roth)
|
232
|
+
@rebalance.rebalance
|
233
|
+
end
|
234
|
+
|
235
|
+
it 'should be rebalanced' do
|
236
|
+
assert_rebalanced @rebalance
|
237
|
+
assert_accounts_have_same_values_after_rebalance @rebalance
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
describe 'with a lot of asset classes' do
|
242
|
+
before do
|
243
|
+
@target = Rebalance::Target.new do
|
244
|
+
asset_class 35, 'US Total Market'
|
245
|
+
asset_class 18, 'Pacific'
|
246
|
+
asset_class 18, 'Europe'
|
247
|
+
asset_class 8, 'Real Estate'
|
248
|
+
asset_class 8, 'Total Bond Market'
|
249
|
+
asset_class 8, 'Inflation-Protected Bonds'
|
250
|
+
asset_class 5, 'US Small Cap Value'
|
251
|
+
end
|
252
|
+
|
253
|
+
@wifes_roth = Rebalance::Account.new "Wife's Roth" do
|
254
|
+
fund 'VIPSX', 'Inflation-Protected Bonds', 285.71, 14.10
|
255
|
+
fund 'VBMFX', 'Total Bond Market', 934.20, 11.01
|
256
|
+
end
|
257
|
+
|
258
|
+
@my_roth = Rebalance::Account.new 'My Roth' do
|
259
|
+
fund 'VISVX', 'US Small Cap Value', 293.85, 13.52
|
260
|
+
fund 'VGSIX', 'Real Estate', 231.16, 16.61
|
261
|
+
fund 'VTSAX', 'US Total Market', 453.90, 28.42
|
262
|
+
fund 'VPACX', 'Pacific', 33.43, 9.17
|
263
|
+
fund 'VEURX', 'Europe', 135.25, 21.97
|
264
|
+
end
|
265
|
+
|
266
|
+
@my_traditional_ira = Rebalance::Account.new 'My Traditional IRA' do
|
267
|
+
fund 'VTSAX', 'US Total Market', 625.33, 28.42
|
268
|
+
fund 'VMMXX', 'Cash', 14524.44, 1.00
|
269
|
+
end
|
270
|
+
|
271
|
+
@rebalance = Rebalance::Rebalancer.new(@target, @wifes_roth, @my_roth, @my_traditional_ira)
|
272
|
+
@rebalance.rebalance
|
273
|
+
end
|
274
|
+
|
275
|
+
it 'should be rebalanced' do
|
276
|
+
assert_rebalanced @rebalance
|
277
|
+
assert_accounts_have_same_values_after_rebalance @rebalance
|
278
|
+
end
|
279
|
+
|
280
|
+
it 'should print results in tabular format' do
|
281
|
+
results = @rebalance.results.to_s
|
282
|
+
results.must_include "| Wife's Roth | VBMFX | Total Bond Market | $11.01 | $0.00 | $8,525.11 |"
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|