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:
co-authored by
Claude Sonnet 4.6
parent
570a54fe60
commit
8848f90b11
@@ -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 },
|
||||
|
||||
+67
-54
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user