Use Cash History report for true after-fee daily P&L, Fills FIFO as fallback

- Primary: Cash History report sums non-Fund-Transaction Deltas per day,
  capturing broker platform fees not present in the Fills report
- Fallback: Fills + FIFO used when Cash History 404s (passed/completed accounts)
- Extracts requestReport() as shared helper to reduce duplication
- debug PATCH endpoint now accepts optional `name` param to test any report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Senofy
2026-03-09 20:37:05 -05:00
co-authored by Claude Sonnet 4.6
parent 570a54fe60
commit 8848f90b11
2 changed files with 69 additions and 56 deletions
+2 -2
View File
@@ -103,12 +103,12 @@ export async function PATCH(req: Request) {
// Full report cycle
if (payload.action === 'report') {
const { account, startDate, endDate } = payload;
const { account, startDate, endDate, name: reportName = 'Fills' } = payload;
try {
let reportData = (await axios.post(
'https://rpt-demo.tradovateapi.com/v1/reports/requestreport',
{
name: 'Fills', representationType: 'json', timezone: -300,
name: reportName, representationType: 'json', timezone: -300,
params: [
{ name: 'startDate', value: startDate },
{ name: 'endDate', value: endDate },
+49 -36
View File
@@ -328,36 +328,24 @@ export class TradovateClient {
return `${m}/${day}/${d.getFullYear()}`;
};
type Fill = {
_tradeDate: string;
_timestamp: string;
_action: number; // 0 = Buy, 1 = Sell
_qty: number;
_price: number;
Product: string;
commission: number;
};
if (!this.lastFetchRaw) this.lastFetchRaw = {};
if (!this.lastFetchErrors) this.lastFetchErrors = {};
interface Lot { price: number; qty: number; commPerUnit: number }
for (const account of this.accountList) {
try {
const requestReport = async (name: string, accountName: string) => {
let reportData = (await axios.post(
'https://rpt-demo.tradovateapi.com/v1/reports/requestreport',
{
name: 'Fills', representationType: 'json', timezone: -300,
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: account.name },
{ name: 'account', value: accountName },
],
},
{ headers: authHeaders }
)).data;
// Poll if queued — getreport also requires the bearer token
let pollAttempts = 0;
while (reportData?.['p-ticket'] && pollAttempts < 30) {
const pTicket: string = reportData['p-ticket'];
@@ -369,14 +357,46 @@ export class TradovateClient {
)).data;
pollAttempts++;
}
return typeof reportData?.data === 'string' ? reportData.data : '[]';
};
if (!this.lastFetchRaw) this.lastFetchRaw = {};
const raw: string = typeof reportData?.data === 'string' ? reportData.data : '[]';
this.lastFetchRaw[account.name] = raw.slice(0, 100);
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)';
// _tradeDate is unquoted in the response (invalid JSON) — fix before parsing
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[] = [];
@@ -388,17 +408,13 @@ export class TradovateClient {
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);
const pnl = (lot.price - fill._price) * closed * pointValue
- (commPerUnit * closed)
- (lot.commPerUnit * closed);
dailyMap[fill._tradeDate] = (dailyMap[fill._tradeDate] ?? 0) + pnl;
lot.qty -= closed;
remaining -= closed;
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 });
@@ -406,12 +422,9 @@ export class TradovateClient {
while (remaining > 0 && longBook.length > 0) {
const lot = longBook[0];
const closed = Math.min(lot.qty, remaining);
const pnl = (fill._price - lot.price) * closed * pointValue
- (commPerUnit * closed)
- (lot.commPerUnit * closed);
dailyMap[fill._tradeDate] = (dailyMap[fill._tradeDate] ?? 0) + pnl;
lot.qty -= closed;
remaining -= closed;
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 });
@@ -424,10 +437,10 @@ export class TradovateClient {
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] report error for ${account.name}:`, msg);
if (!this.lastFetchErrors) this.lastFetchErrors = {};
console.error(`[fetchDaysTraded] both reports failed for ${account.name}:`, msg);
this.lastFetchErrors[account.name] = msg;
this.dailyPnL[account.id] ??= [];
this.daysTraded[account.id] ??= 0;