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:
co-authored by
Claude Sonnet 4.6
parent
6d960ceb7b
commit
4252adfbea
+98
-12
@@ -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'
|
||||||
|
: fundAmt !== undefined
|
||||||
|
? 'bg-amber-50 border border-amber-100'
|
||||||
: 'bg-slate-50 border border-transparent'
|
: '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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user