diff --git a/README.md b/README.md index 8e5c37e..8a9613f 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,22 @@ The dashboard runs on port **3000**: `http://localhost:3000`. On first run, the SQLite database (`autotrader.sqlite`) is created automatically in the project root. No migrations need to be run manually. -Open the app in your browser and add your firms through the UI: +Firms and their account types live on the master dashboard, not here — every +machine pulls the same catalog so they cannot drift apart. This machine only +stores the Tradovate login for each firm. -1. Click **+ Add Firm** on the main page -2. Enter the firm name, Tradovate username, and password -3. Go to the firm's **Settings** page to configure account types (prefix, profit target, consistency %, etc.) -4. Return to the main page — accounts will populate once the firm connects to Tradovate +1. On the **master dashboard**, open **Firms**, add the firm, and give it one + account type per prefix + account size combination (profit target, + consistency %, min trading days, and so on) +2. Set **Dashboard URL** on this machine's **Settings** page so it can reach the + master. The catalog is pulled at startup and then every 60 seconds +3. On the main page, each firm from the catalog appears. Firms without stored + credentials are greyed out — enter the Tradovate username and password in the + fields beside the firm name and click **Save** +4. Accounts populate once the firm connects. Each is matched to an account type + by its name prefix *and* its size, so a new account shows as **Unassigned** + until its opening funding transaction has been fetched. Use the **Account + Type** dropdown to pin one by hand; a manual pick is never overwritten --- diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index da3c5a3..3e0d5c5 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -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 ( + + + {account.assignmentSource === 'manual' && ( + + )} + {account.accountTypeAmbiguous && ( + * + )} + {!account.accountTypeLabel && ( + + Unassigned + + )} + + ); } function fmt(value: number) { @@ -189,9 +242,9 @@ export default function AccountPage() { const [account, setAccount] = useState(null); const [cfg, setCfg] = useState(null); const [firmName, setFirmName] = useState(''); + const [firm, setFirm] = useState(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 (
@@ -339,6 +396,13 @@ export default function AccountPage() { / {firmName} + {firm && ( + { void load(); }} + /> + )} /

{account.name}

{isDead && ( diff --git a/app/add/page.tsx b/app/add/page.tsx deleted file mode 100644 index 1f5931e..0000000 --- a/app/add/page.tsx +++ /dev/null @@ -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 ( -
-
-
- -

Add New Firm

-
-
- {error && ( -
- {error} -
- )} -
- - setFirmName(e.target.value)} className={inputCls} placeholder="e.g. MyFirm" /> -
-
- - setUsername(e.target.value)} className={inputCls} placeholder="Username" /> -
-
- - setPassword(e.target.value)} className={inputCls} placeholder="Password" /> -
-
- - -
-
-
-
- ); -} diff --git a/app/api/account-configs/[id]/route.ts b/app/api/account-configs/[id]/route.ts deleted file mode 100644 index c70f8cd..0000000 --- a/app/api/account-configs/[id]/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/accounts/[id]/account-type/route.ts b/app/api/accounts/[id]/account-type/route.ts new file mode 100644 index 0000000..e745923 --- /dev/null +++ b/app/api/accounts/[id]/account-type/route.ts @@ -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 }); +} diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts index 444b317..378393e 100644 --- a/app/api/debug/route.ts +++ b/app/api/debug/route.ts @@ -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) => { diff --git a/app/api/firm-catalog/route.ts b/app/api/firm-catalog/route.ts new file mode 100644 index 0000000..9cdae99 --- /dev/null +++ b/app/api/firm-catalog/route.ts @@ -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() }); +} diff --git a/app/api/firms/[id]/accounts/route.ts b/app/api/firms/[id]/accounts/route.ts deleted file mode 100644 index 4441548..0000000 --- a/app/api/firms/[id]/accounts/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/firms/[id]/banned-symbols/route.ts b/app/api/firms/[id]/banned-symbols/route.ts deleted file mode 100644 index 77f29a6..0000000 --- a/app/api/firms/[id]/banned-symbols/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/firms/[id]/credentials/route.ts b/app/api/firms/[id]/credentials/route.ts new file mode 100644 index 0000000..19e1f77 --- /dev/null +++ b/app/api/firms/[id]/credentials/route.ts @@ -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 }); +} diff --git a/app/api/firms/[id]/debug-contracts/route.ts b/app/api/firms/[id]/debug-contracts/route.ts index 90bb91e..2bf8bea 100644 --- a/app/api/firms/[id]/debug-contracts/route.ts +++ b/app/api/firms/[id]/debug-contracts/route.ts @@ -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 }); diff --git a/app/api/firms/[id]/orders/route.ts b/app/api/firms/[id]/orders/route.ts index 29203f7..c38eae2 100644 --- a/app/api/firms/[id]/orders/route.ts +++ b/app/api/firms/[id]/orders/route.ts @@ -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; diff --git a/app/api/firms/[id]/route.ts b/app/api/firms/[id]/route.ts index 004b411..d4a4648 100644 --- a/app/api/firms/[id]/route.ts +++ b/app/api/firms/[id]/route.ts @@ -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 }); - } -} diff --git a/app/api/firms/route.ts b/app/api/firms/route.ts index cd04e98..e204edb 100644 --- a/app/api/firms/route.ts +++ b/app/api/firms/route.ts @@ -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 }); - } -} diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 68caf08..216dc61 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -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); diff --git a/app/firms/[id]/settings/page.tsx b/app/firms/[id]/settings/page.tsx index e3ea26f..ce4f594 100644 --- a/app/firms/[id]/settings/page.tsx +++ b/app/firms/[id]/settings/page.tsx @@ -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 ( - - - - - - - ); +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([]); + const [firm, setFirm] = useState(null); + const [masterUrl, setMasterUrl] = useState(null); const [loadError, setLoadError] = useState(''); - // Instruments (globally enabled only) and per-firm bans - const [instruments, setInstruments] = useState([]); - const [bannedSymbols, setBannedSymbols] = useState>(new Set()); - useEffect(() => { fetch(`/api/firms/${id}`) .then((r) => { if (!r.ok) throw new Error('Not found'); return r.json() as Promise; }) - .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) => { - 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 (
@@ -231,11 +55,12 @@ export default function FirmSettingsPage() { ); } + const firmsPageUrl = masterUrl ? new URL('/firms', masterUrl).toString() : null; + return (
- {/* Header */}
-

{firmName || '…'}

+

{firm?.firm ?? '…'}

/ - Settings + Account Types +
+ +
+ 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 && ( + <> + {' '} + + Edit on master → + + + )}
- {/* Account Types */} -

- Account Types -

- - - - - - - - - + ))} - {rows.length === 0 && firmName && ( + {firm && firm.accounts.length === 0 && ( )} - {rows.map((row) => ( - - - - {/* Prefix */} - + + + + + + - - {/* Account Size */} - + - - {/* Profit Target */} - - - {/* Consistency */} - - - {/* Min Day P&L */} - - - {/* Min Trading Days */} - - - {/* Max Contracts */} - - - {/* After Withdrawal */} - - - {/* Actions */} - - - {/* 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 ( - - - - ); - })} - - - - - )} - ))} - - {/* Add row */} - - -
PrefixAccount SizeProfit TargetConsistencyMin Day P&LMin Trading DaysMax ContractsAfter First W/D + {['Label', 'Prefix', 'Account Size', 'Profit Target', 'Consistency', 'Min Day P&L', 'Min Trading Days', 'Max Contracts', 'After First W/D'].map((h) => ( + {h}
- No account types yet + No account types — add them on the master dashboard
- updateRow(row.id, { prefix: e.target.value })} - /> + {firm?.accounts.map((acc) => ( +
{acc.label}{acc.prefix}{fmtSize(acc.accountSize)}{fmtMoney(acc.profitTarget)}{Math.round(acc.consistency * 100)}% + {acc.minDayPnL <= -999 ? '—' : fmtMoney(acc.minDayPnL)} -
- - {row.useCustomSize && ( - - updateRow(row.id, { accountSize: Number(e.target.value) }) - } - /> - )} -
+
{acc.minTradingDays} + {acc.maxPositionSize === 0 ? 'No limit' : acc.maxPositionSize} -
- $ - - updateRow(row.id, { profitTarget: Number(e.target.value) }) - } - /> -
-
-
- - updateRow(row.id, { consistency: Number(e.target.value) / 100 }) - } - /> - % -
-
-
- $ - - updateRow(row.id, { - minDayPnL: e.target.value === '' ? -999 : Number(e.target.value), - }) - } - /> -
-
- - updateRow(row.id, { minTradingDays: Number(e.target.value) }) - } - /> - - - updateRow(row.id, { - maxPositionSize: e.target.value === '' ? 0 : Number(e.target.value), - }) - } - /> - - - -
- {row.error && ( - {row.error} - )} - - {row.confirmDelete ? ( - <> - Delete? - - - - ) : ( - <> - - - - )} -
+
+ {acc.targetSameEquity + ? 'Same equity' + : acc.withdrawalStages.length === 0 + ? 'Base values' + : `${acc.withdrawalStages.length} stage${acc.withdrawalStages.length === 1 ? '' : 's'}`}
-
- - Stage {idx + 2}{showPlus ? '+' : ''} - -
- $ - { - const next = row.withdrawalStages.map((s, i) => - i === idx ? { ...s, profit: Number(e.target.value) } : s - ); - updateRow(row.id, { withdrawalStages: next }); - }} - /> -
- {row.consistency > 0 && ( -
- { - const next = row.withdrawalStages.map((s, i) => - i === idx ? { ...s, consistency: Number(e.target.value) / 100 } : s - ); - updateRow(row.id, { withdrawalStages: next }); - }} - /> - % -
- )} -
- { - const next = row.withdrawalStages.map((s, i) => - i === idx ? { ...s, minTradingDays: Number(e.target.value) } : s - ); - updateRow(row.id, { withdrawalStages: next }); - }} - /> - days -
- -
-
- -
- -
- {/* ── Instruments ── */}

- Instruments + Banned Symbols

-
- {instruments.length === 0 ? ( -
Loading…
+
+ {!firm || firm.bannedSymbols.length === 0 ? ( + None ) : ( - - - - - - - - - {instruments.map((sym) => { - const banned = bannedSymbols.has(sym); - return ( - - - - - ); - })} - -
SymbolBanned
- {sym} - {banned && ( - - {sym} BANNED - - )} - - -
+
+ {firm.bannedSymbols.map((sym) => ( + + {sym} + + ))} +
)}
diff --git a/app/page.tsx b/app/page.tsx index 6b9fad6..3b02cfc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 ( + + + + + + + ); +} + function ChevronIcon({ open }: { open: boolean }) { return ( @@ -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 ( +
+ + + {account.assignmentSource === 'manual' && ( + + )} + {account.accountTypeAmbiguous && ( + * + )} + {danglingPin && ( + ! + )} + {!account.accountTypeLabel && !danglingPin && ( + + Unassigned + + )} +
+ ); +} + +/** 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 ( +
e.stopPropagation()}> + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') void save(); }} + /> + + {error && {error}} +
+ ); +} + +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 {account.realizedPnL >= 0 ? '+' : ''}${fmt(account.realizedPnL)} + + + {account.daysTraded} / {cfg?.minTradingDays ?? '—'} @@ -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 ( <> 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); }} >
- {deleteMode && ( - e.stopPropagation()} - className="w-4 h-4 accent-red-500 cursor-pointer flex-shrink-0" - /> - )} - {state.firm} + {firm.firm} {firm.bannedSymbols?.includes(tradeSymbol) && ( {tradeSymbol} BANNED )} + {hasCredentials && !state.connected && ( + + Connecting… + + )} + {hasCredentials && state.connected && state.accounts.length === 0 && ( + + No accounts + + )}
- + {!hasCredentials ? ( + + + + ) : confirmCreds ? ( + +
e.stopPropagation()}> + + Remove the saved Tradovate login for {firm.firm}? Its accounts disappear until you enter one again. + + + +
+ + ) : ( + <> + )}
+ {hasCredentials && !confirmCreds && ( + + )} e.stopPropagation()} - title="Settings" + title="Account types" > - + {hasCredentials && }
- {open && ( + {open && hasCredentials && ( state.accounts.length === 0 ? ( - - Waiting for data... + + {state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'} ) : ( sortedAccounts.map((acc) => ( - + )) ) )} @@ -273,11 +475,9 @@ interface SchedulerStatus { } export default function Home() { - const router = useRouter(); const [config, setConfig] = useState([]); const [firms, setFirms] = useState([]); - const [deleteMode, setDeleteMode] = useState(false); - const [selected, setSelected] = useState>(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(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 (

AutoTrader

- {deleteMode ? ( - <> - {selected.size > 0 && ( - - )} - - - ) : ( - <> {deadCount > 0 && ( )} - -
{/* ── Trade Controls ── */} @@ -607,6 +770,7 @@ export default function Home() { + Account Type @@ -615,26 +779,39 @@ export default function Home() { {config.length === 0 ? ( - - No firms yet — click + Add New to get started + + {catalog && catalog.lastSyncAt === null ? ( + <> + Catalog never synced — cannot reach {catalog.masterUrl ?? 'the master dashboard'} + {catalog.lastError && <>
{catalog.lastError}} +
+ + + ) : ( + <>No firms yet — add them on the master dashboard + )} ) : ( 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 ( toggleSelected(cfg.id)} hideDead={hideDead} privacy={privacy} sortKey={sortKey} sortDir={sortDir} tradeSymbol={tradeSymbol} + onChanged={fetchConfig} /> ); }) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 62ecb3d..49e3f50 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -26,15 +26,40 @@ export default function SettingsPage() { const [maxConcurrent, setMaxConcurrent] = useState('5'); const [tradingHours, setTradingHours] = useState('full_cme'); const [masterUrl, setMasterUrl] = useState(''); + 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(''); 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" />
+
+
+

Firm catalog

+

+ {catalog + ? `v${catalog.version} · ${catalog.firmCount} firm${catalog.firmCount === 1 ? '' : 's'} · ${ + catalog.lastSyncAt + ? `last synced ${new Date(catalog.lastSyncAt).toLocaleTimeString()}` + : 'never synced' + }` + : 'Loading…'} +

+ {catalog?.lastError && ( +

{catalog.lastError}

+ )} +
+ +
{/* ── Instruments ── */} diff --git a/lib/account-type-resolver.ts b/lib/account-type-resolver.ts new file mode 100644 index 0000000..2f21f44 --- /dev/null +++ b/lib/account-type-resolver.ts @@ -0,0 +1,192 @@ +/** + * Binds a live Tradovate account to one of its firm's account types. + * + * Matching uses the account's name prefix AND its size together, because two + * account types at one firm can share a prefix and differ only by size. The + * size comes from the account's initial funding transaction, which does not + * arrive with the first sync — until it does the account is Unassigned and the + * resolver simply tries again on the next one. + * + * An Unassigned account does not trade: every caller already guards on a + * missing config, which is the behaviour we want. + */ + +import type { CatalogAccountType } from './master-protocol'; +import type { TradovateClient } from './tradovate-class'; +import { + getAssignment, + setAssignment, + setAutoAssignment, + clearAssignment, + listAssignments, + loadAccountMeta, + saveAccountMeta, +} from './db'; +import { getAccountTypeById, getCachedFirm } from './firm-catalog'; + +const INFERRED_SIZE_KEY = 'inferred_account_size'; + +export type AssignmentSource = 'manual' | 'auto' | 'unassigned'; + +export interface ResolvedAssignment { + accountTypeId: string | null; + accountType: CatalogAccountType | null; + source: AssignmentSource; + /** True when more than one account type matched and the tie-break decided. */ + ambiguous: boolean; +} + +const UNASSIGNED: ResolvedAssignment = { + accountTypeId: null, + accountType: null, + source: 'unassigned', + ambiguous: false, +}; + +/** The earliest positive fund transaction — the account's opening balance. + * Returns null while the reports fetch hasn't produced one yet, which is the + * "wait until it is findable" case rather than an error. */ +export function inferAccountSize(fundTxns: { date: string; amount: number }[]): number | null { + const deposits = fundTxns.filter((t) => t.amount > 0); + if (deposits.length === 0) return null; + return [...deposits].sort((a, b) => a.date.localeCompare(b.date))[0].amount; +} + +function sizesToTry(fundTxns: { date: string; amount: number }[]): number[] { + // Earliest deposit first, then every other deposit: an account that was + // reset or refunded can carry more than one, and the first is not always + // the one that equals the account size. + const deposits = [...fundTxns.filter((t) => t.amount > 0)].sort((a, b) => a.date.localeCompare(b.date)); + return [...new Set(deposits.map((t) => t.amount))]; +} + +/** Pure. Exported for the UI's candidate list and for reasoning about ties. */ +export function matchAccountType( + accountName: string, + accountSize: number | null, + types: CatalogAccountType[], +): { type: CatalogAccountType | null; ambiguous: boolean } { + const byPrefix = types.filter((t) => accountName.startsWith(t.prefix)); + if (byPrefix.length === 0) return { type: null, ambiguous: false }; + + // No size yet — stay Unassigned and try again next sync. + if (accountSize === null) return { type: null, ambiguous: false }; + + const candidates = byPrefix.filter((t) => Math.abs(t.accountSize - accountSize) < 0.01); + if (candidates.length === 0) return { type: null, ambiguous: false }; + if (candidates.length === 1) return { type: candidates[0], ambiguous: false }; + + // More than one match. Most recently updated wins; the rest of the ordering + // exists so the outcome is stable across restarts rather than incidental. + const sorted = [...candidates].sort((a, b) => { + const byUpdated = b.updatedAt.localeCompare(a.updatedAt); + if (byUpdated !== 0) return byUpdated; + const byPrefixLength = b.prefix.length - a.prefix.length; + if (byPrefixLength !== 0) return byPrefixLength; + return a.id.localeCompare(b.id); + }); + return { type: sorted[0], ambiguous: true }; +} + +/** THE account→config lookup. Reads the assignment table; never writes. + * Replaces the longest-prefix helper that used to be copied into five files. */ +export function getAccountConfig(accountId: number): ResolvedAssignment { + const row = getAssignment(accountId); + if (!row?.account_type_id) return UNASSIGNED; + + const accountType = getAccountTypeById(row.account_type_id); + if (!accountType) { + // The account type was deleted on the master. A manual pin is kept so + // that a master flap which recreates it restores the user's intent, but + // it reports as unassigned until then, so the account stops trading. + return { + accountTypeId: row.account_type_id, + accountType: null, + source: 'unassigned', + ambiguous: false, + }; + } + + return { + accountTypeId: accountType.id, + accountType, + source: row.manual === 1 ? 'manual' : 'auto', + ambiguous: false, + }; +} + +/** The size this account resolved to, cached once found so a later reset + * deposit can't move it. */ +export function getInferredSize(accountId: number, client: TradovateClient): number | null { + const cached = loadAccountMeta(accountId, INFERRED_SIZE_KEY); + if (cached !== null) { + const value = Number(cached); + if (Number.isFinite(value)) return value; + } + return inferAccountSize(client.fundTransactions?.[accountId] ?? []); +} + +/** Re-resolves every account on one firm's client. Skips manual overrides. + * Called after each Tradovate sync and after the catalog changes. */ +export function resolveFirmAssignments( + firmId: string, + client: TradovateClient, +): { assigned: number; unassigned: number; manual: number } { + const firm = getCachedFirm(firmId); + const types = firm?.accountTypes ?? []; + + let assigned = 0; + let unassigned = 0; + let manual = 0; + + for (const account of client.accountList) { + if (getAssignment(account.id)?.manual === 1) { + manual++; + continue; + } + + const fundTxns = client.fundTransactions?.[account.id] ?? []; + const cached = loadAccountMeta(account.id, INFERRED_SIZE_KEY); + + let matched: { type: CatalogAccountType | null; ambiguous: boolean } = { type: null, ambiguous: false }; + let matchedSize: number | null = null; + + if (cached !== null && Number.isFinite(Number(cached))) { + matchedSize = Number(cached); + matched = matchAccountType(account.name, matchedSize, types); + } else { + for (const size of sizesToTry(fundTxns)) { + const attempt = matchAccountType(account.name, size, types); + if (attempt.type) { + matched = attempt; + matchedSize = size; + break; + } + } + } + + if (matched.type) { + if (matchedSize !== null && cached === null) { + saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize)); + } + setAutoAssignment(account.id, firmId, matched.type.id); + assigned++; + } else { + setAutoAssignment(account.id, firmId, null); + unassigned++; + } + } + + return { assigned, unassigned, manual }; +} + +export function setManualAssignment(accountId: number, firmId: string, accountTypeId: string | null): void { + setAssignment(accountId, firmId, accountTypeId, true); +} + +/** Drops the override so the auto-resolver takes the account back over. */ +export function clearManualAssignment(accountId: number): boolean { + return clearAssignment(accountId); +} + +export { listAssignments }; diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 0bc9535..13b5492 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -7,12 +7,12 @@ * signal but have since exited and are now eligible. */ -import { getFirms, isSymbolBanned, getInstruments, getBannedSymbols } from './db'; +import { getInstruments } from './db'; import { getClients } from './clients'; +import { getTradableFirms } from './firm-catalog'; +import { getAccountConfig } from './account-type-resolver'; import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic'; import { getSetting, setSetting } from './db'; -import type { FirmConfig, AccountConfig } from '@/types'; -import type { FirmWithAccounts } from './db'; // ── helpers ────────────────────────────────────────────────────────────────── @@ -20,34 +20,6 @@ function isAccountDead(amount: number, autoLiqThreshold: number): boolean { return autoLiqThreshold > 0 && amount <= autoLiqThreshold; } -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)); -} - -/** Map DB row (snake_case) → FirmConfig (camelCase) to fix field-name mismatch. */ -function mapFirmConfig(firm: FirmWithAccounts): FirmConfig { - return { - id: firm.id, - firm: firm.name, - username: firm.username, - password: firm.password, - bannedSymbols: getBannedSymbols(firm.id), - accounts: firm.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, - 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 []; } })(), - })), - }; -} @@ -116,7 +88,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) // Block if any account still has an open position (same gate as the scheduler tick) const allClients = getClients(); - const allFirms = getFirms(); + const allFirms = getTradableFirms(); const openPositions = allFirms.reduce((count, firm) => { const c = allClients.get(firm.id); if (!c) return count; @@ -142,7 +114,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`); const maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10); - const firms = getFirms(); + const firms = getTradableFirms(); const clients = getClients(); // ── Phase 1: collect ALL eligible accounts across ALL firms in parallel ── @@ -152,7 +124,6 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) client: any; acc: { id: number; name: string; active: boolean }; contract: { name: string; tickSize: number }; - firmConfig: FirmConfig; cash: { amount: number; realizedPnL: number }; dailyPnL: { date: string; pnl: number }[]; daysTraded: number; @@ -165,12 +136,11 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) if (!client || client.accountList.length === 0) return; // Skip this firm entirely if the symbol is banned for it - if (isSymbolBanned(firm.id, resolvedSymbol)) { + if (firm.bannedSymbols.includes(resolvedSymbol)) { console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`); return; } - const firmConfig = mapFirmConfig(firm); const contract = await client.findFrontMonthContract(resolvedSymbol); if (!contract) return; @@ -185,7 +155,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) if (!acc.active) continue; if (client.positions[acc.id]) continue; - const cfg = getAccountConfig(acc.name, firmConfig); + const cfg = getAccountConfig(acc.id).accountType; if (!cfg) continue; const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); @@ -211,7 +181,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) if (target.amount <= 0 && !isMnqExtraDay) continue; - allEligible.push({ firmName: firm.name, client, acc, contract, firmConfig, cash, dailyPnL, daysTraded }); + allEligible.push({ firmName: firm.name, client, acc, contract, cash, dailyPnL, daysTraded }); } })); @@ -227,8 +197,8 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) // ── Phase 2: fire the batch simultaneously ── const tradeResults = await Promise.allSettled(batch.map(async (item) => { - const { client, acc, contract, firmConfig, cash, dailyPnL, daysTraded } = item; - const cfg = getAccountConfig(acc.name, firmConfig)!; + const { client, acc, contract, cash, dailyPnL, daysTraded } = item; + const cfg = getAccountConfig(acc.id).accountType!; const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); const priorProfit = client.priorProfit?.[acc.id] ?? 0; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; @@ -340,7 +310,7 @@ export async function copyTrade() { return []; } - const firms = getFirms(); + const firms = getTradableFirms(); const clients = getClients(); const maxConcurrent = Math.max(1, parseInt(getSetting('max_concurrent_accounts') ?? '5', 10)); @@ -413,7 +383,6 @@ export async function copyTrade() { client: any; acc: { id: number; name: string; active: boolean }; contract: { name: string; tickSize: number }; - firmConfig: FirmConfig; cash: { amount: number; realizedPnL: number }; dailyPnL: { date: string; pnl: number }[]; daysTraded: number; @@ -426,12 +395,11 @@ export async function copyTrade() { console.log(`[copy-trade] ${firm.name}: no client or empty account list — skip firm`); return; } - if (isSymbolBanned(firm.id, resolvedSymbol!)) { + if (firm.bannedSymbols.includes(resolvedSymbol!)) { console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`); return; } - const firmConfig = mapFirmConfig(firm); const contract = await client.findFrontMonthContract(resolvedSymbol!); if (!contract) { console.log(`[copy-trade] ${firm.name}: cannot resolve front-month contract for ${resolvedSymbol} — skip firm`); @@ -444,7 +412,7 @@ export async function copyTrade() { const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; if (isAccountDead(cash.amount, autoLiqThreshold)) { console.log(`[copy-trade] ${acc.name}: dead — skip`); continue; } if (!acc.active) { console.log(`[copy-trade] ${acc.name}: inactive (DLL) — skip`); continue; } - const cfg = getAccountConfig(acc.name, firmConfig); + const cfg = getAccountConfig(acc.id).accountType; if (!cfg) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; } if (cash.realizedPnL !== 0) { console.log(`[copy-trade] ${acc.name}: already traded today (realizedPnL=${cash.realizedPnL}) — skip`); continue; } @@ -463,7 +431,7 @@ export async function copyTrade() { if (!target) { console.log(`[copy-trade] ${acc.name}: target=null (invalid balance) — skip`); continue; } if (target.amount <= 0) { console.log(`[copy-trade] ${acc.name}: target=$${target.amount} (challenge complete) — skip`); continue; } - eligible.push({ firmName: firm.name, client, acc, contract, firmConfig, cash, dailyPnL, daysTraded }); + eligible.push({ firmName: firm.name, client, acc, contract, cash, dailyPnL, daysTraded }); } })); @@ -477,8 +445,8 @@ export async function copyTrade() { // Fire orders (same as Phase 2 of runTrade) const tradeResults = await Promise.allSettled(batch.map(async (item) => { - const { client, acc, contract, firmConfig, cash, dailyPnL } = item; - const cfg = getAccountConfig(acc.name, firmConfig)!; + const { client, acc, contract, cash, dailyPnL } = item; + const cfg = getAccountConfig(acc.id).accountType!; const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); const priorProfit = client.priorProfit?.[acc.id] ?? 0; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; @@ -542,17 +510,16 @@ export async function copyTrade() { /** Returns true if any configured account could still trade today (not dead, not inactive, hasn't traded, target > 0 or extra-day, or has open position). */ function hasRemainingConfiguredAccounts(): boolean { - const firms = getFirms(); + const firms = getTradableFirms(); const clients = getClients(); for (const firm of firms) { const client = clients.get(firm.id); if (!client || client.accountList.length === 0) continue; - const firmConfig = mapFirmConfig(firm); for (const acc of client.accountList) { - const cfg = getAccountConfig(acc.name, firmConfig); + const cfg = getAccountConfig(acc.id).accountType; if (!cfg) continue; // no config = not our account // Account with open position = still in play @@ -636,7 +603,7 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, // Skip this tick until every client has completed its initial sync (positions are populated) const clients = getClients(); - const firms = getFirms(); + const firms = getTradableFirms(); const notReady = firms.filter(f => { const c = clients.get(f.id); return c && !c.syncComplete; diff --git a/lib/automations.ts b/lib/automations.ts index 7a19e9d..c179735 100644 --- a/lib/automations.ts +++ b/lib/automations.ts @@ -72,7 +72,11 @@ export type AutomationStep = // unrolled before the runner ever sees it — see resolveSteps. | { action: 'repeat'; times?: number; timesFrom?: string; steps: AutomationStep[]; label?: string }; -/** A number the user supplies on the dashboard before starting a run. */ +/** + * NOTE: the `FIRMS` array below is the AutoBuyer's list of prop-firm *websites* + * to click through. It is unrelated to the firm catalog in lib/firm-catalog.ts, + * which is the Tradovate side. Linking the two is future work. + * A number the user supplies on the dashboard before starting a run. */ export interface AutomationInput { id: string; label: string; @@ -141,6 +145,9 @@ export interface Firm { automations: Automation[]; } +// The AutoBuyer's list of prop-firm *websites* to click through. Unrelated to +// the firm catalog in lib/firm-catalog.ts, which is the Tradovate side — the +// names overlap but nothing joins them. Linking the two is future work. export const FIRMS: Firm[] = [ { id: 'tradeify', diff --git a/lib/clients.ts b/lib/clients.ts index f8bc4ed..8804fc9 100644 Binary files a/lib/clients.ts and b/lib/clients.ts differ diff --git a/lib/db.ts b/lib/db.ts index ffd6496..3543a85 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -6,167 +6,138 @@ const db = new Database(path.join(process.cwd(), 'autotrader.sqlite')); db.pragma('foreign_keys = ON'); db.exec(` - CREATE TABLE IF NOT EXISTS firms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - username TEXT NOT NULL, - password TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS account_configs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE, - prefix TEXT NOT NULL, - profit_target REAL NOT NULL DEFAULT 3000, - consistency REAL NOT NULL DEFAULT 0.5, - min_day_pnl REAL NOT NULL DEFAULT -999, - min_trading_days INTEGER NOT NULL DEFAULT 5 - ); - CREATE TABLE IF NOT EXISTS instruments ( symbol TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1 ); `); -// Migration: add account_size column if it doesn't exist yet -try { - db.exec('ALTER TABLE account_configs ADD COLUMN account_size REAL NOT NULL DEFAULT 50000'); -} catch { - // Column already exists -} +// ── Machine-owned firm state ───────────────────────────────────────────────── +// +// Firms and account types themselves come from the master catalog (see +// lib/firm-catalog.ts) and are cached as one JSON blob in `settings`. These two +// tables hold the parts that are genuinely per-machine and must survive the +// catalog changing underneath them. +// +// Both reference firm ids as plain TEXT with NO foreign key on purpose. This +// file sets `foreign_keys = ON`, so an ON DELETE CASCADE here would wipe the +// Tradovate credentials the moment a firm was removed on the master — and +// mirroring master's deletions is routine, not exceptional. They must be +// orphan-tolerant: a firm that disappears and comes back finds its credentials +// and assignments still here. +db.exec(` + CREATE TABLE IF NOT EXISTS firm_credentials ( + firm_id TEXT PRIMARY KEY, + firm_name TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + updated_at TEXT NOT NULL + ); -// Migration: add max_loss column (0 = no max loss limit) -try { - db.exec('ALTER TABLE account_configs ADD COLUMN max_loss REAL NOT NULL DEFAULT 0'); -} catch { - // Column already exists -} + CREATE TABLE IF NOT EXISTS account_assignments ( + account_id INTEGER PRIMARY KEY, + firm_id TEXT NOT NULL, + account_type_id TEXT, + manual INTEGER NOT NULL DEFAULT 0, + resolved_at TEXT NOT NULL + ); +`); -// Migration: add max_position_size column (0 = no limit) -try { - db.exec('ALTER TABLE account_configs ADD COLUMN max_position_size INTEGER NOT NULL DEFAULT 0'); -} catch { - // Column already exists -} +// The legacy `firms`, `account_configs` and `firm_banned_symbols` tables are +// dead: firms now come from the master catalog. They are left on disk rather +// than dropped because `firms` is the only table that ever held plaintext +// Tradovate credentials, and a silent DROP would be unrecoverable if this +// change had to be rolled back. Safe to drop by hand once it has stuck. -// Migration: add target_same_equity flag -try { - db.exec('ALTER TABLE account_configs ADD COLUMN target_same_equity INTEGER NOT NULL DEFAULT 1'); -} catch { - // Column already exists -} +// ── Firm credentials ───────────────────────────────────────────────────────── -// Migration: add withdrawal_stages JSON array -try { - db.exec("ALTER TABLE account_configs ADD COLUMN withdrawal_stages TEXT NOT NULL DEFAULT '[]'"); -} catch { - // Column already exists -} - - - -// ── Interfaces ───────────────────────────────────────────────────────────── - -export interface AccountConfigRow { - id: number; - firm_id: number; - prefix: string; - profit_target: number; - consistency: number; - min_day_pnl: number; - min_trading_days: number; - account_size: number; - max_loss: number; - max_position_size: number; - target_same_equity: number; // 0 | 1 - withdrawal_stages: string; // JSON { profit: number; consistency: number; minTradingDays: number }[] -} - -export interface FirmRow { - id: number; - name: string; +export interface FirmCredentialRow { + firm_id: string; + firm_name: string; username: string; password: string; + updated_at: string; } -export interface FirmWithAccounts extends FirmRow { - accounts: AccountConfigRow[]; +export function getCredentials(firmId: string): { username: string; password: string } | undefined { + const row = db.prepare('SELECT username, password FROM firm_credentials WHERE firm_id = ?').get(firmId) as + { username: string; password: string } | undefined; + return row; } -// ── Firms ─────────────────────────────────────────────────────────────────── - -export function getFirms(): FirmWithAccounts[] { - const firms = db.prepare('SELECT * FROM firms ORDER BY id').all() as FirmRow[]; - const getAccounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id'); - return firms.map((firm) => ({ - ...firm, - accounts: getAccounts.all(firm.id) as AccountConfigRow[], - })); +export function listCredentials(): FirmCredentialRow[] { + return db.prepare('SELECT * FROM firm_credentials').all() as FirmCredentialRow[]; } -export function getFirmById(id: number): FirmWithAccounts | undefined { - const firm = db.prepare('SELECT * FROM firms WHERE id = ?').get(id) as FirmRow | undefined; - if (!firm) return undefined; - const accounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id').all(id) as AccountConfigRow[]; - return { ...firm, accounts }; +/** firm_name is stored alongside the id so credentials can be re-keyed by name + * if the master's database is ever rebuilt and the firms come back with new + * ids. See rekeyCredentialsByName in lib/firm-catalog.ts. */ +export function setCredentials(firmId: string, firmName: string, username: string, password: string): void { + db.prepare(` + INSERT INTO firm_credentials (firm_id, firm_name, username, password, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(firm_id) DO UPDATE SET + firm_name = excluded.firm_name, + username = excluded.username, + password = excluded.password, + updated_at = excluded.updated_at + `).run(firmId, firmName, username, password, new Date().toISOString()); } -export function createFirm(name: string, username: string, password: string): FirmRow { - const stmt = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)'); - const result = stmt.run(name, username, password); - return db.prepare('SELECT * FROM firms WHERE id = ?').get(result.lastInsertRowid) as FirmRow; +export function clearCredentials(firmId: string): boolean { + return db.prepare('DELETE FROM firm_credentials WHERE firm_id = ?').run(firmId).changes > 0; } -export function deleteFirm(id: number): boolean { - const result = db.prepare('DELETE FROM firms WHERE id = ?').run(id); - return result.changes > 0; +export function rekeyCredentials(fromFirmId: string, toFirmId: string): void { + db.prepare('UPDATE firm_credentials SET firm_id = ? WHERE firm_id = ?').run(toFirmId, fromFirmId); + db.prepare('UPDATE account_assignments SET firm_id = ? WHERE firm_id = ?').run(toFirmId, fromFirmId); } -// ── Account Configs ───────────────────────────────────────────────────────── +// ── Account type assignments ───────────────────────────────────────────────── -export function createAccountConfig(firmId: number, data: { - 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 }[]; -}): AccountConfigRow { - const stmt = db.prepare( - 'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size, max_loss, max_position_size, target_same_equity, withdrawal_stages) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ); - const result = stmt.run(firmId, data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? [])); - return db.prepare('SELECT * FROM account_configs WHERE id = ?').get(result.lastInsertRowid) as AccountConfigRow; +export interface AssignmentRow { + account_id: number; + firm_id: string; + account_type_id: string | null; + manual: number; // 0 | 1 + resolved_at: string; } -export function deleteAccountConfig(id: number): boolean { - const result = db.prepare('DELETE FROM account_configs WHERE id = ?').run(id); - return result.changes > 0; +export function getAssignment(accountId: number): AssignmentRow | undefined { + return db.prepare('SELECT * FROM account_assignments WHERE account_id = ?').get(accountId) as AssignmentRow | undefined; } -export function updateAccountConfig(id: number, data: { - 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 }[]; -}): boolean { - const result = db.prepare(` - UPDATE account_configs - SET prefix = ?, profit_target = ?, consistency = ?, min_day_pnl = ?, min_trading_days = ?, account_size = ?, max_loss = ?, max_position_size = ?, target_same_equity = ?, withdrawal_stages = ? - WHERE id = ? - `).run(data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []), id); - return result.changes > 0; +export function listAssignments(firmId: string): AssignmentRow[] { + return db.prepare('SELECT * FROM account_assignments WHERE firm_id = ?').all(firmId) as AssignmentRow[]; +} + +export function setAssignment( + accountId: number, + firmId: string, + accountTypeId: string | null, + manual: boolean, +): void { + db.prepare(` + INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + firm_id = excluded.firm_id, + account_type_id = excluded.account_type_id, + manual = excluded.manual, + resolved_at = excluded.resolved_at + `).run(accountId, firmId, accountTypeId, manual ? 1 : 0, new Date().toISOString()); +} + +/** Writes an auto-resolved assignment, leaving any manual override alone. + * The `manual = 0` guard is what makes a user's pick stick across syncs. */ +export function setAutoAssignment(accountId: number, firmId: string, accountTypeId: string | null): void { + const existing = getAssignment(accountId); + if (existing?.manual === 1) return; + setAssignment(accountId, firmId, accountTypeId, false); +} + +export function clearAssignment(accountId: number): boolean { + return db.prepare('DELETE FROM account_assignments WHERE account_id = ?').run(accountId).changes > 0; } // ── Settings ───────────────────────────────────────────────────────────────── @@ -307,33 +278,6 @@ export function loadAccountMeta(accountId: number, key: string): string | null { return row?.value ?? null; } -// ── 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); - } -} - // ── AutoBuyer captures ─────────────────────────────────────────────────────── db.exec(` diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts new file mode 100644 index 0000000..5d74992 --- /dev/null +++ b/lib/firm-catalog.ts @@ -0,0 +1,306 @@ +/** + * The firm catalog — firms and their account types, owned by the master + * dashboard and pulled from its public GET /api/firms/catalog. + * + * This machine holds a cache of the last complete, valid response. That cache + * is the whole point: lib/auto-trade.ts needs every account's profit target and + * size on each 60s tick, so if those numbers only ever came over the network, a + * master reboot or a flaky link would stop trading with positions open. The + * cache is written only by a successful sync and never edited locally, so it + * cannot drift the way the old per-machine account_configs did. + * + * Stored as one JSON blob in `settings` rather than mirrored into tables: the + * catalog is only ever read whole, or asked "give me firm X's account types". + * + * (Unrelated to the `FIRMS` array in lib/automations.ts, which describes + * prop-firm websites the AutoBuyer clicks through. Different notion of "firm"; + * the two are not linked yet.) + */ + +import type { CatalogAccountType, CatalogFirm, FirmCatalogPayload } from './master-protocol'; +import { + getSetting, + setSetting, + listCredentials, + getCredentials, + rekeyCredentials, +} from './db'; + +const CATALOG_PATH = '/api/firms/catalog'; +const POLL_INTERVAL_MS = 60_000; +const FETCH_TIMEOUT_MS = 10_000; + +// Until the very first successful sync there is nothing to trade from, so back +// off quickly rather than sitting on the full poll interval. +const COLD_RETRY_MIN_MS = 10_000; +const COLD_RETRY_MAX_MS = 60_000; + +interface CatalogCache { + payload: FirmCatalogPayload | null; + /** Index over every firm's account types, for the id lookup. */ + typesById: Map; +} + +const g = global as typeof globalThis & { + __firmCatalog?: CatalogCache; + __firmCatalogTimer?: ReturnType; + __firmCatalogColdDelay?: number; +}; + +function indexPayload(payload: FirmCatalogPayload | null): CatalogCache { + const typesById = new Map(); + for (const firm of payload?.firms ?? []) { + for (const type of firm.accountTypes) typesById.set(type.id, type); + } + return { payload, typesById }; +} + +function cache(): CatalogCache { + if (g.__firmCatalog) return g.__firmCatalog; + + let payload: FirmCatalogPayload | null = null; + const raw = getSetting('catalog_payload'); + if (raw) { + try { + payload = validatePayload(JSON.parse(raw)); + } catch { + console.error('[catalog] cached payload is unreadable — ignoring it'); + } + } + g.__firmCatalog = indexPayload(payload); + return g.__firmCatalog; +} + +export function getCachedCatalog(): CatalogFirm[] { + return cache().payload?.firms ?? []; +} + +export function getCachedFirm(firmId: string): CatalogFirm | undefined { + return getCachedCatalog().find((f) => f.id === firmId); +} + +export function getAccountTypeById(typeId: string): CatalogAccountType | undefined { + return cache().typesById.get(typeId); +} + +export function getCatalogMeta(): { + version: number; + updatedAt: string | null; + lastSyncAt: string | null; + lastError: string | null; + firmCount: number; +} { + return { + version: Number(getSetting('catalog_version') ?? 0), + updatedAt: getSetting('catalog_updated_at'), + lastSyncAt: getSetting('catalog_last_sync_at'), + lastError: getSetting('catalog_last_error'), + firmCount: getCachedCatalog().length, + }; +} + +/** Firms this machine can actually trade: in the catalog AND holding + * credentials. Everything that starts a Tradovate client goes through here. */ +export function getTradableFirms(): (CatalogFirm & { username: string; password: string })[] { + const result: (CatalogFirm & { username: string; password: string })[] = []; + for (const firm of getCachedCatalog()) { + const creds = getCredentials(firm.id); + if (creds) result.push({ ...firm, ...creds }); + } + return result; +} + +// ── Sync ───────────────────────────────────────────────────────────────────── + +function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] { + return Array.isArray(value) && value.every((s) => + typeof s === 'object' && s !== null + && typeof (s as Record).profit === 'number' + && typeof (s as Record).consistency === 'number' + && typeof (s as Record).minTradingDays === 'number'); +} + +/** Rejects anything that isn't a complete catalog. A half-parsed payload would + * silently unassign accounts, so it is better to keep the old cache. */ +function validatePayload(raw: unknown): FirmCatalogPayload { + if (typeof raw !== 'object' || raw === null) throw new Error('payload is not an object'); + const body = raw as Record; + if (typeof body.version !== 'number') throw new Error('missing version'); + if (typeof body.updatedAt !== 'string') throw new Error('missing updatedAt'); + if (!Array.isArray(body.firms)) throw new Error('missing firms'); + + const firms: CatalogFirm[] = body.firms.map((f: unknown, i: number) => { + if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`); + const firm = f as Record; + if (typeof firm.id !== 'string' || typeof firm.name !== 'string') { + throw new Error(`firm ${i} is missing id or name`); + } + if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`); + + const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => { + if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`); + const type = t as Record; + for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) { + if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`); + } + for (const key of [ + 'accountSize', 'profitTarget', 'consistency', 'minDayPnL', + 'minTradingDays', 'maxLoss', 'maxPositionSize', + ]) { + if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`); + } + if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`); + return type as unknown as CatalogAccountType; + }); + + return { + id: firm.id, + name: firm.name, + bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [], + accountTypes, + updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(), + }; + }); + + return { version: body.version, updatedAt: body.updatedAt, firms }; +} + +function catalogUrl(): URL | null { + const base = getSetting('master_dashboard_url'); + if (!base) return null; + try { + // new URL, never string concatenation: the stored value routinely has a + // trailing slash, which would produce //api/firms/catalog. + return new URL(CATALOG_PATH, base); + } catch { + return null; + } +} + +function recordError(message: string): void { + setSetting('catalog_last_error', message); + setSetting('catalog_last_sync_at', new Date().toISOString()); +} + +/** If the master's database was rebuilt, its firms come back with fresh ids and + * every credential row orphans. Match the survivors up by name (once) rather + * than making someone retype every password. */ +function rekeyCredentialsByName(firms: CatalogFirm[]): void { + const byName = new Map(firms.map((f) => [f.name.toLowerCase(), f.id])); + const knownIds = new Set(firms.map((f) => f.id)); + + for (const row of listCredentials()) { + if (knownIds.has(row.firm_id)) continue; + const match = byName.get(row.firm_name.toLowerCase()); + if (!match) continue; + console.warn(`[catalog] re-keying credentials for "${row.firm_name}": ${row.firm_id} -> ${match}`); + rekeyCredentials(row.firm_id, match); + } +} + +export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; version: number; error?: string }> { + const version = Number(getSetting('catalog_version') ?? 0); + const url = catalogUrl(); + if (!url) { + const error = 'master_dashboard_url is not set'; + recordError(error); + return { ok: false, changed: false, version, error }; + } + + // Only the URL is required — deliberately not instance_name, unlike the + // reporter. A freshly imaged machine has to be able to pull its catalog + // before anyone has got round to naming it. + const hasCache = getCachedCatalog().length > 0; + if (hasCache) url.searchParams.set('since', String(version)); + + try { + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) throw new Error(`master returned ${res.status}`); + const body = await res.json(); + + if (body && typeof body === 'object' && (body as Record).unchanged === true) { + setSetting('catalog_last_sync_at', new Date().toISOString()); + setSetting('catalog_last_error', ''); + return { ok: true, changed: false, version }; + } + + const payload = validatePayload(body); + + // A master pointed at the wrong FIRMS_DB, or restored from an empty + // one, serves {"firms":[]} as a perfectly valid 200. Mirroring that + // would stop trading on every machine at once, so keep what we have. + if (payload.firms.length === 0 && hasCache) { + const error = 'master returned an empty catalog — ignored'; + console.error(`[catalog] ${error}`); + recordError(error); + return { ok: false, changed: false, version, error }; + } + + // Same reasoning one level down: a firm losing every account type + // unassigns all of its accounts. Keep the previous types and flag it. + const previous = new Map(getCachedCatalog().map((f) => [f.id, f])); + const suspicious: string[] = []; + for (const firm of payload.firms) { + const before = previous.get(firm.id); + if (firm.accountTypes.length === 0 && before && before.accountTypes.length > 0) { + firm.accountTypes = before.accountTypes; + suspicious.push(firm.name); + } + } + + setSetting('catalog_payload', JSON.stringify(payload)); + setSetting('catalog_version', String(payload.version)); + setSetting('catalog_updated_at', payload.updatedAt); + setSetting('catalog_last_sync_at', new Date().toISOString()); + setSetting('catalog_last_error', suspicious.length > 0 + ? `kept cached account types for ${suspicious.join(', ')} — master sent none` + : ''); + g.__firmCatalog = indexPayload(payload); + + rekeyCredentialsByName(payload.firms); + + console.log(`[catalog] synced v${payload.version} — ${payload.firms.length} firm(s)`); + return { ok: true, changed: true, version: payload.version }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error('[catalog] sync failed:', error); + recordError(error); + return { ok: false, changed: false, version, error }; + } +} + +export function startCatalogSync(onChange?: () => void): void { + stopCatalogSync(); + + const tick = async () => { + const result = await syncCatalog(); + if (result.changed) { + try { + onChange?.(); + } catch (err) { + console.error('[catalog] onChange handler failed', err); + } + } + + let delay = POLL_INTERVAL_MS; + if (getCachedCatalog().length === 0) { + // Never synced: retry hard, with backoff, until something lands. + const current = g.__firmCatalogColdDelay ?? COLD_RETRY_MIN_MS; + delay = current; + g.__firmCatalogColdDelay = Math.min(current * 2, COLD_RETRY_MAX_MS); + } else { + g.__firmCatalogColdDelay = COLD_RETRY_MIN_MS; + // Jitter so a fleet restarting together doesn't poll in lockstep. + delay = POLL_INTERVAL_MS + Math.floor(Math.random() * 10_000); + } + + g.__firmCatalogTimer = setTimeout(() => void tick(), delay); + }; + + void tick(); +} + +export function stopCatalogSync(): void { + if (g.__firmCatalogTimer) clearTimeout(g.__firmCatalogTimer); + g.__firmCatalogTimer = undefined; +} diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 9613c7b..07e6ce7 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -7,11 +7,23 @@ * running different versions of this file at the same time. */ +export interface AccountTypeStats { + accountTypeId: string | null; + label: string; + accountSize: number; + totalAccounts: number; + accountsTraded: number; + inTrade: number; +} + export interface FirmStats { firm: string; totalAccounts: number; accountsTraded: number; inTrade: number; + /** Per account type, summing to the firm's own totals. Optional so a master + * on an older build simply ignores it. */ + accountTypes?: AccountTypeStats[]; } export interface SchedulerState { @@ -36,6 +48,53 @@ export interface InstanceCapabilities { runnerOnline: boolean; } +// ── Firm catalog ───────────────────────────────────────────────────────────── +// Served by the master's public GET /api/firms/catalog and cached locally by +// lib/firm-catalog.ts. Credentials are per-machine and absent from all of it. + +/** One account type as published by the master. + * + * The field names match `AccountConfig` in types.ts on purpose — that is what + * lets the trading code consume a catalog account type wherever it used to + * read a local account_configs row. */ +export interface CatalogAccountType { + id: string; + firmId: string; + label: string; + prefix: string; + accountSize: number; + profitTarget: number; + consistency: number; + minDayPnL: number; + minTradingDays: number; + maxLoss: number; + maxPositionSize: number; + targetSameEquity: boolean; + withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[]; + /** ISO. Ties between two matching account types are broken by this. */ + updatedAt: string; +} + +export interface CatalogFirm { + id: string; + name: string; + bannedSymbols: string[]; + accountTypes: CatalogAccountType[]; + updatedAt: string; +} + +export interface FirmCatalogPayload { + version: number; + updatedAt: string; + firms: CatalogFirm[]; +} + +export interface FirmCatalogUnchanged { + version: number; + updatedAt: string; + unchanged: true; +} + export type Command = | { kind: 'trade'; action: 'Buy' | 'Sell' | 'Auto'; symbol: string; stopAfterAll?: boolean } | { kind: 'stop-trading' } diff --git a/lib/reporter.ts b/lib/reporter.ts index 4a94169..0553cf8 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -13,63 +13,96 @@ import { getSetting } from './db'; import { pushReport } from './master-link'; -import { getFirms } from './db'; import { getClients } from './clients'; +import { getTradableFirms } from './firm-catalog'; +import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; import { computeDailyTarget } from './trading-logic'; -import type { AccountConfigRow } from './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)); -} +import type { AccountTypeStats } from './master-protocol'; function collectFirmStats() { - const firms = getFirms(); + // Only firms this machine has credentials for. A firm it cannot log into + // has nothing to say about, and reporting it as zero accounts would fill + // the master's table with rows for firms it does not run. + const firms = getTradableFirms(); const clients = getClients(); return firms.map((f) => { const client = clients.get(f.id); if (!client || client.accountList.length === 0) { - return { firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0 }; + return { firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0, accountTypes: [] }; } let totalAccounts = 0; let accountsTraded = 0; let inTrade = 0; + // Per account type, so the master can say what the accounts still to + // trade actually are. Keyed by account type id; unassigned accounts + // share the null bucket — they inflate "left to trade" indefinitely, + // which is precisely the thing worth being able to see. + const byType = new Map(); + const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => { + const key = cfg?.id ?? null; + let entry = byType.get(key); + if (!entry) { + entry = { + accountTypeId: key, + label: cfg?.label ?? 'Unassigned', + accountSize: cfg?.accountSize ?? 0, + totalAccounts: 0, + accountsTraded: 0, + inTrade: 0, + }; + byType.set(key, entry); + } + return entry; + }; + for (const acc of client.accountList) { const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold; if (isDead) continue; + const cfg = getAccountConfig(acc.id).accountType; + const slice = bucket(cfg); + totalAccounts++; + slice.totalAccounts++; if (client.positions[acc.id]) { inTrade++; accountsTraded++; + slice.inTrade++; + slice.accountsTraded++; continue; } // Check if target was hit today - const cfg = getAccountConfig(acc.name, f.accounts); if (cfg) { const dailyPnL = client.dailyPnL[acc.id] ?? []; - const equityProfit = cash.amount - cfg.account_size; - const target = computeDailyTarget(cfg.profit_target, cfg.consistency, dailyPnL, cfg.min_day_pnl, cfg.min_trading_days, equityProfit); + const equityProfit = cash.amount - cfg.accountSize; + const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit); const targetHit = !target ? false : - ((target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.min_trading_days) || + ((target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.minTradingDays) || (cash.realizedPnL >= target.amount)); if (targetHit || cash.realizedPnL !== 0) { accountsTraded++; + slice.accountsTraded++; } } } - return { firm: f.name, totalAccounts, accountsTraded, inTrade }; + // Biggest shortfall first — the account types with the most left to do + // are the ones worth reading, and Unassigned sinks to the bottom only + // when it is genuinely small. + const accountTypes = [...byType.values()].sort((a, b) => + (b.totalAccounts - b.accountsTraded) - (a.totalAccounts - a.accountsTraded) + || a.label.localeCompare(b.label)); + + return { firm: f.name, totalAccounts, accountsTraded, inTrade, accountTypes }; }); } @@ -93,7 +126,7 @@ export async function sendReport(): Promise { if (pushReport(firms, scheduler)) return; - await fetch(`${url}/api/report`, { + await fetch(new URL('/api/report', url), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instance: instanceName, firms, scheduler }), diff --git a/types.ts b/types.ts index eebe10c..d9c1afa 100644 --- a/types.ts +++ b/types.ts @@ -1,4 +1,7 @@ export interface AccountConfig { + /** Master's account type id — what a manual assignment pins to. */ + id: string; + label: string; prefix: string; profitTarget: number; consistency: number; @@ -12,12 +15,19 @@ export interface AccountConfig { } export interface FirmConfig { - id: number; + /** Master's firm id (uuid). */ + id: string; firm: string; + /** Blank when this machine has no credentials for the firm. */ username: string; - password: string; + /** True once a Tradovate username and password are stored locally. Firms + * without them are shown greyed out with an inline credential form. */ + hasCredentials: boolean; bannedSymbols: string[]; + /** The firm's account types, straight from the catalog. Doubles as the + * option list for a manual account-type override. */ accounts: AccountConfig[]; + catalogUpdatedAt: string; } export interface AccountState { @@ -46,9 +56,22 @@ export interface AccountState { fullDailyPnL: { date: string; pnl: number }[]; /** Fund transactions (deposits/withdrawals) — used by the calendar and equity curve. */ fundTransactions: { date: string; amount: number }[]; + /** Master account type this account resolved to. null = Unassigned. */ + accountTypeId: string | null; + accountTypeLabel: string | null; + assignmentSource: 'manual' | 'auto' | 'unassigned'; + /** The resolved config inlined, so client components need no second lookup. */ + accountType: AccountConfig | null; + /** Size from the initial funding transaction; null until reports return. */ + inferredAccountSize: number | null; + /** True when more than one account type matched and updatedAt decided it. */ + accountTypeAmbiguous: boolean; } export interface FirmState { + /** Master's firm id — join state to config on this, not on the name, which + * changes the moment someone renames a firm on the master. */ + firmId: string; firm: string; connected: boolean; accounts: AccountState[];