First commit
This commit is contained in:
commit
37ebd9f824
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"
|
||||||
|
}
|
108
README.md
Normal file
108
README.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
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 (Except USD)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The application is built to process a stream of events, such as pricing updates and rebalance triggers. For this reason, no external API was used. As a possibility, I could
|
||||||
|
implement a readable stream which gets initialized from the provided API and then generates random variations of stock prices and a processor that triggers rebalancing on specific
|
||||||
|
events (i.e. APPL went down 5% today)
|
||||||
|
The following message would update the AAPL price to 123.
|
||||||
|
```
|
||||||
|
{type:"pricing", ticker: "AAPL", price: 123}
|
||||||
|
```
|
||||||
|
The following message would trigger an account rebalance on account id 1
|
||||||
|
```
|
||||||
|
{type:"rebalance", accountId: 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
Although much of the code is simplified for presentation only, storage backends using different databases could easily be added and different stream inputs (such as Kafka, NATS,
|
||||||
|
rabbitMQ) could be implemented. Testing of the codebase would remain easy as dependencies are contained in specific modules with simple interfaces.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
This example application currently only depends (ignoreing devDependencies) on the following packages:
|
||||||
|
1. uuid
|
||||||
|
1. strom
|
||||||
|
|
||||||
|
UUID is used only to generate an account ID when none is provided. By default an account with id `1` is present for easier user interaction since the goal of this application is
|
||||||
|
presentation.
|
||||||
|
|
||||||
|
Strom is a dependency-free nodejs stream processing utility library, originally forked from https://github.com/Wenzil/Mhysa. Strom was written by Wenzil, Jerry Kurian and Lewis
|
||||||
|
Diamond (myself).
|
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