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 { NextRequest, NextResponse } from 'next/server';
import { getFirms, createFirm } from '@/lib/db'; import { getFirms, createFirm, getBannedSymbols } from '@/lib/db';
import { initClient } from '@/lib/clients'; import { initClient } from '@/lib/clients';
export async function GET() { export async function GET() {
@@ -10,6 +10,7 @@ export async function GET() {
firm: f.name, firm: f.name,
username: f.username, username: f.username,
password: f.password, password: f.password,
bannedSymbols: getBannedSymbols(f.id),
accounts: f.accounts.map((a) => ({ accounts: f.accounts.map((a) => ({
prefix: a.prefix, prefix: a.prefix,
profitTarget: a.profit_target, profitTarget: a.profit_target,
+80
View File
@@ -86,6 +86,10 @@ export default function FirmSettingsPage() {
const [rows, setRows] = useState<RowState[]>([]); const [rows, setRows] = useState<RowState[]>([]);
const [loadError, setLoadError] = useState(''); 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(() => { useEffect(() => {
fetch(`/api/firms/${id}`) fetch(`/api/firms/${id}`)
.then((r) => { .then((r) => {
@@ -97,8 +101,34 @@ export default function FirmSettingsPage() {
setRows(firm.accounts.map(initRow)); setRows(firm.accounts.map(initRow));
}) })
.catch(() => setLoadError('Failed to load firm')); .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]); }, [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>) => { const updateRow = (rowId: number, patch: Partial<RowState>) => {
setRows((prev) => setRows((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, ...patch, dirty: true } : r)) prev.map((r) => (r.id === rowId ? { ...r, ...patch, dirty: true } : r))
@@ -436,6 +466,56 @@ export default function FirmSettingsPage() {
</table> </table>
</div> </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>
</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; state: FirmState;
firm: FirmConfig; firm: FirmConfig;
deleteMode: boolean; deleteMode: boolean;
@@ -171,6 +171,7 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead, priva
privacy: boolean; privacy: boolean;
sortKey: SortKey | null; sortKey: SortKey | null;
sortDir: SortDir; sortDir: SortDir;
tradeSymbol: string;
}) { }) {
const [open, setOpen] = useState(true); 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> <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> </div>
</td> </td>
<td /><td /><td /><td /> <td /><td /><td /><td />
@@ -540,6 +546,7 @@ export default function Home() {
privacy={privacy} privacy={privacy}
sortKey={sortKey} sortKey={sortKey}
sortDir={sortDir} sortDir={sortDir}
tradeSymbol={tradeSymbol}
/> />
); );
}) })
+7 -1
View File
@@ -7,7 +7,7 @@
* signal but have since exited and are now eligible. * signal but have since exited and are now eligible.
*/ */
import { getFirms } from './db'; import { getFirms, isSymbolBanned } from './db';
import { getClients } from './clients'; import { getClients } from './clients';
import { computeDailyTarget, POINT_VALUES } from './trading-logic'; import { computeDailyTarget, POINT_VALUES } from './trading-logic';
import { getSetting } from './db'; import { getSetting } from './db';
@@ -100,6 +100,12 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string
const client = clients.get(firm.id); const client = clients.get(firm.id);
if (!client || client.accountList.length === 0) return; if (!client || client.accountList.length === 0) return;
// Skip this firm entirely if the symbol is banned for it
if (isSymbolBanned(firm.id, symbol)) {
console.log(`[auto-trade] ${firm.name}: ${symbol} is banned — skipping firm`);
return;
}
const firmConfig = mapFirmConfig(firm); const firmConfig = mapFirmConfig(firm);
const contract = await client.findFrontMonthContract(symbol); const contract = await client.findFrontMonthContract(symbol);
+27
View File
@@ -196,3 +196,30 @@ export function setInstrumentEnabled(symbol: string, enabled: boolean): boolean
const result = db.prepare('UPDATE instruments SET enabled = ? WHERE symbol = ?').run(enabled ? 1 : 0, symbol); const result = db.prepare('UPDATE instruments SET enabled = ? WHERE symbol = ?').run(enabled ? 1 : 0, symbol);
return result.changes > 0; return result.changes > 0;
} }
// ── Firm banned symbols ───────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS firm_banned_symbols (
firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE,
symbol TEXT NOT NULL,
PRIMARY KEY (firm_id, symbol)
);
`);
export function getBannedSymbols(firmId: number): string[] {
return (db.prepare('SELECT symbol FROM firm_banned_symbols WHERE firm_id = ? ORDER BY symbol').all(firmId) as { symbol: string }[])
.map((r) => r.symbol);
}
export function isSymbolBanned(firmId: number, symbol: string): boolean {
return !!db.prepare('SELECT 1 FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').get(firmId, symbol);
}
export function setBannedSymbol(firmId: number, symbol: string, banned: boolean): void {
if (banned) {
db.prepare('INSERT OR IGNORE INTO firm_banned_symbols (firm_id, symbol) VALUES (?, ?)').run(firmId, symbol);
} else {
db.prepare('DELETE FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').run(firmId, symbol);
}
}
+1
View File
@@ -14,6 +14,7 @@ export interface FirmConfig {
firm: string; firm: string;
username: string; username: string;
password: string; password: string;
bannedSymbols: string[];
accounts: AccountConfig[]; accounts: AccountConfig[];
} }