Files
autofirmer-expanded/lib/tradovate-class.ts
T
SenofyandClaude Sonnet 4.6 92b929f4ce Throttle fetchDaysTraded to once per hour per client
Multiple simultaneous fills were triggering concurrent report API calls
to Tradovate for every account in the firm, causing rate limiting.
Added a 1-hour cooldown — the first call always runs (lastDaysFetch=0),
subsequent calls within the same hour are no-ops.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:40:17 -05:00

713 lines
31 KiB
TypeScript

// Create a class with a constructor that takes a username and password and sets the credentials
import axios from 'axios';
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
import { POINT_VALUES } from './trading-logic';
export class TradovateClient {
private name: string;
private password: string;
private accessInfo: AuthLoginResponse;
private deviceId = randomUUIDV4();
public accountList: AccountItem[] = [];
public accountCashBalances: {
[accountId: number]: {
amount: number;
realizedPnL: number;
};
} = {};
public positions: {
[accountId: number]: {
contractId: number;
netPos: number;
netPrice: number;
timestamp: Date;
};
} = {};
public daysTraded: { [accountId: number]: number } = {};
public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
/** Balance floor set by the prop firm — account is blown when amount <= this value (0 = not set) */
public autoLiqThresholds: { [accountId: number]: number } = {};
/** True once fetchDaysTraded() has finished its last full run */
public fetchDaysComplete = false;
/** Timestamp (ms) of the last successful fetchDaysTraded run — throttled to once per hour */
private lastDaysFetch = 0;
/** Last error per account name from fetchDaysTraded() */
public lastFetchErrors: Record<string, string> = {};
/** Raw reports API response sample per account (first 100 chars) for debugging */
public lastFetchRaw: Record<string, string> = {};
public products: { id: number; name: string }[] = [];
/** Rolling buffer of the last 50 raw entity events — useful for debugging */
public recentEntityEvents: { entityType: string; eventType: string; entity: any; ts: number }[] = [];
/** True once the first requestSync has completed and positions/balances are populated. */
public syncComplete = false;
private ws: WebSocket;
private callbackOnSyncRequest: () => Promise<void>;
/** Incrementing ID for outgoing WebSocket messages — ensures concurrent orders don't clobber each other's callbacks. */
private nextMsgId = 100;
private getMsgId(): number { return this.nextMsgId++; }
// Events that we sent out, and tradovate gives us a response for the id we sent out
private directEventCallbacks: {
[id: number]: (response: any) => void;
} = {};
private indirectEventCallbacks: {
entityType:
| 'order'
| 'orderVersion'
| 'auditUserAction'
| 'command'
| 'commandReport'
| 'fill'
| 'executionReport'
| 'cashBalance';
eventType: 'Created' | 'Updated';
// Since the entity is not always the same, we need a validator to check if the response is the one we are looking for
validator: (response: any) => boolean;
callback: (response: any) => void;
}[] = [];
constructor(
name: string,
password: string,
callbackOnSyncRequest: () => Promise<void>
) {
this.name = name;
this.password = password;
this.callbackOnSyncRequest = callbackOnSyncRequest;
this.login().then((res) => {
if (!res?.accessToken) {
console.log('Failed to login', res);
return;
}
console.log('Logged in', res);
// check every 2 minutes if the access token is expired
setInterval(() => {
// If we are within 15 minutes of the expiration time, renew the access token
if (
new Date(res.expirationTime).getTime() <
new Date().getTime() + 15 * 60 * 1000
) {
this.renewAccessToken();
}
}, 2 * 60 * 1000);
this.accessInfo = res;
const randomnumber = Math.random().toString(36).substring(2, 15);
this.ws = new WebSocket(`wss://demo.tradovateapi.com/v1/websocket?r=${randomnumber}`);
this.ws.onopen = () => {
console.log('Connected to websocket');
this.ws.send('authorize\n2\n\n' + this.accessInfo.accessToken);
this.directEventCallbacks[2] = (response: any) => {
this.requestSync();
setInterval(() => this.requestSync(), 60000);
// Every 2.5 seconds send a heartbeat
setInterval(() => {
this.ws.send('[]');
}, 2500);
};
};
this.ws.onmessage = (event) => {
if (event.data[0] === 'a') {
const dataString = event.data.slice(1);
const data: (
| {
i?: number; // Id
s?: number; // Status
d?: any; // Data
}
| {
e?: string;
d?: {
entityType:
| 'order'
| 'orderVersion'
| 'auditUserAction'
| 'command'
| 'commandReport';
eventType: 'Created' | 'Updated';
entity: any;
};
}
)[] = JSON.parse(dataString);
for (const response of data) {
if (
'i' in response &&
response.i &&
this.directEventCallbacks[response.i]
) {
this.directEventCallbacks[response.i](response.d);
}
if ('e' in response && response.e) {
// Handle callbacks
for (const info of this.indirectEventCallbacks) {
if (
response.d?.entityType === info?.entityType &&
response.d?.eventType === info?.eventType &&
info?.validator(response.d.entity)
) {
info?.callback(response.d.entity);
}
}
// Buffer recent entity events (last 50)
if (response.d?.entityType) {
this.recentEntityEvents.push({ entityType: response.d.entityType, eventType: response.d.eventType, entity: response.d.entity, ts: Date.now() });
if (this.recentEntityEvents.length > 50) this.recentEntityEvents.shift();
}
// Update positions from WebSocket position events
if (response.d?.entityType === 'position' && response.d?.entity) {
const pos = response.d.entity;
if (pos.netPos !== 0) {
this.positions[pos.accountId] = {
contractId: pos.contractId,
netPos: pos.netPos,
netPrice: pos.netPrice,
timestamp: new Date(pos.timestamp),
};
} else {
delete this.positions[pos.accountId];
}
}
// Update cash balances from WebSocket cashBalance events
if (response.d?.entityType === 'cashBalance' && response.d?.entity) {
const cb = response.d.entity;
if (cb.accountId) {
this.accountCashBalances[cb.accountId] = {
amount: cb.amount,
realizedPnL: cb.realizedPnL,
};
}
}
}
}
} else if (event.data[0] === 'h') {
// Heartbeat response
}
};
this.ws.onerror = (event) => {
console.error('Error on websocket', event);
};
});
}
private requestSync(): void {
this.directEventCallbacks[3] = (response: any) => {
try {
if (!response) {
console.error('[requestSync] Received null/undefined response — auth may have failed');
return;
}
// liquidateOnly flag lives in accountRiskStatuses
const riskStatusById: { [accountId: number]: { liquidateOnly?: string } } = (
response.accountRiskStatuses || []
).reduce((acc: any, item: any) => {
const key = item.accountId ?? item.id;
acc[key] = item;
return acc;
}, {});
// Auto-liquidation balance floor lives in userAccountAutoLiqs.
// item.id IS the account ID. The floor is: trailingMaxDrawdownLimit - trailingMaxDrawdown.
// Tradovate uses 999999999 as a sentinel for "no limit" — skip those.
for (const item of (response.userAccountAutoLiqs ?? [])) {
const accountId: number = item.id;
const limit: number = item.trailingMaxDrawdownLimit ?? 0;
const drawdown: number = item.trailingMaxDrawdown ?? 0;
const isSentinel = limit >= 999999999;
const floor = (!isSentinel && limit > 0 && drawdown > 0) ? limit - drawdown : 0;
this.autoLiqThresholds[accountId] = floor;
if (floor > 0) {
console.log(`[autoLiq] account ${accountId} → floor $${floor} (hwm=$${limit} drawdown=$${drawdown})`);
}
}
this.accountList = ((response.accounts ?? []) as AccountItem[]).map((account) => {
if (riskStatusById[account.id]?.liquidateOnly) {
return { ...account, active: false };
}
return account;
});
this.accountCashBalances = (response.cashBalances ?? []).reduce(
(
acc: { [accountId: number]: { amount: number; realizedPnL: number } },
item: { accountId: number; amount: number; realizedPnL: number }
) => {
acc[item.accountId] = { amount: item.amount, realizedPnL: item.realizedPnL };
return acc;
},
{} as { [accountId: number]: { amount: number; realizedPnL: number } }
);
this.positions = (response.positions ?? [])
.filter((item: any) => item.netPos !== 0)
.reduce(
(
acc: { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } },
item: { accountId: number; contractId: number; netPos: number; netPrice: number; timestamp: Date }
) => {
acc[item.accountId] = {
contractId: item.contractId,
netPos: item.netPos,
netPrice: item.netPrice,
timestamp: new Date(item.timestamp),
};
return acc;
},
{} as { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } }
);
console.log(`[requestSync] ${this.accountList.length} account(s), ${Object.keys(this.accountCashBalances).length} balance(s)`);
this.fetchDaysTraded();
if (this.products.length > 0) {
this.syncComplete = true;
this.callbackOnSyncRequest();
return;
}
this.directEventCallbacks[30] = (products: any) => {
if (Array.isArray(products) && products.length > 0) {
this.products = products.map((p: any) => ({ id: p.id, name: p.name }));
console.log(`Loaded ${this.products.length} products`);
}
this.syncComplete = true;
this.callbackOnSyncRequest();
};
this.ws.send('product/list\n30\n\n');
} catch (err) {
console.error('[requestSync] Error processing sync response:', err);
}
};
this.ws.send('user/syncrequest\n3\n\n{"splitResponses":false}');
}
public async fetchDaysTraded(): Promise<void> {
if (!this.accessInfo?.accessToken) return;
// Throttle to at most once per hour to avoid rate-limiting the reports API
const ONE_HOUR = 60 * 60 * 1_000;
if (Date.now() - this.lastDaysFetch < ONE_HOUR) return;
this.lastDaysFetch = Date.now();
this.fetchDaysComplete = false;
const authHeaders = { Authorization: `Bearer ${this.accessInfo.accessToken}` };
const now = new Date();
now.setDate(now.getDate() + 1); // endDate must be tomorrow — report server excludes today's fills when endDate=today
const start = new Date();
start.setDate(start.getDate() - 27); // keep total window ≤ 28 days
const fmtDate = (d: Date) => {
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${m}/${day}/${d.getFullYear()}`;
};
if (!this.lastFetchRaw) this.lastFetchRaw = {};
if (!this.lastFetchErrors) this.lastFetchErrors = {};
const requestReport = async (name: string, accountName: string) => {
let reportData = (await axios.post(
'https://rpt-demo.tradovateapi.com/v1/reports/requestreport',
{
name, representationType: 'json', timezone: -300,
params: [
{ name: 'startDate', value: fmtDate(start) },
{ name: 'endDate', value: fmtDate(now) },
{ name: 'startTime', value: '00:00:00' },
{ name: 'endTime', value: '00:00:00' },
{ name: 'account', value: accountName },
],
},
{ headers: authHeaders }
)).data;
let pollAttempts = 0;
while (reportData?.['p-ticket'] && pollAttempts < 30) {
const pTicket: string = reportData['p-ticket'];
const pTime: number = Math.max(1, reportData['p-time'] ?? 1);
await new Promise((r) => setTimeout(r, pTime * 1000));
reportData = (await axios.get(
'https://rpt-demo.tradovateapi.com/v1/reports/getreport',
{ params: { 'p-ticket': pTicket }, headers: authHeaders }
)).data;
pollAttempts++;
}
return typeof reportData?.data === 'string' ? reportData.data : '[]';
};
for (const account of this.accountList) {
// --- Try Cash History first (true after-fee daily P&L) ---
let usedFallback = false;
try {
const raw = await requestReport('Cash History', account.name);
const fixed = raw.replace(/"Date":\s*(\d{4}-\d{2}-\d{2})/g, '"Date": "$1"');
const rows: { Date: string; Delta: string; 'Cash Change Type': string; [k: string]: any }[] = JSON.parse(fixed);
this.lastFetchRaw[account.name] = rows.length > 0 ? JSON.stringify(rows[0]) : '(empty)';
const dailyMap: { [date: string]: number } = {};
for (const row of rows) {
if ((row['Cash Change Type'] ?? '').trim() === 'Fund Transaction') continue;
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
if (isNaN(delta)) continue;
dailyMap[row['Date']] = (dailyMap[row['Date']] ?? 0) + delta;
}
const entries = Object.entries(dailyMap)
.map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 }))
.sort((a, b) => a.date.localeCompare(b.date));
this.dailyPnL[account.id] = entries;
this.daysTraded[account.id] = entries.filter((d) => d.pnl !== 0).length;
delete this.lastFetchErrors[account.name];
continue;
} catch {
usedFallback = true;
}
// --- Fallback: Fills report + FIFO (slightly pre-fee, used for passed accounts) ---
try {
const raw = await requestReport('Fills', account.name);
const fixed = raw.replace(/"_tradeDate":\s*(\d{4}-\d{2}-\d{2})/g, '"_tradeDate": "$1"');
type Fill = { _tradeDate: string; _timestamp: string; _action: number; _qty: number; _price: number; Product: string; commission: number; };
interface Lot { price: number; qty: number; commPerUnit: number; }
const fills: Fill[] = JSON.parse(fixed);
this.lastFetchRaw[account.name] = `[fills fallback] ${raw.slice(0, 100)}`;
const sorted = [...fills].sort((a, b) => a._timestamp.localeCompare(b._timestamp));
const longBook: Lot[] = [];
const shortBook: Lot[] = [];
const dailyMap: { [date: string]: number } = {};
for (const fill of sorted) {
const pointValue = POINT_VALUES[fill.Product] ?? 1;
const isBuy = fill._action === 0;
let remaining = fill._qty;
const commPerUnit = fill._qty > 0 ? fill.commission / fill._qty : 0;
if (isBuy) {
while (remaining > 0 && shortBook.length > 0) {
const lot = shortBook[0];
const closed = Math.min(lot.qty, remaining);
dailyMap[fill._tradeDate] = (dailyMap[fill._tradeDate] ?? 0)
+ (lot.price - fill._price) * closed * pointValue - (commPerUnit + lot.commPerUnit) * closed;
lot.qty -= closed; remaining -= closed;
if (lot.qty === 0) shortBook.shift();
}
if (remaining > 0) longBook.push({ price: fill._price, qty: remaining, commPerUnit });
} else {
while (remaining > 0 && longBook.length > 0) {
const lot = longBook[0];
const closed = Math.min(lot.qty, remaining);
dailyMap[fill._tradeDate] = (dailyMap[fill._tradeDate] ?? 0)
+ (fill._price - lot.price) * closed * pointValue - (commPerUnit + lot.commPerUnit) * closed;
lot.qty -= closed; remaining -= closed;
if (lot.qty === 0) longBook.shift();
}
if (remaining > 0) shortBook.push({ price: fill._price, qty: remaining, commPerUnit });
}
}
const entries = Object.entries(dailyMap)
.map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 }))
.sort((a, b) => a.date.localeCompare(b.date));
this.dailyPnL[account.id] = entries;
this.daysTraded[account.id] = entries.filter((d) => d.pnl !== 0).length;
this.lastFetchErrors[account.name] = 'cash history unavailable (using fills fallback)';
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[fetchDaysTraded] both reports failed for ${account.name}:`, msg);
this.lastFetchErrors[account.name] = msg;
this.dailyPnL[account.id] ??= [];
this.daysTraded[account.id] ??= 0;
}
}
this.fetchDaysComplete = true;
}
private async login(): Promise<AuthLoginResponse> {
const chl = '' + (Date.now() - 1581e9);
const sec = computeSec(this.name, this.password, this.deviceId, chl);
const generateEncodedPassword = (e) => {
const { name: t, password: n } = e,
o = t.length % n.length,
r = (n.slice(o) + n.slice(0, o)).split('').reverse().join('');
return btoa(r);
};
const res = await axios.post('https://live.tradovateapi.com/v1/auth/accesstokenrequest', {
name: this.name,
password: generateEncodedPassword({ name: this.name, password: this.password }),
environment: 'demo',
appId: 'tradovate_trader(web)',
appVersion: '3.251205.0',
deviceId: this.deviceId,
cid: '1',
chl: chl,
sec: sec,
enc: true,
});
if (res.status !== 200) {
console.error('Failed to login', res.data);
}
return res.data;
}
private async renewAccessToken(): Promise<void> {
const res = await axios.get('https://live.tradovateapi.com/v1/auth/renewAccessToken', {
headers: {
Authorization: `Bearer ${this.accessInfo.accessToken}`,
},
});
if (res.status !== 200) {
console.error('Failed to renew access token', res.data);
}
console.log('Renewed access token');
this.accessInfo = res.data;
}
async getAccountList(): Promise<AccountItem[]> {
if (!this.accessInfo?.accessToken) {
console.log('Not authenticated');
return [];
}
const res = await axios.get('https://demo.tradovateapi.com/v1/account/list', {
headers: {
Authorization: `Bearer ${this.accessInfo.accessToken}`,
},
});
return res.data;
}
async findFrontMonthContract(productName: string): Promise<{ id: number; name: string; tickSize: number } | null> {
if (!this.accessInfo?.accessToken) return null;
// Check the volume-based resolver cache first
const { getCachedContract } = require('./contract-resolver') as typeof import('./contract-resolver');
const cached = getCachedContract(productName);
if (cached) return { id: cached.id, name: cached.name, tickSize: cached.tickSize };
// Fallback: use suggest API directly (first call before resolver has run)
const res = await axios.get(
`https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(productName)}&l=20`,
{ headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` } }
);
const contracts: Array<{ id: number; name: string; status: string; providerTickSize: number }> = res.data ?? [];
// Front-month = first contract whose name starts with the product symbol (results are ordered front→back)
const match = contracts.find((c) => c.name.startsWith(productName));
if (!match) return null;
return { id: match.id, name: match.name, tickSize: match.providerTickSize ?? 0.25 };
}
async fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }> {
if (!this.accessInfo?.accessToken || this.products.length === 0) return {};
const productIds = symbols
.map((sym) => this.products.find((p) => p.name === sym)?.id)
.filter((id): id is number => id !== undefined);
const res = await axios.post(
'https://demo.tradovateapi.com/v1/contract/getproductfeeparams',
{ productIds },
{ headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` } }
);
const result: { [symbol: string]: number } = {};
for (const param of (res.data?.params ?? [])) {
const product = this.products.find((p) => p.id === param.productId);
if (product && symbols.includes(product.name)) {
const raw =
(param.clearingFee ?? 0) +
(param.exchangeFee ?? 0) +
(param.nfaFee ?? 0) +
(param.brokerageFee ?? 0) +
(param.ipFee ?? 0) +
(param.commission ?? 0) +
(param.orderRoutingFee ?? 0);
result[product.name] = parseFloat(raw.toFixed(4));
console.log(
`[fees] ${product.name}: clearing=${param.clearingFee ?? 0}` +
` exchange=${param.exchangeFee ?? 0}` +
` nfa=${param.nfaFee ?? 0}` +
` brokerage=${param.brokerageFee ?? 0}` +
` ip=${param.ipFee ?? 0}` +
` commission=${param.commission ?? 0}` +
` routing=${param.orderRoutingFee ?? 0}` +
` → total=${result[product.name]}`
);
}
}
return result;
}
async requestContractsFromSocket(names: string[]): Promise<{
[name: string]: Contract;
}> {
if (!this.ws) {
console.log('Websocket not connected');
return {};
}
return new Promise((resolve) => {
this.directEventCallbacks[10] = (response: any) => {
resolve(
(response || []).reduce((acc: { [name: string]: Contract }, item: Contract) => {
return { ...acc, [item.name]: item };
}, {} as { [name: string]: Contract })
);
};
this.ws.send(`contract/finds\n10\nnames=${names.join(',')}`);
});
}
async sendOrder(
accountId: number,
contractSymbol: string, // e.g. "NQH6" — WebSocket placeorder requires "symbol"
quantity: number,
action: 'Buy' | 'Sell',
orderType: 'Market' | 'Limit',
price?: number
): Promise<Record<string, any>> {
if (!this.ws) throw new Error('WebSocket not connected');
this.ws.send(
`user/registeraudituseraction\n25\n\n${JSON.stringify({
accountId,
actionType: action + orderType,
details: `${action} ${quantity} ${contractSymbol} ${orderType}${price ? ` @ ${price}` : ''}, TIF Day`,
})}`
);
return new Promise((resolve, reject) => {
const msgId = this.getMsgId();
this.directEventCallbacks[msgId] = (response: any) => {
console.log(`[sendOrder] raw ack:`, JSON.stringify(response));
if (typeof response === 'string' || !response) {
reject(new Error(typeof response === 'string' ? response : 'Empty response from order/placeorder'));
return;
}
// Tradovate returns the command object: { id, commandStatus, orderId, ... }
const orderId = response.orderId ?? response.id;
console.log(`[sendOrder] orderId=${orderId} status=${response.commandStatus}`);
// Wait for the fill event — it carries the real execution price
const timeout = setTimeout(() => {
reject(new Error(`Order ${orderId} acknowledged but no fill within 30s (market may be closed)`));
}, 30000);
this.indirectEventCallbacks.push({
entityType: 'fill',
eventType: 'Created',
validator: (item: any) => item?.orderId === orderId,
callback: (fill: any) => {
clearTimeout(timeout);
console.log(`[sendOrder] fill:`, JSON.stringify(fill));
resolve(fill);
},
});
};
this.ws.send(
`order/placeorder\n${msgId}\n\n${JSON.stringify({
accountId,
action,
symbol: contractSymbol,
orderQty: quantity,
orderType,
price,
timeInForce: 'Day',
text: 'DOM',
})}`
);
});
}
/** Place an order and resolve as soon as the command is acknowledged (does not wait for fill). */
async placeOrderNoWait(
accountId: number,
contractSymbol: string,
quantity: number,
action: 'Buy' | 'Sell',
orderType: 'Market' | 'Limit',
price?: number
): Promise<{ orderId?: number }> {
if (!this.ws) throw new Error('WebSocket not connected');
const msgId = this.getMsgId();
return new Promise((resolve, reject) => {
this.directEventCallbacks[msgId] = (response: any) => {
if (typeof response === 'string') {
reject(new Error(response));
return;
}
resolve({ orderId: response?.orderId ?? response?.id });
};
this.ws.send(
`order/placeorder\n${msgId}\n\n${JSON.stringify({
accountId,
action,
symbol: contractSymbol,
orderQty: quantity,
orderType,
price,
timeInForce: 'Day',
text: 'DOM',
})}`
);
});
}
/** Close the WebSocket connection and stop all intervals. Call before discarding the instance. */
public disconnect(): void {
try { this.ws?.close(); } catch { /* ignore */ }
}
/** Register a one-time callback for when a fill arrives for a given orderId. */
onFill(orderId: number, callback: (fill: any) => void): void {
this.indirectEventCallbacks.push({
entityType: 'fill',
eventType: 'Created',
validator: (item: any) => item?.orderId === orderId,
callback,
});
}
}