First commit
This commit is contained in:
commit
f094355d46
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 4,
|
||||
"printWidth": 80,
|
||||
"arrowParens": "avoid"
|
||||
}
|
79
README.md
Normal file
79
README.md
Normal 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
20
package.json
Normal 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
102
src/index.js
Normal 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))));
|
37
src/processors/balancer.js
Normal file
37
src/processors/balancer.js
Normal 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 };
|
||||
}),
|
||||
]);
|
||||
};
|
108
src/processors/balancer.spec.js
Normal file
108
src/processors/balancer.spec.js
Normal 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
15
src/processors/pricing.js
Normal 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
302
src/pure/balancer.js
Normal 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
380
src/pure/balancer.spec.js
Normal 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
12
src/stores/account.js
Normal 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;
|
||||
};
|
89
src/stores/backend/account/memory.js
Normal file
89
src/stores/backend/account/memory.js
Normal 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,
|
||||
};
|
||||
};
|
72
src/stores/backend/account/memory.spec.js
Normal file
72
src/stores/backend/account/memory.spec.js
Normal 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]);
|
||||
});
|
9
src/stores/backend/pricing/memory.js
Normal file
9
src/stores/backend/pricing/memory.js
Normal 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;
|
||||
}, {}),
|
||||
});
|
57
src/stores/backend/pricing/memory.spec.js
Normal file
57
src/stores/backend/pricing/memory.spec.js
Normal 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
20
src/stores/pricing.js
Normal 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
13
test
Normal file
@ -0,0 +1,13 @@
|
||||
AAPL
|
||||
GOOG
|
||||
GFN
|
||||
ACAD
|
||||
CYBR
|
||||
ABB
|
||||
|
||||
AAPL:1000
|
||||
rebalance:1
|
||||
GOOG:1
|
||||
rebalance:1
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user