First commit

This commit is contained in:
Lewis Diamond 2020-07-12 22:47:21 -04:00
commit eac23b1780
17 changed files with 3168 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"
}

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"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"
}
}

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