@alpacahq/alpaca-trade-api
Version:
Javascript library for the Alpaca Trade API
356 lines (355 loc) • 12.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const events = require("events");
const WebSocket = require("ws");
const entity = require("./entity");
// Listeners
// A client can listen on any of the following events, states, or errors
// Connection states. Each of these will also emit EVENT.STATE_CHANGE
var STATE;
(function (STATE) {
STATE.AUTHENTICATING = "authenticating";
STATE.CONNECTED = "connected";
STATE.CONNECTING = "connecting";
STATE.DISCONNECTED = "disconnected";
STATE.WAITING_TO_CONNECT = "waiting to connect";
STATE.WAITING_TO_RECONNECT = "waiting to reconnect";
})((STATE = exports.STATE || (exports.STATE = {})));
// Client events
var EVENT;
(function (EVENT) {
EVENT.CLIENT_ERROR = "client_error";
EVENT.STATE_CHANGE = "state_change";
EVENT.AUTHORIZED = "authorized";
EVENT.UNAUTHORIZED = "unauthorized";
EVENT.ORDER_UPDATE = "trade_updates";
EVENT.ACCOUNT_UPDATE = "account_updates";
EVENT.STOCK_TRADES = "stock_trades";
EVENT.STOCK_QUOTES = "stock_quotes";
EVENT.STOCK_AGG_SEC = "stock_agg_sec";
EVENT.STOCK_AGG_MIN = "stock_agg_min";
})((EVENT = exports.EVENT || (exports.EVENT = {})));
// Connection errors Each of these will also emit EVENT.ERROR
var ERROR;
(function (ERROR) {
ERROR.BAD_KEY_OR_SECRET = "bad key id or secret";
ERROR.CONNECTION_REFUSED = "connection refused";
ERROR.MISSING_API_KEY = "missing api key";
ERROR.MISSING_SECRET_KEY = "missing secret key";
ERROR.UNKNOWN = "unknown error";
})((ERROR = exports.ERROR || (exports.ERROR = {})));
/**
* AlpacaStreamClient manages a connection to Alpaca's websocket api
*/
class AlpacaStreamClient extends events.EventEmitter {
constructor(opts = {}) {
super();
this.defaultOptions = {
// A list of subscriptions to subscribe to on connection
subscriptions: [],
// Whether the library should reconnect automatically
reconnect: true,
// Reconnection backoff: if true, then the reconnection time will be initially
// reconnectTimeout, then will double with each unsuccessful connection attempt.
// It will not exceed maxReconnectTimeout
backoff: true,
// Initial reconnect timeout (seconds) a minimum of 1 will be used if backoff=false
reconnectTimeout: 0,
// The maximum amount of time between reconnect tries (applies to backoff)
maxReconnectTimeout: 30,
// The amount of time to increment the delay between each reconnect attempt
backoffIncrement: 0.5,
// If true, client outputs detailed log messages
verbose: false,
// If true we will use the polygon ws data source, otherwise we use
// alpaca ws data source
usePolygon: false,
};
// Set minimum reconnectTimeout of 1s if backoff=false
if (!opts.backoff && opts.reconnectTimeout < 1) {
opts.reconnectTimeout = 1;
}
// Merge supplied options with defaults
this.session = Object.assign(this.defaultOptions, opts);
this.session.url = this.session.url.replace(/^http/, "ws") + "/stream";
// Keep track of subscriptions in case we need to reconnect after the client
// has called subscribe()
this.subscriptionState = {};
this.session.subscriptions.forEach((x) => {
this.subscriptionState[x] = true;
});
this.currentState = STATE.WAITING_TO_CONNECT;
// Register internal event handlers
// Log and emit every state change
Object.keys(STATE).forEach((s) => {
this.on(STATE[s], () => {
this.currentState = STATE[s];
this.log("info", `state change: ${STATE[s]}`);
this.emit(EVENT.STATE_CHANGE, STATE[s]);
});
});
// Log and emit every error
Object.keys(ERROR).forEach((e) => {
this.on(ERROR[e], () => {
this.log("error", ERROR[e]);
this.emit(EVENT.CLIENT_ERROR, ERROR[e]);
});
});
}
connect() {
// Check the credentials
if (this.session.apiKey.length === 0 && this.session.oauth.length === 0) {
throw new Error(ERROR.MISSING_API_KEY);
}
if (this.session.secretKey.length === 0 && this.session.oauth.length === 0) {
throw new Error(ERROR.MISSING_SECRET_KEY);
}
// Reset reconnectDisabled since the user called connect() again
this.reconnectDisabled = false;
this.emit(STATE.CONNECTING);
this.conn = new WebSocket(this.session.url);
this.conn.once("open", () => {
this.authenticate();
});
this.conn.on("message", (data) => this.handleMessage(data));
this.conn.once("error", (err) => {
this.emit(ERROR.CONNECTION_REFUSED);
});
this.conn.once("close", () => {
this.emit(STATE.DISCONNECTED);
if (this.session.reconnect && !this.reconnectDisabled) {
this.reconnect();
}
});
}
_ensure_polygon(channels) {
if (this.polygon.connectCalled) {
if (channels) {
this.polygon.subscribe(channels);
}
return;
}
this.polygon.connect(channels);
}
_unsubscribe_polygon(channels) {
if (this.polygon.connectCalled) {
if (channels) {
this.polygon.unsubscribe(channels);
}
}
}
subscribe(keys) {
let wsChannels = [];
let polygonChannels = [];
keys.forEach((key) => {
const poly = ["Q.", "T.", "A.", "AM."];
let found = poly.filter((channel) => key.startsWith(channel));
if (found.length > 0) {
polygonChannels.push(key);
}
else {
wsChannels.push(key);
}
});
if (wsChannels.length > 0) {
const subMsg = {
action: "listen",
data: {
streams: wsChannels,
},
};
this.send(JSON.stringify(subMsg));
}
if (polygonChannels.length > 0) {
this._ensure_polygon(polygonChannels);
}
keys.forEach((x) => {
this.subscriptionState[x] = true;
});
}
unsubscribe(keys) {
// Currently, only Polygon channels can be unsubscribed from
let polygonChannels = [];
keys.forEach((key) => {
const poly = ["Q.", "T.", "A.", "AM."];
let found = poly.filter((channel) => key.startsWith(channel));
if (found.length > 0) {
polygonChannels.push(key);
}
});
if (polygonChannels.length > 0) {
this._unsubscribe_polygon(polygonChannels);
}
keys.forEach((x) => {
this.subscriptionState[x] = false;
});
}
subscriptions() {
// if the user unsubscribes from certain equities, they will still be
// under this.subscriptionState but with value "false", so we need to
// filter them out
return Object.keys(this.subscriptionState).filter((x) => this.subscriptionState[x]);
}
onConnect(fn) {
this.on(STATE.CONNECTED, () => fn());
}
onDisconnect(fn) {
this.on(STATE.DISCONNECTED, () => fn());
}
onStateChange(fn) {
this.on(EVENT.STATE_CHANGE, (newState) => fn(newState));
}
onError(fn) {
this.on(EVENT.CLIENT_ERROR, (err) => fn(err));
}
onOrderUpdate(fn) {
this.on(EVENT.ORDER_UPDATE, (orderUpdate) => fn(orderUpdate));
}
onAccountUpdate(fn) {
this.on(EVENT.ACCOUNT_UPDATE, (accountUpdate) => fn(accountUpdate));
}
onPolygonConnect(fn) {
this.polygon.on(STATE.CONNECTED, () => fn());
}
onPolygonDisconnect(fn) {
this.polygon.on(STATE.DISCONNECTED, () => fn());
}
onStockTrades(fn) {
if (this.session.usePolygon) {
this.polygon.on(EVENT.STOCK_TRADES, function (subject, data) {
fn(subject, data);
});
}
else {
this.on(EVENT.STOCK_TRADES, function (subject, data) {
fn(subject, data);
});
}
}
onStockQuotes(fn) {
if (this.session.usePolygon) {
this.polygon.on(EVENT.STOCK_QUOTES, function (subject, data) {
fn(subject, data);
});
}
else {
this.on(EVENT.STOCK_QUOTES, function (subject, data) {
fn(subject, data);
});
}
}
onStockAggSec(fn) {
this.polygon.on(EVENT.STOCK_AGG_SEC, function (subject, data) {
fn(subject, data);
});
}
onStockAggMin(fn) {
if (this.session.usePolygon) {
this.polygon.on(EVENT.STOCK_AGG_MIN, function (subject, data) {
fn(subject, data);
});
}
else {
this.on(EVENT.STOCK_AGG_MIN, function (subject, data) {
fn(subject, data);
});
}
}
send(data) {
this.conn.send(data);
}
disconnect() {
this.reconnectDisabled = true;
this.conn.close();
if (this.polygon) {
this.polygon.close();
}
}
state() {
return this.currentState;
}
get(key) {
return this.session[key];
}
reconnect() {
setTimeout(() => {
if (this.session.backoff) {
this.session.reconnectTimeout += this.session.backoffIncrement;
if (this.session.reconnectTimeout > this.session.maxReconnectTimeout) {
this.session.reconnectTimeout = this.session.maxReconnectTimeout;
}
}
this.connect();
}, this.session.reconnectTimeout * 1000);
this.emit(STATE.WAITING_TO_RECONNECT, this.session.reconnectTimeout);
}
authenticate() {
this.emit(STATE.AUTHENTICATING);
const authMsg = {
action: "authenticate",
data: {
key_id: this.session.apiKey,
secret_key: this.session.secretKey,
},
};
this.send(JSON.stringify(authMsg));
}
handleMessage(data) {
// Heartbeat
const bytes = new Uint8Array(data);
if (bytes.length === 1 && bytes[0] === 1) {
return;
}
let message = JSON.parse(data);
const subject = message.stream;
if ("error" in message.data) {
console.log(message.data.error);
}
switch (subject) {
case "authorization":
this.authResultHandler(message.data.status);
break;
case "listening":
this.log(`listening to the streams: ${message.data.streams}`);
break;
case "trade_updates":
this.emit(EVENT.ORDER_UPDATE, message.data);
break;
case "account_updates":
this.emit(EVENT.ACCOUNT_UPDATE, message.data);
break;
default:
if (message.stream.startsWith("T.")) {
this.emit(EVENT.STOCK_TRADES, subject, entity.AlpacaTrade(message.data));
}
else if (message.stream.startsWith("Q.")) {
this.emit(EVENT.STOCK_QUOTES, subject, entity.AlpacaQuote(message.data));
}
else if (message.stream.startsWith("AM.")) {
this.emit(EVENT.STOCK_AGG_MIN, subject, entity.AggMinuteBar(message.data));
}
else {
this.emit(ERROR.PROTOBUF);
}
}
}
authResultHandler(authResult) {
switch (authResult) {
case "authorized":
this.emit(STATE.CONNECTED);
break;
case "unauthorized":
this.emit(ERROR.BAD_KEY_OR_SECRET);
this.disconnect();
break;
default:
break;
}
}
log(level, ...msg) {
if (this.session.verbose) {
console[level](...msg);
}
}
}
exports.AlpacaStreamClient = AlpacaStreamClient;