From 8bdc85080c84b6ed6eadf7e41d41680ab18c1374 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Mon, 31 Aug 2026 22:17:23 -0500 Subject: [PATCH] Take firms from the master instead of defining them per machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This box no longer decides what firms exist. It caches the master's catalog and stores one thing of its own: the Tradovate login for each firm. Creating firms locally is gone — app/add, POST /api/firms, the account-config routes and the banned-symbol route with it. The cache is the point, not an optimisation. The scheduler needs every account's target and size on each 60s tick, so fetching them per tick would stop trading with positions open the first time the master rebooted. It is one JSON blob in settings, replaced only by a complete valid response, and a response with zero firms is refused outright: a misconfigured FIRMS_DB serves {"firms":[]} as a healthy 200, and mirroring that would idle the whole fleet at once. Accounts bind to an account type by prefix AND size, the size taken from the opening funding transaction. That does not arrive with the first sync, so an account sits Unassigned and the resolver tries again next sync rather than guessing. A type picked by hand is never overwritten by an auto pass. This replaces the longest-prefix helper that had been copy-pasted into five files, which could not tell two types with the same prefix apart at all. firm_credentials and account_assignments deliberately have no foreign key to the cached catalog. foreign_keys is ON, so a cascade would erase the Tradovate passwords the moment a firm was removed on the master — routine, not exceptional. They survive it, and are re-keyed by name if the master's database is ever rebuilt with fresh ids. The legacy firms/account_configs/firm_banned_symbols tables are left on disk, unreferenced. firms is the only table that ever held plaintext credentials and dropping it would make a rollback unrecoverable. Co-Authored-By: Claude Opus 5 --- README.md | 20 +- app/accounts/[id]/page.tsx | 92 ++- app/add/page.tsx | 83 --- app/api/account-configs/[id]/route.ts | 87 --- app/api/accounts/[id]/account-type/route.ts | 68 +++ app/api/debug/route.ts | 6 +- app/api/firm-catalog/route.ts | 31 + app/api/firms/[id]/accounts/route.ts | 76 --- app/api/firms/[id]/banned-symbols/route.ts | 25 - app/api/firms/[id]/credentials/route.ts | 40 ++ app/api/firms/[id]/debug-contracts/route.ts | 3 +- app/api/firms/[id]/orders/route.ts | 4 +- app/api/firms/[id]/route.ts | 61 +- app/api/firms/route.ts | 88 ++- app/api/state/route.ts | 64 +- app/firms/[id]/settings/page.tsx | 637 +++----------------- app/page.tsx | 375 +++++++++--- app/settings/page.tsx | 49 ++ lib/account-type-resolver.ts | 192 ++++++ lib/auto-trade.ts | 71 +-- lib/automations.ts | 9 +- lib/clients.ts | Bin 4584 -> 7672 bytes lib/db.ts | 262 ++++---- lib/firm-catalog.ts | 306 ++++++++++ lib/master-protocol.ts | 59 ++ lib/reporter.ts | 65 +- types.ts | 27 +- 27 files changed, 1489 insertions(+), 1311 deletions(-) delete mode 100644 app/add/page.tsx delete mode 100644 app/api/account-configs/[id]/route.ts create mode 100644 app/api/accounts/[id]/account-type/route.ts create mode 100644 app/api/firm-catalog/route.ts delete mode 100644 app/api/firms/[id]/accounts/route.ts delete mode 100644 app/api/firms/[id]/banned-symbols/route.ts create mode 100644 app/api/firms/[id]/credentials/route.ts create mode 100644 lib/account-type-resolver.ts create mode 100644 lib/firm-catalog.ts 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 f8bc4ed48026615625fc29033a4b6be006d5c524..8804fc9f406fef03eb262caaaed52402c297e4e3 100644 GIT binary patch literal 7672 zcmd5>+j1Mn5zRBdqEn?30R~($56OdMT4h5~nHAGfN%RwoB@T86z=n$*cxD$A!l23@ z;V<-;0?$f6)C>Kj(8+xW&s|vGHjUHE}t{d9W)S3mIW*1F- zbDmeqxzpDtW!yB=&2{Y>+b*!hC3}j=&N3FSCY63y+JzgDYm{xqs!^4heO%XhvUQ3T z=efK}g;?!OwekYKbFQ4#yJxCAH*MXVH|wQ753B6~31d@3rF`qYd3bhkpTFz}&4$tz zch_|xeYyKiqsEt1F`lM`cZgUeUr7 zI%7fq)N7nHS^K?m#ZyP@4=RfhJE9PTy?;Cf+r_!SvB-jHgh1~!1qgVz>~4X37rw;b5+lyaFF0xs3oqPphtWvFGCcI$9JR*;$=piUq+-H zGwNVSSKlTuVqu7p42(N?xO?#&ueiAf$1HiZw`4QATd|43qvp!C=?As(+zOm%oPM# zQ?$9pGu(whOtnK|KqNb|ExtLuR2Dh0T4$Z423Hl|@kRM7xQUaGgB!zz-T?whq5_0Q zfshlU#qH5C-6>u$zCGLparPcg>3sHzJ>(C}U<)KeUBEBlw-G(;$@vRCbUWHQuG~=n z`=}SWP&I73A^G_*+LD&seuKbgTMo3fDPyHt;gzuf+(9*Hs>(_i43tLAdKsk`jM%j>DQ1A5&v# zutjdD71+y2sjbF!(#4DiA9F#|ZCj!+5)3}m#Tg+G40Jua2nb5B`1U+I#%1fr)%tU9J%PMP0x(S!nNr#wR~hH6mzL@O_)0`77tb0nO{kPrD9 zlyt@t$8a4d-G+;xhU+w4YcO%T)y)Q}Gl9OnMFrl4S0D0#H)-abj1?B~UdY zU^xB?|IHzcN)TQQG0>m|$QWUjsb?%uPVr@MyBvX_p&&dhg(O?RR18Ob?&hXJIT{K& z8{Q&=6i8Z-c5$)~O<*VofsB2f3)~!qXA*7VV?iJDDN{B=ffH-jpu`}RRZj}# zUV&v?a(zU2`)wHjI~YuTmYrsZc!azM84@9LKT?Qi5+87^tW?w^g7G8)CcdO64cz+h zMXK{+k{|)?z-)I}#%E@adJR?Y(-`q}q+ByY8f7L)Uvn6+DlyAE+zBtadn&A1E;$Pa zF5^>KJtTt?DTQl++IWXDUV@DxnX+8|+M&#SA{WCX8u=rjlec7gRk1i{G=;ib!{}gi zUX`V^?jJd=uHOKcl2zrLH`yFeCdN9WC=xwpm=5-XOkX??{2_DLo3HtE(&O z?g!#T=UCP)Vw^X|uOrq@JucB!5L+dv$W^7-9DyY10$P00+VNWb@sd0FB1bOa<8jC3 z=>^jBk`g$aH1a;{yb=D4z6gJ^A^`;(Zaa1(H%&c%s8M278g?dJx);kwyQqYUQx6~D z_Z`;8L?dzNe#M6nf8QeadKM2vJ<;%_2D`3(8A=+&@8gGiC)0x88eY#!@V_=&c!5B+ zFYXB;pK^B+_Ct~meRPfSAJ#^e<$aNnNOvg9_f zUB;)Jl#oQ2iFSp#vhnfsb5}X~M8#S?DX=y|jbW_%CdYRHLS!)}JnqK*%xAFMp~Quq zULCLZ=#FdnxKKjevE{D2>iV9!GQmjd`M8^Z?t3+Td!&fpJ}M!)bZ;z44wc2}x)`7P zC35e)P$4HQab0Zo>g7~wAOG>`_7kqExBUAxbH{5+V1%r&R{sAzy9sh<>lqB1#q1>bfW?t$vXCMTvRQ}qAq9iX8Xu6qkGG%JNY|?WwAWx z44EII-F#w{Eg)WoiQ6)Htqc~mCQc_z7FRhnQz#mod|2W#>e3YVyvW&+CsIBcygo$&0)^fZ=p91vd$&WLmg z$kV<`dgt3zwsBXa#vz$IJ<`1TyM=ZW(IGv+1^xcpulPq1n~dr{4&>$k=l5jHc`UyE zc=-(gyyKA&_!N0v!d-eFMQ8$1@FG=dOnDmfsB3RZw8-J%EkBF1Hu^8soQhGM3HH(5fF}InNbIH z7yCL0M{)$ecn(K*^Te|M2niGy%g><9ILLB?uni5Ycahl9Ys6Y*oE!!NCV{GPJG0MT zZEJk9d`177{2R?GbOV?%a)|pnIy($gPj`VtC7a;SZ*@JU`>E@RDA>8T}P`!zJJxfGyaat5Eq ({ - ...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[];