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