laplace-api
Version:
Client library for Laplace API for the US stock market and BIST (Istanbul stock market) fundamental financial data.
565 lines (495 loc) • 15.7 kB
text/typescript
interface RawBISTStockLiveData {
_id: number;
symbol: string;
cl: number;
_i: string;
c: number;
d: number;
}
interface RawUSStockLiveData {
s: string;
p: number;
t: number;
}
export interface BISTStockLiveData {
id: number;
symbol: string;
closePrice: number;
tipId: string;
percentChange: number;
timestamp: number;
}
export interface USStockLiveData {
symbol: string;
closePrice: number;
timestamp: number;
}
export enum LivePriceFeed {
LiveBist = "live_price_tr",
LiveUs = "live_price_us",
DelayedBist = "delayed_price_tr",
DelayedUs = "delayed_price_us",
// DepthBist = "depth_tr",
}
type StockLiveDataType<T extends LivePriceFeed> = T extends
| LivePriceFeed.LiveBist
| LivePriceFeed.DelayedBist
? // | LivePriceFeed.DepthBist
BISTStockLiveData
: USStockLiveData;
export enum LogLevel {
Info = "info",
Warn = "warn",
Error = "error",
}
interface WebSocketOptions {
enableLogging?: boolean;
logLevel?: LogLevel;
reconnectAttempts?: number;
reconnectDelay?: number;
maxReconnectDelay?: number;
}
type WebSocketMessageType = "heartbeat" | "error" | "warning" | "data";
export enum WebSocketErrorType {
MAX_RECONNECT_EXCEEDED = "MAX_RECONNECT_EXCEEDED",
CONNECTION_ERROR = "CONNECTION_ERROR",
CLOSE_ERROR = "CLOSE_ERROR",
WEBSOCKET_NOT_INITIALIZED = "WEBSOCKET_NOT_INITIALIZED",
MESSAGE_PARSE_ERROR = "MESSAGE_PARSE_ERROR",
WEBSOCKET_NOT_CONNECTED = "WEBSOCKET_NOT_CONNECTED",
WEBSOCKET_ERROR = "WEBSOCKET_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
export enum WebSocketCloseReason {
NORMAL_CLOSURE = "NORMAL_CLOSURE",
CONNECTION_ERROR = "CONNECTION_ERROR",
MAX_RECONNECT_EXCEEDED = "MAX_RECONNECT_EXCEEDED",
UNKNOWN = "UNKNOWN",
}
export class WebSocketError extends Error {
constructor(
message: string,
public readonly code: WebSocketErrorType = WebSocketErrorType.UNKNOWN_ERROR
) {
super(message);
this.name = "WebSocketError";
}
}
export class LivePriceWebSocketClient {
private ws: WebSocket | null = null;
private subscriptionCounter = 0;
private subscriptions = new Map<
number,
{
symbols: string[];
handler: (data: BISTStockLiveData | USStockLiveData) => void;
feed: LivePriceFeed;
}
>();
private symbolLastData = new Map<string, BISTStockLiveData | USStockLiveData>();
private reconnectAttempts = 0;
private reconnectTimeout: NodeJS.Timeout | null = null;
private isClosed: boolean = false;
private closedReason: WebSocketCloseReason | null = null;
private wsUrl: string | null = null;
private readonly options: Required<WebSocketOptions>;
private connectPromise: Promise<void> | null = null;
private lastMessageTimestamp: number = 0;
private inactivityCheckInterval: NodeJS.Timeout | null = null;
private readonly INACTIVITY_TIMEOUT = 15000;
constructor(options: WebSocketOptions = {}) {
this.options = {
enableLogging: true,
logLevel: LogLevel.Error,
reconnectAttempts: 5,
reconnectDelay: 5000,
maxReconnectDelay: 30000,
...options,
};
}
private startInactivityInterval() {
this.lastMessageTimestamp = Date.now();
if (this.inactivityCheckInterval) {
clearInterval(this.inactivityCheckInterval);
this.inactivityCheckInterval = null;
}
this.inactivityCheckInterval = setInterval(async ()=> {
if (Date.now() - this.lastMessageTimestamp >= this.INACTIVITY_TIMEOUT) {
this.stopInactivityInterval();
try {
this.attemptReconnect();
} catch(error) {
this.log(`Failed to reconnect: ${error}`, "error");
}
}
}, this.INACTIVITY_TIMEOUT)
}
private stopInactivityInterval() {
if (this.inactivityCheckInterval) {
clearInterval(this.inactivityCheckInterval);
this.inactivityCheckInterval = null;
}
}
private log(message: string, level: "info" | "error" | "warn" = "info") {
if (!this.options.enableLogging) return;
const prefix = `[LivePriceWebSocket][${level.toUpperCase()}]`;
const logLevel = this.options.logLevel;
if (logLevel === LogLevel.Error && level !== "error") {
return;
}
if (logLevel === LogLevel.Warn && level === "info") {
return;
}
switch (level) {
case "error":
console.error(`${prefix} ${message}`);
break;
case "warn":
console.warn(`${prefix} ${message}`);
break;
default:
console.info(`${prefix} ${message}`);
break;
}
}
async connect(url: string): Promise<WebSocket> {
this.log("Connecting to WebSocket...");
this.wsUrl = url;
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
this.ws = new WebSocket(url);
this.connectPromise = this.setupWebSocket();
await this.connectPromise;
this.connectPromise = null;
}
return this.ws;
}
private async setupWebSocket(): Promise<void> {
if (!this.ws) {
throw new WebSocketError(
"WebSocket not initialized",
WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED
);
}
return new Promise((resolve, reject) => {
if (!this.ws) {
return reject(
new WebSocketError(
"WebSocket not initialized",
WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED
)
);
}
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.log("WebSocket connected");
this.startInactivityInterval();
resolve();
};
this.ws.onerror = (error) => {
reject(
new WebSocketError(
`WebSocket connection error: ${error}`,
WebSocketErrorType.CONNECTION_ERROR
)
);
};
this.ws.onclose = () => {
this.isClosed = true;
this.log("WebSocket closed");
this.stopInactivityInterval();
if (this.closedReason !== WebSocketCloseReason.NORMAL_CLOSURE) {
try {
this.attemptReconnect();
} catch (error) {
if (error instanceof WebSocketError) {
switch (error.code) {
case WebSocketErrorType.MAX_RECONNECT_EXCEEDED:
this.closedReason =
WebSocketCloseReason.MAX_RECONNECT_EXCEEDED;
break;
case WebSocketErrorType.CONNECTION_ERROR:
case WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED:
this.closedReason = WebSocketCloseReason.CONNECTION_ERROR;
break;
default:
this.closedReason = WebSocketCloseReason.UNKNOWN;
break;
}
} else {
this.closedReason = WebSocketCloseReason.UNKNOWN;
}
this.log(`Failed to reconnect: ${error}`, "error");
}
}
};
this.ws.onmessage = (event) => {
this.lastMessageTimestamp = Date.now();
try {
const rawData = JSON.parse(event.data.toString());
const feed = rawData.feed as LivePriceFeed;
switch (rawData.type) {
case "data":
const messageData = rawData.message;
if (!messageData) {
throw new WebSocketError(
"Price update message is empty",
WebSocketErrorType.MESSAGE_PARSE_ERROR
);
}
let priceData: BISTStockLiveData | USStockLiveData;
if (
feed === LivePriceFeed.DelayedBist ||
feed === LivePriceFeed.LiveBist
// ||
// feed === LivePriceFeed.DepthBist
) {
const message = messageData as RawBISTStockLiveData;
priceData = {
symbol: message?.symbol,
id: message?._id,
tipId: message?._i,
closePrice: message?.cl,
timestamp: message?.d,
percentChange: message?.c,
} as BISTStockLiveData;
} else {
const message = messageData as RawUSStockLiveData;
priceData = {
symbol: message.s,
closePrice: message.p,
timestamp: message.t,
} as USStockLiveData;
}
if (priceData.symbol) {
this.symbolLastData.set(priceData.symbol, priceData);
const handlers = this.getHandlersForSymbol(
priceData.symbol,
feed
);
handlers.forEach((handler) => handler(priceData));
}
break;
case "heartbeat":
this.log("Received heartbeat");
return;
case "error":
this.log(`Received error: ${rawData.message}`, "error");
return;
case "warning":
this.log(`Received warning: ${rawData.message}`, "warn");
return;
default:
this.log(`Unknown message type: ${rawData.type}`, "error");
return;
}
} catch (error) {
this.log(`Failed to parse WebSocket message: ${error}`, "error");
}
};
});
}
private async attemptReconnect() {
const url = this.wsUrl;
if (!url) {
throw new WebSocketError(
"WebSocket URL is not set",
WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED
);
}
if (this.reconnectAttempts >= this.options.reconnectAttempts) {
throw new WebSocketError(
`Maximum reconnection attempts (${this.options.reconnectAttempts}) reached`,
WebSocketErrorType.MAX_RECONNECT_EXCEEDED
);
}
this.reconnectAttempts++;
const delay = Math.min(
this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
this.options.maxReconnectDelay
);
this.log(
`Attempting to reconnect (${this.reconnectAttempts}/${this.options.reconnectAttempts}) in ${delay}ms...`
);
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.reconnectTimeout = setTimeout(async () => {
try {
await this.connect(url);
this.isClosed = false;
const symbolsByFeed = new Map<LivePriceFeed, string[]>();
this.subscriptions.forEach((subscription) => {
const { symbols, feed } = subscription;
if (!symbolsByFeed.has(feed)) {
symbolsByFeed.set(feed, []);
}
symbols.forEach((symbol) => {
const currentSymbols = symbolsByFeed.get(feed) || [];
if (!currentSymbols.includes(symbol)) {
currentSymbols.push(symbol);
symbolsByFeed.set(feed, currentSymbols);
}
});
});
symbolsByFeed.forEach((symbols, feed) => {
this.addSymbols(symbols, feed);
});
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.reconnectAttempts = 0;
} catch (error) {
this.attemptReconnect();
}
}, delay);
if (typeof process !== "undefined") {
this.reconnectTimeout.unref();
}
}
subscribe<F extends LivePriceFeed>(
symbols: string[],
feed: F,
handler: (data: StockLiveDataType<F>) => void
): () => void {
const subscriptionId = this.subscriptionCounter++;
let symbolsToAdd: string[] = [];
const typedHandler = (data: BISTStockLiveData | USStockLiveData) => {
handler(data as StockLiveDataType<F>);
};
this.subscriptions.set(subscriptionId, {
symbols,
feed,
handler: typedHandler,
});
for (const symbol of symbols) {
const symbolHandlers = this.getHandlersForSymbol(symbol, feed);
if (symbolHandlers.length === 1) {
symbolsToAdd.push(symbol);
} else if (symbolHandlers.length > 1) {
const lastData: BISTStockLiveData | USStockLiveData | undefined = this.symbolLastData.get(symbol);
if (lastData) {
typedHandler(lastData);
}
}
}
this.addSymbols(symbolsToAdd, feed);
return () => {
this.subscriptions.delete(subscriptionId);
const symbolsForRemove = symbols.filter(
(s) => this.getHandlersForSymbol(s, feed).length === 0
);
this.removeSymbols(symbolsForRemove, feed);
};
}
private getHandlersForSymbol(
symbol: string,
feed: LivePriceFeed
): ((data: BISTStockLiveData | USStockLiveData) => void)[] {
return Array.from(this.subscriptions.values())
.filter((s) => s.symbols.includes(symbol) && s.feed === feed)
.map((s) => s.handler);
}
private async removeSymbols(symbols: string[], feed: LivePriceFeed) {
if (symbols.length === 0) return;
if (!this.ws) {
throw new WebSocketError(
"WebSocket is not initialized",
WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED
);
}
if (this.connectPromise) {
await this.connectPromise;
}
if (this.ws.readyState !== WebSocket.OPEN) {
throw new WebSocketError(
"WebSocket is not connected",
WebSocketErrorType.WEBSOCKET_NOT_CONNECTED
);
}
this.ws.send(
JSON.stringify({
type: "unsubscribe",
symbols: symbols,
feed: feed,
})
);
}
private async addSymbols(symbols: string[], feed: LivePriceFeed) {
if (symbols.length === 0) return;
if (!this.ws) {
throw new WebSocketError(
"WebSocket is not initialized",
WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED
);
}
if (this.connectPromise) {
await this.connectPromise;
}
if (this.ws.readyState !== WebSocket.OPEN) {
throw new WebSocketError(
"WebSocket is not connected",
WebSocketErrorType.WEBSOCKET_NOT_CONNECTED
);
}
this.ws.send(
JSON.stringify({
type: "subscribe",
symbols: symbols,
feed: feed,
})
);
}
async close(): Promise<void> {
try {
this.subscriptions.clear();
this.closedReason = WebSocketCloseReason.NORMAL_CLOSURE;
if (this.ws?.readyState === WebSocket.OPEN) {
await new Promise<void>((resolve, reject) => {
if (!this.ws) {
resolve();
return;
}
this.ws.onclose = () => {
this.isClosed = true;
resolve();
};
try {
this.ws.close();
} catch (closeError) {
this.closedReason = null;
reject(
new WebSocketError(
`Failed to initiate close: ${closeError}`,
WebSocketErrorType.CLOSE_ERROR
)
);
}
});
}
} catch (error) {
const errorMessage =
error instanceof WebSocketError
? error.message
: `Unexpected error during close: ${error}`;
this.log(errorMessage, "error");
throw error instanceof WebSocketError
? error
: new WebSocketError(errorMessage, WebSocketErrorType.CLOSE_ERROR);
} finally {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.ws = null;
this.subscriptions.clear();
}
}
isConnectionClosed(): boolean {
return this.isClosed;
}
getCloseReason(): WebSocketCloseReason | null {
return this.closedReason;
}
}