rebalance 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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