- SQLite DB (better-sqlite3) with firms, account_configs, firm_fees, instruments tables - REST API routes: firms CRUD, account configs CRUD, state, accounts, instruments - Live Tradovate WebSocket client: login, sync, positions, auto-liq thresholds - Dashboard (app/page.tsx): per-firm account list with balance, day P&L, days traded, target progress, and Dead/Inactive/Flat status based on Tradovate auto-liq floors - Account detail page: objectives progress, daily P&L chart, consistency tracking - Per-firm settings page: account configs and instrument fee management - Dead detection uses trailingMaxDrawdownLimit - trailingMaxDrawdown from userAccountAutoLiqs; filters Tradovate sentinel value (999999999 = no limit) - FIFO P&L engine with commission accounting for daily P&L history - Removed manual maxLoss fallback in favour of live Tradovate auto-liq data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
466 lines
22 KiB
TypeScript
466 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useRouter, useParams } from 'next/navigation';
|
|
|
|
const SIZE_PRESETS = [5_000, 10_000, 25_000, 50_000, 75_000, 100_000, 150_000];
|
|
|
|
interface FirmFee {
|
|
firmId: number;
|
|
symbol: string;
|
|
allinFee: number;
|
|
roundtripFee: number;
|
|
}
|
|
|
|
interface AccountConfig {
|
|
id: number;
|
|
prefix: string;
|
|
profitTarget: number;
|
|
consistency: number;
|
|
minDayPnL: number;
|
|
minTradingDays: number;
|
|
accountSize: number;
|
|
}
|
|
|
|
interface FirmConfig {
|
|
id: number;
|
|
firm: string;
|
|
accounts: AccountConfig[];
|
|
}
|
|
|
|
interface RowState extends AccountConfig {
|
|
dirty: boolean;
|
|
saving: boolean;
|
|
error: string;
|
|
useCustomSize: boolean;
|
|
confirmDelete: boolean;
|
|
}
|
|
|
|
function initRow(acc: AccountConfig): RowState {
|
|
return {
|
|
...acc,
|
|
dirty: false,
|
|
saving: false,
|
|
error: '',
|
|
useCustomSize: !SIZE_PRESETS.includes(acc.accountSize),
|
|
confirmDelete: false,
|
|
};
|
|
}
|
|
|
|
function newRow(): RowState {
|
|
return {
|
|
id: -(Date.now()),
|
|
prefix: '',
|
|
profitTarget: 3000,
|
|
consistency: 0.5,
|
|
minDayPnL: -999,
|
|
minTradingDays: 5,
|
|
accountSize: 50_000,
|
|
dirty: true,
|
|
saving: false,
|
|
error: '',
|
|
useCustomSize: false,
|
|
confirmDelete: false,
|
|
};
|
|
}
|
|
|
|
function fmtSize(n: number) {
|
|
return n >= 1000 ? `${n / 1000}k` : String(n);
|
|
}
|
|
|
|
function TrashIcon() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6l-1 14H6L5 6" />
|
|
<path d="M10 11v6M14 11v6" />
|
|
<path d="M9 6V4h6v2" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
const inputCls =
|
|
'border border-slate-200 rounded-md px-2 py-1.5 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white w-full';
|
|
|
|
export default function FirmSettingsPage() {
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const id = params.id as string;
|
|
|
|
const [firmName, setFirmName] = useState('');
|
|
const [rows, setRows] = useState<RowState[]>([]);
|
|
const [fees, setFees] = useState<FirmFee[]>([]);
|
|
const [loadError, setLoadError] = useState('');
|
|
|
|
useEffect(() => {
|
|
fetch(`/api/firms/${id}`)
|
|
.then((r) => {
|
|
if (!r.ok) throw new Error('Not found');
|
|
return r.json() as Promise<FirmConfig>;
|
|
})
|
|
.then((firm) => {
|
|
setFirmName(firm.firm);
|
|
setRows(firm.accounts.map(initRow));
|
|
})
|
|
.catch(() => setLoadError('Failed to load firm'));
|
|
|
|
fetch(`/api/firms/${id}/instrument-configs`)
|
|
.then((r) => r.json() as Promise<FirmFee[]>)
|
|
.then(setFees)
|
|
.catch(() => {});
|
|
}, [id]);
|
|
|
|
const updateRow = (rowId: number, patch: Partial<RowState>) => {
|
|
setRows((prev) =>
|
|
prev.map((r) => (r.id === rowId ? { ...r, ...patch, dirty: true } : r))
|
|
);
|
|
};
|
|
|
|
const saveRow = async (row: RowState) => {
|
|
setRows((prev) =>
|
|
prev.map((r) => (r.id === row.id ? { ...r, saving: true, error: '' } : r))
|
|
);
|
|
try {
|
|
const body = {
|
|
prefix: row.prefix,
|
|
profitTarget: row.profitTarget,
|
|
consistency: row.consistency,
|
|
minDayPnL: row.minDayPnL,
|
|
minTradingDays: row.minTradingDays,
|
|
accountSize: row.accountSize,
|
|
};
|
|
|
|
if (row.id < 0) {
|
|
const res = await fetch(`/api/firms/${id}/accounts`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error('Failed');
|
|
const created = await res.json() as AccountConfig;
|
|
setRows((prev) =>
|
|
prev.map((r) =>
|
|
r.id === row.id ? { ...initRow(created), dirty: false } : r
|
|
)
|
|
);
|
|
} else {
|
|
const res = await fetch(`/api/account-configs/${row.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error('Failed');
|
|
setRows((prev) =>
|
|
prev.map((r) =>
|
|
r.id === row.id ? { ...r, saving: false, dirty: false, error: '' } : r
|
|
)
|
|
);
|
|
}
|
|
} catch {
|
|
setRows((prev) =>
|
|
prev.map((r) =>
|
|
r.id === row.id ? { ...r, saving: false, error: 'Save failed' } : r
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
const confirmDeleteRow = (rowId: number) => {
|
|
setRows((prev) =>
|
|
prev.map((r) => (r.id === rowId ? { ...r, confirmDelete: true } : r))
|
|
);
|
|
};
|
|
|
|
const cancelDeleteRow = (rowId: number) => {
|
|
setRows((prev) =>
|
|
prev.map((r) => (r.id === rowId ? { ...r, confirmDelete: false } : r))
|
|
);
|
|
};
|
|
|
|
const deleteRow = async (row: RowState) => {
|
|
if (row.id < 0) {
|
|
setRows((prev) => prev.filter((r) => r.id !== row.id));
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(`/api/account-configs/${row.id}`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error('Failed');
|
|
setRows((prev) => prev.filter((r) => r.id !== row.id));
|
|
} catch {
|
|
setRows((prev) =>
|
|
prev.map((r) =>
|
|
r.id === row.id ? { ...r, confirmDelete: false, error: 'Delete failed' } : r
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
if (loadError) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 p-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
<p className="text-sm text-red-600">{loadError}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 p-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<button
|
|
onClick={() => router.push('/')}
|
|
className="text-slate-400 hover:text-slate-600 text-sm transition-colors"
|
|
>
|
|
← Back
|
|
</button>
|
|
<h1 className="text-2xl font-bold text-slate-900">{firmName || '…'}</h1>
|
|
<span className="text-slate-300 text-2xl font-light">/</span>
|
|
<span className="text-2xl font-bold text-slate-400">Settings</span>
|
|
</div>
|
|
|
|
{/* Account Types */}
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
|
Account Types
|
|
</h2>
|
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
|
<table className="w-full text-sm border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-slate-200 bg-slate-50">
|
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Prefix</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Account Size</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Profit Target</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Consistency</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Day P&L</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Trading Days</th>
|
|
<th className="px-3 py-3 w-32" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.length === 0 && firmName && (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-6 text-center text-sm text-slate-400 italic">
|
|
No account types yet
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{rows.map((row) => (
|
|
<tr key={row.id} className="border-b border-slate-100 last:border-0">
|
|
|
|
{/* Prefix */}
|
|
<td className="px-4 py-2.5">
|
|
<input
|
|
className={`${inputCls} font-mono w-32`}
|
|
value={row.prefix}
|
|
placeholder="e.g. MYACCT"
|
|
onChange={(e) => updateRow(row.id, { prefix: e.target.value })}
|
|
/>
|
|
</td>
|
|
|
|
{/* Account Size */}
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
className={`${inputCls} w-24`}
|
|
value={row.useCustomSize ? 'custom' : row.accountSize}
|
|
onChange={(e) => {
|
|
if (e.target.value === 'custom') {
|
|
updateRow(row.id, { useCustomSize: true });
|
|
} else {
|
|
updateRow(row.id, {
|
|
accountSize: Number(e.target.value),
|
|
useCustomSize: false,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
{SIZE_PRESETS.map((s) => (
|
|
<option key={s} value={s}>{fmtSize(s)}</option>
|
|
))}
|
|
<option value="custom">Custom</option>
|
|
</select>
|
|
{row.useCustomSize && (
|
|
<input
|
|
type="number"
|
|
className={`${inputCls} w-28`}
|
|
value={row.accountSize || ''}
|
|
min={0}
|
|
step={1000}
|
|
placeholder="e.g. 35000"
|
|
onChange={(e) =>
|
|
updateRow(row.id, { accountSize: Number(e.target.value) })
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
</td>
|
|
|
|
{/* Profit Target */}
|
|
<td className="px-4 py-2.5">
|
|
<div className="relative flex items-center">
|
|
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
|
|
<input
|
|
type="number"
|
|
className={`${inputCls} pl-5 w-28`}
|
|
value={row.profitTarget}
|
|
min={0}
|
|
step={100}
|
|
onChange={(e) =>
|
|
updateRow(row.id, { profitTarget: Number(e.target.value) })
|
|
}
|
|
/>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Consistency */}
|
|
<td className="px-4 py-2.5">
|
|
<div className="relative flex items-center">
|
|
<input
|
|
type="number"
|
|
className={`${inputCls} pr-6 w-24`}
|
|
value={Math.round(row.consistency * 100)}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
onChange={(e) =>
|
|
updateRow(row.id, { consistency: Number(e.target.value) / 100 })
|
|
}
|
|
/>
|
|
<span className="absolute right-2.5 text-slate-400 text-sm pointer-events-none">%</span>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Min Day P&L */}
|
|
<td className="px-4 py-2.5">
|
|
<div className="relative flex items-center">
|
|
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
|
|
<input
|
|
type="number"
|
|
className={`${inputCls} pl-5 w-28`}
|
|
value={row.minDayPnL <= -999 ? '' : row.minDayPnL}
|
|
placeholder="None"
|
|
step={100}
|
|
onChange={(e) =>
|
|
updateRow(row.id, {
|
|
minDayPnL: e.target.value === '' ? -999 : Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Min Trading Days */}
|
|
<td className="px-4 py-2.5">
|
|
<input
|
|
type="number"
|
|
className={`${inputCls} w-20`}
|
|
value={row.minTradingDays}
|
|
min={0}
|
|
step={1}
|
|
onChange={(e) =>
|
|
updateRow(row.id, { minTradingDays: Number(e.target.value) })
|
|
}
|
|
/>
|
|
</td>
|
|
|
|
{/* Actions */}
|
|
<td className="px-3 py-2.5">
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
{row.error && (
|
|
<span className="text-xs text-red-500 font-medium mr-1">{row.error}</span>
|
|
)}
|
|
|
|
{row.confirmDelete ? (
|
|
<>
|
|
<span className="text-xs text-slate-500 mr-0.5">Delete?</span>
|
|
<button
|
|
onClick={() => deleteRow(row)}
|
|
className="px-2 py-1 bg-red-100 hover:bg-red-200 text-red-700 text-xs font-semibold rounded-md transition-colors"
|
|
>
|
|
Yes
|
|
</button>
|
|
<button
|
|
onClick={() => cancelDeleteRow(row.id)}
|
|
className="px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md transition-colors"
|
|
>
|
|
No
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => saveRow(row)}
|
|
disabled={row.saving || !row.dirty}
|
|
className={`px-3 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors ${!row.dirty ? 'invisible' : ''}`}
|
|
>
|
|
{row.saving ? '…' : 'Save'}
|
|
</button>
|
|
<button
|
|
onClick={() => confirmDeleteRow(row.id)}
|
|
className="p-1 text-slate-300 hover:text-red-400 hover:bg-red-50 rounded-md transition-colors"
|
|
title="Delete account type"
|
|
>
|
|
<TrashIcon />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
|
|
{/* Add row */}
|
|
<tr className="border-t border-dashed border-slate-200">
|
|
<td colSpan={7} className="px-2 py-2">
|
|
<button
|
|
onClick={() => setRows((prev) => [...prev, newRow()])}
|
|
className="w-full py-1.5 text-sm text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-lg transition-colors"
|
|
>
|
|
+ Add account type
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Fees */}
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-8 mb-3">
|
|
Fees
|
|
</h2>
|
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
|
{fees.length === 0 ? (
|
|
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
|
|
No fees loaded yet
|
|
</div>
|
|
) : (
|
|
<table className="w-full text-sm border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-slate-200 bg-slate-50">
|
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">All-In Fee</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Roundtrip Fee</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{fees.map((fee) => (
|
|
<tr key={fee.symbol} className="border-b border-slate-100 last:border-0">
|
|
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">{fee.symbol}</td>
|
|
<td className="px-4 py-2.5 font-mono text-slate-600">${fee.allinFee.toFixed(4)}</td>
|
|
<td className="px-4 py-2.5 font-mono text-slate-600">${fee.roundtripFee.toFixed(4)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|