diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index cc9f43a..84f5561 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -86,7 +86,7 @@ const MONTH_NAMES = [ ]; const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; -function CalendarMonth({ year, month, pnlMap }: { year: number; month: number; pnlMap: Map }) { +function CalendarMonth({ year, month, pnlMap, fundMap }: { year: number; month: number; pnlMap: Map; fundMap: Map }) { const daysInMonth = new Date(year, month, 0).getDate(); 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
; const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const pnl = pnlMap.get(key); + const fundAmt = fundMap.get(key); const hasData = pnl !== undefined; const positive = hasData && pnl! >= 0; return ( @@ -142,11 +143,13 @@ function CalendarMonth({ year, month, pnlMap }: { year: number; month: number; p ? positive ? 'bg-green-50 border border-green-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' }`} > {day} @@ -157,6 +160,11 @@ function CalendarMonth({ year, month, pnlMap }: { year: number; month: number; p {positive ? '+' : '−'}${fmt(Math.abs(pnl!))} )} + {fundAmt !== undefined && ( + + W/D {fundAmt >= 0 ? '+' : '−'}${fmt(Math.abs(fundAmt))} + + )}
); })} @@ -214,6 +222,8 @@ export default function AccountPage() { // dailyPnL comes from state — same source as dailyTarget, no separate fetch needed. 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 daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays; @@ -240,22 +250,40 @@ export default function AccountPage() { ? Math.round(maxDayPnL / cfg.consistency * 100) / 100 : 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(); + 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; const equityData = [ - { label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0 }, - ...dailyPnL.map((d) => { - running += d.pnl; + { label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false }, + ...sortedEvents.map((ev) => { + if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity + if (ev.pnl !== undefined) running += ev.pnl; const equity = Math.round(running * 100) / 100; return { - label: fmtDate(d.date), + label: fmtDate(ev.date), equity, - pnl: d.pnl, - pos: Math.max(0, equity), // above-zero portion for green fill - neg: Math.min(0, equity), // below-zero portion for red fill + pnl: ev.pnl ?? 0, + pos: Math.max(0, equity), + 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; // Equity range @@ -277,7 +305,8 @@ export default function AccountPage() { // Build calendar data 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 (
@@ -446,6 +475,23 @@ export default function AccountPage() { }} /> )} + {/* Vertical lines at fund transaction dates */} + {withdrawalLabels.map((lbl) => ( + + ))} {/* Green fill: positive equity only, fills down to y=0 */} {calendarMonths.map((ym) => { const [y, m] = ym.split('-').map(Number); - return ; + return ; })}
)} + {/* Cash History Table */} + {(dailyPnL.length > 0 || fundTransactions.length > 0) && ( +
+

Cash History

+
+ + + + + + + + + + {[ + ...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) => ( + + + + + + ))} + +
DateTypeAmount
{row.date} + {row.type === 'W/D' ? ( + W/D + ) : ( + Trade + )} + = 0 ? 'text-green-600' : 'text-red-500'}`}> + {row.amount >= 0 ? '+' : '−'}${fmt(Math.abs(row.amount))} +
+
+
+ )} + ); diff --git a/app/api/state/route.ts b/app/api/state/route.ts index bb666ed..a1265c7 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -56,6 +56,7 @@ export async function GET() { targetHit, dailyTarget, dailyPnL, + fundTransactions: client.fundTransactions[acc.id] ?? [], }; }); return { firm: f.name, connected: true, accounts, perContractFees: client.perContractFees }; diff --git a/lib/db.ts b/lib/db.ts index da2246c..2c78dd1 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -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 }[]); } +// ── 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 ───────────────────────────────────────────────────────────── db.exec(` diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index cd2fa64..ad2949b 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -5,7 +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, saveAccountMeta, loadAccountMeta } from './db'; +import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions } from './db'; export class TradovateClient { private name: string; @@ -33,6 +33,8 @@ export class TradovateClient { public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; /** Date of the last fund transaction per account — days traded are counted from this date onwards */ 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) */ public autoLiqThresholds: { [accountId: number]: number } = {}; @@ -408,9 +410,10 @@ export class TradovateClient { }; 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'); 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 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); 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 } = {}; for (const row of rows) { 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; } const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, '')); @@ -439,8 +443,17 @@ export class TradovateClient { 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 - const lastFundDate = fundDates.sort().pop() ?? null; + const lastFundDate = [...Object.keys(fundMap)].sort().pop() ?? null; if (lastFundDate) { saveAccountMeta(account.id, 'last_fund_date', lastFundDate); this.lastFundDates[account.id] = lastFundDate; diff --git a/types.ts b/types.ts index 635fb64..4b6b8a2 100644 --- a/types.ts +++ b/types.ts @@ -36,6 +36,8 @@ export interface AccountState { dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null; /** FIFO daily P&L history — used by the equity curve and calendar. */ dailyPnL: { date: string; pnl: number }[]; + /** Fund transactions (deposits/withdrawals) — used by the calendar and equity curve. */ + fundTransactions: { date: string; amount: number }[]; } export interface FirmState {