Add per-firm banned symbols feature

- lib/db.ts: new firm_banned_symbols table with getBannedSymbols,
  isSymbolBanned, and setBannedSymbol helpers
- app/api/firms/[id]/banned-symbols/route.ts: GET lists banned symbols,
  PATCH toggles a ban for a given symbol
- app/api/firms/route.ts: include bannedSymbols[] in firm list response
- app/firms/[id]/settings/page.tsx: Instruments section shows all
  globally-enabled symbols with a red toggle to ban/unban; banned
  symbols display a "SYMBOL BANNED" pill next to their name
- app/page.tsx: FirmRows shows "ES BANNED" (or current symbol) pill next
  to the firm name when the selected trade symbol is banned for that firm
- lib/auto-trade.ts: skip firms entirely when the trade symbol is banned
- types.ts: add bannedSymbols field to FirmConfig

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Senofy
2026-03-12 03:29:16 -05:00
co-authored by Claude Sonnet 4.6
parent d945e0038f
commit 6d3e8b6065
7 changed files with 150 additions and 3 deletions
@@ -0,0 +1,25 @@
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 });
}
+2 -1
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getFirms, createFirm } from '@/lib/db';
import { getFirms, createFirm, getBannedSymbols } from '@/lib/db';
import { initClient } from '@/lib/clients';
export async function GET() {
@@ -10,6 +10,7 @@ export async function GET() {
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,
+80
View File
@@ -86,6 +86,10 @@ export default function FirmSettingsPage() {
const [rows, setRows] = useState<RowState[]>([]);
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) => {
@@ -97,8 +101,34 @@ export default function FirmSettingsPage() {
setRows(firm.accounts.map(initRow));
})
.catch(() => setLoadError('Failed to load firm'));
// Load globally-enabled instruments
fetch('/api/instruments')
.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)));
}, [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))
@@ -436,6 +466,56 @@ export default function FirmSettingsPage() {
</table>
</div>
{/* ── Instruments ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 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">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>
</div>
</div>
);
+8 -1
View File
@@ -161,7 +161,7 @@ function AccountRow({ account, firm, hideDead, privacy }: { account: AccountStat
);
}
function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead, privacy, sortKey, sortDir }: {
function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead, privacy, sortKey, sortDir, tradeSymbol }: {
state: FirmState;
firm: FirmConfig;
deleteMode: boolean;
@@ -171,6 +171,7 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead, priva
privacy: boolean;
sortKey: SortKey | null;
sortDir: SortDir;
tradeSymbol: string;
}) {
const [open, setOpen] = useState(true);
@@ -209,6 +210,11 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead, priva
/>
)}
<span className="font-bold text-sm text-slate-800">{state.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>
)}
</div>
</td>
<td /><td /><td /><td />
@@ -540,6 +546,7 @@ export default function Home() {
privacy={privacy}
sortKey={sortKey}
sortDir={sortDir}
tradeSymbol={tradeSymbol}
/>
);
})