src/index.js
import clone from 'clone';
import {EventEmitter} from 'events';
import * as ProbJS from 'prob.js';
let privateNextId = 1;
function nextId(){ return privateNextId++; }
function sum(a){
let i,l,total=0;
for(i=0,l=a.length;i<l;++i)
total += a[i];
return total;
}
function dot(a,b){
let i,l,total=0;
/* istanbul ignore next */
if (a.length!==b.length)
throw new Error("market-agents: vector dimensions do not match in dot(a,b)");
for(i=0,l=a.length;i<l;++i)
if (b[i])
total += a[i]*b[i];
return total;
}
function poissonWake(){
const delta = ProbJS.exponential(this.rate)();
const result = this.wakeTime+delta;
if (result>0)
return result;
}
/**
* Agent with Poisson-distributed opportunities to act, with period managment, optional inventory, unit values and costs, and end-of-period production and consumption to satisfy trades
*
*/
export class Agent extends EventEmitter {
/**
* creates an Agent with clone of specified options and initializes with .init().
* Option properties are stored directly on the created agent's this.
*
* @param {Object} options Agent creation options
* @param {string} [options.description] text description of agent, optional
* @param {Object} [options.inventory={}] initial inventory, as object with good names as keys and levels as values
* @param {string} [options.money='money'] Good used as money by this agent
* @param {Object} [options.values={}] marginal value table of agent for goods that are redeemed at end-of-period, as object with goods as keys and numeric arrays as values
* @param {Object} [options.costs={}] marginal cost table of agent for goods that are produced at end-of-period, as object with goods as keys and numeric arrays as values
* @param {number} [options.wakeTime=0] initial wake-up time for agent, adjusted by this.init() to first poisson-based wake with .nextWake()
* @param {number} [options.rate=1] Poisson-arrival rate of agent wake events
* @param {function():number} [options.nextWake=poissonWake] calculates next Agent wake-up time
*
*/
constructor(options){
super();
const defaults = {
id: nextId(),
description: 'blank agent',
inventory: {},
money: 'money',
values: {},
costs: {},
wakeTime: 0,
rate: 1,
period: {
number:0,
duration:1000,
equalDuration: true
},
nextWake: poissonWake
};
Object.assign(this, defaults, clone(options, false));
this.init();
}
/**
* initialize an agent to new settings
* @param {Object} [newSettings] see constructor
*
*/
init(newSettings){
if (typeof(newSettings)==='object'){
// work with a shallow copy of the newSettings so
// the code can delete the inventory setting without side effects
let mySettings = Object.assign({}, newSettings);
// copy new values to inventory. do not reset other inventory values
Object.assign(this.inventory, mySettings.inventory);
// reset non-inventory as specified, completely overwriting previous
// to execute this reset, first: delete the inventory settings, then apply the remainder
delete mySettings.inventory;
Object.assign(this, mySettings);
}
// if this.money is defined but is not in inventory, zero the inventory of this.money
if (this.money && !(this.inventory[this.money]))
this.inventory[this.money] = 0;
/**
* time, in JS ms since epoch, of agent wake
* @type {number} this.wakeTime
*/
this.wakeTime = this.nextWake();
}
/**
* re-initialize agent to the beginning of a new simulation period
*
* @param {number|Object} period A period initialization object, or a number indicating a new period using the previous period's initialization object
* @param {number} period.number A number, usually sequential, identifying the next period, e.g. 1,2,3,4,5,...
* @param {boolean} [period.equalDuration=false] with positive period.duration, autogenerates startTime and endTime as n or n+1 times period.duration
* @param {number} [period.duration] the length of the period, used with period.equalDuration
* @param {number} [period.startTime] period begins, manual setting for initial time value for agent wakeTime
* @param {number} [period.endTime] period ends, no agent wake events will be emitted for this period after this time
* @param {Object} [period.init] initializer for other agent properties, passed to .init()
* @emits {pre-period} when initialization to new period is complete
* @example
* myAgent.initPeriod({number:1, duration:1000, equalDuration: true});
* myAgent.initPeriod(2);
*/
initPeriod(period){
// period might look like this
// period = {number:5, startTime:50000, init: {inventory:{X:0, Y:0}, values:{X:[300,200,100,0,0,0,0]}}}
// or period could be simply a number
if (typeof(period)==='object')
this.period = clone(period, false);
else if (typeof(period)==='number')
this.period.number = period;
if (this.period.equalDuration && this.period.duration){
this.period.startTime = this.period.number*this.period.duration;
this.period.endTime = (1+this.period.number)*this.period.duration;
}
if (typeof(this.period.startTime)==='number')
this.wakeTime = this.period.startTime;
this.init(this.period.init);
this.emit('pre-period');
}
/**
* ends current period, causing agent to undertake end-of-period tasks such as production and redemption of units
*
* @emits {post-period} when period ends, always, but after first completing any production/redemption
*/
endPeriod(){
if (typeof(this.produce)==='function') this.produce();
if (typeof(this.redeem)==='function') this.redeem();
this.emit('post-period');
}
/**
* percent of period used
*
* @return {number} proportion of period time used as a number from 0.0, at beginning of period, to 1.0 at end of period.
*
*/
pctPeriod(){
if ((this.period.startTime!==undefined) && (this.period.endTime>0) && (this.wakeTime!==undefined)){
return (this.wakeTime-this.period.startTime)/(this.period.endTime-this.period.startTime);
}
}
/**
* guess at number of random Poisson wakes remaining in period
*
* @return {number} "expected" number of remaining random Poisson wakes, calculated as (this.period.endTime-this.wakeTime)*rate
*
*/
poissonWakesRemainingInPeriod(){
if ((this.rate>0) && (this.wakeTime!==undefined) && (this.period.endTime>0)){
return (this.period.endTime - this.wakeTime)*this.rate;
}
}
/**
* wakes agent so it can act, emitting wake, and sets next wakeTime from this.nextWake() unless period.endTime exceeded
*
* @param {Object} [info] optional info passed to this.emit('wake', info)
* @emits {wake(info)} immediately
*/
wake(info){
this.emit('wake', info);
const nextTime = this.nextWake();
if (this.period.endTime){
if (nextTime<this.period.endTime)
this.wakeTime = nextTime;
else
this.wakeTime = undefined;
} else {
this.wakeTime = nextTime;
}
}
/**
* increases or decreases agent's inventories of one or more goods and/or money
*
* @param {Object} myTransfers object with goods as keys and changes in inventory as number values
* @param {Object} [memo] optional memo passed to event listeners
* @emits {pre-transfer(myTransfers, memo)} before transfer takes place, modifications to myTransfers will change transfer
* @emits {post-transfer(myTransfers, memo)} after transfer takes place
*/
transfer(myTransfers, memo){
if (myTransfers){
this.emit('pre-transfer', myTransfers, memo);
const goods = Object.keys(myTransfers);
for(let i=0,l=goods.length;i<l;++i){
if (this.inventory[goods[i]])
this.inventory[goods[i]] += myTransfers[goods[i]];
else
this.inventory[goods[i]] = myTransfers[goods[i]];
}
this.emit('post-transfer', myTransfers, memo);
}
}
/**
* agent's marginal cost of producing next unit
*
* @param {String} good (e.g. "X", "Y")
* @param {Object} hypotheticalInventory object with goods as keys and values as numeric levels of inventory
* @return {number} marginal unit cost of next unit, at given (negative) hypothetical inventory, using agent's configured costs
*/
unitCostFunction(good, hypotheticalInventory){
const costs = this.costs[good];
if ((Array.isArray(costs)) && (hypotheticalInventory[good] <= 0)){
return costs[-hypotheticalInventory[good]];
}
}
/**
* agent's marginal value for redeeming next unit
*
* @param {String} good (e.g. "X", "Y")
* @param {Object} hypotheticalInventory object with goods as keys and values as numeric levels of inventory
* @return {number} marginal unit value of next unit, at given (positive) hypothetical inventory, using agent's configured values
*/
unitValueFunction(good, hypotheticalInventory){
const vals = this.values[good];
if ((Array.isArray(vals)) && (hypotheticalInventory[good] >= 0)){
return vals[hypotheticalInventory[good]];
}
}
/**
* redeems units in positive inventory with configured values, usually called automatically at end-of-period.
* transfer uses memo object {isRedeem:1}
*
* @emits {pre-redeem(transferAmounts)} before calling .transfer, can modify transferAmounts
* @emits {post-redeem(transferAmounts)} after calling .transfer
*/
redeem(){
if (this.values){
const trans = {};
const goods = Object.keys(this.values);
trans[this.money] = 0;
for(let i=0,l=goods.length;i<l;++i){
let g = goods[i];
if (this.inventory[g]>0){
trans[g] = -this.inventory[g];
trans[this.money] += sum(this.values[g].slice(0,this.inventory[g]));
}
}
this.emit('pre-redeem', trans);
this.transfer(trans, {isRedeem:1});
this.emit('post-redeem',trans);
}
}
/**
* produces units in negative inventory with configured costs, usually called automatically at end-of-period.
* transfer uses memo object {isProduce:1}
*
* @emits {pre-redeem(transferAmounts)} before calling .transfer, can modify transferAmounts
* @emits {post-redeem(transferAmounts)} after calling .transfer
*/
produce(){
if (this.costs){
const trans = {};
const goods = Object.keys(this.costs);
trans[this.money] = 0;
for(let i=0,l=goods.length;i<l;++i){
let g = goods[i];
if (this.inventory[g]<0){
trans[this.money] -= sum(this.costs[g].slice(0,-this.inventory[g]));
trans[g] = -this.inventory[g];
}
}
this.emit('pre-produce', trans);
this.transfer(trans, {isProduce:1});
this.emit('post-produce', trans);
}
}
}
/**
* agent that places trades in one or more markets based on marginal costs or values
*
* This is an abstract class, meant to be subclassed for particular strategies.
*
*/
export class Trader extends Agent {
/**
* @param {Object} [options] passed to Agent constructor(); Trader specific properties detailed below
* @param {Array<Object>} [options.markets=[]] list of market objects where this agent acts on wake
* @param {number} [options.minPrice=0] minimum price when submitting limit orders to buy
* @param {number} [options.maxPrice=1000] maximum price when submitting sell limit orders to sell
* @param {boolean} [options.ignoreBudgetConstraint=false] ignore budget constraint, substituting maxPrice for unit value when bidding, and minPrice for unit cost when selling
* @listens {wake} to trigger sendBidsAndAsks()
*
*/
constructor(options){
const defaults = {
description: 'Trader',
markets: [],
minPrice: 0,
maxPrice: 1000
};
super(Object.assign({}, defaults, options));
this.on('wake', this.sendBidsAndAsks);
}
/** send a limit order to buy one unit to the indicated market at myPrice. Placeholder throws error. Must be overridden and implemented in other code.
* @abstract
* @param {Object} market
* @param {number} myPrice
* @throws {Error} when calling placeholder
*/
// eslint-disable-next-line no-unused-vars
bid(market, myPrice){
throw new Error("called placeholder for abstract method .bid(market,myPrice) -- you must implement this method");
}
/**
* send a limit order to sell one unit to the indicated market at myPrice. Placeholder throws error. Must be overridden and implemented in other code.
* @abstract
* @param {Object} market
* @param {number} myPrice
* @throws {Error} when calling placeholder
*/
// eslint-disable-next-line no-unused-vars
ask(market, myPrice){
throw new Error("called placeholder for abstract method .ask(market,myPrice) -- you must implement this method");
}
/**
* calculate price this agent is willing to pay. Placeholder throws error. Must be overridden and implemented in other code.
*
* @abstract
* @param {number} marginalValue The marginal value of redeeming the next unit.
* @param {Object} market For requesting current market conditions, previous trade price, etc.
* @return {number|undefined} agent's buy price or undefined if not willing to buy
* @throws {Error} when calling placeholder
*/
// eslint-disable-next-line no-unused-vars
bidPrice(marginalValue, market){
throw new Error("called placeholder for abstract method .bidPrice(marginalValue, market) -- you must implement this method");
}
/**
* calculate price this agent is willing to accept. Placeholder throws error. Must be overridden and implemented in other code.
*
*
* @abstract
* @param {number} marginalCost The marginal cost of producing the next unit.
* @param {Object} market For requesting current market conditions, previous trade price, etc.
* @return {number|undefined} agent's sell price or undefined if not willing to sell
* @throws {Error} when calling placeholder
*/
// eslint-disable-next-line no-unused-vars
askPrice(marginalCost, market){
throw new Error("called placeholder for abstract method .bidPrice(marginalValue, market) -- you must implement this method");
}
/**
* For each market in agent's configured markets, calculates agent's price strategy for buy or sell prices and then sends limit orders for 1 unit at those prices.
* Normally you do not need to explicltly call this function: the wake listener set in the constructor of Trader and subclasses calls sendBidsAndAsks() automatcally on each wake event.
*
*
*/
sendBidsAndAsks(){
for(let i=0,l=this.markets.length;i<l;++i){
let market = this.markets[i];
let unitValue = this.unitValueFunction(market.goods, this.inventory);
if (unitValue>0){
if (this.ignoreBudgetConstraint)
unitValue = this.maxPrice;
let myPrice = this.bidPrice(unitValue, market); // calculate my buy price proposal
if (myPrice)
this.bid(market, myPrice); // send my price proposal
}
let unitCost = this.unitCostFunction(market.goods, this.inventory);
if (unitCost>0){
if (this.ignoreBudgetConstraint)
unitCost = this.minPrice;
let myPrice = this.askPrice(unitCost, market); // calculate my sell price proposal
if (myPrice)
this.ask(market, myPrice); // send my price proposal
}
}
}
}
/**
* a reimplementation of Gode and Sunder's "Zero Intelligence" robots, as described in the economics research literature.
*
* see
*
* Gode, Dhananjay K., and S. Sunder. [1993]. ‘Allocative efficiency of markets with zero-intelligence traders: Market as a partial substitute for individual rationality.’ Journal of Political Economy, vol. 101, pp.119-137.
*
* Gode, Dhananjay K., and S. Sunder. [1993b]. ‘Lower bounds for efficiency of surplus extraction in double auctions.’ In Friedman, D. and J. Rust (eds). The Double Auction Market: Institutions, Theories, and Evidence, pp. 199-219.
*
* Gode, Dhananjay K., and S. Sunder. [1997a]. ‘What makes markets allocationally efficient?’ Quarterly Journal of Economics, vol. 112 (May), pp.603-630.
*
*/
export class ZIAgent extends Trader {
/**
* creates "Zero Intelligence" robot agent similar to those described in Gode and Sunder (1993)
*
* @param {Object} [options] passed to Trader and Agent constructors()
* @param {boolean} [options.integer] true instructs pricing routines to use positive integer prices, false allows positive real number prices
*/
constructor(options){
super(Object.assign({}, {description: 'Gode and Sunder Style ZI Agent'} , options));
}
/**
* calculate price this agent is willing to pay as a uniform random number ~ U[minPrice, marginalValue] inclusive.
* If this.integer is true, the returned price will be an integer.
*
*
* @param {number} marginalValue the marginal value of redeeming the next unit. sets the maximum price for random price generation
* @return {number|undefined} randomized buy price or undefined if marginalValue non-numeric or less than this.minPrice
*/
bidPrice(marginalValue){
if (typeof(marginalValue)!=='number') return undefined;
let p;
if (marginalValue===this.minPrice) return marginalValue;
if (marginalValue<this.minPrice) return undefined;
if (this.integer){
/* because Floor rounds down, add 1 to value to be in the range of possible prices */
/* guard against rare edge case with do/while */
do {
p = Math.floor(ProbJS.uniform(this.minPrice,marginalValue+1)());
} while (p>marginalValue);
} else {
p = ProbJS.uniform(this.minPrice, marginalValue)();
}
return p;
}
/**
* calculate price this agent is willing to accept as a uniform random number ~ U[marginalCost, maxPrice] inclusive.
* If this.integer is true, the returned price will be an integer.
*
*
* @param {number} marginalCost the marginal coat of producing the next unit. sets the minimum price for random price generation
* @return {number|undefined} randomized sell price or undefined if marginalCost non-numeric or greater than this.maxPrice
*/
askPrice(marginalCost){
if (typeof(marginalCost)!=='number') return undefined;
let p;
if (marginalCost===this.maxPrice) return marginalCost;
if (marginalCost>this.maxPrice) return undefined;
if (this.integer){
/* because Floor rounds down, add 1 to value to be in the range of possible prices */
/* guard against rare edge case with do/while */
do {
p = Math.floor(ProbJS.uniform(marginalCost,this.maxPrice+1)());
} while (p>this.maxPrice);
} else {
p = ProbJS.uniform(marginalCost, this.maxPrice)();
}
return p;
}
}
const um1p2 = ProbJS.uniform(-1,2);
const um1p1 = ProbJS.uniform(-1,1);
/**
* Unit agent: uses ZIAgent algorithm if there is no previous market price, afterward, bids/asks randomly within 1 price unit of previous price
*
* see also Brewer, Paul Chapter 4 in Handbook of Experimental Economics Results, Charles R. Plott and Vernon L. Smith, eds., Elsevier: 2008
*
* Chapter available on Google Books at https://books.google.com search for "Handbook of Experimental Economics Results" and go to pp. 31-45.
* or on Science Direct (paywall) at http://www.sciencedirect.com/science/article/pii/S1574072207000042
*
*
*
*/
export class UnitAgent extends ZIAgent {
/**
* creates "Unit" robot agent similar to those described in Brewer(2008)
*
* @param {Object} [options] passed to Trader and Agent constructors()
*/
constructor(options){
const defaults = {
description: "Paul Brewer's UNIT agent that bids/asks within 1 price unit of previous price"
};
super(Object.assign({}, defaults, options));
}
/**
* calculates random change from previous transaction price
* @return {number} a uniform random number on [-1,1]; or, if this.integer is set, picked randomly from the set {-1,0,1}
*/
randomDelta(){
let delta;
if (this.integer){
do {
delta = Math.floor(um1p2());
} while ((delta <= -2) || (delta >= 2.0));
} else {
do {
delta = um1p1();
} while ( (delta < -1) || (delta > 1) );
}
return delta;
}
/**
* Calculate price this agent is willing to pay.
* The returned price is within one price unit of the previous market trade price, or uses the ZIAgent random algorithm if there is no previous market trade price.
* Undefined (no bid) is returned if the propsed price would exceed the marginalValue parameter
* If this.integer is true, the returned price will be an integer.
*
*
* @param {number} marginalValue the marginal value of redeeming the next unit. sets the maximum price for allowable random price generation
* @param {Object} market The market for which a bid is being prepared. An object with lastTradePrice() method.
* @return {number|undefined} agent's buy price or undefined
*/
bidPrice(marginalValue, market){
let p;
if (typeof(marginalValue)!=='number') return undefined;
const previous = market.lastTradePrice();
if (previous)
p = previous+this.randomDelta();
else
p = super.bidPrice(marginalValue);
if ((p>marginalValue) || (p>this.maxPrice) || (p<this.minPrice)) return undefined;
return (p && this.integer)? Math.floor(p): p;
}
/**
* Calculate price this agent is willing to accept.
* The returned price is within one price unit of the previous market trade price, or uses the ZIAgent random algorithm if there is no previous market trade price.
* Undefined (no ask) is returned if the propsed price would be lower than the marginalCost parameter
* If this.integer is true, the returned price will be an integer.
*
*
* @param {number} marginalCost the marginal cost of producing the next unit. sets the minimum price for allowable random price generation
* @param {Object} market The market for which a bid is being prepared. An object with lastTradePrice() method.
* @return {number|undefined} agent's buy price or undefined
*/
askPrice(marginalCost, market){
if (typeof(marginalCost)!=='number') return undefined;
let p;
const previous = market.lastTradePrice();
if (previous)
p = previous+this.randomDelta();
else
p = super.askPrice(marginalCost);
if ((p<marginalCost) || (p>this.maxPrice) || (p<this.minPrice)) return undefined;
return (p && this.integer)? Math.floor(p): p;
}
}
/**
* OneupmanshipAgent is a robotic version of that annoying market participant who starts at extremely high or low price, and always bid $1 more, or ask $1 less than any competition
*
*/
export class OneupmanshipAgent extends Trader {
/**
* create OneupmanshipAgent
* @param {Object} [options] Passed to Trader and Agent constructors
*
*/
constructor(options){
const defaults = {
description: "Brewer's OneupmanshipAgent that increases the market bid or decreases the market ask by one price unit, if profitable to do so according to MV or MC"
};
super(Object.assign({}, defaults, options));
}
/**
* Calculate price this agent is willing to pay.
* The returned price is either this.minPrice (no bidding), or market.currentBidPrice()+1, or undefined.
* Undefined (no bid) is returned if the propsed price would exceed the marginalValue parameter
* this.integer is ignored
*
*
* @param {number} marginalValue the marginal value of redeeming the next unit. sets the maximum price for allowable bidding
* @param {Object} market The market for which a bid is being prepared. An object with currentBidPrice() and currentAskPrice() methods.
* @return {number|undefined} agent's buy price or undefined
*/
bidPrice(marginalValue, market){
if (typeof(marginalValue)!=='number') return undefined;
const currentBid = market.currentBidPrice();
if (!currentBid)
return this.minPrice;
if (currentBid<(marginalValue-1))
return currentBid+1;
}
/**
* Calculate price this agent is willing to accept.
* The returned price is either this.maxPrice (no asks), or market.currentAskPrice()-1, or undefined.
* Undefined (no bid) is returned if the propsed price is less than the marginalCost parameter
* this.integer is ignored
*
*
* @param {number} marginalCost the marginal cost of producing the next unit. sets the minimum price for allowable bidding
* @param {Object} market The market for which a bid is being prepared. An object with currentBidPrice() and currentAskPrice() methods.
* @return {number|undefined} agent's buy price or undefined
*/
askPrice(marginalCost, market){
if (typeof(marginalCost)!=='number') return undefined;
const currentAsk = market.currentAskPrice();
if (!currentAsk)
return this.maxPrice;
if (currentAsk>(marginalCost+1))
return currentAsk-1;
}
}
/**
* MidpointAgent - An agent that bids/asks halfway between the current bid and current ask.
* When there is no current bid or current ask, the agent bids minPrice or asks maxPrice.
*
*/
export class MidpointAgent extends Trader {
constructor(options){
const defaults = {
description: "Brewer's MidpointAgent bids/asks halfway between the bid and ask, if profitable to do according to MC or MV"
};
super(Object.assign({}, defaults, options));
}
/**
* Calculate price this agent is willing to pay.
* The returned price is either the min price, the midpoint of the bid/ask, or undefined.
* Undefined (no bid) is returned if the propsed price would exceed the marginalValue parameter
* this.integer==true causes midpoint prices to be rounded up to the next integer before comparison with marginalValue
*
* @param {number} marginalValue the marginal value of redeeming the next unit. sets the maximum price for allowable bidding
* @param {Object} market The market for which a bid is being prepared. An object with currentBidPrice() and currentAskPrice() methods.
* @return {number|undefined} agent's buy price or undefined
*/
bidPrice(marginalValue, market){
if (typeof(marginalValue)!=='number') return undefined;
const currentBid = market.currentBidPrice();
if (!currentBid)
return (this.minPrice <= marginalValue)? this.minPrice: undefined;
const currentAsk = market.currentAskPrice();
if (currentAsk){
const midpoint = (currentBid+currentAsk)/2;
const myBid = (this.integer)? Math.ceil(midpoint): midpoint;
if (myBid <= marginalValue)
return myBid;
}
}
/**
* Calculate price this agent is willing to accept.
* The returned price is either the max price, the midpoint of the bid/ask, or undefined.
* Undefined (no ask) is returned if the propsed price is less than the marginalCost parameter
* this.integer==true causes midpoint prices to be rounded up to the next integer before comparison with marginalValue
*
*
* @param {number} marginalCost the marginal cost of producing the next unit. sets the minimum price for allowable bidding
* @param {Object} market The market for which a bid is being prepared. An object with currentBidPrice() and currentAskPrice() methods.
* @return {number|undefined} agent's buy price or undefined
*/
askPrice(marginalCost, market){
if (typeof(marginalCost)!=='number') return undefined;
const currentAsk = market.currentAskPrice();
if (!currentAsk)
return (this.maxPrice>=marginalCost)? this.maxPrice: undefined;
const currentBid = market.currentBidPrice();
if (currentBid){
const midpoint = (currentBid+currentAsk)/2;
const myAsk = (this.integer)? Math.floor(midpoint): midpoint;
if (myAsk >= marginalCost)
return myAsk;
}
}
}
/**
* a reimplementation of a Kaplan Sniper Agent (JavaScript implementation by Paul Brewer)
*
* see e.g. "High Performance Bidding Agents for the Continuous Double Auction"
* Gerald Tesauro and Rajarshi Das, Institute for Advanced Commerce, IBM
*
* http://researcher.watson.ibm.com/researcher/files/us-kephart/dblauc.pdf
*
* for discussion of Kaplan's Sniper traders on pp. 4-5
*/
export class KaplanSniperAgent extends Trader {
/**
* Create KaplanSniperAgent
*
* @param {Object} [options] options passed to Trader and Agent constructors
* @param {number} [options.desiredSpread=10] desiredSpread for sniping; agent will accept trade if ||market.currentAskPrice()-market.currentBidPrice()||<=desiredSpread
*/
constructor(options){
const defaults = {
description: "Kaplan's snipers, trade on 'juicy' price, or low spread, or end of period",
desiredSpread: 10
};
super(Object.assign({}, defaults, options));
}
/**
* Calculates price this agent is willing to pay.
* The returned price always equals either undefined or the price of market.currentAsk(), triggering an immediate trade.
*
* The KaplanSniperAgent will buy, if market.currentAskPrice<=marginalValue, during one of three conditions:
* (A) market ask price is less than or equal to .getJuicyAskPrice(), which needs to be set at the simulation level to the previous period low trade price
* (B) when spread = (market ask price - market bid price) is less than or equal to agent's desiredSpread (default: 10)
* (C) when period is ending
*
* @param {number} marginalValue The marginal value of redeeming the next unit. Sets the maximum price for trading.
* @param {Object} market The market for which a bid is being prepared. An object with currentBidPrice() and currentAskPrice() methods.
* @return {number|undefined} agent's buy prce or undefined
*/
bidPrice(marginalValue, market){
if (typeof(marginalValue)!=='number') return undefined;
const currentBid = market.currentBidPrice();
const currentAsk = market.currentAskPrice();
// a trade can only occur if currentAsk <= marginalValue
if (currentAsk <= marginalValue){
// snipe if ask price is less than or equal to juicy ask price
const juicyPrice = this.getJuicyAskPrice();
if ((juicyPrice>0) && (currentAsk<=juicyPrice))
return currentAsk;
// snipe if low bid ask spread
if ((currentAsk>0) && (currentBid>0) && ((currentAsk-currentBid)<=this.desiredSpread))
return currentAsk;
// snipe if period end is three wakes away or less
if (this.poissonWakesRemainingInPeriod()<=3)
return currentAsk;
}
// otherwise return undefined
}
/**
* Calculates price this agent is willing to accept.
* The returned price always equals either undefined or the price of market.currentBid(), triggering an immediate trade.
*
* The KaplanSniperAgent will sell, if marginalCost<=market.currentBidPrice, during one of three conditions:
* (A) market bid price is greater than or equal to .getJuicyBidPrice(), which needs to be set at the simulation level to the previous period high trade price
* (B) when spread = (market ask price - market bid price) is less than or equal to agent's desiredSpread (default: 10)
* (C) when period is ending
*
* @param {number} marginalCost The marginal cost of producing the next unit. Sets the minimum price for trading.
* @param {Object} market The market for which an ask is being prepared. An object with currentBidPrice() and currentAskPrice() methods.
* @return {number|undefined} agent's sell price or undefined
*/
askPrice(marginalCost, market){
if (typeof(marginalCost)!=='number') return undefined;
const currentBid = market.currentBidPrice();
const currentAsk = market.currentAskPrice();
// only trade if currentBid >= marginalCost
if (currentBid >= marginalCost){
// snipe if bid price is greater than or equal to juicy bid price
const juicyPrice = this.getJuicyBidPrice();
if ((juicyPrice>0) && (currentBid>=juicyPrice))
return currentBid;
// snipe if low bid ask spread
if ((currentAsk>0) && (currentBid>0) && ((currentAsk-currentBid)<=this.desiredSpread))
return currentBid;
// snipe if period end is three wakes away or less
if (this.poissonWakesRemainingInPeriod()<=3)
return currentBid;
}
// otherwise return undefined
}
}
/**
* Pool for managing a collection of agents.
* Agents may belong to multiple pools.
*
*/
export class Pool {
constructor(){
this.agents = [];
this.agentsById = {};
}
/**
* Add an agent to the Pool
* @param {Object} agent to add to pool. Should be instanceof Agent, including subclasses.
*/
push(agent){
if (!(agent instanceof Agent))
throw new Error("Pool.push(agent), agent is not an instance of Agent or descendents");
if (!this.agentsById[agent.id]){
this.agents.push(agent);
this.agentsById[agent.id] = agent;
}
}
/**
* finds agent from Pool with lowest wakeTime
* @return {Object}
*/
next(){
if (this.nextCache) return this.nextCache;
let tMin=1e20, i=0, l=this.agents.length, A=this.agents, t=0, result=0;
for(;i<l;i++){
t = A[i].wakeTime;
if ( (t>0) && (t<tMin) ){
result = A[i];
tMin = t;
}
}
this.nextCache = result;
return result;
}
/**
* wakes agent in Pool with lowest wakeTime
*/
wake(){
const A = this.next();
if (A){
A.wake();
// wipe nextCache
delete this.nextCache;
}
}
/**
* finds latest period.endTime of all agent in Pool
* @return {number} max of agents period.endTime
*/
endTime(){
let endTime = 0;
for(let i=0,l=this.agents.length;i<l;++i){
let a = this.agents[i];
if (a.period.endTime > endTime)
endTime = a.period.endTime;
}
if (endTime>0) return endTime;
}
/**
* Repeatedly wake agents in Pool, until simulation time "untilTime" is reached. For a synchronous equivalent, see syncRun(untilTime, limitCalls)
*
* @param {number} untilTime Stop time for this run
* @param {number} batch Batch size of number of agents to wake up synchronously before surrendering to event loop
* @return {Promise<Object,Error>} returns promise resolving to pool, with caught errors passed to reject handler.
*/
runAsPromise(untilTime, batch){
const pool = this;
return new Promise(function(resolve,reject){
function loop(){
let nextAgent = 0;
try {
pool.syncRun(untilTime, (batch||1));
nextAgent = pool.next();
} catch(e){
return reject(e);
}
return (nextAgent && (nextAgent.wakeTime<untilTime))? setImmediate(loop): resolve(pool);
}
setImmediate(loop);
});
}
/**
* Repeatedly wake agents in Pool, until simulation time "untilTime" or "limitCalls" agent wake calls are reached.
* This method runs synchronously. It returns only when finished.
*
* @param {number} untilTime Stop time for this run
* @param {number} [limitCalls] Stop run once this number of agent wake up calls have been executed.
*
*/
syncRun(untilTime, limitCalls){
let nextAgent = this.next();
let calls = 0;
while (nextAgent && (nextAgent.wakeTime < untilTime) && (!(calls>=limitCalls)) ){
this.wake();
nextAgent = this.next();
calls++;
}
}
/**
* calls .initPeriod for all agents in the Pool
*
* @param {Object|number} param passed to each agent's .initPeriod()
*/
initPeriod(param){
// passing param to all the agents is safe because Agent.initPeriod does a deep clone
if (Array.isArray(param) && (param.length>0)){
for(let i=0,l=this.agents.length;i<l;i++)
this.agents[i].initPeriod(param[i%(param.length)]);
} else {
for(let i=0,l=this.agents.length;i<l;i++)
this.agents[i].initPeriod(param);
}
}
/**
* calls .endPeriod for all agents in the Pool
*/
endPeriod(){
for(let i=0,l=this.agents.length;i<l;i++)
this.agents[i].endPeriod();
}
/**
* adjusts Pool agents inventories, via agent.transfer(), in response to one or more trades
* @param {Object} tradeSpec Object providing specifics of trades.
* @param {string} tradeSpec.bs 'b' for buy trade, 's' for sell trade. In a buy trade, buyQ, buyId are single element arrays. In a sell trade, sellQ, sellId are single element arrays,
* @param {string} tradeSpec.goods the name of the goods, as stored in agent inventory object
* @param {string} tradeSpec.money the name of money used for payment, as stored in agent inventory object
* @param {number[]} tradeSpec.prices the price of each trade
* @param {number[]} tradeSpec.buyId the agent id of a buyer in a trade
* @param {number[]} tradeSpec.buyQ the number bought by the corresponding agent in .buyId
* @param {number[]} tradeSpec.sellId the agent id of a seller in a trade
* @param {number[]} tradeSPec.sellQ the number bought by he corresponding agent in .sellId
* @throws {Error} when accounting identities do not balance or trade invalid
*/
trade(tradeSpec){
let i,l,buyerTransfer,sellerTransfer;
if (typeof(tradeSpec)!=='object') return;
if ( (tradeSpec.bs) &&
(tradeSpec.goods) &&
(tradeSpec.money) &&
(Array.isArray(tradeSpec.prices)) &&
(Array.isArray(tradeSpec.buyQ)) &&
(Array.isArray(tradeSpec.sellQ)) &&
(Array.isArray(tradeSpec.buyId)) &&
(Array.isArray(tradeSpec.sellId)) ){
if (tradeSpec.bs==='b'){
if (tradeSpec.buyId.length!==1)
throw new Error("Pool.trade expected tradeSpec.buyId.length===1, got:"+tradeSpec.buyId.length);
if (tradeSpec.buyQ[0] !== sum(tradeSpec.sellQ))
throw new Error("Pool.trade invalid buy -- tradeSpec buyQ[0] != sum(sellQ)");
buyerTransfer = {};
buyerTransfer[tradeSpec.goods] = tradeSpec.buyQ[0];
buyerTransfer[tradeSpec.money] = -dot(tradeSpec.sellQ,tradeSpec.prices);
this.agentsById[tradeSpec.buyId[0]].transfer(buyerTransfer, {isTrade:1, isBuy:1});
for(i=0,l=tradeSpec.prices.length;i<l;++i){
sellerTransfer = {};
sellerTransfer[tradeSpec.goods] = -tradeSpec.sellQ[i];
sellerTransfer[tradeSpec.money] = tradeSpec.prices[i]*tradeSpec.sellQ[i];
this.agentsById[tradeSpec.sellId[i]].transfer(sellerTransfer, {isTrade:1, isSellAccepted:1});
}
} else if (tradeSpec.bs==='s'){
if (tradeSpec.sellId.length!==1)
throw new Error("Pool.trade expected tradeSpec.sellId.length===1. got:"+tradeSpec.sellId.length);
if (tradeSpec.sellQ[0] !== sum(tradeSpec.buyQ))
throw new Error("Pool.trade invalid sell -- tradeSpec sellQ[0] != sum(buyQ)");
sellerTransfer = {};
sellerTransfer[tradeSpec.goods] = -tradeSpec.sellQ[0];
sellerTransfer[tradeSpec.money] = dot(tradeSpec.buyQ,tradeSpec.prices);
this.agentsById[tradeSpec.sellId[0]].transfer(sellerTransfer, {isTrade:1, isSell:1});
for(i=0,l=tradeSpec.prices.length;i<l;++i){
buyerTransfer = {};
buyerTransfer[tradeSpec.goods] = tradeSpec.buyQ[i];
buyerTransfer[tradeSpec.money] = -tradeSpec.prices[i]*tradeSpec.buyQ[i];
this.agentsById[tradeSpec.buyId[i]].transfer(buyerTransfer, {isTrade:1, isBuyAccepted:1});
}
}
}
}
/**
* distribute an aggregate setting of buyer Values or seller Costs to a pool of sellers, by giving each agent a successive value from the array without replacement
*
* @param {string} field "values" or "costs"
* @param {good} good name of good for agents inventories.
* @param {number[]|string} aggregateArray list of numeric values or costs reflecting the aggregate pool values or costs
* @throws {Error} when field is invalid or aggregateArray is wrong type
*/
distribute(field, good, aggregateArray){
let i,l;
let myCopy;
if (Array.isArray(aggregateArray)){
myCopy = aggregateArray.slice();
} else if (typeof(aggregateArray)==='string') {
myCopy = (aggregateArray
.replace(/,/g," ")
.split(/\s+/)
.map(function(s){ return +s; })
.filter(function(v){ return (v>0); })
);
} else {
/* istanbul ignore next */
throw new Error("Error: Pool.prototype.distribute: expected aggregate to be Array or String, got: "+typeof(aggregateArray));
}
if ((field!=='values') && (field!=='costs'))
throw new Error("Pool.distribute(field,good,aggArray) field should be 'values' or 'costs', got:"+field);
for(i=0,l=this.agents.length;i<l;++i){
if (typeof(this.agents[i][field])==='undefined')
this.agents[i][field] = {};
this.agents[i][field][good] = [];
}
i = 0;
l = this.agents.length;
while(myCopy.length>0){
this.agents[i][field][good].push(myCopy.shift());
i = (i+1) % l;
}
}
}