Tracks Fund Transaction entries in the Cash History report to find the most recent account funding/reset date. Only trading days on or after that date count toward daysTraded and the daily target calculation. The last fund date is persisted in SQLite so it survives beyond the 28-day Tradovate report window. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
802 lines
36 KiB
TypeScript
802 lines
36 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';
|
|
import { getCachedContract, resolveContracts } from './contract-resolver';
|
|
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta } from './db';
|
|
|
|
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 }[] } = {};
|
|
/** Date of the last fund transaction per account — days traded are counted from this date onwards */
|
|
public lastFundDates: { [accountId: number]: string | null } = {};
|
|
/** 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;
|
|
/** NodeJS.Timeout handle for the hourly dailyPnL refresh */
|
|
private daysFetchInterval: ReturnType<typeof setInterval> | null = null;
|
|
/** Interval handles tracked so they can be cleared on reconnect */
|
|
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
|
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
|
private tokenRenewalInterval: ReturnType<typeof setInterval> | null = null;
|
|
/** Set to true by disconnect() to suppress reconnect on close */
|
|
private intentionalDisconnect = false;
|
|
/** 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.connectAndAuth();
|
|
}
|
|
|
|
private connectAndAuth(): void {
|
|
this.login().then((res) => {
|
|
if (!res?.accessToken) {
|
|
console.log('Failed to login', res);
|
|
return;
|
|
}
|
|
|
|
console.log('Logged in', res);
|
|
this.accessInfo = res;
|
|
|
|
// Clear and restart token renewal interval
|
|
if (this.tokenRenewalInterval) clearInterval(this.tokenRenewalInterval);
|
|
this.tokenRenewalInterval = setInterval(() => {
|
|
if (
|
|
new Date(this.accessInfo.expirationTime).getTime() <
|
|
new Date().getTime() + 15 * 60 * 1000
|
|
) {
|
|
this.renewAccessToken();
|
|
}
|
|
}, 2 * 60 * 1000);
|
|
|
|
// Clear stale WS intervals before opening a new connection
|
|
if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; }
|
|
if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; }
|
|
|
|
// Reset sync state so scheduler waits for the new sync to complete
|
|
this.syncComplete = false;
|
|
|
|
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();
|
|
this.syncInterval = setInterval(() => this.requestSync(), 60000);
|
|
|
|
// Every 2.5 seconds send a heartbeat
|
|
this.heartbeatInterval = 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);
|
|
};
|
|
|
|
this.ws.onclose = (event) => {
|
|
if (this.intentionalDisconnect) return;
|
|
console.warn(`[tradovate] WebSocket closed (code=${event.code}) — reconnecting in 5s`);
|
|
if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; }
|
|
if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; }
|
|
setTimeout(() => this.connectAndAuth(), 5_000);
|
|
};
|
|
});
|
|
}
|
|
|
|
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();
|
|
|
|
// Refresh daily P&L once per hour — avoids hammering the reports API
|
|
if (this.daysFetchInterval) clearInterval(this.daysFetchInterval);
|
|
this.daysFetchInterval = setInterval(
|
|
() => this.fetchDaysTraded().catch((err) => console.error('[hourly fetchDaysTraded]', err)),
|
|
60 * 60 * 1_000
|
|
);
|
|
|
|
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;
|
|
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 : '[]';
|
|
};
|
|
|
|
// Merge fresh API entries on top of cached historical entries (fresh takes precedence for overlapping dates)
|
|
const mergePnL = (
|
|
cached: { date: string; pnl: number }[],
|
|
fresh: { date: string; pnl: number }[]
|
|
): { date: string; pnl: number }[] => {
|
|
const map = new Map<string, number>();
|
|
for (const e of cached) map.set(e.date, e.pnl);
|
|
for (const e of fresh) map.set(e.date, e.pnl);
|
|
return Array.from(map.entries())
|
|
.map(([date, pnl]) => ({ date, pnl }))
|
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
};
|
|
|
|
for (const account of this.accountList) {
|
|
// Load last known fund date from DB — used to filter days traded to the current challenge period
|
|
const storedFundDate = loadAccountMeta(account.id, 'last_fund_date');
|
|
this.lastFundDates[account.id] = storedFundDate;
|
|
|
|
// Load cache first — serves as both the startup baseline and the fallback if API fails
|
|
const cached = loadDailyPnL(account.id);
|
|
if (cached.length > 0) {
|
|
const active = storedFundDate ? cached.filter((d) => d.date >= storedFundDate) : cached;
|
|
this.dailyPnL[account.id] = active;
|
|
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
|
}
|
|
|
|
// --- Try Cash History first (true after-fee daily P&L) ---
|
|
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 fundDates: string[] = [];
|
|
const dailyMap: { [date: string]: number } = {};
|
|
for (const row of rows) {
|
|
if ((row['Cash Change Type'] ?? '').trim() === 'Fund Transaction') {
|
|
fundDates.push(row['Date']);
|
|
continue;
|
|
}
|
|
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
|
|
if (isNaN(delta)) continue;
|
|
dailyMap[row['Date']] = (dailyMap[row['Date']] ?? 0) + delta;
|
|
}
|
|
|
|
// Use the most recent fund transaction as the reset point — persist it so it survives beyond the 28-day window
|
|
const lastFundDate = fundDates.sort().pop() ?? null;
|
|
if (lastFundDate) {
|
|
saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
|
|
this.lastFundDates[account.id] = lastFundDate;
|
|
}
|
|
const fundDate = this.lastFundDates[account.id];
|
|
|
|
const fresh = Object.entries(dailyMap)
|
|
.map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 }))
|
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
|
|
const merged = mergePnL(cached, fresh);
|
|
const active = fundDate ? merged.filter((d) => d.date >= fundDate) : merged;
|
|
this.dailyPnL[account.id] = active;
|
|
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
|
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
|
delete this.lastFetchErrors[account.name];
|
|
continue;
|
|
} catch {
|
|
// fall through to Fills fallback
|
|
}
|
|
|
|
// --- 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 });
|
|
}
|
|
}
|
|
|
|
// Fills report has no fund transaction data — use stored fund date
|
|
const fundDate = this.lastFundDates[account.id];
|
|
|
|
const fresh = Object.entries(dailyMap)
|
|
.map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 }))
|
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
|
|
const merged = mergePnL(cached, fresh);
|
|
const active = fundDate ? merged.filter((d) => d.date >= fundDate) : merged;
|
|
this.dailyPnL[account.id] = active;
|
|
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
|
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
|
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);
|
|
// Cache already loaded and filtered at top of loop — just log the error
|
|
if (cached.length > 0) {
|
|
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
|
|
console.log(`[fetchDaysTraded] using ${cached.length} cached entries from DB for ${account.name}`);
|
|
} else {
|
|
this.dailyPnL[account.id] ??= [];
|
|
this.daysTraded[account.id] ??= 0;
|
|
this.lastFetchErrors[account.name] = msg;
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
|
|
// Prefer the volume-based resolver so the trading path matches the settings page.
|
|
const cached = getCachedContract(productName);
|
|
if (cached) return { id: cached.id, name: cached.name, tickSize: cached.tickSize };
|
|
|
|
// If the cache is cold, resolve on demand with the same logic the settings page uses.
|
|
const resolved = await resolveContracts([productName], this.accessInfo.accessToken);
|
|
const contract = resolved[productName];
|
|
if (contract) {
|
|
return { id: contract.id, name: contract.name, tickSize: contract.tickSize };
|
|
}
|
|
|
|
// Last resort: fall back to Tradovate's ordered suggestions.
|
|
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 ?? [];
|
|
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 {
|
|
this.intentionalDisconnect = true;
|
|
if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; }
|
|
if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; }
|
|
if (this.tokenRenewalInterval) { clearInterval(this.tokenRenewalInterval); this.tokenRenewalInterval = null; }
|
|
if (this.daysFetchInterval) { clearInterval(this.daysFetchInterval); this.daysFetchInterval = null; }
|
|
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,
|
|
});
|
|
}
|
|
}
|