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:
co-authored by
Claude Sonnet 4.6
parent
d945e0038f
commit
6d3e8b6065
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user