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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a9b6acd479
commit
dd18f91584
@@ -0,0 +1,465 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user