Persist daily P&L beyond 28-day Tradovate window

Adds a daily_pnl SQLite table that accumulates trade history indefinitely.
On each hourly fetch, fresh API data is upserted (not replaced) so entries
older than Tradovate's 28-day limit are preserved. Cache is loaded at startup
and used as fallback when both report requests fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-03-21 03:40:37 -05:00
co-authored by Claude Sonnet 4.6
parent 131c9bca9f
commit 6b299f5359
3 changed files with 3882 additions and 11 deletions
+26
View File
@@ -201,6 +201,32 @@ export function setInstrumentEnabled(symbol: string, enabled: boolean): boolean
return result.changes > 0;
}
// ── Daily P&L Cache ──────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS daily_pnl (
account_id INTEGER NOT NULL,
account_name TEXT NOT NULL,
date TEXT NOT NULL,
pnl REAL NOT NULL,
PRIMARY KEY (account_id, date)
);
`);
export function saveDailyPnL(accountId: number, accountName: string, entries: { date: string; pnl: number }[]): void {
const ins = db.prepare('INSERT OR REPLACE INTO daily_pnl (account_id, account_name, date, pnl) VALUES (?, ?, ?, ?)');
const txn = db.transaction(() => {
for (const e of entries) {
ins.run(accountId, accountName, e.date, e.pnl);
}
});
txn();
}
export function loadDailyPnL(accountId: number): { date: string; pnl: number }[] {
return (db.prepare('SELECT date, pnl FROM daily_pnl WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; pnl: number }[]);
}
// ── Firm banned symbols ───────────────────────────────────────────────────────
db.exec(`
+39 -9
View File
@@ -5,6 +5,7 @@ import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpe
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
import { POINT_VALUES } from './trading-logic';
import { getCachedContract, resolveContracts } from './contract-resolver';
import { saveDailyPnL, loadDailyPnL } from './db';
export class TradovateClient {
private name: string;
@@ -391,9 +392,28 @@ export class TradovateClient {
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 cache first — serves as both the startup baseline and the fallback if API fails
const cached = loadDailyPnL(account.id);
if (cached.length > 0) {
this.dailyPnL[account.id] = cached;
this.daysTraded[account.id] = cached.filter((d) => d.pnl !== 0).length;
}
// --- 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"');
@@ -408,16 +428,18 @@ export class TradovateClient {
dailyMap[row['Date']] = (dailyMap[row['Date']] ?? 0) + delta;
}
const entries = Object.entries(dailyMap)
const fresh = 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;
const merged = mergePnL(cached, fresh);
this.dailyPnL[account.id] = merged;
this.daysTraded[account.id] = merged.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 {
usedFallback = true;
// fall through to Fills fallback
}
// --- Fallback: Fills report + FIFO (slightly pre-fee, used for passed accounts) ---
@@ -462,19 +484,27 @@ export class TradovateClient {
}
}
const entries = Object.entries(dailyMap)
const fresh = 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;
const merged = mergePnL(cached, fresh);
this.dailyPnL[account.id] = merged;
this.daysTraded[account.id] = merged.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);
this.lastFetchErrors[account.name] = msg;
// Cache already loaded 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;
+3815
View File
File diff suppressed because it is too large Load Diff