src/index.js
// Copyright 2016 Paul Brewer, Economic and FInancial Technology Consulting LLC
// This is open source software. The MIT License applies to this software.
// see https://opensource.org/licenses/MIT or included License.md file
// order format
// order = [
// 0 counter: // strictly increasing, may have gaps
// 1 tlocal: // local insertion time (numeric JS timestamp)
// 2 t: // official time
// 3 tx: // expiration time, in units of official time
// 4 u: user number
// 5 c: // 1 to cancel all active orders by userid
// 6 q: // quantity (could be 0)
// 7 b: // limit order price to buy
// 8 s: // limit order price to sell
// 9 bs: // buy stop. rising price triggers market order to buy (numeric)
// 10 bsp: // buy stop limit price. buy limit price sent when trade price is greater than or equal to stop
// 11 ss: // sell stop. falling price triggers market order to sell (numeric)
// 12 ssp: // sell stop limit price. sell limit price sent when trade price is less than or equal to stop
// 13 trigb: // triggers new buy limit order asap
// 14 trigs: // triggers new sell limit order asap
// 15 trigbs: // triggers new buy stop order asap
// 16 trigbsp: // limit price if triggered buy stop is activated
// 17 trigss: // triggers new sell stop order asap
// 18 trigssp: // limit price if triggered sell stop is activated
// ]
import {MarketEngine} from 'market-engine';
import marketPricing from 'market-pricing';
import PartialIndex from 'partial-index';
/**
* orderHeader defines the order of fields in an accepted order. pre-orders start with field 2, the 't' field, as field 0.
*
* @type {string[]} orderHeader
*/
export const orderHeader = [
'count',
'tlocal',
't',
'tx',
'id',
'cancel',
'q',
'buyPrice',
'sellPrice',
'buyStop',
'buyStopPrice',
'sellStop',
'sellStopPrice',
'triggersBuyPrice',
'triggersSellPrice',
'triggersBuyStop',
'triggersBuyStopPrice',
'triggersSellStop',
'triggersSellStopPrice'
];
/**
* convert order from array format to object format using field names from orderHeader
*
* @param {number[]} ordera Order in array format, either a 17 number array or a 19 number array.
* @return {Object} order object with fields from orderHeader
*/
export function ao(ordera){
const obj = {};
let i=0,l=orderHeader.length,offset=0;
if (ordera.length===orderHeader.length){
offset=0;
} else if (ordera.length===(orderHeader.length-2)){
offset=2;
} else {
throw new Error("market-example-contingent function ao(), expected order array to have length 17 or 19, got "+ordera.length);
}
// always report orderHeader fields 0-6, afterward, report only nonzero fields
for(i=offset;i<l;++i)
if ((i<=6) || (ordera[i-offset]))
obj[orderHeader[i]] = ordera[i-offset];
return obj;
}
/**
* convert order from object format to 17-element pre-order array format
* @param oin Object format order in, using keys from orderHeader
* @return {number[]} 17 element pre-order array, suitable for use with .push()
*/
export function oa(oin){
const a = [];
let i,l;
if (typeof(oin)==='object'){
for(i=2,l=orderHeader.length;i<l;++i){
a[i-2] = oin[orderHeader[i]];
if (!a[i-2]) a[i-2] = 0;
}
}
return a;
}
/**
* Market with contingent order features, such as stop orders, one-cancels-other orders and one-sends-other orders
*/
export class Market extends MarketEngine {
/**
* Market constructor
*
* @param {Object} options Options affecting market behavior. Also passed to marekt-engine constructor. Accessible later in this.o
* @param {number} [options.buyImprove] If positive, indicates entry in buy book new buy order must beat to be acceptable. 0=off. 1= new buy must beat book highest buy. 2=must beat 2nd best book,etc.
* @param {number} [options.sellImprove] If positive, indicates entry in sell book new sell order must beat to be acceptable. 0=off. 1= new sell must be lower than lowest previous sell order on book.
* @param {boolean} [options.resetAfterEachTrade] If true, calls .clear() after each trade, clearing the market books and active trade list.
* @param {number} [options.buySellBookLimit] If positive, after each trade keeps at most buySellBookLimit orders in the buy book, and buySellBookLimit orders in the sell book, deleting other orders.
* @param {boolean} [options.bookfixed=1] If true, books are fixed size and scan active list after each trade. If false, books are accordian-style that can shrink 50% before re-scanning old orders.
* @param {number} [options.booklimit=100] Indicates maximum and initial size, in orders, of order book for each category (buy,sell,buystop,sellstop).
* @listens {bump} triggering book update with .cleanup() when orders are bumped off due to cancellation/expiration
* @listens {before-order} triggering check of .improvementRule() to check new orders against .buyImprove/.sellImprove
* @listens {order} to detect trades between orders, and when trades are found, calling market-engine inherited .trade() method
* @listens {trade} triggering one-sends-other orders via .tradeTrigger() to be pushed to .inbox
* @listens {trade-cleanup} triggering stop orders to .inbox, rescanning order books and applying post-trade book size limits
* @listens {stops} to push buy/sell orders resulting from stops to .inbox
*/
constructor(options){
// defaults defined standard this.o.tCol, etc. is authoritative for locating particular data in an order
const defaults = {
pushArray:1,
countCol:0,
tlocalCol:1,
tCol:2,
txCol:3,
idCol:4,
cancelCol:5,
qCol:6,
bpCol:7,
spCol:8,
bsCol:9,
bspCol:10,
ssCol:11,
sspCol:12,
trigSliceBegin:13,
trigSliceEnd:19,
bookfixed: 1,
booklimit: 100
};
super(Object.assign({}, defaults, options));
this.on('bump', this.cleanup); // update books if orders are bumped off in a bump even
this.on('before-order', this.improvementRule);
this.on('order', function(){
// order has already been pushed to .a in MarketEngine.order
this.book.buy.syncLast();
this.book.sell.syncLast();
this.book.buyStop.syncLast();
this.book.sellStop.syncLast();
this.findAndProcessTrades();
});
this.on('trade', this.tradeTrigger);
this.on('trade-cleanup', function(tradespec){
this.findAndProcessStops(tradespec);
this.cleanup();
this.bookSizeRule();
});
this.on('stops', this.stopsTrigger);
this.clear();
}
/**
* submit order to the Market's inbox for eventual processing
*
* @param {number[]} neworder a 17 element number array represeting an unentered order.
* @return {string|undefined} Error message on invalid order format, undefined on ok submission
*/
submit(neworder){
if (Array.isArray(neworder) && (neworder.length===(orderHeader.length-2))){
this.inbox.push(neworder);
return undefined;
}
return "market-example-contingent.submit: Invalid order, not an array of the correct length, got:"+JSON.stringify(neworder);
}
/**
* process order from the top of the inbox, returning inbox length
*
* @return {number} number of orders remaining in inbox
*/
process(){
if (this.inbox.length>0)
this.push(this.inbox.shift());
return this.inbox.length;
}
/**
* before-order event-handler for enforcing improvementRule. Note: you would not normally need to explicitly call this method, as the constructor attaches it as a before-order handler.
*
* @param {number[]} A pre-order which is a 17 element number array. Provided by market-engine before-order event handler.
* @param {function(rejectedOrder:number[])} Function with side-effect of marking orders as rejected. Provided by market-engine before-order event handler.
* @private
*/
improvementRule(neworder, reject){
const bpCol = this.o.bpCol, spCol=this.o.spCol;
// if buyImprove rule in effect, reject buy orders if new order price not above price from book
if ( (this.o.buyImprove && neworder[bpCol]) &&
(this.book.buy.idx) &&
(this.book.buy.idx.length >= this.o.buyImprove) &&
(neworder[bpCol] <= this.book.buy.val(this.o.buyImprove-1))
) return reject(neworder);
// if sellImprove rule in effect, reject sell orders if new order price not below price from book
if ( (this.o.sellImprove && neworder[spCol]) &&
(this.book.sell.idx) &&
(this.book.sell.idx.length >= this.o.sellImprove) &&
(neworder[spCol] >= this.book.sell.val(this.o.sellImprove-1))
) return reject(neworder);
}
/**
* enforce market reset or book trimming after each trade. Called automatically by trade-cleanup event handler.
* @private
*/
bookSizeRule(){
if (this.o.resetAfterEachTrade)
return this.clear();
const buySellBookLimit = this.o.buySellBookLimit;
if (buySellBookLimit>0){
const keep = {};
[this.book.buy,this.book.sell].forEach(function(B){
let i,l;
for(i=0,l=Math.min(B.idx.length,buySellBookLimit);i<l;++i)
keep[B.idx[i]] = 1;
});
[this.book.buyStop,this.book.sellStop].forEach(function(B){
let i,l;
for(i=0,l=B.idx.length;i<l;i++)
keep[B.idx[i]] = 1;
});
const keepidx = Object.keys(keep).sort(function(a,b){ return +a-b; });
let i,l,temp=[];
for(i=0,l=keepidx.length;i<l;++i)
temp[i] = this.a[keepidx[i]];
for(i=0,l=keepidx.length;i<l;++i)
this.a[i] = temp[i];
this.a.length = l;
this.books.forEach(function(B){ B.scan(); });
}
}
/**
* market current Bid Price
* @return {number|undefined} price of highest buy limit order from market buy limit order book, if any.
*/
currentBidPrice(){
// relies on buy book sorted by price first because .val returns primary sort key
return this.book.buy.val(0);
}
/**
* market current Ask Price
* @return {number|undefined} price of lowest sell limit order from market sell limit order book, if any.
*/
currentAskPrice(){
// relies on sell book sorted by price first because .val returns primary sort key
return this.book.sell.val(0);
}
/**
* last trade price, if any.
* @return {number|undefined}
*/
lastTradePrice(){
if (this.lastTrade && this.lastTrade.prices && this.lastTrade.prices.length)
return this.lastTrade.prices[this.lastTrade.prices.length-1];
}
/**
* called automatically in after-trade listener: searches stop books for stop orders and emits stop for stop orders triggered by the trading in parameter tradespec
* @param {Object} tradespec Trading specification produced from limit order matching.
* @emits {stops(t, matches)} when a change in trade price should trigger a stop order
* @private
*/
findAndProcessStops(tradespec){
let matches;
for(matches=this.stopsMatch(tradespec);Math.max(...matches)>0;matches=this.stopsMatch(tradespec)){
this.emit('stops', tradespec.t, matches);
}
}
/**
* called automatically in order listener: determines trades between limit buy orders and limit sell orders, calling market-engine .trade()
* @private
*/
findAndProcessTrades(){
let seqtrades;
let tradeSpec;
let i,l;
while ((seqtrades = marketPricing.sequential(this.book.buy.idxdata(),
this.book.sell.idxdata(),
this.o.countCol,
this.o.bpCol,
this.o.qCol,
this.o.spCol,
this.o.qCol))!==undefined){
// returns seqtrades = ['b'||'s', prices[], totalQ, buyQ[], sellQ[] ]
tradeSpec = {
t: ((seqtrades[0]==='b')? (this.book.buy.idxdata(0)[this.o.tCol]): (this.book.sell.idxdata(0)[this.o.tCol])),
bs: seqtrades[0],
prices: seqtrades[1],
totalQ: seqtrades[2],
buyQ: seqtrades[3],
sellQ: seqtrades[4],
buyA: this.book.buy.idx.slice(0,seqtrades[3].length),
sellA: this.book.sell.idx.slice(0,seqtrades[4].length)
};
tradeSpec.buyId = [];
tradeSpec.sellId = [];
for(i=0,l=tradeSpec.buyA.length;i<l;++i)
tradeSpec.buyId[i] = this.a[tradeSpec.buyA[i]][this.o.idCol];
for(i=0,l=tradeSpec.sellA.length;i<l;++i)
tradeSpec.sellId[i] = this.a[tradeSpec.sellA[i]][this.o.idCol];
this.trade(tradeSpec);
this.lastTrade = tradeSpec;
}
}
/**
* returns a 2 element array indicating [number of buy-stop, number of sell-stop] that are triggered by the reported trades in parameter tradespec
* called automatically in stop order scanning
* @param {Object} tradespec Trade specification
* @private
*/
stopsMatch(tradespec){
const prices = tradespec.prices;
const low = Math.min(...prices);
const high = Math.max(...prices);
return [
( this.book.buyStop.valBisect(high) || 0),
( this.book.sellStop.valBisect(low) || 0)
];
}
/**
* changes a portion or all of one or more stop orders into limit orders for execution that are pushed into .inbox
* @param {number} t Effective time.
* @param {matches} two element array from Market#stopsMatch
* @private
*/
stopsTrigger(t, matches){
const o = this.o;
if (!matches) return;
function toBuyAtMarket(buystop){
const neworder = buystop.slice(); // includes triggers in copies, if any
neworder[o.tCol] = t;
neworder[o.txCol] = 0;
neworder[o.cancelCol] = 0;
neworder[o.bpCol] = neworder[o.bspCol];
neworder[o.bsCol] = 0;
neworder[o.bspCol] = 0;
neworder[o.spCol] = 0;
neworder[o.ssCol] = 0;
neworder[o.sspCol] = 0;
neworder.splice(0,2);
return neworder;
}
function toSellAtMarket(sellstop){
const neworder = sellstop.slice(); // includes triggers in copies, if any
neworder[o.tCol] = t;
neworder[o.txCol] = 0;
neworder[o.cancelCol] = 0;
neworder[o.bpCol] = 0;
neworder[o.bsCol] = 0;
neworder[o.bspCol] = 0;
neworder[o.spCol] = neworder[o.sspCol];
neworder[o.ssCol] = 0;
neworder[o.sspCol] = 0;
neworder.splice(0,2);
return neworder;
}
if (matches[0]){
const bs = this.book.buyStop;
const newOrders = (bs
.idxdata()
.slice(0,matches[0])
.map(toBuyAtMarket)
);
const trashIdxs = bs.idx.slice(0,matches[0]);
this.inbox.push(...newOrders);
this.trash.push(...trashIdxs);
}
if (matches[1]){
const ss = this.book.sellStop;
const newOrders = (ss
.idxdata()
.slice(0,matches[1])
.map(toSellAtMarket)
);
const trashIdxs = ss.idx.slice(0,matches[1]);
this.inbox.push(...newOrders);
this.trash.push(...trashIdxs);
}
this.cleanup();
}
/**
* Push to .inbox an order triggered by partial or full execution of an OSO one-sends-other
* i.e. any order with the last 6 fields filled.
* @param {number} j The OSO order's index in the active list a[]
* @param {number} q The quantity executed of the OSO order, determining the q of the new order for execution.
* @param {number} t The effective time
* @private
*/
triggerOrderToInbox(j,q,t){
if ((j===undefined) || (!q)) return;
const myorder = this.a[j];
const o = this.o;
const qCol = o.qCol;
const bpCol = o.bpCol;
const tCol = o.tCol;
const idCol = o.idCol;
const trigSliceBegin = o.trigSliceBegin;
const trigSliceEnd = o.trigSliceEnd;
const inbox = this.inbox;
if (myorder && (myorder[qCol]>=q)){
if ((myorder[trigSliceBegin]>0) ||
(myorder[trigSliceBegin+1]>0) ||
(myorder[trigSliceBegin+2]>0) ||
(myorder[trigSliceBegin+3]>0) ||
(myorder[trigSliceBegin+4]>0) ||
(myorder[trigSliceBegin+5]>0)){
let trigorder = [];
for(let ii=0,ll=trigSliceEnd-2;ii<ll;++ii)
trigorder[ii] = 0;
trigorder[tCol-2] = t;
trigorder[idCol-2] = myorder[idCol];
trigorder[qCol-2] = q;
for(let ii=0,ll=trigSliceEnd-trigSliceBegin;ii<ll;++ii)
trigorder[bpCol+ii-2] = myorder[ii+trigSliceBegin];
inbox.push(trigorder);
}
}
}
/**
* Push to .inbox any orders triggered by OSO orders involved in trades in parameter tradespec.
* Called automatically by trade listener set up in constructor
* @param {Object} tradespec Trade specification
* @private
*/
tradeTrigger(tradespec){
const t = tradespec.t;
const buyA = tradespec.buyA, sellA=tradespec.sellA;
const buyQ = tradespec.buyQ, sellQ=tradespec.sellQ;
if (buyA)
for(let i=0,l=buyA.length;i<l;++i){
this.triggerOrderToInbox(buyA[i],buyQ[i],t);
}
if (sellA)
for(let i=0,l=sellA.length;i<l;++i){
this.triggerOrderToInbox(sellA[i],sellQ[i],t);
}
}
/**
* clears or resets market to initial "new" condition, clearing active list, books, and trash
*/
clear(){
super.clear(); // clears .a and .trash
/**
* container for books and book settings
* @type {Object} this.book
*/
this.book = {};
/**
* upper limit for book size
* @type {number} this.book.limit
*/
this.book.limit = this.o.booklimit || 100;
/**
* indicator that book is fixed-size (true) or accordian (false)
* @type {boolean} this.book.fixed
*/
this.book.fixed = this.o.bookfixed;
/**
* buy order book provided by PartialIndex
* @type {Object} this.book.buy
*/
this.book.buy = new PartialIndex(this.a,this.book.limit,this.o.bpCol,-1,this.o.countCol,1,this.o.qCol,1);
/**
* sell order book provided by PartialIndex
* @type {Object} this.book.sell
*/
this.book.sell = new PartialIndex(this.a,this.book.limit,this.o.spCol,1,this.o.countCol,1,this.o.qCol,1);
/**
* buyStop order book provided by PartialIndex
* @type {Object} this.book.buyStop
*/
this.book.buyStop = new PartialIndex(this.a,this.book.limit,this.o.bsCol,1,this.o.countCol,1,this.o.qCol,1);
/**
* sellStop order book provided by PartialIndex
* @type {Object} this.book.sellStop
*/
this.book.sellStop = new PartialIndex(this.a,this.book.limit,this.o.ssCol,-1,this.o.countCol,1,this.o.qCol,1);
/**
* list of all books
* @type {Array<Object>} this.books
*/
this.books = [this.book.buy,this.book.sell,this.book.buyStop,this.book.sellStop];
/**
* inbox for pre-orders from internal processes such as stops and triggers. new orders should also be pushed here.
* @type {Array<number[]>} this.inbox
*/
this.inbox = [];
}
/**
* emties trashed orders from book lists and scans active list to refill books.
* Called by other methods as needed. You probably won't need to call this function, unless implementing new functionality that affects the books or trashes orders.
* @private
*/
cleanup(){
const blimit = this.book.limit;
const bfixed = this.book.fixed;
const r = this.emptyTrash();
this.books.forEach(function (b){
if ((!bfixed) && (r.length < 10)){
b.remove(r, {shrink:1});
if (b.limit < (blimit/2))
b.scan(blimit);
} else {
b.scan(blimit);
}
});
}
}