Files
autofirmer-expanded/app/firms/[id]/settings/page.tsx
T
SenofyandClaude Sonnet 4.6 dd18f91584 Add full Next.js autotrader app with SQLite persistence and live Tradovate data
- 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>
2026-03-08 15:03:21 -05:00

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>
);
}