Permalink
Browse files

Added demo usage and spreadsheet template to readme

  • Loading branch information...
gnmerritt committed Jun 10, 2018
1 parent 4004092 commit 039f88c3533373790b775496b9844552e110ce04
Showing with 254 additions and 54 deletions.
  1. +14 −54 README.md
  2. +58 −0 plan.md
  3. +182 −0 spreadsheet/Code.gs
  4. BIN spreadsheet/accounts.png
  5. BIN spreadsheet/dashboard.png
@@ -4,7 +4,16 @@
Try and optimally balance an account of Vanguard ETFs given a target allocation. Runs as an API for easy use from google sheets.
## usage:
## features
Balances funds to a target percentage across multiple accounts.
* high-yield funds prioritized to tax-sheltered accounts
* can avoid sales in taxable accounts
* minimizes uninvested cash in each account
* spreadsheet auto-updates to graph returns and balances over time
## CLI usage:
Describe your target allocation, your accounts and provide current market quotes
for the funds you're interested in balancing, something like this (via the
@@ -14,59 +23,10 @@ excellent [httpie](https://httpie.org/)):
http POST etf.gnmerritt.net/balance target:='{"VEU":0.7,"VOO":0.3}' accounts:='[{"name":"taxed", "tax_sheltered":false,"cash":1000, "positions":{"VEU":2, "VOO":2}}]' market:='[{"symbol":"VEU", "price":54.33},{"symbol":"VOO", "price":254.77}]'
```
## Given info
```
symbols: [world, mid cap, reit, 500, small cap, bond]
desired %: [25, 10, 5, 35, 10, 15]
current $: []
yield %: [2.6, 1.3, 4.4, 1.7, 1.2, 2.6]
```
shares-per-account: []
free cash per account (roth, ira, taxed)
total funds = sum(free cash) + sum(invested)
delta $ per ETF: [desired - invested foreach ETF]
delta shares per ETF: [delta/share price foreach ETF]
## ideas for allocation
* put higher yield ETFs into tax-advantaged accts
* keep most rebalancing in tax-advantaged accts
* this means some of each fund needs to be tax sheltered
* do as little as possible (minimize # transactions from current state)
assign primary allocations:
```
15% roth -> bond, 1/2 reit
30% ira -> foreign, 1/2 reit
55% taxable -> us small, us mid, 500
```
10% of each account should mirror portfolio, to ease rebalancing
* TODO: how to test this over time?
## Iterative algorithm
## spreadsheet usage:
```
for each ETF:
if we're overweight, sell shares
- from a sheltered account first
- TODO: when okay to sell from taxable account?
I talk to the API via a google sheet which contains my account info, you can find a template here: [Google Sheet Template](https://docs.google.com/spreadsheets/d/1o8sxqQx-XOBXjGqna-EQ-8smx7PInTiPLUEc6tUZhr4/edit?usp=sharing). If you make yourself a copy you can start using it to balance your own accounts.
for each account:
make sure that there's at least 10% * desired % of each ETF
-only buy shares if there's: free cash && we need shares
![dashboard](./spreadsheet/dashboard.png)
for each primary allocation:
buy shares in acct until one of
- we don't need more
- account has no more free cash
for each share we still need more of, sorted by yield:
fill tax advantaged accounts with shares
fill taxable accounts
```
![accounts](./spreadsheet/accounts.png)
58 plan.md
@@ -0,0 +1,58 @@
# implementation plan
## Given info
```
symbols: [world, mid cap, reit, 500, small cap, bond]
desired %: [25, 10, 5, 35, 10, 15]
current $: []
yield %: [2.6, 1.3, 4.4, 1.7, 1.2, 2.6]
```
shares-per-account: []
free cash per account (roth, ira, taxed)
total funds = sum(free cash) + sum(invested)
delta $ per ETF: [desired - invested foreach ETF]
delta shares per ETF: [delta/share price foreach ETF]
## ideas for allocation
* put higher yield ETFs into tax-advantaged accts
* keep most rebalancing in tax-advantaged accts
* this means some of each fund needs to be tax sheltered
* do as little as possible (minimize # transactions from current state)
assign primary allocations:
```
15% roth -> bond, 1/2 reit
30% ira -> foreign, 1/2 reit
55% taxable -> us small, us mid, 500
```
10% of each account should mirror portfolio, to ease rebalancing
* TODO: how to test this over time?
## Iterative algorithm
```
for each ETF:
if we're overweight, sell shares
- from a sheltered account first
- TODO: when okay to sell from taxable account?
for each account:
make sure that there's at least 10% * desired % of each ETF
-only buy shares if there's: free cash && we need shares
for each primary allocation:
buy shares in acct until one of
- we don't need more
- account has no more free cash
for each share we still need more of, sorted by yield:
fill tax advantaged accounts with shares
fill taxable accounts
```
@@ -0,0 +1,182 @@
function onOpen() {
var ui = SpreadsheetApp.getUi();
ui.createMenu('Account actions')
.addItem('Log a deposit', 'contribute')
.addItem('Rerun balancing', 'remoteBalance')
.addSeparator()
.addItem('Record current balance', 'populateBalance')
.addToUi();
}
function contribute() {
var ui = SpreadsheetApp.getUi();
var response = ui.prompt('Log a deposit', 'How much did you deposit?', ui.ButtonSet.OK_CANCEL);
if (response.getSelectedButton() === ui.Button.OK) {
var ss = SpreadsheetApp.getActive();
var amount = response.getResponseText();
try {
var deposit = parseInt(amount, 10);
if (!deposit) return;
var depositRange = ss.getRangeByName("deposit");
if (depositRange.getValue() > 0) {
ui.alert("assign the last deposit first");
return;
}
depositRange.setValue(deposit);
populateBalance(deposit);
} catch (e) {
ui.alert("Couldn't handle that amount " + e);
}
}
}
function populateBalanceCron() {
populateBalance(0); // google puts dumb things into arguments, make sure they don't affect us
}
function populateBalance(cashflow) {
if (!cashflow) cashflow = 0;
var ss = SpreadsheetApp.getActive();
var balanceCell = ss.getRangeByName("currentBalance");
var balance = balanceCell.getCell(1, 1).getValue();
if (cashflow > 0) balance = balance + cashflow;
var ledger = ss.getSheetByName("ledger");
// loop until we find the first row with an empty date
for (var insertRow = 1; ledger.getRange(insertRow, 1).getValue(); insertRow++) {
}
// insert today's date, cashflow & the balance on the empty row
ledger.getRange(insertRow, 1).setValue(new Date());
ledger.getRange(insertRow, 2).setValue(cashflow);
ledger.getRange(insertRow, 4).setValue(balance);
// insert account balances too
var accounts = ss.getRangeByName("account_balances");
ledger.getRange(insertRow, 7).setValue(accounts.getCell(1, 1).getValue());
ledger.getRange(insertRow, 8).setValue(accounts.getCell(1, 2).getValue());
ledger.getRange(insertRow, 9).setValue(accounts.getCell(1, 3).getValue());
}
var URL = 'http://etf.gnmerritt.net/balance';
function remoteBalance() {
var accounts = buildAccounts();
const data = buildData();
var target = {};
var market = [];
for (var i = 0; i < data.stocks.length; i++) {
var s = data.stocks[i];
target[s.ticker] = s.percent;
market.push({symbol: s.ticker, price: s.price, div_yield: s.yield });
}
var portfolio = {
accounts: accounts,
target: target,
market: market
};
var options = {
'method' : 'post',
'contentType': 'application/json',
'payload' : JSON.stringify(portfolio)
};
var response = UrlFetchApp.fetch(URL, options);
var text = response.getContentText();
var json = JSON.parse(text);
handleResults(json);
}
function handleResults(results) {
const ss = SpreadsheetApp.getActive();
const data = ss.getRangeByName("results");
for (var i = 1; i <= data.getNumRows(); i++) {
var symbol = data.getCell(i, SYMBOL).getValue();
if (!symbol) continue;
data.getCell(i, TAXED).setValue(balance(results, "taxed", symbol));
data.getCell(i, ROTH).setValue(balance(results, "roth", symbol));
data.getCell(i, IRA).setValue(balance(results, "ira", symbol));
}
}
function balance(results, account, symbol) {
if (symbol.indexOf("Cash") != -1) {
return results.cash[account];
}
return results.positions[account][symbol] || 0.0;
}
var TAXED = 2;
var ROTH = 3;
var IRA = 4;
function buildAccounts() {
const ss = SpreadsheetApp.getActive();
const data = ss.getRangeByName("positions");
var taxed = account("taxed");
var roth = account("roth", true);
var ira = account("ira", true);
for (var i = 1; i <= data.getNumRows(); i++) {
var symbol = data.getCell(i, SYMBOL).getValue();
if (!symbol) { continue; }
if (symbol.indexOf("cash") != -1) {
taxed.cash = data.getCell(i, TAXED).getValue();
roth.cash = data.getCell(i, ROTH).getValue();
ira.cash = data.getCell(i, IRA).getValue();
} else {
taxed.positions[symbol] = data.getCell(i, TAXED).getValue();
roth.positions[symbol] = data.getCell(i, ROTH).getValue();
ira.positions[symbol] = data.getCell(i, IRA).getValue();
}
}
return [taxed, roth, ira];
}
function account(name, tax_sheltered) {
return {
name: name,
tax_sheltered: !!tax_sheltered,
positions: {},
cash: 0.0
};
}
var SYMBOL = 1;
var YIELD = 3;
var PERCENT = 4;
var BALANCED = 5;
var ACTUAL = 6;
var DELTA = 7;
var PRICE = 9;
function buildData() {
const ss = SpreadsheetApp.getActive();
const data = ss.getRangeByName("data");
const stocks = [];
const symbols = [];
for (var i = 1; i <= data.getNumRows(); i++) {
var stock = {
ticker: data.getCell(i, SYMBOL).getValue(),
yield: data.getCell(i, YIELD).getValue(),
percent: data.getCell(i, PERCENT).getValue(),
price: data.getCell(i, PRICE).getValue(),
current: data.getCell(i, ACTUAL).getValue(),
balanced: data.getCell(i, BALANCED).getValue(),
delta: data.getCell(i, DELTA).getValue(),
needed: 0
};
stocks.push(stock);
symbols.push(stock.ticker);
}
return { stocks: stocks, symbols: symbols };
}
Binary file not shown.
Binary file not shown.

0 comments on commit 039f88c

Please sign in to comment.