Show fund transactions (W/D) on calendar, equity curve, and cash history table

- Persist fund transactions to SQLite (fund_transactions table) so they
  survive beyond Tradovate's 28-day report window
- Calendar: highlight W/D dates in amber with the amount shown below the day
- Equity curve: reduce running equity at withdrawal dates and show a vertical
  dashed amber line labelled W/D
- New Cash History table below calendar listing all trades and W/D events
  sorted newest-first

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-03-21 04:16:24 -05:00
co-authored by Claude Sonnet 4.6
parent 6d960ceb7b
commit 4252adfbea
5 changed files with 144 additions and 18 deletions
+99 -13
View File
@@ -86,7 +86,7 @@ const MONTH_NAMES = [
]; ];
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function CalendarMonth({ year, month, pnlMap }: { year: number; month: number; pnlMap: Map<string, number> }) { function CalendarMonth({ year, month, pnlMap, fundMap }: { year: number; month: number; pnlMap: Map<string, number>; fundMap: Map<string, number> }) {
const daysInMonth = new Date(year, month, 0).getDate(); const daysInMonth = new Date(year, month, 0).getDate();
const firstDow = new Date(year, month - 1, 1).getDay(); // 0 = Sunday const firstDow = new Date(year, month - 1, 1).getDay(); // 0 = Sunday
@@ -132,6 +132,7 @@ function CalendarMonth({ year, month, pnlMap }: { year: number; month: number; p
if (day === null) return <div key={`empty-${i}`} />; if (day === null) return <div key={`empty-${i}`} />;
const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const pnl = pnlMap.get(key); const pnl = pnlMap.get(key);
const fundAmt = fundMap.get(key);
const hasData = pnl !== undefined; const hasData = pnl !== undefined;
const positive = hasData && pnl! >= 0; const positive = hasData && pnl! >= 0;
return ( return (
@@ -142,11 +143,13 @@ function CalendarMonth({ year, month, pnlMap }: { year: number; month: number; p
? positive ? positive
? 'bg-green-50 border border-green-100' ? 'bg-green-50 border border-green-100'
: 'bg-red-50 border border-red-100' : 'bg-red-50 border border-red-100'
: 'bg-slate-50 border border-transparent' : fundAmt !== undefined
? 'bg-amber-50 border border-amber-100'
: 'bg-slate-50 border border-transparent'
}`} }`}
> >
<span className={`text-[11px] font-medium leading-none mb-1.5 ${ <span className={`text-[11px] font-medium leading-none mb-1.5 ${
hasData ? (positive ? 'text-green-700' : 'text-red-600') : 'text-slate-400' hasData ? (positive ? 'text-green-700' : 'text-red-600') : fundAmt !== undefined ? 'text-amber-700' : 'text-slate-400'
}`}> }`}>
{day} {day}
</span> </span>
@@ -157,6 +160,11 @@ function CalendarMonth({ year, month, pnlMap }: { year: number; month: number; p
{positive ? '+' : ''}${fmt(Math.abs(pnl!))} {positive ? '+' : ''}${fmt(Math.abs(pnl!))}
</span> </span>
)} )}
{fundAmt !== undefined && (
<span className="text-[10px] font-semibold tabular-nums leading-tight mt-0.5 text-amber-600">
W/D {fundAmt >= 0 ? '+' : ''}${fmt(Math.abs(fundAmt))}
</span>
)}
</div> </div>
); );
})} })}
@@ -214,6 +222,8 @@ export default function AccountPage() {
// dailyPnL comes from state — same source as dailyTarget, no separate fetch needed. // dailyPnL comes from state — same source as dailyTarget, no separate fetch needed.
const dailyPnL = account.dailyPnL; const dailyPnL = account.dailyPnL;
const fundTransactions = account.fundTransactions ?? [];
const fundMap = new Map(fundTransactions.map((f) => [f.date, f.amount]));
const hasLossLimit = cfg != null && cfg.minDayPnL !== -999; const hasLossLimit = cfg != null && cfg.minDayPnL !== -999;
const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays; const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays;
@@ -240,22 +250,40 @@ export default function AccountPage() {
? Math.round(maxDayPnL / cfg.consistency * 100) / 100 ? Math.round(maxDayPnL / cfg.consistency * 100) / 100
: null; : null;
// Build equity curve: FIFO daily increments, origin at $0 // Build equity curve: FIFO daily increments + withdrawal step-downs, origin at $0
// Merge daily P&L and fund transactions into a single sorted timeline
type EquityEvent = { date: string; pnl?: number; fundAmt?: number };
const allDates = new Set([...dailyPnL.map((d) => d.date), ...fundTransactions.map((f) => f.date)]);
const eventsByDate = new Map<string, EquityEvent>();
for (const d of dailyPnL) eventsByDate.set(d.date, { date: d.date, pnl: d.pnl });
for (const f of fundTransactions) {
const existing = eventsByDate.get(f.date);
eventsByDate.set(f.date, { ...existing, date: f.date, fundAmt: f.amount });
}
const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!);
let running = 0; let running = 0;
const equityData = [ const equityData = [
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0 }, { label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
...dailyPnL.map((d) => { ...sortedEvents.map((ev) => {
running += d.pnl; if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
if (ev.pnl !== undefined) running += ev.pnl;
const equity = Math.round(running * 100) / 100; const equity = Math.round(running * 100) / 100;
return { return {
label: fmtDate(d.date), label: fmtDate(ev.date),
equity, equity,
pnl: d.pnl, pnl: ev.pnl ?? 0,
pos: Math.max(0, equity), // above-zero portion for green fill pos: Math.max(0, equity),
neg: Math.min(0, equity), // below-zero portion for red fill neg: Math.min(0, equity),
isWithdrawal: ev.fundAmt !== undefined,
fundAmt: ev.fundAmt,
date: ev.date,
}; };
}), }),
]; ];
// Dates with fund transactions that appear in the chart (for vertical reference lines)
const withdrawalLabels = equityData.filter((d) => d.isWithdrawal).map((d) => d.label);
const isPositive = totalProfit >= 0; const isPositive = totalProfit >= 0;
// Equity range // Equity range
@@ -277,7 +305,8 @@ export default function AccountPage() {
// Build calendar data // Build calendar data
const pnlMap = new Map(dailyPnL.map((d) => [d.date, d.pnl])); const pnlMap = new Map(dailyPnL.map((d) => [d.date, d.pnl]));
const calendarMonths = [...new Set(dailyPnL.map((d) => d.date.slice(0, 7)))].sort(); const allCalendarDates = [...dailyPnL.map((d) => d.date), ...fundTransactions.map((f) => f.date)];
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
return ( return (
<div className="min-h-screen bg-slate-50 p-8"> <div className="min-h-screen bg-slate-50 p-8">
@@ -446,6 +475,23 @@ export default function AccountPage() {
}} }}
/> />
)} )}
{/* Vertical lines at fund transaction dates */}
{withdrawalLabels.map((lbl) => (
<ReferenceLine
key={`wd-${lbl}`}
x={lbl}
stroke="#f59e0b"
strokeWidth={1.5}
strokeDasharray="4 3"
label={{
value: 'W/D',
position: 'insideTopRight',
fontSize: 10,
fontWeight: 700,
fill: '#d97706',
}}
/>
))}
{/* Green fill: positive equity only, fills down to y=0 */} {/* Green fill: positive equity only, fills down to y=0 */}
<Area <Area
type="monotone" type="monotone"
@@ -506,12 +552,52 @@ export default function AccountPage() {
<div className="grid grid-cols-1 gap-8" style={{ gridTemplateColumns: `repeat(${Math.min(calendarMonths.length, 3)}, minmax(0, 1fr))` }}> <div className="grid grid-cols-1 gap-8" style={{ gridTemplateColumns: `repeat(${Math.min(calendarMonths.length, 3)}, minmax(0, 1fr))` }}>
{calendarMonths.map((ym) => { {calendarMonths.map((ym) => {
const [y, m] = ym.split('-').map(Number); const [y, m] = ym.split('-').map(Number);
return <CalendarMonth key={ym} year={y} month={m} pnlMap={pnlMap} />; return <CalendarMonth key={ym} year={y} month={m} pnlMap={pnlMap} fundMap={fundMap} />;
})} })}
</div> </div>
</div> </div>
)} )}
{/* Cash History Table */}
{(dailyPnL.length > 0 || fundTransactions.length > 0) && (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
<h2 className="text-slate-900 font-bold text-base mb-4">Cash History</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100">
<th className="text-left text-slate-400 font-medium py-2 pr-4">Date</th>
<th className="text-left text-slate-400 font-medium py-2 pr-4">Type</th>
<th className="text-right text-slate-400 font-medium py-2">Amount</th>
</tr>
</thead>
<tbody>
{[
...dailyPnL.map((d) => ({ date: d.date, type: 'Trade' as const, amount: d.pnl })),
...fundTransactions.map((f) => ({ date: f.date, type: 'W/D' as const, amount: f.amount })),
]
.sort((a, b) => b.date.localeCompare(a.date))
.map((row, i) => (
<tr key={i} className="border-b border-slate-50 last:border-0">
<td className="py-2.5 pr-4 text-slate-500 tabular-nums">{row.date}</td>
<td className="py-2.5 pr-4">
{row.type === 'W/D' ? (
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-amber-100 text-amber-700">W/D</span>
) : (
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-slate-100 text-slate-500">Trade</span>
)}
</td>
<td className={`py-2.5 text-right font-bold tabular-nums ${row.amount >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{row.amount >= 0 ? '+' : ''}${fmt(Math.abs(row.amount))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div> </div>
</div> </div>
); );
+1
View File
@@ -56,6 +56,7 @@ export async function GET() {
targetHit, targetHit,
dailyTarget, dailyTarget,
dailyPnL, dailyPnL,
fundTransactions: client.fundTransactions[acc.id] ?? [],
}; };
}); });
return { firm: f.name, connected: true, accounts, perContractFees: client.perContractFees }; return { firm: f.name, connected: true, accounts, perContractFees: client.perContractFees };
+24
View File
@@ -227,6 +227,30 @@ 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 }[]); return (db.prepare('SELECT date, pnl FROM daily_pnl WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; pnl: number }[]);
} }
// ── Fund Transactions Cache ───────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS fund_transactions (
account_id INTEGER NOT NULL,
account_name TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
PRIMARY KEY (account_id, date)
);
`);
export function saveFundTransactions(accountId: number, accountName: string, entries: { date: string; amount: number }[]): void {
const ins = db.prepare('INSERT OR REPLACE INTO fund_transactions (account_id, account_name, date, amount) VALUES (?, ?, ?, ?)');
const txn = db.transaction(() => {
for (const e of entries) ins.run(accountId, accountName, e.date, e.amount);
});
txn();
}
export function loadFundTransactions(accountId: number): { date: string; amount: number }[] {
return db.prepare('SELECT date, amount FROM fund_transactions WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; amount: number }[];
}
// ── Account Meta ───────────────────────────────────────────────────────────── // ── Account Meta ─────────────────────────────────────────────────────────────
db.exec(` db.exec(`
+18 -5
View File
@@ -5,7 +5,7 @@ import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpe
import { computeSec, randomUUIDV4 } from './tradovate-helpers'; import { computeSec, randomUUIDV4 } from './tradovate-helpers';
import { POINT_VALUES } from './trading-logic'; import { POINT_VALUES } from './trading-logic';
import { getCachedContract, resolveContracts } from './contract-resolver'; import { getCachedContract, resolveContracts } from './contract-resolver';
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta } from './db'; import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions } from './db';
export class TradovateClient { export class TradovateClient {
private name: string; private name: string;
@@ -33,6 +33,8 @@ export class TradovateClient {
public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
/** Date of the last fund transaction per account — days traded are counted from this date onwards */ /** Date of the last fund transaction per account — days traded are counted from this date onwards */
public lastFundDates: { [accountId: number]: string | null } = {}; public lastFundDates: { [accountId: number]: string | null } = {};
/** All fund transactions (deposits/withdrawals) per account */
public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {};
/** Balance floor set by the prop firm — account is blown when amount <= this value (0 = not set) */ /** Balance floor set by the prop firm — account is blown when amount <= this value (0 = not set) */
public autoLiqThresholds: { [accountId: number]: number } = {}; public autoLiqThresholds: { [accountId: number]: number } = {};
@@ -408,9 +410,10 @@ export class TradovateClient {
}; };
for (const account of this.accountList) { for (const account of this.accountList) {
// Load last known fund date from DB — used to filter days traded to the current challenge period // Load last known fund date and fund transactions from DB
const storedFundDate = loadAccountMeta(account.id, 'last_fund_date'); const storedFundDate = loadAccountMeta(account.id, 'last_fund_date');
this.lastFundDates[account.id] = storedFundDate; 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 // Load cache first — serves as both the startup baseline and the fallback if API fails
const cached = loadDailyPnL(account.id); const cached = loadDailyPnL(account.id);
@@ -427,11 +430,12 @@ export class TradovateClient {
const rows: { Date: string; Delta: string; 'Cash Change Type': string; [k: string]: any }[] = JSON.parse(fixed); 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)'; this.lastFetchRaw[account.name] = rows.length > 0 ? JSON.stringify(rows[0]) : '(empty)';
const fundDates: string[] = []; const fundMap: { [date: string]: number } = {};
const dailyMap: { [date: string]: number } = {}; const dailyMap: { [date: string]: number } = {};
for (const row of rows) { for (const row of rows) {
if ((row['Cash Change Type'] ?? '').trim() === 'Fund Transaction') { if ((row['Cash Change Type'] ?? '').trim() === 'Fund Transaction') {
fundDates.push(row['Date']); const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
if (!isNaN(delta)) fundMap[row['Date']] = (fundMap[row['Date']] ?? 0) + delta;
continue; continue;
} }
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, '')); const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
@@ -439,8 +443,17 @@ export class TradovateClient {
dailyMap[row['Date']] = (dailyMap[row['Date']] ?? 0) + delta; 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 // Use the most recent fund transaction as the reset point — persist it so it survives beyond the 28-day window
const lastFundDate = fundDates.sort().pop() ?? null; const lastFundDate = [...Object.keys(fundMap)].sort().pop() ?? null;
if (lastFundDate) { if (lastFundDate) {
saveAccountMeta(account.id, 'last_fund_date', lastFundDate); saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
this.lastFundDates[account.id] = lastFundDate; this.lastFundDates[account.id] = lastFundDate;
+2
View File
@@ -36,6 +36,8 @@ export interface AccountState {
dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null; dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null;
/** FIFO daily P&L history — used by the equity curve and calendar. */ /** FIFO daily P&L history — used by the equity curve and calendar. */
dailyPnL: { date: string; pnl: number }[]; dailyPnL: { date: string; pnl: number }[];
/** Fund transactions (deposits/withdrawals) — used by the calendar and equity curve. */
fundTransactions: { date: string; amount: number }[];
} }
export interface FirmState { export interface FirmState {