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,493 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
Dot,
|
||||
} from 'recharts';
|
||||
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
||||
|
||||
interface DailyPnL {
|
||||
date: string;
|
||||
pnl: number;
|
||||
}
|
||||
|
||||
function getAccountConfig(name: string, firm: FirmConfig): AccountConfig | undefined {
|
||||
return [...firm.accounts]
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length)
|
||||
.find((a) => name.startsWith(a.prefix));
|
||||
}
|
||||
|
||||
function fmt(value: number) {
|
||||
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
const [, m, d] = iso.split('-');
|
||||
return `${parseInt(m)}/${parseInt(d)}`;
|
||||
}
|
||||
|
||||
function PerfRow({ label, value, badge, positive }: { label: string; value: string | number; badge?: string; positive?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||||
<span className="text-slate-500 text-sm">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{badge != null && (
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${positive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-slate-800 font-bold tabular-nums">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjRow({ passed, label, value }: { passed: boolean; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{passed
|
||||
? <span className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0 text-white text-[11px] font-bold">✓</span>
|
||||
: <span className="w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center flex-shrink-0 text-white text-[11px] font-bold">!</span>
|
||||
}
|
||||
<span className="text-slate-700 text-sm">{label}</span>
|
||||
</div>
|
||||
<span className="text-slate-800 font-bold tabular-nums text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EquityTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
// Always read from the raw data point so fill-only series don't interfere
|
||||
const equity: number = payload[0].payload.equity;
|
||||
const daily: number = payload[0].payload.pnl;
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-lg shadow-sm px-3 py-2.5 text-sm min-w-[120px]">
|
||||
<p className="text-slate-400 text-xs mb-1">{label}</p>
|
||||
<p className={`font-bold tabular-nums ${equity >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{equity >= 0 ? '+' : ''}${fmt(equity)}
|
||||
</p>
|
||||
<p className={`text-xs tabular-nums mt-0.5 ${daily >= 0 ? 'text-green-500' : 'text-red-400'}`}>
|
||||
{daily >= 0 ? '▲' : '▼'} ${fmt(Math.abs(daily))} day
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function CalendarMonth({ year, month, pnlMap }: { year: number; month: number; pnlMap: Map<string, number> }) {
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const firstDow = new Date(year, month - 1, 1).getDay(); // 0 = Sunday
|
||||
|
||||
// Monthly total from only the days that have data
|
||||
let monthTotal = 0;
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const key = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const pnl = pnlMap.get(key);
|
||||
if (pnl !== undefined) monthTotal += pnl;
|
||||
}
|
||||
monthTotal = Math.round(monthTotal * 100) / 100;
|
||||
|
||||
// Build flat cell array: null = empty leading cell, number = day of month
|
||||
const cells: (number | null)[] = [
|
||||
...Array.from({ length: firstDow }, () => null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Month header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-slate-700 font-semibold text-sm">
|
||||
{MONTH_NAMES[month - 1]} {year}
|
||||
</span>
|
||||
{monthTotal !== 0 && (
|
||||
<span className={`text-sm font-bold tabular-nums ${monthTotal >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{monthTotal >= 0 ? '+' : '−'}${fmt(Math.abs(monthTotal))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day-of-week headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{DOW_LABELS.map((d) => (
|
||||
<div key={d} className="text-center text-[11px] font-medium text-slate-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`empty-${i}`} />;
|
||||
const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const pnl = pnlMap.get(key);
|
||||
const hasData = pnl !== undefined;
|
||||
const positive = hasData && pnl! >= 0;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`rounded-lg px-1.5 pt-1.5 pb-2 min-h-14 flex flex-col ${
|
||||
hasData
|
||||
? positive
|
||||
? 'bg-green-50 border border-green-100'
|
||||
: 'bg-red-50 border border-red-100'
|
||||
: 'bg-slate-50 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-[11px] font-medium leading-none mb-1.5 ${
|
||||
hasData ? (positive ? 'text-green-700' : 'text-red-600') : 'text-slate-400'
|
||||
}`}>
|
||||
{day}
|
||||
</span>
|
||||
{hasData && (
|
||||
<span className={`text-[11px] font-bold tabular-nums leading-tight ${
|
||||
positive ? 'text-green-700' : 'text-red-600'
|
||||
}`}>
|
||||
{positive ? '+' : '−'}${fmt(Math.abs(pnl!))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const accountId = Number(id);
|
||||
|
||||
const [account, setAccount] = useState<AccountState | null>(null);
|
||||
const [cfg, setCfg] = useState<AccountConfig | null>(null);
|
||||
const [firmName, setFirmName] = useState('');
|
||||
const [dailyPnL, setDailyPnL] = useState<DailyPnL[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [stateRes, firmsRes, dailyRes] = await Promise.all([
|
||||
fetch('/api/state'),
|
||||
fetch('/api/firms'),
|
||||
fetch(`/api/accounts/${accountId}/daily-pnl`),
|
||||
]);
|
||||
const states: FirmState[] = await stateRes.json();
|
||||
const firms: FirmConfig[] = await firmsRes.json();
|
||||
const daily: DailyPnL[] = await dailyRes.json();
|
||||
|
||||
setDailyPnL(daily);
|
||||
|
||||
for (const firmState of states) {
|
||||
const acc = firmState.accounts.find((a) => a.id === accountId);
|
||||
if (acc) {
|
||||
const firmCfg = firms.find((f) => f.firm === firmState.firm);
|
||||
setAccount(acc);
|
||||
setFirmName(firmState.firm);
|
||||
if (firmCfg) setCfg(getAccountConfig(acc.name, firmCfg) ?? null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [accountId]);
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/" className="text-slate-400 hover:text-slate-600 text-sm transition-colors">← Back</Link>
|
||||
</div>
|
||||
<p className="text-slate-400 italic text-sm">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasLossLimit = cfg != null && cfg.minDayPnL !== -999;
|
||||
const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays;
|
||||
// Dead when balance hits Tradovate's auto-liquidation floor
|
||||
const isDead = account.autoLiqThreshold > 0 && account.amount <= account.autoLiqThreshold;
|
||||
const liqFloor = account.autoLiqThreshold > 0 ? account.autoLiqThreshold : null;
|
||||
|
||||
// Ground-truth profit = balance minus funded account size.
|
||||
// Falls back to FIFO total when accountSize is unavailable.
|
||||
const fifoTotal = dailyPnL.reduce((s, d) => s + d.pnl, 0);
|
||||
const totalProfit = cfg?.accountSize
|
||||
? Math.round((account.amount - cfg.accountSize) * 100) / 100
|
||||
: Math.round(fifoTotal * 100) / 100;
|
||||
const profitPct = cfg?.accountSize ? (totalProfit / cfg.accountSize) * 100 : null;
|
||||
const profitPassed = cfg != null && totalProfit >= cfg.profitTarget;
|
||||
const lossPassed = !hasLossLimit || (cfg != null && totalProfit >= cfg.minDayPnL);
|
||||
|
||||
// Build equity curve: FIFO daily increments, origin at $0
|
||||
let running = 0;
|
||||
const equityData = [
|
||||
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0 },
|
||||
...dailyPnL.map((d) => {
|
||||
running += d.pnl;
|
||||
const equity = Math.round(running * 100) / 100;
|
||||
return {
|
||||
label: fmtDate(d.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
|
||||
};
|
||||
}),
|
||||
];
|
||||
const isPositive = totalProfit >= 0;
|
||||
|
||||
// Equity range
|
||||
const equityValues = equityData.map((d) => d.equity);
|
||||
// Y-axis domain: include profit target so its reference line stays visible
|
||||
const rawMin = Math.min(0, ...equityValues);
|
||||
const rawMax = Math.max(0, ...equityValues, cfg?.profitTarget ?? 0);
|
||||
|
||||
// Stroke gradient split: use only the actual equity range, NOT the profit target.
|
||||
// The SVG gradient bounding box is the line's bbox, so inflating by profitTarget
|
||||
// would shift the green→red transition away from y=0.
|
||||
const gradMax = Math.max(0, ...equityValues);
|
||||
const gradMin = rawMin; // rawMin never includes profitTarget
|
||||
const gradRange = gradMax - gradMin;
|
||||
const zeroFraction = gradRange > 0 ? gradMax / gradRange : 0.5;
|
||||
const zeroPct = `${(Math.max(0, Math.min(1, zeroFraction)) * 100).toFixed(2)}%`;
|
||||
|
||||
const strokeGradId = 'equityStroke';
|
||||
|
||||
// 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();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/" className="text-slate-400 hover:text-slate-600 text-sm transition-colors">
|
||||
← Back
|
||||
</Link>
|
||||
<span className="text-slate-300">/</span>
|
||||
<span className="text-slate-400 text-sm">{firmName}</span>
|
||||
<span className="text-slate-300">/</span>
|
||||
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
|
||||
{isDead && (
|
||||
<span className="ml-2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-slate-900 text-white tracking-wide">
|
||||
☠ DEAD
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDead && (
|
||||
<div className="mb-5 flex items-center gap-3 bg-slate-900 text-white rounded-xl px-5 py-4">
|
||||
<span className="text-2xl leading-none">☠</span>
|
||||
<div>
|
||||
<p className="font-bold text-sm">Account Blown</p>
|
||||
<p className="text-slate-300 text-xs mt-0.5">
|
||||
Balance ${fmt(account.amount)} has breached the Tradovate auto-liquidation floor
|
||||
{liqFloor != null ? ` of $${fmt(liqFloor)}` : ''}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-5 mb-5">
|
||||
<div className="flex flex-col w-1/2 bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-slate-900 font-bold text-base mb-1">Overall Performance</h2>
|
||||
<PerfRow label="Account Balance" value={`$${fmt(account.amount)}`} />
|
||||
<PerfRow
|
||||
label="Total Profit"
|
||||
value={`${totalProfit >= 0 ? '+' : ''}$${fmt(totalProfit)}`}
|
||||
badge={profitPct != null ? `${profitPct >= 0 ? '↑' : '↓'} ${Math.abs(profitPct).toFixed(1)}%` : undefined}
|
||||
positive={profitPct != null && profitPct >= 0}
|
||||
/>
|
||||
<PerfRow label="Trading Days" value={account.daysTraded} />
|
||||
<PerfRow label="Daily Loss Limit" value={hasLossLimit ? `$${fmt(cfg!.minDayPnL)}` : 'None'} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-1/2 bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-slate-900 font-bold text-base mb-1">Objectives</h2>
|
||||
<ObjRow
|
||||
passed={profitPassed}
|
||||
label="Profit Target"
|
||||
value={cfg ? `$${fmt(totalProfit)} of $${fmt(cfg.profitTarget)}` : '—'}
|
||||
/>
|
||||
<ObjRow
|
||||
passed={daysPassed}
|
||||
label="Trading Days"
|
||||
value={cfg ? `${account.daysTraded} of ${cfg.minTradingDays}` : '—'}
|
||||
/>
|
||||
{hasLossLimit && (
|
||||
<ObjRow
|
||||
passed={lossPassed}
|
||||
label="Daily Loss Limit"
|
||||
value={`$${fmt(cfg!.minDayPnL)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equity Curve */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mb-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-slate-900 font-bold text-base">Equity Curve</h2>
|
||||
{dailyPnL.length > 0 && (
|
||||
<span className={`text-sm font-bold tabular-nums ${isPositive ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{isPositive ? '+' : ''}${fmt(totalProfit)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{dailyPnL.length === 0 ? (
|
||||
<p className="text-slate-400 italic text-sm text-center py-8">No trading history available</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart data={equityData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
|
||||
<defs>
|
||||
{/* Positive fill: opaque at peak, fades to transparent at zero */}
|
||||
<linearGradient id="greenFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.20} />
|
||||
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
{/* Negative fill: transparent at zero, opaque at trough */}
|
||||
<linearGradient id="redFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.02} />
|
||||
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.20} />
|
||||
</linearGradient>
|
||||
{/* Stroke: green above zero, red below */}
|
||||
<linearGradient id={strokeGradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22c55e" />
|
||||
<stop offset={zeroPct} stopColor="#22c55e" />
|
||||
<stop offset={zeroPct} stopColor="#ef4444" />
|
||||
<stop offset="100%" stopColor="#ef4444" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[
|
||||
() => rawMin,
|
||||
() => rawMax,
|
||||
]}
|
||||
tickFormatter={(v) => {
|
||||
const abs = Math.abs(v);
|
||||
const sign = v < 0 ? '-' : '';
|
||||
return abs >= 1000 ? `${sign}$${(abs / 1000).toFixed(1)}k` : `${sign}$${v}`;
|
||||
}}
|
||||
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<EquityTooltip />}
|
||||
cursor={{ stroke: '#e2e8f0', strokeWidth: 1 }}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="#e2e8f0" strokeWidth={1.5} />
|
||||
{cfg?.profitTarget != null && (
|
||||
<ReferenceLine
|
||||
y={cfg.profitTarget}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="6 3"
|
||||
label={{
|
||||
value: `$${fmt(cfg.profitTarget)} target`,
|
||||
position: 'insideTopRight',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
fill: '#d97706',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Green fill: positive equity only, fills down to y=0 */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="pos"
|
||||
baseValue={0}
|
||||
stroke="none"
|
||||
fill="url(#greenFill)"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
legendType="none"
|
||||
tooltipType="none"
|
||||
/>
|
||||
{/* Red fill: negative equity only, fills up to y=0 */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="neg"
|
||||
baseValue={0}
|
||||
stroke="none"
|
||||
fill="url(#redFill)"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
legendType="none"
|
||||
tooltipType="none"
|
||||
/>
|
||||
{/* Equity line: stroke-only, green above zero / red below */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="equity"
|
||||
baseValue={0}
|
||||
stroke={`url(#${strokeGradId})`}
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
dot={(props: any) => {
|
||||
if (props.payload?.origin) return <g key={props.key} />;
|
||||
return (
|
||||
<Dot
|
||||
key={props.key}
|
||||
cx={props.cx}
|
||||
cy={props.cy}
|
||||
r={3.5}
|
||||
fill={props.payload.pnl >= 0 ? '#22c55e' : '#ef4444'}
|
||||
stroke="white"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
activeDot={{ r: 5, fill: '#64748b', stroke: 'white', strokeWidth: 2 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily P&L Calendar */}
|
||||
{calendarMonths.length > 0 && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-slate-900 font-bold text-base mb-6">Daily P&L</h2>
|
||||
<div className="grid grid-cols-1 gap-8" style={{ gridTemplateColumns: `repeat(${Math.min(calendarMonths.length, 3)}, minmax(0, 1fr))` }}>
|
||||
{calendarMonths.map((ym) => {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
return <CalendarMonth key={ym} year={y} month={m} pnlMap={pnlMap} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { updateAccountConfig, deleteAccountConfig } from '@/lib/db';
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id: idStr } = await params;
|
||||
const id = parseInt(idStr, 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await req.json() as {
|
||||
prefix?: string;
|
||||
profitTarget?: number;
|
||||
consistency?: number;
|
||||
minDayPnL?: number;
|
||||
minTradingDays?: number;
|
||||
accountSize?: number;
|
||||
maxLoss?: number;
|
||||
};
|
||||
|
||||
const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss } = body;
|
||||
|
||||
if (
|
||||
typeof prefix !== 'string' || !prefix.trim() ||
|
||||
typeof profitTarget !== 'number' ||
|
||||
typeof consistency !== 'number' ||
|
||||
typeof minDayPnL !== 'number' ||
|
||||
typeof minTradingDays !== 'number' ||
|
||||
typeof accountSize !== 'number'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = updateAccountConfig(id, {
|
||||
prefix: prefix.trim(),
|
||||
profitTarget,
|
||||
consistency,
|
||||
minDayPnL,
|
||||
minTradingDays,
|
||||
accountSize,
|
||||
maxLoss: maxLoss ?? 0,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Account config not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[PUT /api/account-configs/:id]', err);
|
||||
return NextResponse.json({ error: 'Failed to update' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id: idStr } = await params;
|
||||
const id = parseInt(idStr, 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = deleteAccountConfig(id);
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ error: 'Account config not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[DELETE /api/account-configs/:id]', err);
|
||||
return NextResponse.json({ error: 'Failed to delete' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getClients } from '@/lib/clients';
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const accountId = Number(id);
|
||||
|
||||
const clients = getClients();
|
||||
for (const client of clients.values()) {
|
||||
const daily = client.dailyPnL?.[accountId];
|
||||
if (daily !== undefined) {
|
||||
return NextResponse.json(daily);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getFirmById, createAccountConfig } from '@/lib/db';
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id: idStr } = await params;
|
||||
const firmId = parseInt(idStr, 10);
|
||||
|
||||
if (isNaN(firmId)) {
|
||||
return NextResponse.json({ error: 'Invalid firm id' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!getFirmById(firmId)) {
|
||||
return NextResponse.json({ error: 'Firm not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json() as {
|
||||
prefix?: string;
|
||||
profitTarget?: number;
|
||||
consistency?: number;
|
||||
minDayPnL?: number;
|
||||
minTradingDays?: number;
|
||||
accountSize?: number;
|
||||
maxLoss?: number;
|
||||
};
|
||||
|
||||
const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss } = body;
|
||||
|
||||
if (
|
||||
typeof prefix !== 'string' || !prefix.trim() ||
|
||||
typeof profitTarget !== 'number' ||
|
||||
typeof consistency !== 'number' ||
|
||||
typeof minDayPnL !== 'number' ||
|
||||
typeof minTradingDays !== 'number' ||
|
||||
typeof accountSize !== 'number'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const row = createAccountConfig(firmId, {
|
||||
prefix: prefix.trim(),
|
||||
profitTarget,
|
||||
consistency,
|
||||
minDayPnL,
|
||||
minTradingDays,
|
||||
accountSize,
|
||||
maxLoss: maxLoss ?? 0,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: row.id,
|
||||
prefix: row.prefix,
|
||||
profitTarget: row.profit_target,
|
||||
consistency: row.consistency,
|
||||
minDayPnL: row.min_day_pnl,
|
||||
minTradingDays: row.min_trading_days,
|
||||
accountSize: row.account_size,
|
||||
maxLoss: row.max_loss,
|
||||
}, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error('[POST /api/firms/:id/accounts]', err);
|
||||
return NextResponse.json({ error: 'Failed to create account type' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { upsertFirmInstrumentConfig } from '@/lib/db';
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; instrumentId: string }> }
|
||||
) {
|
||||
const { id: idStr, instrumentId: instrIdStr } = await params;
|
||||
const firmId = parseInt(idStr, 10);
|
||||
const instrumentId = parseInt(instrIdStr, 10);
|
||||
|
||||
if (isNaN(firmId) || isNaN(instrumentId)) {
|
||||
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await req.json() as {
|
||||
allinFee?: number;
|
||||
roundtripFee?: number;
|
||||
banned?: boolean;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof body.allinFee !== 'number' ||
|
||||
typeof body.roundtripFee !== 'number' ||
|
||||
typeof body.banned !== 'boolean'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
upsertFirmInstrumentConfig(firmId, instrumentId, {
|
||||
allinFee: body.allinFee,
|
||||
roundtripFee: body.roundtripFee,
|
||||
banned: body.banned,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[PUT /api/firms/:id/instrument-configs/:instrumentId]', err);
|
||||
return NextResponse.json({ error: 'Failed to save config' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getFirmFees } from '@/lib/db';
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id: idStr } = await params;
|
||||
const firmId = parseInt(idStr, 10);
|
||||
|
||||
if (isNaN(firmId)) {
|
||||
return NextResponse.json({ error: 'Invalid firm id' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json(getFirmFees(firmId));
|
||||
} catch (err) {
|
||||
console.error('[GET /api/firms/:id/instrument-configs]', err);
|
||||
return NextResponse.json({ error: 'Failed to fetch fees' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { deleteFirm } from '@/lib/db';
|
||||
import { getFirmById, deleteFirm } from '@/lib/db';
|
||||
import { removeClient } from '@/lib/clients';
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id: idStr } = await params;
|
||||
const id = parseInt(idStr, 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const firm = getFirmById(id);
|
||||
if (!firm) {
|
||||
return NextResponse.json({ error: 'Firm not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: firm.id,
|
||||
firm: firm.name,
|
||||
username: firm.username,
|
||||
password: firm.password,
|
||||
accounts: firm.accounts.map((a) => ({
|
||||
id: a.id,
|
||||
prefix: a.prefix,
|
||||
profitTarget: a.profit_target,
|
||||
consistency: a.consistency,
|
||||
minDayPnL: a.min_day_pnl,
|
||||
minTradingDays: a.min_trading_days,
|
||||
accountSize: a.account_size,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
||||
@@ -16,6 +16,8 @@ export async function GET() {
|
||||
consistency: a.consistency,
|
||||
minDayPnL: a.min_day_pnl,
|
||||
minTradingDays: a.min_trading_days,
|
||||
accountSize: a.account_size,
|
||||
maxLoss: a.max_loss,
|
||||
})),
|
||||
}));
|
||||
return NextResponse.json(result);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { setInstrumentEnabled } from '@/lib/db';
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ symbol: string }> }
|
||||
) {
|
||||
const { symbol } = await params;
|
||||
const body = await request.json();
|
||||
const ok = setInstrumentEnabled(symbol, !!body.enabled);
|
||||
if (!ok) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ symbol, enabled: !!body.enabled });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getInstruments } from '@/lib/db';
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(getInstruments());
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export async function GET() {
|
||||
realizedPnL: cash.realizedPnL,
|
||||
daysTraded: client.daysTraded[acc.id] ?? 0,
|
||||
hasPosition: !!client.positions[acc.id],
|
||||
autoLiqThreshold: client.autoLiqThresholds[acc.id] ?? 0,
|
||||
};
|
||||
});
|
||||
return { firm: f.name, connected: true, accounts };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+73
-11
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
||||
|
||||
function getAccountConfig(name: string, firm: FirmConfig): AccountConfig | undefined {
|
||||
@@ -14,6 +15,11 @@ function fmt(value: number) {
|
||||
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** Returns true when an account's balance has breached Tradovate's auto-liquidation floor. */
|
||||
function isAccountDead(account: AccountState): boolean {
|
||||
return account.autoLiqThreshold > 0 && account.amount <= account.autoLiqThreshold;
|
||||
}
|
||||
|
||||
function SettingsIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -31,12 +37,26 @@ function ChevronIcon({ open }: { open: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRow({ account, firm }: { account: AccountState; firm: FirmConfig }) {
|
||||
function DetailsIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRow({ account, firm, hideDead }: { account: AccountState; firm: FirmConfig; hideDead: boolean }) {
|
||||
const cfg = getAccountConfig(account.name, firm);
|
||||
const dead = isAccountDead(account);
|
||||
|
||||
if (hideDead && dead) return null;
|
||||
|
||||
const pnlColor = account.realizedPnL > 0 ? 'text-green-600 font-medium' : account.realizedPnL < 0 ? 'text-red-600 font-medium' : 'text-slate-400';
|
||||
|
||||
return (
|
||||
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active ? ' opacity-40' : ''}`}>
|
||||
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active || dead ? ' opacity-50' : ''}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 font-medium text-slate-800 pl-4">
|
||||
{account.name}
|
||||
@@ -53,22 +73,35 @@ function AccountRow({ account, firm }: { account: AccountState; firm: FirmConfig
|
||||
${cfg?.profitTarget?.toLocaleString() ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{!account.active
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Inactive</span>
|
||||
: account.hasPosition
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-700">In Trade</span>
|
||||
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
|
||||
<div className="flex items-center justify-between">
|
||||
{dead
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-800 text-white">Dead</span>
|
||||
: !account.active
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Inactive</span>
|
||||
: account.hasPosition
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-700">In Trade</span>
|
||||
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
|
||||
<Link
|
||||
href={`/accounts/${account.id}`}
|
||||
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
|
||||
title="View details"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DetailsIcon />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function FirmRows({ state, firm, deleteMode, selected, onToggle }: {
|
||||
function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead }: {
|
||||
state: FirmState;
|
||||
firm: FirmConfig;
|
||||
deleteMode: boolean;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
hideDead: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
@@ -95,13 +128,14 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle }: {
|
||||
<td /><td /><td /><td />
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
<Link
|
||||
href={`/firms/${firm.id}/settings`}
|
||||
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</Link>
|
||||
<ChevronIcon open={open} />
|
||||
</div>
|
||||
</td>
|
||||
@@ -116,7 +150,7 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle }: {
|
||||
</tr>
|
||||
) : (
|
||||
state.accounts.map((acc) => (
|
||||
<AccountRow key={acc.id} account={acc} firm={firm} />
|
||||
<AccountRow key={acc.id} account={acc} firm={firm} hideDead={hideDead} />
|
||||
))
|
||||
)
|
||||
)}
|
||||
@@ -130,6 +164,7 @@ export default function Home() {
|
||||
const [firms, setFirms] = useState<FirmState[]>([]);
|
||||
const [deleteMode, setDeleteMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [hideDead, setHideDead] = useState(false);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
@@ -163,6 +198,13 @@ export default function Home() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Count dead accounts across all firms for the toggle button label
|
||||
const deadCount = firms.reduce((total, firmState) => {
|
||||
const firmCfg = config.find((c) => c.firm === firmState.firm);
|
||||
if (!firmCfg) return total;
|
||||
return total + firmState.accounts.filter((acc) => isAccountDead(acc)).length;
|
||||
}, 0);
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
await Promise.all(
|
||||
[...selected].map((id) =>
|
||||
@@ -208,6 +250,18 @@ export default function Home() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{deadCount > 0 && (
|
||||
<button
|
||||
onClick={() => setHideDead((h) => !h)}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors border ${
|
||||
hideDead
|
||||
? 'bg-slate-800 text-white border-slate-800 hover:bg-slate-700'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{hideDead ? `Show Dead (${deadCount})` : `Hide Dead (${deadCount})`}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push('/add')}
|
||||
className="px-4 py-2 bg-green-100 hover:bg-green-200 text-green-700 text-sm font-semibold rounded-lg transition-colors"
|
||||
@@ -220,6 +274,13 @@ export default function Home() {
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="px-3 py-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors inline-flex items-center"
|
||||
title="Global Settings"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -254,6 +315,7 @@ export default function Home() {
|
||||
deleteMode={deleteMode}
|
||||
selected={selected.has(cfg.id)}
|
||||
onToggle={() => toggleSelected(cfg.id)}
|
||||
hideDead={hideDead}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Instrument {
|
||||
symbol: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [instruments, setInstruments] = useState<Instrument[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/instruments')
|
||||
.then((r) => r.json())
|
||||
.then(setInstruments);
|
||||
}, []);
|
||||
|
||||
async function toggle(symbol: string, enabled: boolean) {
|
||||
setInstruments((prev) =>
|
||||
prev.map((i) => (i.symbol === symbol ? { ...i, enabled } : i))
|
||||
);
|
||||
await fetch(`/api/instruments/${symbol}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-slate-400 hover:text-slate-600 text-sm transition-colors"
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Settings</h1>
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||
Instruments
|
||||
</h2>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
||||
{instruments.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
|
||||
Loading…
|
||||
</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-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{instruments.map((instr) => (
|
||||
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">{instr.symbol}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button
|
||||
onClick={() => toggle(instr.symbol, !instr.enabled)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
||||
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
instr.enabled ? 'translate-x-4' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user