ib-client.ts•22.9 kB
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
import https from "https";
import { Logger } from "./logger.js";
interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
metadata?: { requestId: string };
}
export interface IBClientConfig {
host: string;
port: number;
}
export interface OrderRequest {
accountId: string;
symbol: string;
action: "BUY" | "SELL";
orderType: "MKT" | "LMT" | "STP";
quantity: number;
price?: number;
stopPrice?: number;
suppressConfirmations?: boolean;
}
const isError = (error: unknown): error is Error => {
return error instanceof Error;
};
export class IBClient {
private client!: AxiosInstance;
private baseUrl!: string;
private config: IBClientConfig;
private isAuthenticated = false;
private authAttempts = 0;
private maxAuthAttempts = 3;
private tickleInterval?: NodeJS.Timeout;
private tickleIntervalMs = 30000; // 30 seconds (well within 1/sec rate limit)
constructor(config: IBClientConfig) {
this.config = config;
this.initializeClient();
}
private initializeClient(): void {
// Use HTTPS as IB Gateway expects it
this.baseUrl = `https://${this.config.host}:${this.config.port}/v1/api`;
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
// Allow self-signed certificates
httpsAgent: new https.Agent({
rejectUnauthorized: false,
}),
});
// Add request interceptor to ensure authentication and log requests
this.client.interceptors.request.use(async (config) => {
const requestId = Math.random().toString(36).substr(2, 9);
Logger.log(`[REQUEST-${requestId}] ${config.method?.toUpperCase()} ${config.url}`, {
baseURL: config.baseURL,
timeout: config.timeout,
headers: config.headers,
data: config.data
});
if (!this.isAuthenticated) {
Logger.log(`[REQUEST-${requestId}] Not authenticated, authenticating... (attempt ${this.authAttempts + 1}/${this.maxAuthAttempts})`);
if (this.authAttempts >= this.maxAuthAttempts) {
throw new Error(`Max authentication attempts (${this.maxAuthAttempts}) exceeded`);
}
await this.authenticate();
}
// Store requestId for response logging
(config as ExtendedAxiosRequestConfig).metadata = { requestId };
return config;
});
// Add response interceptor for logging
this.client.interceptors.response.use(
(response) => {
const requestId = (response.config as ExtendedAxiosRequestConfig).metadata?.requestId || 'unknown';
Logger.log(`[RESPONSE-${requestId}] ${response.status} ${response.statusText}`, {
url: response.config.url,
responseSize: JSON.stringify(response.data).length,
headers: response.headers,
dataPreview: JSON.stringify(response.data).substring(0, 500) + '...'
});
return response;
},
(error) => {
const requestId = (error.config as ExtendedAxiosRequestConfig)?.metadata?.requestId || 'unknown';
Logger.error(`[ERROR-${requestId}] Request failed:`, {
url: error.config?.url,
status: error.response?.status,
statusText: error.response?.statusText,
message: error.message,
responseData: error.response?.data
});
return Promise.reject(error);
}
);
}
updatePort(newPort: number): void {
if (this.config.port !== newPort) {
Logger.log(`[CLIENT] Updating port from ${this.config.port} to ${newPort}`);
this.stopTickle(); // Stop tickle for old session
this.config.port = newPort;
this.isAuthenticated = false; // Force re-authentication with new port
this.authAttempts = 0; // Reset auth attempts
this.initializeClient(); // Re-initialize client with new port
}
}
/**
* Check authentication status with IB Gateway without triggering automatic authentication
*/
async checkAuthenticationStatus(): Promise<boolean> {
try {
Logger.log("[AUTH-CHECK] Checking authentication status...");
// Create a new axios instance without interceptors to avoid triggering authentication
const authClient = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
httpsAgent: new https.Agent({
rejectUnauthorized: false,
}),
});
const response = await authClient.get("/iserver/auth/status");
Logger.log("[AUTH-CHECK] Auth status response:", response.data);
const authenticated = response.data.authenticated === true;
this.isAuthenticated = authenticated;
if (authenticated) {
this.authAttempts = 0; // Reset auth attempts on successful check
this.startTickle(); // Start session maintenance
} else {
this.stopTickle(); // Stop tickle if not authenticated
}
return authenticated;
} catch (error) {
this.isAuthenticated = false;
this.stopTickle();
return false;
}
}
/**
* Send a tickle request to maintain the session
* Rate limit: 1 request per second (we use 30 second intervals to be safe)
*/
private async tickle(): Promise<void> {
try {
// Create a new axios instance without interceptors to avoid triggering authentication
const tickleClient = axios.create({
baseURL: this.baseUrl,
timeout: 10000,
httpsAgent: new https.Agent({
rejectUnauthorized: false,
}),
});
await tickleClient.post("/tickle");
Logger.log("[TICKLE] Session maintenance ping sent successfully");
} catch (error) {
Logger.warn("[TICKLE] Failed to send session maintenance ping:", error);
// If tickle fails, check authentication status
const isAuth = await this.checkAuthenticationStatus();
if (!isAuth) {
Logger.warn("[TICKLE] Session expired, stopping tickle interval");
this.stopTickle();
}
}
}
/**
* Start automatic session maintenance
*/
private startTickle(): void {
if (this.tickleInterval) {
return; // Already running
}
Logger.log(`[TICKLE] Starting automatic session maintenance (interval: ${this.tickleIntervalMs}ms)`);
this.tickleInterval = setInterval(() => {
this.tickle();
}, this.tickleIntervalMs);
}
/**
* Stop automatic session maintenance
*/
private stopTickle(): void {
if (this.tickleInterval) {
Logger.log("[TICKLE] Stopping automatic session maintenance");
clearInterval(this.tickleInterval);
this.tickleInterval = undefined;
}
}
/**
* Cleanup method to stop tickle when client is destroyed
*/
public destroy(): void {
this.stopTickle();
}
private async authenticate(): Promise<void> {
Logger.log(`[AUTH] Starting authentication process... (attempt ${this.authAttempts + 1}/${this.maxAuthAttempts})`);
this.authAttempts++;
try {
// Create a new axios instance without interceptors to avoid infinite recursion
const authClient = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
httpsAgent: new https.Agent({
rejectUnauthorized: false,
}),
});
// Check if already authenticated
Logger.log("[AUTH] Checking authentication status...");
const response = await authClient.get("/iserver/auth/status");
Logger.log("[AUTH] Auth status response:", response.data);
if (response.data.authenticated) {
Logger.log("[AUTH] Already authenticated");
this.isAuthenticated = true;
this.authAttempts = 0; // Reset on success
this.startTickle(); // Start session maintenance
return;
}
// Re-authenticate if needed
Logger.log("[AUTH] Re-authenticating...");
await authClient.post("/iserver/reauthenticate");
Logger.log("[AUTH] Re-authentication successful");
this.isAuthenticated = true;
this.authAttempts = 0; // Reset on success
this.startTickle(); // Start session maintenance
} catch (error) {
Logger.error(`[AUTH] Authentication failed (attempt ${this.authAttempts}/${this.maxAuthAttempts}):`, isError(error) && error.message, isError(error) && error.stack);
if (this.authAttempts >= this.maxAuthAttempts) {
throw new Error(`Failed to authenticate with IB Gateway after ${this.maxAuthAttempts} attempts`);
}
throw new Error("Failed to authenticate with IB Gateway");
}
}
async getAccountInfo(): Promise<any> {
Logger.log("[ACCOUNT-INFO] Starting getAccountInfo request...");
try {
Logger.log("[ACCOUNT-INFO] Fetching portfolio accounts...");
const accountsResponse = await this.client.get("/portfolio/accounts");
const accounts = accountsResponse.data;
Logger.log(`[ACCOUNT-INFO] Found ${accounts?.length || 0} accounts:`, accounts);
const result = {
accounts: accounts,
summaries: [] as any[]
};
Logger.log("[ACCOUNT-INFO] Processing account summaries...");
for (let i = 0; i < accounts.length; i++) {
const account = accounts[i];
Logger.log(`[ACCOUNT-INFO] Processing account ${i + 1}/${accounts.length}: ${account.id}`);
const summaryResponse = await this.client.get(
`/portfolio/${account.id}/summary`
);
const summary = summaryResponse.data;
Logger.log(`[ACCOUNT-INFO] Account ${account.id} summary:`, summary);
result.summaries.push({
accountId: account.id,
summary: summary
});
}
Logger.log(`[ACCOUNT-INFO] Completed processing ${result.summaries.length} accounts`);
return result;
} catch (error) {
Logger.error("[ACCOUNT-INFO] Failed to get account info:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error("Authentication required to retrieve account information. Please authenticate with Interactive Brokers first.");
(authError as any).isAuthError = true;
throw authError;
}
throw new Error("Failed to retrieve account information");
}
}
async getPositions(accountId?: string): Promise<any> {
try {
let url = "/portfolio/positions";
if (accountId) {
url = `/portfolio/${accountId}/positions`;
}
const response = await this.client.get(url);
return response.data;
} catch (error) {
Logger.error("Failed to get positions:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error("Authentication required to retrieve positions. Please authenticate with Interactive Brokers first.");
(authError as any).isAuthError = true;
throw authError;
}
throw new Error("Failed to retrieve positions");
}
}
async getMarketData(symbol: string, exchange?: string): Promise<any> {
try {
// First, get the contract ID for the symbol
const searchResponse = await this.client.get(
`/iserver/secdef/search?symbol=${symbol}`
);
if (!searchResponse.data || searchResponse.data.length === 0) {
throw new Error(`Symbol ${symbol} not found`);
}
const contract = searchResponse.data[0];
const conid = contract.conid;
// Get market data snapshot
// Using corrected field IDs based on IB Client Portal API documentation:
// 31=Last Price, 70=Day High, 71=Day Low, 82=Change, 83=Change%,
// 84=Bid, 85=Ask Size, 86=Ask, 87=Volume, 88=Bid Size
const response = await this.client.get(
`/iserver/marketdata/snapshot?conids=${conid}&fields=31,70,71,82,83,84,85,86,87,88`
);
return {
symbol: symbol,
contract: contract,
marketData: response.data
};
} catch (error) {
Logger.error("Failed to get market data:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error(`Authentication required to retrieve market data for ${symbol}. Please authenticate with Interactive Brokers first.`);
(authError as any).isAuthError = true;
throw authError;
}
throw new Error(`Failed to retrieve market data for ${symbol}`);
}
}
private isAuthenticationError(error: any): boolean {
if (!error) return false;
const errorMessage = error.message || error.toString();
const errorStatus = error.response?.status;
const responseData = error.response?.data;
// Check for common authentication error patterns
return (
errorStatus === 401 ||
errorStatus === 403 ||
errorStatus === 500 || // IB Gateway sometimes returns 500 for auth issues
errorMessage.includes("authentication") ||
errorMessage.includes("authenticate") ||
errorMessage.includes("unauthorized") ||
errorMessage.includes("not authenticated") ||
errorMessage.includes("login") ||
responseData?.error?.message?.includes("not authenticated") ||
responseData?.error?.message?.includes("authentication") ||
// IB Gateway specific patterns
responseData?.error === "not authenticated" ||
(errorStatus === 500 && responseData?.error?.includes("authentication"))
);
}
async placeOrder(orderRequest: OrderRequest): Promise<any> {
try {
// First, get the contract ID for the symbol
const searchResponse = await this.client.get(
`/iserver/secdef/search?symbol=${orderRequest.symbol}`
);
if (!searchResponse.data || searchResponse.data.length === 0) {
throw new Error(`Symbol ${orderRequest.symbol} not found`);
}
const contract = searchResponse.data[0];
const conid = contract.conid;
// Prepare order object
const order = {
conid: Number(conid), // Ensure conid is number
orderType: orderRequest.orderType,
side: orderRequest.action,
quantity: Number(orderRequest.quantity), // Ensure quantity is number
tif: "DAY", // Time in force
};
// Add price for limit orders
if (orderRequest.orderType === "LMT" && orderRequest.price !== undefined) {
(order as any).price = Number(orderRequest.price);
}
// Add stop price for stop orders
if (orderRequest.orderType === "STP" && orderRequest.stopPrice !== undefined) {
(order as any).auxPrice = Number(orderRequest.stopPrice);
}
// Place the order
const response = await this.client.post(
`/iserver/account/${orderRequest.accountId}/orders`,
{
orders: [order],
}
);
// Check if we received confirmation messages that need to be handled
if (response.data && Array.isArray(response.data) && response.data.length > 0) {
const firstResponse = response.data[0];
// Check if this is a confirmation message response
if (firstResponse.id && firstResponse.message && firstResponse.messageIds && orderRequest.suppressConfirmations) {
Logger.log("Order confirmation received, automatically confirming...", firstResponse);
// Automatically confirm all messages
const confirmResponse = await this.confirmOrder(firstResponse.id, firstResponse.messageIds);
return confirmResponse;
}
}
return response.data;
} catch (error) {
Logger.error("Failed to place order:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error("Authentication required to place orders. Please authenticate with Interactive Brokers first.");
(authError as any).isAuthError = true;
throw authError;
}
throw new Error("Failed to place order");
}
}
/**
* Confirm an order by replying to confirmation messages
* @param replyId The reply ID from the confirmation response
* @param messageIds Array of message IDs to confirm
* @returns The confirmation response
*/
async confirmOrder(replyId: string, messageIds: string[]): Promise<any> {
try {
Logger.log(`Confirming order with reply ID ${replyId} and message IDs:`, messageIds);
const response = await this.client.post(`/iserver/reply/${replyId}`, {
confirmed: true,
messageIds: messageIds
});
Logger.log("Order confirmation response:", response.data);
return response.data;
} catch (error) {
Logger.error("Failed to confirm order:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error("Authentication required to confirm orders. Please authenticate with Interactive Brokers first.");
(authError as any).isAuthError = true;
throw authError;
}
throw new Error("Failed to confirm order: " + (error as any).message);
}
}
async getOrderStatus(orderId: string): Promise<any> {
try {
const response = await this.client.get(`/iserver/account/orders/${orderId}`);
return response.data;
} catch (error) {
Logger.error("Failed to get order status:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error(`Authentication required to get order status for order ${orderId}. Please authenticate with Interactive Brokers first.`);
(authError as any).isAuthError = true;
throw authError;
}
throw new Error(`Failed to get status for order ${orderId}`);
}
}
async getOrders(accountId?: string): Promise<any> {
try {
const url = "/iserver/account/orders";
const params: any = {};
if (accountId) {
params.accountId = accountId;
}
const response = await this.client.get(url, { params });
return response.data;
} catch (error) {
Logger.error("Failed to get orders:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error("Authentication required to retrieve orders. Please authenticate with Interactive Brokers first.");
(authError as any).isAuthError = true;
throw authError;
}
throw new Error("Failed to retrieve orders");
}
}
/**
* Get all alerts for an account
* @param accountId The account ID
* @returns The list of alerts
*/
async getAlerts(accountId: string): Promise<any> {
try {
Logger.log(`[ALERT] Getting alerts for account ${accountId}`);
const response = await this.client.get(
`/iserver/account/${accountId}/alerts`
);
Logger.log("[ALERT] Get alerts response:", response.data);
return response.data;
} catch (error) {
Logger.error("[ALERT] Failed to get alerts:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error("Authentication required to get alerts. Please authenticate with Interactive Brokers first.");
(authError as any).isAuthError = true;
throw authError;
}
throw new Error("Failed to get alerts: " + (error as any).message);
}
}
/**
* Create a new alert for an account
* @param accountId The account ID
* @param alertRequest The alert configuration
* @returns The alert creation response
*/
async createAlert(accountId: string, alertRequest: any): Promise<any> {
try {
Logger.log(`[ALERT] Creating alert for account ${accountId}:`, alertRequest);
const response = await this.client.post(
`/iserver/account/${accountId}/alert`,
alertRequest
);
Logger.log("[ALERT] Alert creation response:", response.data);
return response.data;
} catch (error) {
Logger.error("[ALERT] Failed to create alert:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error("Authentication required to create alerts. Please authenticate with Interactive Brokers first.");
(authError as any).isAuthError = true;
throw authError;
}
throw new Error("Failed to create alert: " + (error as any).message);
}
}
/**
* Activate an alert
* @param accountId The account ID
* @param alertId The alert ID to activate
* @returns The activation response
*/
async activateAlert(accountId: string, alertId: string): Promise<any> {
try {
Logger.log(`[ALERT] Activating alert ${alertId} for account ${accountId}`);
const response = await this.client.post(
`/iserver/account/${accountId}/alert/activate`,
{ alertId }
);
Logger.log("[ALERT] Alert activation response:", response.data);
return response.data;
} catch (error) {
Logger.error("[ALERT] Failed to activate alert:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error("Authentication required to activate alerts. Please authenticate with Interactive Brokers first.");
(authError as any).isAuthError = true;
throw authError;
}
throw new Error("Failed to activate alert: " + (error as any).message);
}
}
/**
* Delete an alert
* @param accountId The account ID
* @param alertId The alert ID to delete
* @returns The deletion response
*/
async deleteAlert(accountId: string, alertId: string): Promise<any> {
try {
Logger.log(`[ALERT] Deleting alert ${alertId} for account ${accountId}`);
const response = await this.client.delete(
`/iserver/account/${accountId}/alert/${alertId}`
);
Logger.log("[ALERT] Alert deletion response:", response.data);
return response.data;
} catch (error) {
Logger.error("[ALERT] Failed to delete alert:", error);
// Check if this is likely an authentication error
if (this.isAuthenticationError(error)) {
const authError = new Error("Authentication required to delete alerts. Please authenticate with Interactive Brokers first.");
(authError as any).isAuthError = true;
throw authError;
}
throw new Error("Failed to delete alert: " + (error as any).message);
}
}
}