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,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
@@ -0,0 +1,3 @@
1
+ module Rebalance
2
+ VERSION = "0.0.1"
3
+ end
@@ -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