Files
autofirmer-expanded/lib/tradovate-class.ts
T
Brandon LiandClaude Opus 4.6 c3ec477c50 Treat Manual Adjustment cash changes as fund transactions
Some firms record withdrawals as "Manual Adjustment" instead of
"Fund Transaction" in the Cash History. Include both types so the
withdrawal-aware cycle filtering still works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:59:22 -05:00

932 lines
43 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, saveFundTransactions, loadFundTransactions } 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 }[] } = {};
/** Full P&L history (all cycles) — used for calendar display */
public fullDailyPnL: { [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 } = {};
/** All fund transactions (deposits/withdrawals) per account */
public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {};
/** Sum of daily P&L from before the last fund transaction — used for "Target Same Equity" mode */
public priorProfit: { [accountId: number]: 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;
/** Prevent overlapping report fetches and throttle them to hourly cadence with retry backoff. */
private daysFetchInFlight = false;
private lastDaysFetchStartedAt = 0;
private lastDaysFetchSucceededAt = 0;
private nextDaysFetchRetryAt = 0;
/** 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.maybeFetchDaysTraded();
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}');
}
private maybeFetchDaysTraded(): void {
if (this.daysFetchInFlight) return;
const now = Date.now();
const initialFetchDue = this.lastDaysFetchStartedAt === 0;
const retryPending = this.nextDaysFetchRetryAt > 0;
const retryDue = retryPending && now >= this.nextDaysFetchRetryAt;
const hourlyRefreshDue =
!retryPending &&
this.lastDaysFetchSucceededAt > 0 &&
now - this.lastDaysFetchSucceededAt >= 60 * 60 * 1_000;
if (!initialFetchDue && !retryDue && !hourlyRefreshDue) return;
const reason = retryDue
? 'retry after failure'
: initialFetchDue
? 'initial sync'
: 'hourly refresh';
this.daysFetchInFlight = true;
this.lastDaysFetchStartedAt = now;
if (retryDue) this.nextDaysFetchRetryAt = 0;
this.fetchDaysTraded()
.then(({ failedAccounts }) => {
if (failedAccounts > 0) {
this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000;
console.warn(`[fetchDaysTraded] ${failedAccounts} account(s) failed during ${reason} — retrying in 5 minutes`);
return;
}
this.lastDaysFetchSucceededAt = Date.now();
})
.catch((err) => {
this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000;
console.error(`[fetchDaysTraded] ${reason} failed — retrying in 5 minutes`, err);
})
.finally(() => {
this.daysFetchInFlight = false;
});
}
/**
* Returns true when a withdrawal happened before the trading session started,
* meaning that day's trades belong to the NEW cycle.
* Timestamp format from Tradovate: "MM/DD/YYYY HH:MM:SS" in Central Time.
* Cutoff: before 9:00 AM CT → "before trading".
*/
private static isWithdrawalBeforeTrading(timestamp: string | null): boolean {
if (!timestamp) return false;
const match = timestamp.match(/\d{2}\/\d{2}\/\d{4}\s+(\d{2}):\d{2}:\d{2}/);
if (!match) return false;
return parseInt(match[1], 10) < 9;
}
/**
* Filters daily PnL entries for the current cycle based on fund date and withdrawal timing.
* - Deposit: include the fund date (trading can start same day)
* - Withdrawal before trading session: include the fund date (day's trades are new cycle)
* - Withdrawal during/after trading: exclude the fund date (day's trades are old cycle)
*/
private static filterActivePnL(
entries: { date: string; pnl: number }[],
fundDate: string | null,
isWithdrawal: boolean,
fundTimestamp: string | null
): { active: { date: string; pnl: number }[]; prior: { date: string; pnl: number }[] } {
if (!fundDate) return { active: entries, prior: [] };
// Withdrawal before trading → day belongs to NEW cycle (use >=)
// Withdrawal during/after trading → day belongs to OLD cycle (use >)
// Deposit → always include the day (use >=)
const excludeFundDate = isWithdrawal && !TradovateClient.isWithdrawalBeforeTrading(fundTimestamp);
const active = entries.filter((d) => excludeFundDate ? d.date > fundDate : d.date >= fundDate);
const prior = entries.filter((d) => excludeFundDate ? d.date <= fundDate : d.date < fundDate);
return { active, prior };
}
public async fetchDaysTraded(): Promise<{ failedAccounts: number }> {
if (!this.accessInfo?.accessToken) return { failedAccounts: 0 };
this.fetchDaysComplete = false;
let failedAccounts = 0;
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 and fund transactions from DB
const storedFundDate = loadAccountMeta(account.id, 'last_fund_date');
this.lastFundDates[account.id] = storedFundDate;
this.fundTransactions[account.id] = loadFundTransactions(account.id);
// Load cache first — serves as both the startup baseline and the fallback if API fails
const cached = loadDailyPnL(account.id);
const storedFundTimestamp = loadAccountMeta(account.id, 'last_fund_timestamp');
if (cached.length > 0) {
const storedFundTxns = this.fundTransactions[account.id] ?? [];
const storedLastFundAmt = storedFundDate ? (storedFundTxns.find((f) => f.date === storedFundDate)?.amount ?? null) : null;
const storedIsWithdrawal = storedLastFundAmt !== null && storedLastFundAmt < 0;
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
cached, storedFundDate, storedIsWithdrawal, storedFundTimestamp
);
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
this.fullDailyPnL[account.id] = 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 fundMap: { [date: string]: number } = {};
const fundTimestampMap: { [date: string]: string } = {};
const dailyMap: { [date: string]: number } = {};
for (const row of rows) {
const changeType = (row['Cash Change Type'] ?? '').trim();
if (changeType === 'Fund Transaction' || changeType === 'Manual Adjustment') {
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
if (!isNaN(delta)) {
fundMap[row['Date']] = (fundMap[row['Date']] ?? 0) + delta;
if (row['Timestamp']) fundTimestampMap[row['Date']] = row['Timestamp'];
}
continue;
}
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
if (isNaN(delta)) continue;
dailyMap[row['Date']] = (dailyMap[row['Date']] ?? 0) + delta;
}
// Persist fund transactions so they survive beyond the 28-day window
const freshFundTxns = Object.entries(fundMap)
.map(([date, amount]) => ({ date, amount: Math.round(amount * 100) / 100 }))
.sort((a, b) => a.date.localeCompare(b.date));
if (freshFundTxns.length > 0) {
saveFundTransactions(account.id, account.name, freshFundTxns);
this.fundTransactions[account.id] = loadFundTransactions(account.id); // reload merged full history
}
// Use the most recent fund transaction as the reset point — persist it so it survives beyond the 28-day window
const lastFundDate = [...Object.keys(fundMap)].sort().pop() ?? null;
if (lastFundDate) {
saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
this.lastFundDates[account.id] = lastFundDate;
if (fundTimestampMap[lastFundDate]) {
saveAccountMeta(account.id, 'last_fund_timestamp', fundTimestampMap[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 lastFundAmt = fundDate ? (fundMap[fundDate] ?? null) : null;
const isWithdrawal = lastFundAmt !== null && lastFundAmt < 0;
const lastFundTs = fundDate ? (fundTimestampMap[fundDate] ?? loadAccountMeta(account.id, 'last_fund_timestamp')) : null;
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
merged, fundDate, isWithdrawal, lastFundTs
);
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
this.fullDailyPnL[account.id] = 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 and stored fund transactions
const fundDate = this.lastFundDates[account.id];
const storedFundTxns = this.fundTransactions[account.id] ?? [];
const lastFundAmt = fundDate ? (storedFundTxns.find((f) => f.date === fundDate)?.amount ?? null) : null;
const isWithdrawalFills = lastFundAmt !== null && lastFundAmt < 0;
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 fillsFundTs = loadAccountMeta(account.id, 'last_fund_timestamp');
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
merged, fundDate, isWithdrawalFills, fillsFundTs
);
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
this.fullDailyPnL[account.id] = 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);
failedAccounts++;
// 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;
return { failedAccounts };
}
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; }
this.daysFetchInFlight = false;
this.nextDaysFetchRetryAt = 0;
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,
});
}
}