From 8848f90b1167b90eaf1de80473fbf58af602e1c6 Mon Sep 17 00:00:00 2001 From: Senofy <63175905+Senofy@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:37:05 -0500 Subject: [PATCH] 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 --- app/api/debug/route.ts | 4 +- lib/tradovate-class.ts | 121 +++++++++++++++++++++++------------------ 2 files changed, 69 insertions(+), 56 deletions(-) diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts index 070c725..a1cdc64 100644 --- a/app/api/debug/route.ts +++ b/app/api/debug/route.ts @@ -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 }, diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 5453c59..ffe1cd1 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -328,55 +328,75 @@ 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 = {}; + + 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 : '[]'; }; - interface Lot { price: number; qty: number; commPerUnit: number } - for (const account of this.accountList) { + // --- Try Cash History first (true after-fee daily P&L) --- + let usedFallback = false; try { - let reportData = (await axios.post( - 'https://rpt-demo.tradovateapi.com/v1/reports/requestreport', - { - name: 'Fills', 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 }, - ], - }, - { headers: authHeaders } - )).data; + 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)'; - // Poll if queued — getreport also requires the bearer token - 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++; + 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; } - if (!this.lastFetchRaw) this.lastFetchRaw = {}; - const raw: string = typeof reportData?.data === 'string' ? reportData.data : '[]'; - this.lastFetchRaw[account.name] = raw.slice(0, 100); + const entries = Object.entries(dailyMap) + .map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 })) + .sort((a, b) => a.date.localeCompare(b.date)); - // _tradeDate is unquoted in the response (invalid JSON) — fix before parsing + 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;