First commit

This commit is contained in:
Lewis Diamond 2020-07-12 22:47:21 -04:00
commit f094355d46
18 changed files with 3248 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
*node_modules/
*yarn-error.log*
*.orig
# Swap
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"trailingComma": "all",
"tabWidth": 4,
"printWidth": 80,
"arrowParens": "avoid"
}

79
README.md Normal file
View File

@ -0,0 +1,79 @@
Quick attempt at an optimal portfolio balancer.
##Notes:
1. The balancing algorithm is not currently optimal (Work in progress). [See Closeness section](#closeness)
1. No external stock API is used. The given API gives only daily time-series output. This application is built as a stream processing system which would allow automatic rebalancing for specific events such as a price change of more than X%. I may add a randomized price generator as an input.
## Install
```
yarn
```
## To run
Using the pre-seed file with pricing from Jul 12th:
```
< test node .
```
Using the pre-seed file and allowing interactive commands:
```
cat test - | node .
```
## Interactive Commands:
`
<TICKER>:<price>
`
Sets the stock price for the given ticker
example:
```
AAPL:152.3
```
`
rebalance:<account_id>
`
Triggers a rebalance for the given account ID
example:
```
rebalance:1
```
note: Account 1 is there by default.
## Closeness
The problem of portfolio balancing can be described as such:
```
Minimize:
w_f - w_t
e.g.
SUM( |c_i*p_i - t_i| ) for stocks i
or
SUM( (c_i*p_i - t_i)^2 ) for stocks i
Subject to:
SUM( c_i*p_i ) <= T
c_i are Integers >= 0
Where
w_f is the final portfolio
w_t is the target portfolio
c_i is the number of shares of stock i
p_i is the price of stock i
t_i is the total optimal fractional amount invested in stock i
e.g. % allocation * total portfolio value
```
This problem statement falls in the Mixed Integers Quadratic Programming category. This problem can be solved through enumeration in exponential time `2^n` where n is the total
number of possible stocks purchase (not just ticker), which is very much impractical. Optimisations can be applied to reduce it to `2^n` where n is the total number of tickers,
although still exponential. From there (or possibly through a simplified problem statement), this can possibly be solved in pseudo-polynomial time using algorithms such as
branch-and-bound or Quadknap.
Currently, the algorithm used is non-optimal in some edge-cases but produces very close results in most cases. That algorithm runs in linear time (quasilinear if tickers are not
previously sorted) and can be used to evaluate the bounds in a branch-and-bound algorithm for potentially optimal results.
Unfortunately, time constraints prevented me from finishing the implementation as of Sunday July 12th. I will update this README if that changes.

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "portfolio-blancer",
"version": "1.0.0",
"main": "index.js",
"author": "Lewis Diamond",
"license": "MIT",
"dependencies": {
"strom": "ssh://git@git.lewis.id:ldiamond/strom.git",
"uuid": "^8.2.0"
},
"devDependencies": {
"ava": "^3.9.0",
"prettier": "^2.0.5"
},
"scripts": {
"test": "ava",
"test:debug": "node inspect node_modules/ava/profile.js"
},
"main": "src/index.js"
}

102
src/index.js Normal file
View File

@ -0,0 +1,102 @@
const strom = require("strom").strom();
const { allInUSD } = require("./pure/balancer");
const REBALANCE = "rebalance";
const PRICING = "pricing";
//Pricing as of July 12th;
const SEED = {
AAPL: 383.68,
GOOG: 1541.74,
ACAD: 55.45,
GFN: 6.23,
CYBR: 109.0,
ABB: 24.49,
};
const pricingStore = require("./stores/pricing").global({ seed: SEED });
const pricing = require("./processors/pricing")();
const balancer = require("./processors/balancer")();
//Conversion layer for manual input
const stdinReader = strom.compose([
strom.split(),
strom.map(data => data.trim()),
strom.filter(str => !!str),
strom.map(str => {
const [cmd, param] = str.split(":");
switch (cmd) {
case REBALANCE:
return { type: REBALANCE, accountId: param };
default:
return { type: PRICING, ticker: cmd, price: param };
}
}),
]);
//This pipeline could be built in a configurable way such that the input is
//either stdin + interactive, stdin as JSON/protobuff, a Kafka topic, a NATS
//topic, a RabbitMQ topic, a file, etc.
process.stdin
//The conversion layer would be added conditionally
.pipe(stdinReader)
.pipe(
strom.demux(key => {
switch (key) {
case REBALANCE:
return balancer;
break;
case PRICING:
return pricing;
break;
}
}, "type"),
)
//This is logging for presentation only. This could also be a module added
//conditionally.
.pipe(
strom.map(data => {
const { account, balanced, actions, pricing } = data;
if (account && balanced && actions && pricing) {
const balancedValue = allInUSD(balanced, pricing);
const previousValue = allInUSD(account.openPositions, pricing);
console.log("Previous positions");
console.table(account.openPositions);
console.log("Balanced portfolio");
console.table(balanced);
console.log("Transactions required");
const transactionsPivot = Array.from(
new Set(Object.keys({ ...actions.buy, ...actions.sell })),
).reduce((acc, ticker) => {
acc[ticker] = {
buy: actions.buy[ticker] || 0,
sell: actions.sell[ticker] || 0,
};
return acc;
}, {});
console.table(transactionsPivot);
console.log("Total value");
console.table({
balanced: balancedValue,
previous: previousValue,
});
console.table(
Object.entries(account.targets).reduce(
(acc, [ticker, target]) => {
const price = pricing[ticker];
acc[ticker] = {
target,
balanced:
(balanced[ticker] * price) / previousValue,
previous:
(account.openPositions[ticker] * price) /
previousValue,
};
return acc;
},
{},
),
);
}
return data;
}),
)
.pipe(strom.map(out => console.log(JSON.stringify(out))));

View File

@ -0,0 +1,37 @@
const strom = require("strom").strom();
const account = require("../stores/account");
const pricing = require("../stores/pricing");
const { balance, portfolioDiff, allInUSD } = require("../pure/balancer");
module.exports = function ({
accounts = undefined,
accountStore = account({ accounts }),
pricingStore = pricing.global(),
} = {}) {
return strom.compose([
strom.map(({ accountId }) => {
try {
const account = accountStore.get(accountId);
const { openPositions, targets } = account;
const pricing = pricingStore.subset(
...Object.keys(openPositions),
...Object.keys(targets),
);
const balanced = balance(openPositions, targets, pricing);
return { account, balanced, pricing };
} catch (e) {
console.error(e);
console.error("Invalid rebalance command or invalid accountId");
}
}),
strom.map(({ account, balanced, pricing }) => {
const actions = {
accountId: account.id,
...portfolioDiff(account.openPositions, balanced),
};
accountStore.setOpenPositions(account.id, balanced);
return { account, balanced, pricing, actions };
}),
]);
};

View File

@ -0,0 +1,108 @@
const test = require("ava");
const strom = require("strom");
const { map, fromArray, collect, collected } = strom.strom();
const uuidv4 = require("uuid/v4");
const PricingStore = require("../stores/pricing");
const Balancer = require("./balancer");
test.beforeEach(t => {
//Pricing as of July 12th;
const pricingCache = {
AAPL: 383.68,
GOOG: 1541.74,
ACAD: 55.45,
GFN: 6.23,
CYBR: 109.0,
ABB: 24.49,
};
const accountId = uuidv4();
const accounts = {
[accountId]: {
id: accountId,
openPositions: {
AAPL: 50,
GOOG: 200,
CYBR: 150,
ABB: 900,
USD: 0,
},
targets: {
AAPL: 0.22,
GOOG: 0.38,
GFN: 0.25,
ACAD: 0.15,
USD: 0,
},
},
};
const pricingStore = PricingStore.instance({ cache: pricingCache });
const balancer = Balancer({ accounts, pricingStore });
t.context = {
balancer,
pricingStore,
pricingCache,
accountId,
accounts,
};
});
test("Accounts are read, pricing is obtained and a balanced account is derived", async t => {
const {
balancer,
accountStore,
accounts,
accountId,
pricingStore,
} = t.context;
const account = accounts[accountId];
const result = await strom.collected(
fromArray([{ accountId }]).pipe(balancer).pipe(collect()),
);
t.deepEqual(result, [
{
account,
balanced: {
AAPL: 210,
GOOG: 90,
GFN: 14703,
ACAD: 991,
USD: 42.959999999984575,
},
actions: {
accountId,
buy: {
AAPL: 160,
ACAD: 991,
GFN: 14703,
USD: 42.959999999984575,
},
sell: {
GOOG: 110,
CYBR: 150,
ABB: 900,
},
},
pricing: pricingStore.subset(
...Object.keys(account.openPositions),
...Object.keys(account.targets),
),
},
]);
});
test("No order will be sent without pricing data on all relevant titles", async t => {
const {
balancer,
accountStore,
accounts,
accountId,
pricingStore,
} = t.context;
pricingStore.set("AAPL", 0);
const result = await strom.collected(
fromArray([{ accountId }]).pipe(balancer).pipe(collect()),
);
t.deepEqual(result, []);
});

15
src/processors/pricing.js Normal file
View File

@ -0,0 +1,15 @@
const strom = require("strom").strom();
const pricing = require("../stores/pricing");
module.exports = function ({
seed = undefined,
pricingStore = pricing.global({ seed }),
} = {}) {
return strom.map(({ ticker, price }) => {
if (price) {
pricingStore.set(ticker, parseFloat(price));
}
console.log(`Pricing for ${ticker} set to ${pricingStore.get(ticker)}`);
return;
});
};

302
src/pure/balancer.js Normal file
View File

@ -0,0 +1,302 @@
const USD = "USD";
function allInUSD(openPositions, pricing) {
return Object.entries(openPositions).reduce((acc, [ticker, amnt]) => {
const price = pricing[ticker];
//0, undefined and any falsy value should throw
if (!price) {
throw new Error(
`Can't get portfolio value because of missing pricing information on ${ticker}`,
);
}
return acc + price * amnt;
}, 0);
}
/*
* Takes a portfolio and the total value and returns the amount of USD available
*/
function leftOverCash(positions, value, pricing) {
return (
Object.entries(positions).reduce(
(acc, [k, v]) => acc - v * pricing[k],
value,
) + (positions.USD || 0)
);
}
/*
* This function rounds half away from infinity
* That is:
* 5.5 -> 5
* -5.5 -> -6
*/
function roundHAFI(number) {
return -Math.round(-number);
}
function balance(openPositions, targetPositions, pricing, algo = round) {
const value = allInUSD(openPositions, pricing);
const balanced = algo(targetPositions, value, pricing);
return balanced.result;
}
/* Takes the target positions, portfolio value and pricing and returns the
* optimal stock allocation with no integer requirement
*/
function optimalFractional(targetPositions, value, pricing) {
return Object.fromEntries(
Object.entries(targetPositions).map(([k, v]) => [
k,
(v * value) / pricing[k],
]),
);
}
/* Non-optimal (but linear-time) function to get ticker quantities based on
* target positions, pricing and total portfolio value s.t.
* SUM(c_1*p_1 + c_2*p_2 + ... + c_i*p_i) <= T
* AND
* c_i*p_i <= t_i * T for i in t [excluding USD]
* WHERE
* c_i is the number of share of i
* p_i is the value of a share of i
* T is the total value of the portfolio
* t_i is the target allocation (in decimal %, e.g. 0.22 for 22%)
* t in the target allocations
*
*/
function floor(targetPositions, value, pricing) {
const positions = Object.fromEntries(
Object.entries(targetPositions).map(([ticker, target]) => [
ticker,
Math.floor((target * value) / pricing[ticker]),
]),
);
positions.USD = leftOverCash(positions, value, pricing);
return {
result: positions,
diff: difference(positions, targetPositions, value, pricing),
};
}
/* Non-optimal (but linear-time/quasilinear) function to get ticker quantities
* based on target positions, pricing and total portfolio value
* s.t.
* SUM(c_1*p_1 + c_2*p_2 + ... + c_i*p_i) <= T
* and minimizes USD
* WHERE
* c_i is the number of share of i
* p_i is the value of a share of i
* T is the total value of the portfolio
* t_i is the target allocation (in decimal %, e.g. 0.22 for 22%)
* t in the target allocations
*
*/
function round(targetPositions, value, pricing) {
//Descending order
const sortedPricing = Object.entries(targetPositions)
.filter(([x]) => x !== USD)
.sort(([, p1], [, p2]) => p2 - p1);
const final = sortedPricing.reduce(
(acc, [ticker], idx) => {
const { result, cash, remaining } = acc;
const price = pricing[ticker];
const targetTicker = targetPositions[ticker];
const optimalQty = (cash * (targetTicker / acc.remaining)) / price;
const roundQty = Math.round(optimalQty);
const floorQty = Math.floor(optimalQty);
const qty = roundQty * price <= cash ? roundQty : floorQty;
result[ticker] = qty;
acc.cash -= qty * price;
acc.remaining -= targetTicker;
return acc;
},
{ result: {}, cash: value, remaining: 1 },
);
final.result.USD = final.cash;
return {
result: final.result,
diff: difference(final.result, targetPositions, value, pricing),
};
}
/*
* Optimal solution using enumeration, exponential time-complexity.
*
* Minimizes SUM( ABS(c_i * p_i - T * t_i) ) for i in t
* Subject to:
* SUM( c_i*p_i ) = T
* c_i is Integer (except for USD)
*/
function enumeration(targetPositions, value, pricing) {
const optimal = optimalFractional(targetPositions, value, pricing);
const zero = Object.entries(targetPositions).reduce(
(acc, [k, v]) => ({
[k]: 0,
...acc,
}),
{ USD: value },
);
const best = Infinity;
const optimize = (positions, state) => {
if (positions.length === 0) {
const leftOver = leftOverCash(state, value, pricing);
const result = leftOver >= 0 ? { ...state, USD: leftOver } : zero;
return {
result,
diff: difference(result, targetPositions, value, pricing),
};
}
const currentTicker = positions.pop();
const currentAlloc = optimal[currentTicker];
const left = optimize([...positions], {
...state,
[currentTicker]: Math.floor(currentAlloc),
});
const right = optimize([...positions], {
...state,
[currentTicker]: Math.ceil(currentAlloc),
});
if (right.diff == left.diff) {
return right.result.USD >= left.result.USD ? left : right;
} else {
return right.diff >= left.diff ? left : right;
}
};
return optimize(Object.keys(targetPositions), {});
}
/*
* Optimal solution using branch-and-bound, ? time-complexity.
*
* This essentially enumerates all solitions but improves performance by
* skipping branches that can't possibly lead to a better result than already
* found.
*
* Minimizes SUM( ABS(c_i * p_i - T * t_i) ) for i in t
* Subject to:
* SUM( c_i*p_i ) = T
* c_i is Integer (except for USD)
*/
function branchAndBound(targetPositions, value, pricing) {
const optimal = optimalFractional(targetPositions, value, pricing);
const { diff: floorDiff } = floor(targetPositions, value, pricing);
const zero = {
result: Object.entries(targetPositions).reduce(
(acc, [k, v]) => ({
[k]: 0,
...acc,
}),
{ USD: value },
),
diff: Infinity,
};
let best = floorDiff;
const optimize = state => {
const currentNode = { ...optimal, ...state };
const [bindTicker, bindCount] =
Object.entries(currentNode)
.sort(
([ticker], [ticker2]) => pricing[ticker2] - pricing[ticker],
)
.find(([ticker, count]) => ticker !== USD && count % 1 !== 0) ||
[];
if (bindTicker) {
let left = {
...currentNode,
[bindTicker]: Math.floor(bindCount),
};
let right = {
...currentNode,
[bindTicker]: Math.ceil(bindCount),
};
const diffLeft = difference(left, targetPositions, value, pricing);
const diffRight = difference(
right,
targetPositions,
value,
pricing,
);
left = diffLeft < best ? optimize(left) : zero;
right = diffRight < best ? optimize(right) : zero;
return left.diff <= right.diff ? left : right;
} else {
//All are integers, we're done with this branch
const leftOver = leftOverCash(currentNode, value, pricing);
const final = {
...currentNode,
[USD]: leftOver,
};
const feasible = allInUSD(final, pricing) <= value && leftOver >= 0;
const diff = difference(final, targetPositions, value, pricing);
best = diff < best && feasible ? diff : best;
return feasible && leftOver >= 0
? {
result: final,
diff,
}
: zero;
}
};
return optimize({});
}
/* Computes the sum of the absolute difference to target value
*/
function difference(
positions,
targetPositions,
value,
pricing,
ignoreUSD = false,
) {
//All keys from positions and targetPositions are the same.
return Object.entries(positions)
.filter(([ticker]) => !ignoreUSD || ticker !== USD)
.map(([ticker, count]) =>
Math.abs(count * pricing[ticker] - value * targetPositions[ticker]),
)
.reduce((acc, v) => acc + v, 0);
}
function simplex(targetPositions, value, locked) {}
function portfolioDiff(openPositions, finalPositions) {
const ret = { sell: {}, buy: {} };
const tickers = new Set([
...Object.keys(openPositions),
...Object.keys(finalPositions),
]);
tickers.forEach(ticker => {
const n = (finalPositions[ticker] || 0) - (openPositions[ticker] || 0);
if (n < 0) {
ret.sell[ticker] = -n;
} else if (n > 0) {
ret.buy[ticker] = n;
}
});
return ret;
}
module.exports = {
floor,
round,
balance,
leftOverCash,
allInUSD,
enumeration,
difference,
optimalFractional,
branchAndBound,
portfolioDiff,
};

380
src/pure/balancer.spec.js Normal file
View File

@ -0,0 +1,380 @@
const test = require("ava");
const {
allInUSD,
balance,
floor,
leftOverCash,
round,
enumeration,
difference,
optimalFractional,
branchAndBound,
} = require("./balancer");
const almostEqual = (n1, n2, d = 0.000001) => {
return Math.abs(n1 - n2) <= d;
};
const REAL_CASE = {
//Pricing as of July 12th;
pricing: {
AAPL: 383.68,
GOOG: 1541.74,
ACAD: 55.45,
GFN: 6.23,
CYBR: 109.0,
ABB: 24.49,
USD: 1,
},
openPositions: {
AAPL: 50,
GOOG: 200,
CYBR: 150,
ABB: 900,
USD: 0,
},
targets: {
AAPL: 0.22,
GOOG: 0.38,
GFN: 0.25,
ACAD: 0.15,
USD: 0,
},
};
test("Balance takes a portfolio and returns the balanced result based on the given algorithm", t => {
const { pricing, openPositions, targets } = REAL_CASE;
const value = allInUSD(openPositions, pricing);
const floored = balance(openPositions, targets, pricing, floor);
const enumerated = balance(openPositions, targets, pricing, enumeration);
const bnb = balance(openPositions, targets, pricing, branchAndBound);
const rounded = balance(openPositions, targets, pricing, round);
const expectedFloor = {
AAPL: 209,
GOOG: 90,
GFN: 14683,
ACAD: 989,
USD: 662.1399999999849,
};
t.deepEqual(floored, expectedFloor);
//The portfolio value does not change.
t.true(almostEqual(allInUSD(enumerated, pricing), value), "Enumeration");
const optimalDiff = difference(enumerated, targets, value, pricing);
//Floored
t.is(allInUSD(floored, pricing), value);
//Rounded
//In this case, we get an optimal result
t.true(almostEqual(allInUSD(rounded, pricing), value));
t.true(
almostEqual(optimalDiff, difference(rounded, targets, value, pricing)),
);
//Branch and bound
t.true(almostEqual(allInUSD(rounded, pricing), value));
t.true(
almostEqual(optimalDiff, difference(rounded, targets, value, pricing)),
);
});
test("floor -- Simple balancing", t => {
const pricing = {
AAPL: 1000,
GOOG: 500,
GFN: 200,
ACAD: 110,
USD: 1,
};
const targets = {
AAPL: 0.22,
GOOG: 0.38,
GFN: 0.25,
ACAD: 0.15,
USD: 0,
};
const value = 25000;
const expected = {
AAPL: 5,
GOOG: 19,
GFN: 31,
ACAD: 34,
};
expected.USD = leftOverCash(expected, value, pricing);
const floored = floor(targets, value, pricing);
t.deepEqual(floored.result, expected);
});
test("floor -- can't allocate a stock if the target allocation is below the ticker price", t => {
const pricing = {
AAPL: 1000,
GOOG: 500,
GFN: 200,
ACAD: 110,
USD: 1,
};
const targets = {
AAPL: 0.22,
GOOG: 0.38,
GFN: 0.25,
ACAD: 0.15,
USD: 0,
};
const value = 4000;
const expected = {
AAPL: 0,
GOOG: 3,
ACAD: 5,
GFN: 5,
};
expected.USD = leftOverCash(expected, value, pricing);
const floored = floor(targets, value, pricing);
t.deepEqual(floored.result, expected);
});
test("all -- a USD position is not rounded", t => {
const pricing = {
AAPL: 4.103775,
GOOG: 4.103775,
USD: 1,
};
const targets = {
AAPL: 0.4103775,
GOOG: 0.4103775,
USD: 0.179245,
};
const value = 10;
const floored = floor(targets, value, pricing);
const rounded = round(targets, value, pricing);
const bnb = branchAndBound(targets, value, pricing);
const optimal = enumeration(targets, value, pricing);
const resultUSD = 1.7924500000000005;
t.true(almostEqual(floored.result.USD, resultUSD));
t.true(almostEqual(rounded.result.USD, resultUSD));
t.true(almostEqual(bnb.result.USD, resultUSD));
t.true(almostEqual(optimal.result.USD, resultUSD));
});
test("round,enumeration,branchAndBound -- A USD target position is respected", t => {
const pricing = {
AAPL: 10,
GOOG: 10,
USD: 1,
};
const targets = {
AAPL: 0.6,
GOOG: 0.3,
USD: 0.1,
};
const value = 100;
const rounded = round(targets, value, pricing);
const enumerated = enumeration(targets, value, pricing);
const bnb = branchAndBound(targets, value, pricing);
t.is(rounded.result.USD, 10);
t.is(enumerated.result.USD, 10);
t.is(bnb.result.USD, 10);
t.is(allInUSD(rounded.result, pricing), 100);
t.is(allInUSD(enumerated.result, pricing), 100);
t.is(allInUSD(bnb.result, pricing), 100);
});
test("round,enumeration,branchAndBound -- A USD target position is respected #2 ", t => {
const pricing = {
AAPL: 10,
GOOG: 16,
USD: 1,
};
const targets = {
AAPL: 0.6,
GOOG: 0.3,
USD: 0.1,
};
const value = 100;
const rounded = round(targets, value, pricing);
const enumerated = enumeration(targets, value, pricing);
const bnb = branchAndBound(targets, value, pricing);
t.is(rounded.result.USD, 8);
t.is(enumerated.result.USD, 8);
t.is(bnb.result.USD, 8);
});
test("Rounding - rounding up a stock doesn't over-commit available capital", t => {
const pricing = {
AAPL: 100,
GOOG: 10,
USD: 1,
};
const targets = {
AAPL: 0.51,
GOOG: 0.49,
USD: 0,
};
const value = 99;
const rounded = round(targets, value, pricing);
const bnb = branchAndBound(targets, value, pricing);
const _enum = enumeration(targets, value, pricing);
t.true(rounded.result.USD >= 0);
t.true(bnb.result.USD >= 0);
t.true(_enum.result.USD >= 0);
t.is(rounded.result.AAPL, 0);
t.is(bnb.result.AAPL, 0);
t.is(_enum.result.AAPL, 0);
t.is(allInUSD(rounded.result, pricing), value);
t.is(allInUSD(bnb.result, pricing), value);
t.is(allInUSD(_enum.result, pricing), value);
t.is(_enum.diff, rounded.diff);
t.is(_enum.diff, bnb.diff);
});
test("Can't buy a stock that is too expensive", t => {
const pricing = {
AAPL: 10,
USD: 1,
};
const targets = {
AAPL: 1,
USD: 0,
};
const value = 5;
const floored = floor(targets, value, pricing);
const rounded = round(targets, value, pricing);
const enumerated = enumeration(targets, value, pricing);
const bnb = branchAndBound(targets, value, pricing);
t.is(floored.result.AAPL, 0);
t.is(floored.result.USD, 5);
t.is(rounded.result.AAPL, 0);
t.is(rounded.result.USD, 5);
t.is(enumerated.result.AAPL, 0);
t.is(enumerated.result.USD, 5);
t.is(bnb.result.AAPL, 0);
t.is(bnb.result.USD, 5);
});
test("Rounding up would have negative consequences on portfolio closeness -- <0.5 allocation + highest price rounded", t => {
const value = 1100;
const pricing = {
AAPL: 1000,
GOOG: 561,
USD: 1,
};
const targets = {
AAPL: 0.49,
GOOG: 0.51,
USD: 0,
};
const rounded = round(targets, value, pricing);
const floored = floor(targets, value, pricing);
const bnb = branchAndBound(targets, value, pricing);
const enumerated = enumeration(targets, value, pricing);
t.is(rounded.result.GOOG, 1);
t.is(floored.result.GOOG, 1);
t.is(bnb.result.GOOG, 1);
t.is(enumerated.result.GOOG, 1);
t.true(almostEqual(allInUSD(rounded.result, pricing), value));
t.true(almostEqual(allInUSD(floored.result, pricing), value));
t.true(almostEqual(allInUSD(bnb.result, pricing), value));
t.true(almostEqual(allInUSD(enumerated.result, pricing), value));
});
//This proves that a portfolio where all positions are less than 1 unit away
//from the fractional optimal (i.e. rounded up or down) are not always the
//optimal integer values.
test.skip("Optimal result may be achieved by more than rounding (remove more than 1 stock from fractional)", t => {
const value = 1100;
const pricing = {
AAPL: 1000,
GOOG: 269.5,
TSLA: 1,
USD: 1,
};
const targets = {
AAPL: 0.49,
GOOG: 0.255,
TSLA: 0.255,
USD: 0,
};
const rounded = round(targets, value, pricing);
const bnb = branchAndBound(targets, value, pricing);
const enumerated = enumeration(targets, value, pricing);
t.true(almostEqual(allInUSD(rounded.result, pricing), value));
t.true(almostEqual(allInUSD(bnb.result, pricing), value));
t.true(almostEqual(allInUSD(enumerated.result, pricing), value));
t.true(rounded.diff >= enumerated.diff);
t.true(bnb.diff >= enumerated.diff);
});
test("Target to exactly x + half a stock must be rounded up for optimal result", t => {
const value = 1000;
const pricing = {
AAPL: 2,
GOOG: 499,
USD: 1,
};
const targets = {
AAPL: 0.511,
GOOG: 0.489,
USD: 0,
};
const rounded = round(targets, value, pricing);
const bnb = branchAndBound(targets, value, pricing);
const enumerated = enumeration(targets, value, pricing);
t.true(rounded.result.USD >= 0);
t.true(rounded.diff <= enumerated.diff);
t.true(bnb.result.USD >= 0);
t.true(bnb.diff <= enumerated.diff);
});
//This test requires an optimal solved solution first
test.skip("Rounding up can not have negative consequences on portfolio closeness -- >0.5 + lower price rounded up", t => {
const value = 1000;
const pricing = {
AAPL: 2,
GOOG: 489,
USD: 1,
};
const targets = {
AAPL: 0.511,
GOOG: 0.489,
USD: 0,
};
const rounded = round(targets, value, pricing);
const enumerated = enumeration(targets, value, pricing);
const resultbnb = branchAndBound(targets, value, pricing);
t.true(resultRound.result.USD >= 0);
t.true(resultRound.diff <= resultEnum.diff);
});
test("Left over cash is computed correctly", t => {
const pricing = {
AAPL: 100,
GOOG: 200,
USD: 1,
};
const positions = {
AAPL: 20,
GOOG: 10,
USD: 0,
};
t.is(leftOverCash(positions, 4000, pricing), 0);
t.is(leftOverCash(positions, 4800, pricing), 800);
t.is(leftOverCash({ ...positions, USD: 300 }, 4800, pricing), 800);
});

12
src/stores/account.js Normal file
View File

@ -0,0 +1,12 @@
const memoryBackend = require("./backend/account/memory.js");
//Data-related logic would live here. For example caching. Since we only
//have a in-memory implementation for now, caching is useless.
//This is also where the data API is defined, abstracting the underlying storage
//system.
module.exports = function ({
accounts = undefined,
backend = memoryBackend({ accounts }),
} = {}) {
return backend;
};

View File

@ -0,0 +1,89 @@
//This module exists only for simplicity allowing the application to be run without hosting a database.
const uuidv4 = require("uuid/v4");
//Default accounts as provided in the requirements
const _id = 1;
const _accounts = {
[_id]: {
id: _id,
openPositions: {
AAPL: 50,
GOOG: 200,
CYBR: 150,
ABB: 900,
USD: 0,
},
targets: {
AAPL: 0.22,
GOOG: 0.38,
GFN: 0.25,
ACAD: 0.15,
USD: 0,
},
},
};
module.exports = function ({ accounts = _accounts } = {}) {
//Use JSON instead of adding Lodash for cloneDeep.
//This isn't a real use-case anyways since this would be a database implementation.
accounts = JSON.parse(JSON.stringify(accounts));
const addTargets = (id, targets, destination) => {
Object.keys(targets).forEach(
k => (destination[k] = [...(destination[k] || []), id]),
);
};
const addOpenPositions = (id, positions, destination) => {
Object.keys(positions).forEach(
k => (destination[k] = [...(destination[k] || []), id]),
);
};
const {
accountIdsByOpenPositions,
accountIdsByTargetPositions,
} = Object.entries(accounts).reduce(
(acc, [id, { openPositions, targets }]) => {
addTargets(id, targets, acc.accountIdsByTargetPositions);
addOpenPositions(id, openPositions, acc.accountIdsByOpenPositions);
return acc;
},
{ accountIdsByOpenPositions: {}, accountIdsByTargetPositions: {} },
);
const addAccount = (targets, accountId) => {
const id = accountId || uuidv4();
accounts[id] = { id, openPositions: {}, targets: { ...targets } };
addTargets(id, targets, accountIdsByTargetPositions);
return id;
};
const setOpenPositions = (accountId, positions) => {
const account = accounts[accountId];
if (account) {
account.openPositions = positions;
addOpenPositions(accountId, positions, accountIdsByOpenPositions);
} else {
throw new Error("Can't set positions to a non-existing account");
}
};
const get = uuid => ({ ...accounts[uuid] });
const getAccountsByOpenPosition = ticker => [
...(accountIdsByOpenPositions[ticker] || []),
];
const getAccountsByTargetPosition = ticker => [
...(accountIdsByTargetPositions[ticker] || []),
];
const getAccountsByPosition = ticker =>
new Set([
...getAccountsByOpenPosition(ticker),
...getAccountsByTargetPosition(ticker),
]);
return {
addAccount,
get,
getAccountsByOpenPosition,
getAccountsByTargetPosition,
getAccountsByPosition,
setOpenPositions,
};
};

View File

@ -0,0 +1,72 @@
const test = require("ava");
const uuidv4 = require("uuid/v4");
const accountBackend = require("./memory");
test.beforeEach(t => {
const accountId = uuidv4();
const accounts = {
[accountId]: {
openPositions: {
AAPL: 50,
GOOG: 200,
CYBR: 150,
ABB: 900,
USD: 0,
},
targets: {
AAPL: 0.22,
GOOG: 0.38,
GFN: 0.25,
ACAD: 0.15,
USD: 0,
},
},
};
t.context.backend = accountBackend({ accounts });
t.context.accountId = accountId;
t.context.accounts = accounts;
});
test("Accounts can be retrieved by ID, a copy is returned", t => {
const { backend, accountId, accounts } = t.context;
const fromBackend = backend.get(accountId);
const original = accounts[accountId];
t.deepEqual(fromBackend, original);
t.not(fromBackend, original);
});
test("Accounts can be retrieved by ticker", t => {
const { backend, accountId, accounts } = t.context;
t.deepEqual(Array.from(backend.getAccountsByPosition("ABB")), [accountId]);
t.deepEqual(Array.from(backend.getAccountsByOpenPosition("ABB")), [
accountId,
]);
t.deepEqual(Array.from(backend.getAccountsByTargetPosition("ACAD")), [
accountId,
]);
});
test("If no accounts have the requested position, an empty array is returned", t => {
const { backend, accountId, accounts } = t.context;
t.deepEqual(Array.from(backend.getAccountsByPosition("AAA")), []);
t.deepEqual(Array.from(backend.getAccountsByOpenPosition("AAA")), []);
t.deepEqual(Array.from(backend.getAccountsByTargetPosition("AAA")), []);
});
test("An account can be added and retrieved by target position or id", t => {
const { backend, accountId, accounts } = t.context;
const targets = {
"QBTC.u": 0.66,
VOO: 0.33,
USD: 0,
};
const newAccountId = backend.addAccount(targets);
const newAccount = {
id: newAccountId,
targets,
openPositions: {},
};
t.deepEqual(backend.get(newAccountId), newAccount);
t.deepEqual(backend.getAccountsByTargetPosition("QBTC.u"), [newAccountId]);
});

View File

@ -0,0 +1,9 @@
module.exports = (cache = {}) => ({
get: ticker => Math.max(+cache[ticker], 0) || undefined,
set: (ticker, price) => (cache[ticker] = Math.max(0, +price) || undefined),
subset: (...tickers) =>
tickers.reduce((acc, ticker) => {
acc[ticker] = cache[ticker];
return acc;
}, {}),
});

View File

@ -0,0 +1,57 @@
const test = require("ava");
const memoryBackend = require("./memory.js");
test("Pricing for a ticker can be fetched", t => {
t.is(memoryBackend({ AAPL: 100 }).get("AAPL"), 100);
});
test("Pricing for a ticker can be set", t => {
const pricing = memoryBackend({ AAPL: 100 });
pricing.set("AAPL", 101);
t.is(pricing.get("AAPL"), 101);
});
test("Pricing for a ticker can be removed", t => {
const pricing = memoryBackend({
AAPL: 100,
GOOG: 1000,
AMZN: 2000,
TSLA: 3000,
});
pricing.set("AAPL", 0);
pricing.set("GOOG", null);
pricing.set("AMZN", false);
pricing.set("TSLA", -1);
t.is(pricing.get("AAPL"), undefined);
t.is(pricing.get("GOOG"), undefined);
t.is(pricing.get("AMZN"), undefined);
t.is(pricing.get("TSLA"), undefined);
});
test("Missing pricing gives undefined", t => {
const pricing = memoryBackend({
AAPL: 0,
GOOG: null,
AMZN: undefined,
TSLA: -1,
});
t.is(pricing.get("AAPL"), undefined);
t.is(pricing.get("GOOG"), undefined);
t.is(pricing.get("AMZN"), undefined);
t.is(pricing.get("TSLA"), undefined);
t.is(pricing.get("NONE"), undefined);
});
test("A subset of ticker values can be obtained", t => {
const pricing = memoryBackend({
AAPL: 100,
GOOG: 1000,
AMZN: 2000,
TSLA: 3000,
});
t.deepEqual(pricing.subset("AAPL", "AMZN"), { AAPL: 100, AMZN: 2000 });
});

20
src/stores/pricing.js Normal file
View File

@ -0,0 +1,20 @@
const memoryBackend = require("./backend/pricing/memory.js");
const globalCache = {};
let globalInstance;
function global({ seed = {}, backend = memoryBackend } = {}) {
if (!globalInstance) {
Object.assign(globalCache, seed, { USD: 1 });
globalInstance = backend(globalCache);
}
return globalInstance;
}
function instance({ cache = {}, backend = memoryBackend } = {}) {
return backend(Object.assign(cache, { USD: 1 }));
}
module.exports = {
global,
instance,
};

13
test Normal file
View File

@ -0,0 +1,13 @@
AAPL
GOOG
GFN
ACAD
CYBR
ABB
AAPL:1000
rebalance:1
GOOG:1
rebalance:1

1916
yarn.lock Normal file

File diff suppressed because it is too large Load Diff