Take firms from the master instead of defining them per machine
This box no longer decides what firms exist. It caches the master's catalog
and stores one thing of its own: the Tradovate login for each firm. Creating
firms locally is gone — app/add, POST /api/firms, the account-config routes
and the banned-symbol route with it.
The cache is the point, not an optimisation. The scheduler needs every
account's target and size on each 60s tick, so fetching them per tick would
stop trading with positions open the first time the master rebooted. It is
one JSON blob in settings, replaced only by a complete valid response, and a
response with zero firms is refused outright: a misconfigured FIRMS_DB serves
{"firms":[]} as a healthy 200, and mirroring that would idle the whole fleet
at once.
Accounts bind to an account type by prefix AND size, the size taken from the
opening funding transaction. That does not arrive with the first sync, so an
account sits Unassigned and the resolver tries again next sync rather than
guessing. A type picked by hand is never overwritten by an auto pass. This
replaces the longest-prefix helper that had been copy-pasted into five files,
which could not tell two types with the same prefix apart at all.
firm_credentials and account_assignments deliberately have no foreign key to
the cached catalog. foreign_keys is ON, so a cascade would erase the Tradovate
passwords the moment a firm was removed on the master — routine, not
exceptional. They survive it, and are re-keyed by name if the master's database
is ever rebuilt with fresh ids.
The legacy firms/account_configs/firm_banned_symbols tables are left on disk,
unreferenced. firms is the only table that ever held plaintext credentials and
dropping it would make a rollback unrecoverable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8b2e78d766
commit
8bdc85080c
+78
-14
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
@@ -17,10 +17,63 @@ import {
|
||||
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
|
||||
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));
|
||||
/** Reassign this account to another of its firm's account types, or hand it
|
||||
* back to the auto-resolver. Mirrors the control on the dashboard. */
|
||||
function AccountTypePicker({ account, firm, onAssigned }: {
|
||||
account: AccountState;
|
||||
firm: FirmConfig;
|
||||
onAssigned: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function assign(accountTypeId: string) {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (accountTypeId === '') {
|
||||
await fetch(`/api/accounts/${account.id}/account-type`, { method: 'DELETE' });
|
||||
} else {
|
||||
await fetch(`/api/accounts/${account.id}/account-type`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accountTypeId }),
|
||||
});
|
||||
}
|
||||
onAssigned();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={account.assignmentSource === 'manual' ? (account.accountTypeId ?? '') : ''}
|
||||
disabled={saving}
|
||||
onChange={(e) => assign(e.target.value)}
|
||||
className="border border-slate-200 rounded-md px-1.5 py-1 text-xs bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
>
|
||||
<option value="">
|
||||
{account.accountTypeLabel ? `Auto — ${account.accountTypeLabel}` : 'Auto — unassigned'}
|
||||
</option>
|
||||
{firm.accounts.map((type) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.label} · {type.prefix} · {type.accountSize >= 1000 ? `${type.accountSize / 1000}k` : type.accountSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{account.assignmentSource === 'manual' && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" title="Pinned by hand" />
|
||||
)}
|
||||
{account.accountTypeAmbiguous && (
|
||||
<span className="text-amber-500 text-xs font-bold" title="Several account types matched — using the most recently updated one">*</span>
|
||||
)}
|
||||
{!account.accountTypeLabel && (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700">
|
||||
Unassigned
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(value: number) {
|
||||
@@ -189,9 +242,9 @@ export default function AccountPage() {
|
||||
const [account, setAccount] = useState<AccountState | null>(null);
|
||||
const [cfg, setCfg] = useState<AccountConfig | null>(null);
|
||||
const [firmName, setFirmName] = useState('');
|
||||
const [firm, setFirm] = useState<FirmConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const load = useCallback(async () => {
|
||||
const [stateRes, firmsRes] = await Promise.all([
|
||||
fetch('/api/state'),
|
||||
fetch('/api/firms'),
|
||||
@@ -202,20 +255,24 @@ export default function AccountPage() {
|
||||
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);
|
||||
// Join on the master's firm id — the name changes the
|
||||
// moment someone renames a firm there.
|
||||
const firmCfg = firms.find((f) => f.id === firmState.firmId);
|
||||
setAccount(acc);
|
||||
setFirmName(firmState.firm);
|
||||
if (firmCfg) setCfg(getAccountConfig(acc.name, firmCfg) ?? null);
|
||||
setFirm(firmCfg ?? null);
|
||||
setCfg(acc.accountType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const interval = setInterval(() => { void load(); }, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [load]);
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-8">
|
||||
@@ -339,6 +396,13 @@ export default function AccountPage() {
|
||||
</Link>
|
||||
<span className="text-slate-300">/</span>
|
||||
<span className="text-slate-400 text-sm">{firmName}</span>
|
||||
{firm && (
|
||||
<AccountTypePicker
|
||||
account={account}
|
||||
firm={firm}
|
||||
onAssigned={() => { void load(); }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-slate-300">/</span>
|
||||
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
|
||||
{isDead && (
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function AddPage() {
|
||||
const router = useRouter();
|
||||
const [firmName, setFirmName] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const inputCls = 'w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-400';
|
||||
const labelCls = 'block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1';
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!firmName.trim() || !username.trim() || !password.trim()) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/firms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: firmName.trim(), username: username.trim(), password: password.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json() as { error?: string };
|
||||
setError(data.error ?? 'Failed to save firm');
|
||||
return;
|
||||
}
|
||||
router.push('/');
|
||||
} catch {
|
||||
setError('Could not connect to server');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<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">Add New Firm</h1>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 shadow-sm max-w-md space-y-4">
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className={labelCls}>Firm Name</label>
|
||||
<input value={firmName} onChange={(e) => setFirmName(e.target.value)} className={inputCls} placeholder="e.g. MyFirm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Username</label>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} placeholder="Username" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Password</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className={inputCls} placeholder="Password" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => router.push('/')} className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !firmName.trim() || !username.trim() || !password.trim()}
|
||||
className="px-4 py-2 bg-green-100 hover:bg-green-200 disabled:opacity-40 disabled:cursor-not-allowed text-green-700 text-sm font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Add Firm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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;
|
||||
maxPositionSize?: number;
|
||||
targetSameEquity?: boolean;
|
||||
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
||||
};
|
||||
|
||||
const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss, maxPositionSize, targetSameEquity, withdrawalStages } = 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,
|
||||
maxPositionSize: maxPositionSize ?? 0,
|
||||
targetSameEquity: targetSameEquity ?? false,
|
||||
withdrawalStages: withdrawalStages ?? [],
|
||||
});
|
||||
|
||||
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,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAccountTypeById } from '@/lib/firm-catalog';
|
||||
import { getAssignment } from '@/lib/db';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import {
|
||||
clearManualAssignment,
|
||||
resolveFirmAssignments,
|
||||
setManualAssignment,
|
||||
} from '@/lib/account-type-resolver';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
function parseAccountId(raw: string): number | null {
|
||||
const id = parseInt(raw, 10);
|
||||
return Number.isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
/** Pin an account to one of its firm's account types. A manual pick is never
|
||||
* overwritten by the auto-resolver — that is the point of it. */
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const { id: idStr } = await params;
|
||||
const accountId = parseAccountId(idStr);
|
||||
if (accountId === null) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
|
||||
const { accountTypeId } = await req.json() as { accountTypeId?: string | null };
|
||||
|
||||
// The firm comes from the existing assignment, or from whichever client is
|
||||
// holding this account — an account the machine has never seen has nothing
|
||||
// to pin to.
|
||||
let firmId = getAssignment(accountId)?.firm_id ?? null;
|
||||
if (!firmId) {
|
||||
for (const [candidateFirmId, client] of getClients()) {
|
||||
if (client.accountList.some((a) => a.id === accountId)) {
|
||||
firmId = candidateFirmId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!firmId) return NextResponse.json({ error: 'Unknown account' }, { status: 404 });
|
||||
|
||||
if (accountTypeId) {
|
||||
const type = getAccountTypeById(accountTypeId);
|
||||
if (!type) return NextResponse.json({ error: 'Unknown account type' }, { status: 400 });
|
||||
if (type.firmId !== firmId) {
|
||||
return NextResponse.json({ error: 'That account type belongs to another firm' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
setManualAssignment(accountId, firmId, accountTypeId ?? null);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
/** Drop the override and hand the account back to the auto-resolver. */
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
const { id: idStr } = await params;
|
||||
const accountId = parseAccountId(idStr);
|
||||
if (accountId === null) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
|
||||
const firmId = getAssignment(accountId)?.firm_id ?? null;
|
||||
clearManualAssignment(accountId);
|
||||
|
||||
if (firmId) {
|
||||
const client = getClients().get(firmId);
|
||||
if (client) resolveFirmAssignments(firmId, client);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getClients, resetClients } from '@/lib/clients';
|
||||
import { getFirms } from '@/lib/db';
|
||||
import { getTradableFirms } from '@/lib/firm-catalog';
|
||||
import axios from 'axios';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const clients = getClients();
|
||||
const firms = getFirms();
|
||||
const firms = getTradableFirms();
|
||||
|
||||
const debug = firms.map((firm) => {
|
||||
const client = clients.get(firm.id);
|
||||
@@ -48,7 +48,7 @@ export async function GET() {
|
||||
export async function POST() {
|
||||
try {
|
||||
const clients = getClients();
|
||||
const firms = getFirms();
|
||||
const firms = getTradableFirms();
|
||||
|
||||
const results = await Promise.all(
|
||||
firms.map(async (firm) => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getCatalogMeta, syncCatalog } from '@/lib/firm-catalog';
|
||||
import { getClients, reconcileClients } from '@/lib/clients';
|
||||
import { resolveFirmAssignments } from '@/lib/account-type-resolver';
|
||||
import { getSetting } from '@/lib/db';
|
||||
|
||||
/** Sync status for the Settings page. */
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
...getCatalogMeta(),
|
||||
masterUrl: getSetting('master_dashboard_url'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Force a pull now, instead of waiting for the poll. */
|
||||
export async function POST() {
|
||||
const result = await syncCatalog();
|
||||
|
||||
if (result.changed) {
|
||||
reconcileClients();
|
||||
for (const [firmId, client] of getClients()) {
|
||||
try {
|
||||
resolveFirmAssignments(firmId, client);
|
||||
} catch (err) {
|
||||
console.error(`[resolver] re-resolve failed for ${firmId}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...result, ...getCatalogMeta() });
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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;
|
||||
maxPositionSize?: number;
|
||||
targetSameEquity?: boolean;
|
||||
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
|
||||
};
|
||||
|
||||
const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss, maxPositionSize, targetSameEquity, withdrawalStages } = 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,
|
||||
maxPositionSize: maxPositionSize ?? 0,
|
||||
targetSameEquity: targetSameEquity ?? false,
|
||||
withdrawalStages: withdrawalStages ?? [],
|
||||
});
|
||||
|
||||
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,
|
||||
maxPositionSize: row.max_position_size,
|
||||
targetSameEquity: row.target_same_equity === 1,
|
||||
withdrawalStages: (() => { try { return JSON.parse(row.withdrawal_stages ?? '[]') as { profit: number; consistency: number; minTradingDays: number }[]; } catch { return []; } })(),
|
||||
}, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error('[POST /api/firms/:id/accounts]', err);
|
||||
return NextResponse.json({ error: 'Failed to create account type' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getBannedSymbols, setBannedSymbol } from '@/lib/db';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** GET /api/firms/[id]/banned-symbols — list banned symbols for a firm */
|
||||
export async function GET(_req: NextRequest, { params }: Params) {
|
||||
const { id: idStr } = await params;
|
||||
const firmId = parseInt(idStr, 10);
|
||||
if (isNaN(firmId)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
return NextResponse.json(getBannedSymbols(firmId));
|
||||
}
|
||||
|
||||
/** PATCH /api/firms/[id]/banned-symbols — { symbol, banned } to ban or unban */
|
||||
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||
const { id: idStr } = await params;
|
||||
const firmId = parseInt(idStr, 10);
|
||||
if (isNaN(firmId)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
|
||||
const { symbol, banned } = await req.json() as { symbol: string; banned: boolean };
|
||||
if (!symbol) return NextResponse.json({ error: 'symbol required' }, { status: 400 });
|
||||
|
||||
setBannedSymbol(firmId, symbol, banned);
|
||||
return NextResponse.json({ firmId, symbol, banned });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { setCredentials, clearCredentials } from '@/lib/db';
|
||||
import { reconcileClients, removeClient } from '@/lib/clients';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** The Tradovate login for one firm. This is the only firm data this machine
|
||||
* owns — everything else comes from the master catalog, and these credentials
|
||||
* never go the other way. */
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
|
||||
const firm = getCachedFirm(id);
|
||||
if (!firm) return NextResponse.json({ error: 'Firm not found' }, { status: 404 });
|
||||
|
||||
const body = await req.json() as { username?: string; password?: string };
|
||||
const username = body.username?.trim() ?? '';
|
||||
const password = body.password?.trim() ?? '';
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
setCredentials(id, firm.name, username, password);
|
||||
// Picks up the new login immediately rather than at the next restart.
|
||||
reconcileClients();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[PUT /api/firms/:id/credentials]', err);
|
||||
return NextResponse.json({ error: 'Failed to save credentials' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const cleared = clearCredentials(id);
|
||||
removeClient(id);
|
||||
return NextResponse.json({ ok: cleared });
|
||||
}
|
||||
@@ -7,8 +7,7 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const firmId = parseInt(id, 10);
|
||||
const { id: firmId } = await params;
|
||||
const clients = getClients();
|
||||
const client = clients.get(firmId) as any;
|
||||
if (!client) return NextResponse.json({ error: 'Client not found' }, { status: 404 });
|
||||
|
||||
@@ -6,9 +6,7 @@ export async function POST(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const firmId = parseInt(id, 10);
|
||||
if (isNaN(firmId)) return NextResponse.json({ error: 'Invalid firm ID' }, { status: 400 });
|
||||
const { id: firmId } = await params;
|
||||
|
||||
const body = await req.json() as {
|
||||
accountId: number;
|
||||
|
||||
+13
-48
@@ -1,64 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getFirmById, deleteFirm } from '@/lib/db';
|
||||
import { removeClient } from '@/lib/clients';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
|
||||
/** One firm from the cached catalog. Read-only: firms and their account types
|
||||
* are owned by the master dashboard, and there is no local DELETE — a firm
|
||||
* goes away here when it goes away there. */
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id: idStr } = await params;
|
||||
const id = parseInt(idStr, 10);
|
||||
const { id } = await params;
|
||||
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const firm = getFirmById(id);
|
||||
const firm = getCachedFirm(id);
|
||||
if (!firm) {
|
||||
return NextResponse.json({ error: 'Firm not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const creds = getCredentials(id);
|
||||
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,
|
||||
maxLoss: a.max_loss,
|
||||
maxPositionSize: a.max_position_size,
|
||||
targetSameEquity: a.target_same_equity === 1,
|
||||
withdrawalStages: (() => { try { return JSON.parse(a.withdrawal_stages ?? '[]') as { profit: number; consistency: number; minTradingDays: number }[]; } catch { return []; } })(),
|
||||
})),
|
||||
username: creds?.username ?? '',
|
||||
hasCredentials: !!creds,
|
||||
bannedSymbols: firm.bannedSymbols,
|
||||
accounts: firm.accountTypes,
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
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 = deleteFirm(id);
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ error: 'Firm not found' }, { status: 404 });
|
||||
}
|
||||
removeClient(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[DELETE /api/firms/:id]', err);
|
||||
return NextResponse.json({ error: 'Failed to delete firm' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
+40
-48
@@ -1,57 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getFirms, createFirm, getBannedSymbols } from '@/lib/db';
|
||||
import { initClient } from '@/lib/clients';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
import type { FirmConfig } from '@/types';
|
||||
|
||||
/**
|
||||
* The firms this machine knows about — the master's catalog, annotated with
|
||||
* whether credentials for each are stored here.
|
||||
*
|
||||
* Firms are created and edited on the master dashboard; there is no POST. The
|
||||
* only writable thing per firm is its credentials, at
|
||||
* PUT /api/firms/[id]/credentials.
|
||||
*
|
||||
* The stored password is deliberately not returned. It used to be, and nothing
|
||||
* ever read it.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const firms = getFirms();
|
||||
const result = firms.map((f) => ({
|
||||
id: f.id,
|
||||
firm: f.name,
|
||||
username: f.username,
|
||||
password: f.password,
|
||||
bannedSymbols: getBannedSymbols(f.id),
|
||||
accounts: f.accounts.map((a) => ({
|
||||
prefix: a.prefix,
|
||||
profitTarget: a.profit_target,
|
||||
consistency: a.consistency,
|
||||
minDayPnL: a.min_day_pnl,
|
||||
minTradingDays: a.min_trading_days,
|
||||
accountSize: a.account_size,
|
||||
maxLoss: a.max_loss,
|
||||
})),
|
||||
}));
|
||||
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
|
||||
const creds = getCredentials(firm.id);
|
||||
return {
|
||||
id: firm.id,
|
||||
firm: firm.name,
|
||||
username: creds?.username ?? '',
|
||||
hasCredentials: !!creds,
|
||||
bannedSymbols: firm.bannedSymbols,
|
||||
accounts: firm.accountTypes.map((a) => ({
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
prefix: a.prefix,
|
||||
profitTarget: a.profitTarget,
|
||||
consistency: a.consistency,
|
||||
minDayPnL: a.minDayPnL,
|
||||
minTradingDays: a.minTradingDays,
|
||||
accountSize: a.accountSize,
|
||||
maxLoss: a.maxLoss,
|
||||
maxPositionSize: a.maxPositionSize,
|
||||
targetSameEquity: a.targetSameEquity,
|
||||
withdrawalStages: a.withdrawalStages,
|
||||
})),
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
};
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
console.error('[GET /api/firms]', err);
|
||||
return NextResponse.json({ error: 'Failed to fetch firms' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json() as { name?: string; username?: string; password?: string };
|
||||
const { name, username, password } = body;
|
||||
|
||||
if (!name?.trim() || !username?.trim() || !password?.trim()) {
|
||||
return NextResponse.json({ error: 'name, username, and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const firm = createFirm(name.trim(), username.trim(), password.trim());
|
||||
initClient(firm.id, firm.username, firm.password, firm.name);
|
||||
return NextResponse.json({
|
||||
id: firm.id,
|
||||
firm: firm.name,
|
||||
username: firm.username,
|
||||
password: firm.password,
|
||||
accounts: [],
|
||||
}, { status: 201 });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('UNIQUE')) {
|
||||
return NextResponse.json({ error: 'A firm with that name already exists' }, { status: 409 });
|
||||
}
|
||||
console.error('[POST /api/firms]', err);
|
||||
return NextResponse.json({ error: 'Failed to create firm' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
+39
-25
@@ -1,36 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getFirms, loadDailyPnL } from '@/lib/db';
|
||||
import { loadDailyPnL } from '@/lib/db';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
||||
import { computeDailyTarget, resolveEffectiveConfig } from '@/lib/trading-logic';
|
||||
import type { AccountConfigRow } from '@/lib/db';
|
||||
|
||||
function getAccountConfig(name: string, accounts: AccountConfigRow[]): AccountConfigRow | undefined {
|
||||
return [...accounts]
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length)
|
||||
.find((a) => name.startsWith(a.prefix));
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const firms = getFirms();
|
||||
const firms = getCachedCatalog();
|
||||
const clients = getClients();
|
||||
|
||||
const state = firms.map((f) => {
|
||||
const client = clients.get(f.id);
|
||||
// connected == the Tradovate sync has come back, NOT "has accounts".
|
||||
// A firm with no accounts is a real, settled state and must not be
|
||||
// reported the same way as one that is still authenticating.
|
||||
const connected = !!client && client.syncComplete;
|
||||
if (!client || client.accountList.length === 0) {
|
||||
return { firm: f.name, connected: false, accounts: [] };
|
||||
return { firmId: f.id, firm: f.name, connected, accounts: [] };
|
||||
}
|
||||
const accounts = client.accountList.map((acc) => {
|
||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
|
||||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
||||
|
||||
// Compute daily target and targetHit in one place — the single source of truth.
|
||||
const cfg = getAccountConfig(acc.name, f.accounts);
|
||||
// One lookup against the assignment table — no prefix matching
|
||||
// here any more. An unassigned account gets no targets, which
|
||||
// is also what stops it being traded.
|
||||
const resolved = getAccountConfig(acc.id);
|
||||
const cfg = resolved.accountType;
|
||||
const inferredAccountSize = getInferredSize(acc.id, client);
|
||||
// Days traded counts only days that hit minDayPnL (when set) — that's what
|
||||
// the firm requires toward the min trading day rule.
|
||||
const daysTraded: number = cfg && cfg.min_day_pnl > 0
|
||||
? dailyPnL.filter((d) => d.pnl >= cfg.min_day_pnl).length
|
||||
const daysTraded: number = cfg && cfg.minDayPnL > 0
|
||||
? dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL).length
|
||||
: (client.daysTraded[acc.id] ?? 0);
|
||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||||
const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold;
|
||||
@@ -39,31 +42,36 @@ export async function GET() {
|
||||
// Stage = 1 + number of withdrawals (negative fund transactions)
|
||||
const stage = 1 + allFundTxns.filter((f) => f.amount < 0).length;
|
||||
// Hide initial funding (amount === accountSize) from display
|
||||
const displayFundTxns = cfg
|
||||
? allFundTxns.filter((f) => f.amount !== cfg.account_size)
|
||||
const fundingAmount = cfg?.accountSize ?? inferredAccountSize;
|
||||
const displayFundTxns = fundingAmount !== null
|
||||
? allFundTxns.filter((f) => f.amount !== fundingAmount)
|
||||
: allFundTxns;
|
||||
// Surfaced so the dashboard can flag an account whose type was
|
||||
// picked out of several equally valid matches.
|
||||
const ambiguous = cfg
|
||||
? matchAccountType(acc.name, inferredAccountSize, f.accountTypes).ambiguous
|
||||
: false;
|
||||
let targetHit = false;
|
||||
let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null;
|
||||
let effectiveProfitTarget: number | null = null;
|
||||
if (cfg && !isDead) {
|
||||
const withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[] = (() => { try { return JSON.parse(cfg.withdrawal_stages ?? '[]'); } catch { return []; } })();
|
||||
const effective = resolveEffectiveConfig(
|
||||
cfg.profit_target,
|
||||
cfg.profitTarget,
|
||||
cfg.consistency,
|
||||
cfg.min_trading_days,
|
||||
cfg.target_same_equity === 1,
|
||||
withdrawalStages,
|
||||
cfg.minTradingDays,
|
||||
cfg.targetSameEquity,
|
||||
cfg.withdrawalStages,
|
||||
priorProfit,
|
||||
allFundTxns
|
||||
);
|
||||
const equityProfit = cash.amount - cfg.account_size;
|
||||
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.min_day_pnl, effective.minTradingDays, equityProfit);
|
||||
const equityProfit = cash.amount - cfg.accountSize;
|
||||
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
|
||||
dailyTarget = target;
|
||||
|
||||
// Effective profit target = max(stage target, consistency realTarget).
|
||||
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
||||
// in total trading profit for the stage, not just profitTarget.
|
||||
const qualifying = cfg.min_day_pnl === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= cfg.min_day_pnl);
|
||||
const qualifying = cfg.minDayPnL === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL);
|
||||
const maxDay = qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0;
|
||||
const consistencyReal = (effective.consistency > 0 && effective.consistency < 1 && maxDay > 0)
|
||||
? maxDay / effective.consistency : 0;
|
||||
@@ -96,9 +104,15 @@ export async function GET() {
|
||||
dailyPnL,
|
||||
fullDailyPnL: client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id),
|
||||
fundTransactions: displayFundTxns,
|
||||
accountTypeId: resolved.accountTypeId,
|
||||
accountTypeLabel: cfg?.label ?? null,
|
||||
assignmentSource: resolved.source,
|
||||
accountType: cfg,
|
||||
inferredAccountSize,
|
||||
accountTypeAmbiguous: ambiguous,
|
||||
};
|
||||
});
|
||||
return { firm: f.name, connected: true, accounts };
|
||||
return { firmId: f.id, firm: f.name, connected, accounts };
|
||||
});
|
||||
|
||||
return NextResponse.json(state);
|
||||
|
||||
@@ -1,226 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import type { FirmConfig } from '@/types';
|
||||
|
||||
const SIZE_PRESETS = [5_000, 10_000, 25_000, 50_000, 75_000, 100_000, 150_000];
|
||||
|
||||
interface AccountConfig {
|
||||
id: number;
|
||||
prefix: string;
|
||||
profitTarget: number;
|
||||
consistency: number;
|
||||
minDayPnL: number;
|
||||
minTradingDays: number;
|
||||
accountSize: number;
|
||||
maxPositionSize: number;
|
||||
targetSameEquity: boolean;
|
||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: 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,
|
||||
maxPositionSize: 0,
|
||||
targetSameEquity: true,
|
||||
withdrawalStages: [],
|
||||
dirty: true,
|
||||
saving: false,
|
||||
error: '',
|
||||
useCustomSize: false,
|
||||
confirmDelete: false,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Read-only view of one firm's account types and banned symbols.
|
||||
*
|
||||
* Both are owned by the master dashboard now. This page renders from the local
|
||||
* catalog cache rather than linking straight out to the master, because the
|
||||
* moment you most want to know what an account type says is the moment the
|
||||
* master is unreachable.
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
function fmtMoney(n: number) {
|
||||
return `$${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
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 [firm, setFirm] = useState<FirmConfig | null>(null);
|
||||
const [masterUrl, setMasterUrl] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
|
||||
// Instruments (globally enabled only) and per-firm bans
|
||||
const [instruments, setInstruments] = useState<string[]>([]);
|
||||
const [bannedSymbols, setBannedSymbols] = useState<Set<string>>(new Set());
|
||||
|
||||
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));
|
||||
})
|
||||
.then(setFirm)
|
||||
.catch(() => setLoadError('Failed to load firm'));
|
||||
|
||||
// Load globally-enabled instruments
|
||||
fetch('/api/instruments')
|
||||
fetch('/api/firm-catalog')
|
||||
.then((r) => r.json())
|
||||
.then((data: { symbol: string; enabled: boolean }[]) =>
|
||||
setInstruments(data.filter((i) => i.enabled).map((i) => i.symbol))
|
||||
);
|
||||
|
||||
// Load this firm's banned symbols
|
||||
fetch(`/api/firms/${id}/banned-symbols`)
|
||||
.then((r) => r.json())
|
||||
.then((syms: string[]) => setBannedSymbols(new Set(syms)));
|
||||
.then((meta: { masterUrl?: string | null }) => setMasterUrl(meta.masterUrl ?? null))
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
async function toggleBan(symbol: string) {
|
||||
const nowBanned = !bannedSymbols.has(symbol);
|
||||
setBannedSymbols((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (nowBanned) next.add(symbol); else next.delete(symbol);
|
||||
return next;
|
||||
});
|
||||
await fetch(`/api/firms/${id}/banned-symbols`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ symbol, banned: nowBanned }),
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
maxPositionSize: row.maxPositionSize,
|
||||
targetSameEquity: row.targetSameEquity,
|
||||
withdrawalStages: row.withdrawalStages,
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -231,11 +55,12 @@ export default function FirmSettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const firmsPageUrl = masterUrl ? new URL('/firms', masterUrl).toString() : null;
|
||||
|
||||
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('/')}
|
||||
@@ -243,402 +68,88 @@ export default function FirmSettingsPage() {
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{firmName || '…'}</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{firm?.firm ?? '…'}</h1>
|
||||
<span className="text-slate-300 text-2xl font-light">/</span>
|
||||
<span className="text-2xl font-bold text-slate-400">Settings</span>
|
||||
<span className="text-2xl font-bold text-slate-400">Account Types</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow-sm">
|
||||
Account types and banned symbols are managed on the master dashboard and pulled from it — this is a
|
||||
read-only copy of the last successful sync.
|
||||
{firmsPageUrl && (
|
||||
<>
|
||||
{' '}
|
||||
<a
|
||||
href={firmsPageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Edit on master →
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</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-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Max Contracts</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">After First W/D</th>
|
||||
<th className="px-3 py-3 w-32" />
|
||||
{['Label', 'Prefix', 'Account Size', 'Profit Target', 'Consistency', 'Min Day P&L', 'Min Trading Days', 'Max Contracts', 'After First W/D'].map((h) => (
|
||||
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && firmName && (
|
||||
{firm && firm.accounts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-6 text-center text-sm text-slate-400 italic">
|
||||
No account types yet
|
||||
No account types — add them on the master dashboard
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{rows.map((row) => (
|
||||
<React.Fragment key={row.id}>
|
||||
<tr 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 })}
|
||||
/>
|
||||
{firm?.accounts.map((acc) => (
|
||||
<tr key={acc.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2.5 font-medium text-slate-800">{acc.label}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-slate-700">{acc.prefix}</td>
|
||||
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{fmtSize(acc.accountSize)}</td>
|
||||
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{fmtMoney(acc.profitTarget)}</td>
|
||||
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{Math.round(acc.consistency * 100)}%</td>
|
||||
<td className="px-4 py-2.5 text-slate-600 tabular-nums">
|
||||
{acc.minDayPnL <= -999 ? '—' : fmtMoney(acc.minDayPnL)}
|
||||
</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 className="px-4 py-2.5 text-slate-600 tabular-nums">{acc.minTradingDays}</td>
|
||||
<td className="px-4 py-2.5 text-slate-600 tabular-nums">
|
||||
{acc.maxPositionSize === 0 ? 'No limit' : acc.maxPositionSize}
|
||||
</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>
|
||||
|
||||
{/* Max Contracts */}
|
||||
<td className="px-4 py-2.5">
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputCls} w-20`}
|
||||
value={row.maxPositionSize === 0 ? '' : row.maxPositionSize}
|
||||
placeholder="No limit"
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, {
|
||||
maxPositionSize: e.target.value === '' ? 0 : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* After Withdrawal */}
|
||||
<td className="px-4 py-2.5">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.targetSameEquity}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, { targetSameEquity: e.target.checked })
|
||||
}
|
||||
className="rounded border-slate-300 text-blue-500 focus:ring-blue-400"
|
||||
/>
|
||||
<span className="text-xs text-slate-600">Same equity</span>
|
||||
</label>
|
||||
</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 className="px-4 py-2.5 text-slate-600">
|
||||
{acc.targetSameEquity
|
||||
? 'Same equity'
|
||||
: acc.withdrawalStages.length === 0
|
||||
? 'Base values'
|
||||
: `${acc.withdrawalStages.length} stage${acc.withdrawalStages.length === 1 ? '' : 's'}`}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Stage sub-rows — shown when targetSameEquity is OFF */}
|
||||
{!row.targetSameEquity && (
|
||||
<>
|
||||
{row.withdrawalStages.map((stage, idx) => {
|
||||
const isLast = idx === row.withdrawalStages.length - 1;
|
||||
const showPlus = isLast;
|
||||
return (
|
||||
<tr key={`${row.id}-stage-${idx}`} className="bg-slate-50/60 border-b border-slate-100 last:border-0">
|
||||
<td colSpan={9} className="pl-10 pr-4 py-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-400 w-16 shrink-0">
|
||||
Stage {idx + 2}{showPlus ? '+' : ''}
|
||||
</span>
|
||||
<div className="relative flex items-center">
|
||||
<span className="absolute left-2 text-slate-400 text-xs pointer-events-none">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="border border-slate-200 rounded px-1.5 py-1 pl-4 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-24"
|
||||
value={stage.profit}
|
||||
min={0}
|
||||
step={100}
|
||||
placeholder="Profit"
|
||||
onChange={(e) => {
|
||||
const next = row.withdrawalStages.map((s, i) =>
|
||||
i === idx ? { ...s, profit: Number(e.target.value) } : s
|
||||
);
|
||||
updateRow(row.id, { withdrawalStages: next });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{row.consistency > 0 && (
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
className="border border-slate-200 rounded px-1.5 py-1 pr-5 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-20"
|
||||
value={Math.round(stage.consistency * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
placeholder="Cons%"
|
||||
onChange={(e) => {
|
||||
const next = row.withdrawalStages.map((s, i) =>
|
||||
i === idx ? { ...s, consistency: Number(e.target.value) / 100 } : s
|
||||
);
|
||||
updateRow(row.id, { withdrawalStages: next });
|
||||
}}
|
||||
/>
|
||||
<span className="absolute right-1.5 text-slate-400 text-xs pointer-events-none">%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
className="border border-slate-200 rounded px-1.5 py-1 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-12"
|
||||
value={stage.minTradingDays}
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="Days"
|
||||
onChange={(e) => {
|
||||
const next = row.withdrawalStages.map((s, i) =>
|
||||
i === idx ? { ...s, minTradingDays: Number(e.target.value) } : s
|
||||
);
|
||||
updateRow(row.id, { withdrawalStages: next });
|
||||
}}
|
||||
/>
|
||||
<span className="text-slate-400 text-[10px] shrink-0">days</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = row.withdrawalStages.filter((_, i) => i !== idx);
|
||||
updateRow(row.id, { withdrawalStages: next });
|
||||
}}
|
||||
className="text-slate-300 hover:text-red-400 text-sm leading-none"
|
||||
title="Remove stage"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="bg-slate-50/60 border-b border-dashed border-slate-200">
|
||||
<td colSpan={9} className="pl-10 pr-4 py-1">
|
||||
<button
|
||||
onClick={() =>
|
||||
updateRow(row.id, {
|
||||
withdrawalStages: [
|
||||
...row.withdrawalStages,
|
||||
{ profit: row.profitTarget, consistency: row.consistency, minTradingDays: row.minTradingDays },
|
||||
],
|
||||
})
|
||||
}
|
||||
className="text-xs text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
+ Add stage
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* Add row */}
|
||||
<tr className="border-t border-dashed border-slate-200">
|
||||
<td colSpan={9} 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>
|
||||
|
||||
{/* ── Instruments ── */}
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
|
||||
Instruments
|
||||
Banned Symbols
|
||||
</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>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
{!firm || firm.bannedSymbols.length === 0 ? (
|
||||
<span className="text-sm text-slate-400 italic">None</span>
|
||||
) : (
|
||||
<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">Banned</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{instruments.map((sym) => {
|
||||
const banned = bannedSymbols.has(sym);
|
||||
return (
|
||||
<tr key={sym} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2.5 flex items-center gap-2">
|
||||
<span className="font-mono font-semibold text-slate-800">{sym}</span>
|
||||
{banned && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
|
||||
{sym} BANNED
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button
|
||||
onClick={() => toggleBan(sym)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
||||
banned ? 'bg-red-500' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
banned ? 'translate-x-4' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{firm.bannedSymbols.map((sym) => (
|
||||
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-red-100 text-red-700">
|
||||
{sym}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+276
-99
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
||||
import type { FirmConfig, FirmState, AccountState } from '@/types';
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
|
||||
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
|
||||
@@ -19,13 +18,6 @@ function statusRank(account: AccountState): number {
|
||||
return 3; // In Trade
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -44,6 +36,17 @@ function SettingsIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function KeyOffIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="M10.7 12.3 21 2" />
|
||||
<path d="m17 6 3 3" />
|
||||
<line x1="2" y1="2" x2="22" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg className={`chevron${open ? ' open' : ''}`} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -113,8 +116,142 @@ function SortHeader({ label, col, sortKey, sortDir, onSort }: {
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRow({ account, firm, hideDead, privacy }: { account: AccountState; firm: FirmConfig; hideDead: boolean; privacy: boolean }) {
|
||||
const cfg = getAccountConfig(account.name, firm);
|
||||
/** The account-type override. "Auto" hands the account back to the resolver;
|
||||
* anything else pins it, and a pin survives every later sync. */
|
||||
function AccountTypeCell({ account, firm, onAssigned }: {
|
||||
account: AccountState;
|
||||
firm: FirmConfig;
|
||||
onAssigned: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function assign(accountTypeId: string) {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (accountTypeId === '') {
|
||||
await fetch(`/api/accounts/${account.id}/account-type`, { method: 'DELETE' });
|
||||
} else {
|
||||
await fetch(`/api/accounts/${account.id}/account-type`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accountTypeId }),
|
||||
});
|
||||
}
|
||||
onAssigned();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// A pinned type that no longer exists on the master: keep the pin, but say
|
||||
// so, because the account has silently stopped trading.
|
||||
const danglingPin = account.assignmentSource === 'unassigned' && account.accountTypeId !== null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={account.assignmentSource === 'manual' ? (account.accountTypeId ?? '') : ''}
|
||||
disabled={saving}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => assign(e.target.value)}
|
||||
className={`border border-slate-200 rounded-md px-1.5 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 max-w-[10rem] ${
|
||||
account.accountTypeLabel ? 'text-slate-700' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<option value="">
|
||||
{account.accountTypeLabel ? `Auto — ${account.accountTypeLabel}` : 'Auto — unassigned'}
|
||||
</option>
|
||||
{firm.accounts.map((type) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.label} · {type.prefix} · {type.accountSize >= 1000 ? `${type.accountSize / 1000}k` : type.accountSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{account.assignmentSource === 'manual' && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" title="Pinned by hand" />
|
||||
)}
|
||||
{account.accountTypeAmbiguous && (
|
||||
<span className="text-amber-500 text-xs font-bold shrink-0" title="Several account types matched — using the most recently updated one">*</span>
|
||||
)}
|
||||
{danglingPin && (
|
||||
<span className="text-amber-600 text-xs shrink-0" title="The pinned account type no longer exists on the master — this account will not trade">!</span>
|
||||
)}
|
||||
{!account.accountTypeLabel && !danglingPin && (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 shrink-0">
|
||||
Unassigned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline credential entry, shown to the right of a firm that has none. */
|
||||
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function save() {
|
||||
if (!username.trim() || !password.trim()) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const cls = 'border border-slate-200 rounded-md px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 w-32';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
className={cls}
|
||||
placeholder="Tradovate username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={cls}
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
|
||||
/>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving || !username.trim() || !password.trim()}
|
||||
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
{saving ? '…' : 'Save'}
|
||||
</button>
|
||||
{error && <span className="text-xs text-red-500">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
account: AccountState;
|
||||
firm: FirmConfig;
|
||||
hideDead: boolean;
|
||||
privacy: boolean;
|
||||
onAssigned: () => void;
|
||||
}) {
|
||||
const cfg = account.accountType;
|
||||
const dead = isAccountDead(account);
|
||||
|
||||
if (hideDead && dead) return null;
|
||||
@@ -132,6 +269,9 @@ function AccountRow({ account, firm, hideDead, privacy }: { account: AccountStat
|
||||
<td className={`px-4 py-3 tabular-nums ${pnlColor}`}>
|
||||
{account.realizedPnL >= 0 ? '+' : ''}${fmt(account.realizedPnL)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||
{account.daysTraded} / {cfg?.minTradingDays ?? '—'}
|
||||
</td>
|
||||
@@ -173,19 +313,35 @@ function AccountRow({ account, firm, hideDead, privacy }: { account: AccountStat
|
||||
);
|
||||
}
|
||||
|
||||
function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead, privacy, sortKey, sortDir, tradeSymbol }: {
|
||||
function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged }: {
|
||||
state: FirmState;
|
||||
firm: FirmConfig;
|
||||
deleteMode: boolean;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
hideDead: boolean;
|
||||
privacy: boolean;
|
||||
sortKey: SortKey | null;
|
||||
sortDir: SortDir;
|
||||
tradeSymbol: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [confirmCreds, setConfirmCreds] = useState(false);
|
||||
const [removingCreds, setRemovingCreds] = useState(false);
|
||||
const hasCredentials = firm.hasCredentials;
|
||||
|
||||
// Clearing the login drops the Tradovate client, so the firm's accounts go
|
||||
// with it until someone enters one again — worth a confirm step.
|
||||
async function removeCredentials() {
|
||||
setRemovingCreds(true);
|
||||
try {
|
||||
await fetch(`/api/firms/${firm.id}/credentials`, { method: 'DELETE' });
|
||||
onChanged();
|
||||
} catch {
|
||||
/* the row simply stays as it was */
|
||||
} finally {
|
||||
setRemovingCreds(false);
|
||||
setConfirmCreds(false);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedAccounts = sortKey === null ? state.accounts : [...state.accounts].sort((a, b) => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
@@ -195,8 +351,8 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead, priva
|
||||
case 'dayPnL': return dir * (a.realizedPnL - b.realizedPnL);
|
||||
case 'daysTraded': return dir * (a.daysTraded - b.daysTraded);
|
||||
case 'target': {
|
||||
const at = getAccountConfig(a.name, firm)?.profitTarget ?? 0;
|
||||
const bt = getAccountConfig(b.name, firm)?.profitTarget ?? 0;
|
||||
const at = a.effectiveProfitTarget ?? a.accountType?.profitTarget ?? 0;
|
||||
const bt = b.effectiveProfitTarget ?? b.accountType?.profitTarget ?? 0;
|
||||
return dir * (at - bt);
|
||||
}
|
||||
case 'status': return dir * (statusRank(a) - statusRank(b));
|
||||
@@ -207,54 +363,100 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead, priva
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-t border-slate-200 bg-slate-50 hover:bg-slate-100 cursor-pointer select-none transition-colors"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`border-t border-slate-200 bg-slate-50 transition-colors ${
|
||||
hasCredentials ? 'hover:bg-slate-100 cursor-pointer select-none' : 'opacity-50'
|
||||
}`}
|
||||
onClick={() => { if (hasCredentials) setOpen((o) => !o); }}
|
||||
>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{deleteMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-4 h-4 accent-red-500 cursor-pointer flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="font-bold text-sm text-slate-800">{state.firm}</span>
|
||||
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
|
||||
{firm.bannedSymbols?.includes(tradeSymbol) && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
|
||||
{tradeSymbol} BANNED
|
||||
</span>
|
||||
)}
|
||||
{hasCredentials && !state.connected && (
|
||||
<span className="text-xs bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded font-semibold">
|
||||
Connecting…
|
||||
</span>
|
||||
)}
|
||||
{hasCredentials && state.connected && state.accounts.length === 0 && (
|
||||
<span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded font-semibold">
|
||||
No accounts
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td /><td /><td /><td />
|
||||
{!hasCredentials ? (
|
||||
<td colSpan={5} className="px-4 py-2.5">
|
||||
<CredentialForm firm={firm} onSaved={onChanged} />
|
||||
</td>
|
||||
) : confirmCreds ? (
|
||||
<td colSpan={5} className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-xs text-slate-500">
|
||||
Remove the saved Tradovate login for {firm.firm}? Its accounts disappear until you enter one again.
|
||||
</span>
|
||||
<button
|
||||
onClick={removeCredentials}
|
||||
disabled={removingCreds}
|
||||
className="px-2.5 py-1 bg-red-100 hover:bg-red-200 disabled:opacity-50 text-red-700 text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
{removingCreds ? '…' : 'Remove'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmCreds(false)}
|
||||
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
) : (
|
||||
<><td /><td /><td /><td /><td /></>
|
||||
)}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{hasCredentials && !confirmCreds && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
||||
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
|
||||
title="Remove saved Tradovate login"
|
||||
>
|
||||
<KeyOffIcon />
|
||||
</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"
|
||||
title="Account types"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Link>
|
||||
<ChevronIcon open={open} />
|
||||
{hasCredentials && <ChevronIcon open={open} />}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{open && (
|
||||
{open && hasCredentials && (
|
||||
state.accounts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
|
||||
Waiting for data...
|
||||
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
|
||||
{state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedAccounts.map((acc) => (
|
||||
<AccountRow key={acc.id} account={acc} firm={firm} hideDead={hideDead} privacy={privacy} />
|
||||
<AccountRow
|
||||
key={acc.id}
|
||||
account={acc}
|
||||
firm={firm}
|
||||
hideDead={hideDead}
|
||||
privacy={privacy}
|
||||
onAssigned={onChanged}
|
||||
/>
|
||||
))
|
||||
)
|
||||
)}
|
||||
@@ -273,11 +475,9 @@ interface SchedulerStatus {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [config, setConfig] = useState<FirmConfig[]>([]);
|
||||
const [firms, setFirms] = useState<FirmState[]>([]);
|
||||
const [deleteMode, setDeleteMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [catalog, setCatalog] = useState<{ lastSyncAt: string | null; lastError: string | null; masterUrl: string | null } | null>(null);
|
||||
const [hideDead, setHideDead] = useState(false);
|
||||
const [privacy, setPrivacy] = useState(false);
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
||||
@@ -313,6 +513,21 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCatalog = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/firm-catalog');
|
||||
if (res.ok) setCatalog(await res.json());
|
||||
} catch { /* server not running yet */ }
|
||||
};
|
||||
|
||||
const syncCatalogNow = async () => {
|
||||
try {
|
||||
await fetch('/api/firm-catalog', { method: 'POST' });
|
||||
} catch { /* ignore */ }
|
||||
fetchCatalog();
|
||||
fetchConfig();
|
||||
};
|
||||
|
||||
const fetchScheduler = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auto-trade');
|
||||
@@ -338,6 +553,7 @@ export default function Home() {
|
||||
.catch(() => {});
|
||||
|
||||
fetchConfig();
|
||||
fetchCatalog();
|
||||
fetchScheduler();
|
||||
|
||||
const fetchState = async () => {
|
||||
@@ -390,56 +606,17 @@ export default function Home() {
|
||||
|
||||
// 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);
|
||||
const firmCfg = config.find((c) => c.id === firmState.firmId);
|
||||
if (!firmCfg) return total;
|
||||
return total + firmState.accounts.filter((acc) => isAccountDead(acc)).length;
|
||||
}, 0);
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
await Promise.all(
|
||||
[...selected].map((id) =>
|
||||
fetch(`/api/firms/${id}`, { method: 'DELETE' }).catch(() => {})
|
||||
)
|
||||
);
|
||||
setSelected(new Set());
|
||||
setDeleteMode(false);
|
||||
fetchConfig();
|
||||
};
|
||||
|
||||
const toggleSelected = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">AutoTrader</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{deleteMode ? (
|
||||
<>
|
||||
{selected.size > 0 && (
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
className="px-4 py-2 bg-red-100 hover:bg-red-200 text-red-700 text-sm font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
Confirm Delete ({selected.size})
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setDeleteMode(false); setSelected(new Set()); }}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-200 bg-white rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{deadCount > 0 && (
|
||||
<button
|
||||
onClick={() => setHideDead((h) => !h)}
|
||||
@@ -452,18 +629,6 @@ export default function Home() {
|
||||
{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"
|
||||
>
|
||||
+ Add New
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteMode(true)}
|
||||
className="px-4 py-2 bg-red-100 hover:bg-red-200 text-red-700 text-sm font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPrivacy((p) => !p)}
|
||||
className={`px-3 py-2 rounded-lg transition-colors inline-flex items-center ${privacy ? 'text-blue-600 bg-blue-50 hover:bg-blue-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
|
||||
@@ -478,8 +643,6 @@ export default function Home() {
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* ── Trade Controls ── */}
|
||||
@@ -607,6 +770,7 @@ export default function Home() {
|
||||
<SortHeader label="Account" col="name" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label="Balance" col="balance" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label="Day P&L" col="dayPnL" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Account Type</th>
|
||||
<SortHeader label="Days Traded" col="daysTraded" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label="Target" col="target" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortHeader label="Status" col="status" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
@@ -615,26 +779,39 @@ export default function Home() {
|
||||
<tbody>
|
||||
{config.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-sm text-slate-400 italic">
|
||||
No firms yet — click + Add New to get started
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-sm text-slate-400 italic">
|
||||
{catalog && catalog.lastSyncAt === null ? (
|
||||
<>
|
||||
Catalog never synced — cannot reach {catalog.masterUrl ?? 'the master dashboard'}
|
||||
{catalog.lastError && <><br />{catalog.lastError}</>}
|
||||
<br />
|
||||
<button
|
||||
onClick={syncCatalogNow}
|
||||
className="mt-2 px-3 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md not-italic transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>No firms yet — add them on the master dashboard</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
config.map((cfg) => {
|
||||
const state = firms.find((f) => f.firm === cfg.firm) ?? { firm: cfg.firm, connected: false, accounts: [] };
|
||||
const state = firms.find((f) => f.firmId === cfg.id)
|
||||
?? { firmId: cfg.id, firm: cfg.firm, connected: false, accounts: [] };
|
||||
return (
|
||||
<FirmRows
|
||||
key={cfg.id}
|
||||
state={state}
|
||||
firm={cfg}
|
||||
deleteMode={deleteMode}
|
||||
selected={selected.has(cfg.id)}
|
||||
onToggle={() => toggleSelected(cfg.id)}
|
||||
hideDead={hideDead}
|
||||
privacy={privacy}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
tradeSymbol={tradeSymbol}
|
||||
onChanged={fetchConfig}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -26,15 +26,40 @@ export default function SettingsPage() {
|
||||
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
|
||||
const [tradingHours, setTradingHours] = useState<string>('full_cme');
|
||||
const [masterUrl, setMasterUrl] = useState<string>('');
|
||||
const [catalog, setCatalog] = useState<{
|
||||
version: number;
|
||||
firmCount: number;
|
||||
lastSyncAt: string | null;
|
||||
lastError: string | null;
|
||||
} | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [instanceName, setInstanceName] = useState<string>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const fetchCatalog = () => {
|
||||
fetch('/api/firm-catalog')
|
||||
.then((r) => r.json())
|
||||
.then(setCatalog)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const syncCatalogNow = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await fetch('/api/firm-catalog', { method: 'POST' });
|
||||
} catch { /* the error lands in lastError */ }
|
||||
fetchCatalog();
|
||||
setSyncing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/instruments')
|
||||
.then((r) => r.json())
|
||||
.then(setInstruments);
|
||||
|
||||
fetchCatalog();
|
||||
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then((s: AppSettings) => {
|
||||
@@ -213,6 +238,30 @@ export default function SettingsPage() {
|
||||
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">Firm catalog</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{catalog
|
||||
? `v${catalog.version} · ${catalog.firmCount} firm${catalog.firmCount === 1 ? '' : 's'} · ${
|
||||
catalog.lastSyncAt
|
||||
? `last synced ${new Date(catalog.lastSyncAt).toLocaleTimeString()}`
|
||||
: 'never synced'
|
||||
}`
|
||||
: 'Loading…'}
|
||||
</p>
|
||||
{catalog?.lastError && (
|
||||
<p className="text-xs text-red-600 mt-0.5">{catalog.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={syncCatalogNow}
|
||||
disabled={syncing}
|
||||
className="text-sm px-3 py-1.5 rounded-lg font-medium bg-slate-100 hover:bg-slate-200 text-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{syncing ? 'Syncing…' : 'Sync now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Instruments ── */}
|
||||
|
||||
Reference in New Issue
Block a user