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 { 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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user