Take firms from the master instead of defining them per machine

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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 22:17:23 -05:00
co-authored by Claude Opus 5
parent 8b2e78d766
commit 8bdc85080c
27 changed files with 1489 additions and 1311 deletions
+15 -5
View File
@@ -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
---
+78 -14
View File
@@ -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 (
<span className="flex items-center gap-1.5">
<select
value={account.assignmentSource === 'manual' ? (account.accountTypeId ?? '') : ''}
disabled={saving}
onChange={(e) => assign(e.target.value)}
className="border border-slate-200 rounded-md px-1.5 py-1 text-xs bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400"
>
<option value="">
{account.accountTypeLabel ? `Auto — ${account.accountTypeLabel}` : 'Auto — unassigned'}
</option>
{firm.accounts.map((type) => (
<option key={type.id} value={type.id}>
{type.label} · {type.prefix} · {type.accountSize >= 1000 ? `${type.accountSize / 1000}k` : type.accountSize}
</option>
))}
</select>
{account.assignmentSource === 'manual' && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" title="Pinned by hand" />
)}
{account.accountTypeAmbiguous && (
<span className="text-amber-500 text-xs font-bold" title="Several account types matched — using the most recently updated one">*</span>
)}
{!account.accountTypeLabel && (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700">
Unassigned
</span>
)}
</span>
);
}
function fmt(value: number) {
@@ -189,9 +242,9 @@ export default function AccountPage() {
const [account, setAccount] = useState<AccountState | null>(null);
const [cfg, setCfg] = useState<AccountConfig | null>(null);
const [firmName, setFirmName] = useState('');
const [firm, setFirm] = useState<FirmConfig | null>(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 (
<div className="min-h-screen bg-slate-50 p-8">
@@ -339,6 +396,13 @@ export default function AccountPage() {
</Link>
<span className="text-slate-300">/</span>
<span className="text-slate-400 text-sm">{firmName}</span>
{firm && (
<AccountTypePicker
account={account}
firm={firm}
onAssigned={() => { void load(); }}
/>
)}
<span className="text-slate-300">/</span>
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
{isDead && (
-83
View File
@@ -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 (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<button onClick={() => router.push('/')} className="text-slate-400 hover:text-slate-600 text-sm transition-colors">
← Back
</button>
<h1 className="text-2xl font-bold text-slate-900">Add New Firm</h1>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-6 shadow-sm max-w-md space-y-4">
{error && (
<div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-3 py-2">
{error}
</div>
)}
<div>
<label className={labelCls}>Firm Name</label>
<input value={firmName} onChange={(e) => setFirmName(e.target.value)} className={inputCls} placeholder="e.g. MyFirm" />
</div>
<div>
<label className={labelCls}>Username</label>
<input value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} placeholder="Username" />
</div>
<div>
<label className={labelCls}>Password</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className={inputCls} placeholder="Password" />
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => router.push('/')} className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 transition-colors">
Cancel
</button>
<button
onClick={handleSave}
disabled={saving || !firmName.trim() || !username.trim() || !password.trim()}
className="px-4 py-2 bg-green-100 hover:bg-green-200 disabled:opacity-40 disabled:cursor-not-allowed text-green-700 text-sm font-semibold rounded-lg transition-colors"
>
{saving ? 'Saving...' : 'Add Firm'}
</button>
</div>
</div>
</div>
</div>
);
}
-87
View File
@@ -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 });
}
}
@@ -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 });
}
+3 -3
View File
@@ -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) => {
+31
View File
@@ -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() });
}
-76
View File
@@ -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 });
}
}
@@ -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 });
}
+40
View File
@@ -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 });
}
+1 -2
View File
@@ -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 });
+1 -3
View File
@@ -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;
+13 -48
View File
@@ -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 });
}
}
+37 -45
View File
@@ -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) => ({
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.profit_target,
profitTarget: a.profitTarget,
consistency: a.consistency,
minDayPnL: a.min_day_pnl,
minTradingDays: a.min_trading_days,
accountSize: a.account_size,
maxLoss: a.max_loss,
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 });
}
}
+39 -25
View File
@@ -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);
+76 -565
View File
@@ -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 (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4h6v2" />
</svg>
);
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<RowState[]>([]);
const [firm, setFirm] = useState<FirmConfig | null>(null);
const [masterUrl, setMasterUrl] = useState<string | null>(null);
const [loadError, setLoadError] = useState('');
// Instruments (globally enabled only) and per-firm bans
const [instruments, setInstruments] = useState<string[]>([]);
const [bannedSymbols, setBannedSymbols] = useState<Set<string>>(new Set());
useEffect(() => {
fetch(`/api/firms/${id}`)
.then((r) => {
if (!r.ok) throw new Error('Not found');
return r.json() as Promise<FirmConfig>;
})
.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<RowState>) => {
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 (
<div className="min-h-screen bg-slate-50 p-8">
@@ -231,11 +55,12 @@ export default function FirmSettingsPage() {
);
}
const firmsPageUrl = masterUrl ? new URL('/firms', masterUrl).toString() : null;
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => router.push('/')}
@@ -243,402 +68,88 @@ export default function FirmSettingsPage() {
>
← Back
</button>
<h1 className="text-2xl font-bold text-slate-900">{firmName || '…'}</h1>
<h1 className="text-2xl font-bold text-slate-900">{firm?.firm ?? '…'}</h1>
<span className="text-slate-300 text-2xl font-light">/</span>
<span className="text-2xl font-bold text-slate-400">Settings</span>
<span className="text-2xl font-bold text-slate-400">Account Types</span>
</div>
<div className="mb-4 rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow-sm">
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 && (
<>
{' '}
<a
href={firmsPageUrl}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Edit on master →
</a>
</>
)}
</div>
{/* Account Types */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Account Types
</h2>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Prefix</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Account Size</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Profit Target</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Consistency</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Day P&L</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Trading Days</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Max Contracts</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">After First W/D</th>
<th className="px-3 py-3 w-32" />
{['Label', 'Prefix', 'Account Size', 'Profit Target', 'Consistency', 'Min Day P&L', 'Min Trading Days', 'Max Contracts', 'After First W/D'].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 && firmName && (
{firm && firm.accounts.length === 0 && (
<tr>
<td colSpan={9} className="px-4 py-6 text-center text-sm text-slate-400 italic">
No account types yet
No account types — add them on the master dashboard
</td>
</tr>
)}
{rows.map((row) => (
<React.Fragment key={row.id}>
<tr className="border-b border-slate-100 last:border-0">
{/* Prefix */}
<td className="px-4 py-2.5">
<input
className={`${inputCls} font-mono w-32`}
value={row.prefix}
placeholder="e.g. MYACCT"
onChange={(e) => updateRow(row.id, { prefix: e.target.value })}
/>
{firm?.accounts.map((acc) => (
<tr key={acc.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2.5 font-medium text-slate-800">{acc.label}</td>
<td className="px-4 py-2.5 font-mono text-slate-700">{acc.prefix}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{fmtSize(acc.accountSize)}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{fmtMoney(acc.profitTarget)}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{Math.round(acc.consistency * 100)}%</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">
{acc.minDayPnL <= -999 ? '—' : fmtMoney(acc.minDayPnL)}
</td>
{/* Account Size */}
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<select
className={`${inputCls} w-24`}
value={row.useCustomSize ? 'custom' : row.accountSize}
onChange={(e) => {
if (e.target.value === 'custom') {
updateRow(row.id, { useCustomSize: true });
} else {
updateRow(row.id, {
accountSize: Number(e.target.value),
useCustomSize: false,
});
}
}}
>
{SIZE_PRESETS.map((s) => (
<option key={s} value={s}>{fmtSize(s)}</option>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{acc.minTradingDays}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">
{acc.maxPositionSize === 0 ? 'No limit' : acc.maxPositionSize}
</td>
<td className="px-4 py-2.5 text-slate-600">
{acc.targetSameEquity
? 'Same equity'
: acc.withdrawalStages.length === 0
? 'Base values'
: `${acc.withdrawalStages.length} stage${acc.withdrawalStages.length === 1 ? '' : 's'}`}
</td>
</tr>
))}
<option value="custom">Custom</option>
</select>
{row.useCustomSize && (
<input
type="number"
className={`${inputCls} w-28`}
value={row.accountSize || ''}
min={0}
step={1000}
placeholder="e.g. 35000"
onChange={(e) =>
updateRow(row.id, { accountSize: Number(e.target.value) })
}
/>
)}
</div>
</td>
{/* Profit Target */}
<td className="px-4 py-2.5">
<div className="relative flex items-center">
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
<input
type="number"
className={`${inputCls} pl-5 w-28`}
value={row.profitTarget}
min={0}
step={100}
onChange={(e) =>
updateRow(row.id, { profitTarget: Number(e.target.value) })
}
/>
</div>
</td>
{/* Consistency */}
<td className="px-4 py-2.5">
<div className="relative flex items-center">
<input
type="number"
className={`${inputCls} pr-6 w-24`}
value={Math.round(row.consistency * 100)}
min={0}
max={100}
step={1}
onChange={(e) =>
updateRow(row.id, { consistency: Number(e.target.value) / 100 })
}
/>
<span className="absolute right-2.5 text-slate-400 text-sm pointer-events-none">%</span>
</div>
</td>
{/* Min Day P&L */}
<td className="px-4 py-2.5">
<div className="relative flex items-center">
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
<input
type="number"
className={`${inputCls} pl-5 w-28`}
value={row.minDayPnL <= -999 ? '' : row.minDayPnL}
placeholder="None"
step={100}
onChange={(e) =>
updateRow(row.id, {
minDayPnL: e.target.value === '' ? -999 : Number(e.target.value),
})
}
/>
</div>
</td>
{/* Min Trading Days */}
<td className="px-4 py-2.5">
<input
type="number"
className={`${inputCls} w-20`}
value={row.minTradingDays}
min={0}
step={1}
onChange={(e) =>
updateRow(row.id, { minTradingDays: Number(e.target.value) })
}
/>
</td>
{/* Max Contracts */}
<td className="px-4 py-2.5">
<input
type="number"
className={`${inputCls} w-20`}
value={row.maxPositionSize === 0 ? '' : row.maxPositionSize}
placeholder="No limit"
min={0}
step={1}
onChange={(e) =>
updateRow(row.id, {
maxPositionSize: e.target.value === '' ? 0 : Number(e.target.value),
})
}
/>
</td>
{/* After Withdrawal */}
<td className="px-4 py-2.5">
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={row.targetSameEquity}
onChange={(e) =>
updateRow(row.id, { targetSameEquity: e.target.checked })
}
className="rounded border-slate-300 text-blue-500 focus:ring-blue-400"
/>
<span className="text-xs text-slate-600">Same equity</span>
</label>
</td>
{/* Actions */}
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-1.5">
{row.error && (
<span className="text-xs text-red-500 font-medium mr-1">{row.error}</span>
)}
{row.confirmDelete ? (
<>
<span className="text-xs text-slate-500 mr-0.5">Delete?</span>
<button
onClick={() => deleteRow(row)}
className="px-2 py-1 bg-red-100 hover:bg-red-200 text-red-700 text-xs font-semibold rounded-md transition-colors"
>
Yes
</button>
<button
onClick={() => cancelDeleteRow(row.id)}
className="px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md transition-colors"
>
No
</button>
</>
) : (
<>
<button
onClick={() => saveRow(row)}
disabled={row.saving || !row.dirty}
className={`px-3 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors ${!row.dirty ? 'invisible' : ''}`}
>
{row.saving ? '…' : 'Save'}
</button>
<button
onClick={() => confirmDeleteRow(row.id)}
className="p-1 text-slate-300 hover:text-red-400 hover:bg-red-50 rounded-md transition-colors"
title="Delete account type"
>
<TrashIcon />
</button>
</>
)}
</div>
</td>
</tr>
{/* 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 (
<tr key={`${row.id}-stage-${idx}`} className="bg-slate-50/60 border-b border-slate-100 last:border-0">
<td colSpan={9} className="pl-10 pr-4 py-1.5">
<div className="flex items-center gap-3">
<span className="text-xs text-slate-400 w-16 shrink-0">
Stage {idx + 2}{showPlus ? '+' : ''}
</span>
<div className="relative flex items-center">
<span className="absolute left-2 text-slate-400 text-xs pointer-events-none">$</span>
<input
type="number"
className="border border-slate-200 rounded px-1.5 py-1 pl-4 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-24"
value={stage.profit}
min={0}
step={100}
placeholder="Profit"
onChange={(e) => {
const next = row.withdrawalStages.map((s, i) =>
i === idx ? { ...s, profit: Number(e.target.value) } : s
);
updateRow(row.id, { withdrawalStages: next });
}}
/>
</div>
{row.consistency > 0 && (
<div className="relative flex items-center">
<input
type="number"
className="border border-slate-200 rounded px-1.5 py-1 pr-5 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-20"
value={Math.round(stage.consistency * 100)}
min={0}
max={100}
step={1}
placeholder="Cons%"
onChange={(e) => {
const next = row.withdrawalStages.map((s, i) =>
i === idx ? { ...s, consistency: Number(e.target.value) / 100 } : s
);
updateRow(row.id, { withdrawalStages: next });
}}
/>
<span className="absolute right-1.5 text-slate-400 text-xs pointer-events-none">%</span>
</div>
)}
<div className="flex items-center gap-1">
<input
type="number"
className="border border-slate-200 rounded px-1.5 py-1 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-12"
value={stage.minTradingDays}
min={0}
step={1}
placeholder="Days"
onChange={(e) => {
const next = row.withdrawalStages.map((s, i) =>
i === idx ? { ...s, minTradingDays: Number(e.target.value) } : s
);
updateRow(row.id, { withdrawalStages: next });
}}
/>
<span className="text-slate-400 text-[10px] shrink-0">days</span>
</div>
<button
onClick={() => {
const next = row.withdrawalStages.filter((_, i) => i !== idx);
updateRow(row.id, { withdrawalStages: next });
}}
className="text-slate-300 hover:text-red-400 text-sm leading-none"
title="Remove stage"
>
×
</button>
</div>
</td>
</tr>
);
})}
<tr className="bg-slate-50/60 border-b border-dashed border-slate-200">
<td colSpan={9} className="pl-10 pr-4 py-1">
<button
onClick={() =>
updateRow(row.id, {
withdrawalStages: [
...row.withdrawalStages,
{ profit: row.profitTarget, consistency: row.consistency, minTradingDays: row.minTradingDays },
],
})
}
className="text-xs text-blue-400 hover:text-blue-600"
>
+ Add stage
</button>
</td>
</tr>
</>
)}
</React.Fragment>
))}
{/* Add row */}
<tr className="border-t border-dashed border-slate-200">
<td colSpan={9} className="px-2 py-2">
<button
onClick={() => setRows((prev) => [...prev, newRow()])}
className="w-full py-1.5 text-sm text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-lg transition-colors"
>
+ Add account type
</button>
</td>
</tr>
</tbody>
</table>
</div>
{/* ── Instruments ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
Instruments
Banned Symbols
</h2>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
{instruments.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">Loading…</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
{!firm || firm.bannedSymbols.length === 0 ? (
<span className="text-sm text-slate-400 italic">None</span>
) : (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Banned</th>
</tr>
</thead>
<tbody>
{instruments.map((sym) => {
const banned = bannedSymbols.has(sym);
return (
<tr key={sym} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2.5 flex items-center gap-2">
<span className="font-mono font-semibold text-slate-800">{sym}</span>
{banned && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
{sym} BANNED
<div className="flex flex-wrap gap-1.5">
{firm.bannedSymbols.map((sym) => (
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-red-100 text-red-700">
{sym}
</span>
)}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => toggleBan(sym)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
banned ? 'bg-red-500' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
banned ? 'translate-x-4' : 'translate-x-1'
}`}
/>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
))}
</div>
)}
</div>
+276 -99
View File
@@ -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 (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7.5" cy="15.5" r="4.5" />
<path d="M10.7 12.3 21 2" />
<path d="m17 6 3 3" />
<line x1="2" y1="2" x2="22" y2="22" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg className={`chevron${open ? ' open' : ''}`} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
@@ -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 (
<div className="flex items-center gap-1.5">
<select
value={account.assignmentSource === 'manual' ? (account.accountTypeId ?? '') : ''}
disabled={saving}
onClick={(e) => e.stopPropagation()}
onChange={(e) => assign(e.target.value)}
className={`border border-slate-200 rounded-md px-1.5 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 max-w-[10rem] ${
account.accountTypeLabel ? 'text-slate-700' : 'text-slate-400'
}`}
>
<option value="">
{account.accountTypeLabel ? `Auto — ${account.accountTypeLabel}` : 'Auto — unassigned'}
</option>
{firm.accounts.map((type) => (
<option key={type.id} value={type.id}>
{type.label} · {type.prefix} · {type.accountSize >= 1000 ? `${type.accountSize / 1000}k` : type.accountSize}
</option>
))}
</select>
{account.assignmentSource === 'manual' && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" title="Pinned by hand" />
)}
{account.accountTypeAmbiguous && (
<span className="text-amber-500 text-xs font-bold shrink-0" title="Several account types matched — using the most recently updated one">*</span>
)}
{danglingPin && (
<span className="text-amber-600 text-xs shrink-0" title="The pinned account type no longer exists on the master — this account will not trade">!</span>
)}
{!account.accountTypeLabel && !danglingPin && (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 shrink-0">
Unassigned
</span>
)}
</div>
);
}
/** 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 (
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<input
className={cls}
placeholder="Tradovate username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
className={cls}
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
/>
<button
onClick={save}
disabled={saving || !username.trim() || !password.trim()}
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
>
{saving ? '…' : 'Save'}
</button>
{error && <span className="text-xs text-red-500">{error}</span>}
</div>
);
}
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
<td className={`px-4 py-3 tabular-nums ${pnlColor}`}>
{account.realizedPnL >= 0 ? '+' : ''}${fmt(account.realizedPnL)}
</td>
<td className="px-4 py-3">
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
</td>
<td className="px-4 py-3 text-slate-600 tabular-nums">
{account.daysTraded} / {cfg?.minTradingDays ?? '—'}
</td>
@@ -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 (
<>
<tr
className="border-t border-slate-200 bg-slate-50 hover:bg-slate-100 cursor-pointer select-none transition-colors"
onClick={() => 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); }}
>
<td className="px-4 py-3.5">
<div className="flex items-center gap-2.5">
{deleteMode && (
<input
type="checkbox"
checked={selected}
onChange={onToggle}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 accent-red-500 cursor-pointer flex-shrink-0"
/>
)}
<span className="font-bold text-sm text-slate-800">{state.firm}</span>
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
{firm.bannedSymbols?.includes(tradeSymbol) && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
{tradeSymbol} BANNED
</span>
)}
{hasCredentials && !state.connected && (
<span className="text-xs bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded font-semibold">
Connecting…
</span>
)}
{hasCredentials && state.connected && state.accounts.length === 0 && (
<span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded font-semibold">
No accounts
</span>
)}
</div>
</td>
<td /><td /><td /><td />
{!hasCredentials ? (
<td colSpan={5} className="px-4 py-2.5">
<CredentialForm firm={firm} onSaved={onChanged} />
</td>
) : confirmCreds ? (
<td colSpan={5} className="px-4 py-2.5">
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-slate-500">
Remove the saved Tradovate login for {firm.firm}? Its accounts disappear until you enter one again.
</span>
<button
onClick={removeCredentials}
disabled={removingCreds}
className="px-2.5 py-1 bg-red-100 hover:bg-red-200 disabled:opacity-50 text-red-700 text-xs font-semibold rounded-md transition-colors"
>
{removingCreds ? '…' : 'Remove'}
</button>
<button
onClick={() => setConfirmCreds(false)}
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md transition-colors"
>
Cancel
</button>
</div>
</td>
) : (
<><td /><td /><td /><td /><td /></>
)}
<td className="px-4 py-3.5">
<div className="flex items-center justify-end gap-1">
{hasCredentials && !confirmCreds && (
<button
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
title="Remove saved Tradovate login"
>
<KeyOffIcon />
</button>
)}
<Link
href={`/firms/${firm.id}/settings`}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
onClick={(e) => e.stopPropagation()}
title="Settings"
title="Account types"
>
<SettingsIcon />
</Link>
<ChevronIcon open={open} />
{hasCredentials && <ChevronIcon open={open} />}
</div>
</td>
</tr>
{open && (
{open && hasCredentials && (
state.accounts.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
Waiting for data...
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
{state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'}
</td>
</tr>
) : (
sortedAccounts.map((acc) => (
<AccountRow key={acc.id} account={acc} firm={firm} hideDead={hideDead} privacy={privacy} />
<AccountRow
key={acc.id}
account={acc}
firm={firm}
hideDead={hideDead}
privacy={privacy}
onAssigned={onChanged}
/>
))
)
)}
@@ -273,11 +475,9 @@ interface SchedulerStatus {
}
export default function Home() {
const router = useRouter();
const [config, setConfig] = useState<FirmConfig[]>([]);
const [firms, setFirms] = useState<FirmState[]>([]);
const [deleteMode, setDeleteMode] = useState(false);
const [selected, setSelected] = useState<Set<number>>(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<SortKey | null>(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 (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-slate-900">AutoTrader</h1>
<div className="flex items-center gap-2">
{deleteMode ? (
<>
{selected.size > 0 && (
<button
onClick={handleConfirmDelete}
className="px-4 py-2 bg-red-100 hover:bg-red-200 text-red-700 text-sm font-semibold rounded-lg transition-colors"
>
Confirm Delete ({selected.size})
</button>
)}
<button
onClick={() => { setDeleteMode(false); setSelected(new Set()); }}
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-200 bg-white rounded-lg transition-colors"
>
Cancel
</button>
</>
) : (
<>
{deadCount > 0 && (
<button
onClick={() => setHideDead((h) => !h)}
@@ -452,18 +629,6 @@ export default function Home() {
{hideDead ? `Show Dead (${deadCount})` : `Hide Dead (${deadCount})`}
</button>
)}
<button
onClick={() => router.push('/add')}
className="px-4 py-2 bg-green-100 hover:bg-green-200 text-green-700 text-sm font-semibold rounded-lg transition-colors"
>
+ Add New
</button>
<button
onClick={() => setDeleteMode(true)}
className="px-4 py-2 bg-red-100 hover:bg-red-200 text-red-700 text-sm font-semibold rounded-lg transition-colors"
>
Delete
</button>
<button
onClick={() => setPrivacy((p) => !p)}
className={`px-3 py-2 rounded-lg transition-colors inline-flex items-center ${privacy ? 'text-blue-600 bg-blue-50 hover:bg-blue-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
@@ -478,8 +643,6 @@ export default function Home() {
>
<SettingsIcon />
</Link>
</>
)}
</div>
</div>
{/* ── Trade Controls ── */}
@@ -607,6 +770,7 @@ export default function Home() {
<SortHeader label="Account" col="name" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Balance" col="balance" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Day P&L" col="dayPnL" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Account Type</th>
<SortHeader label="Days Traded" col="daysTraded" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Target" col="target" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Status" col="status" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
@@ -615,26 +779,39 @@ export default function Home() {
<tbody>
{config.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-slate-400 italic">
No firms yet — click + Add New to get started
<td colSpan={7} className="px-4 py-8 text-center text-sm text-slate-400 italic">
{catalog && catalog.lastSyncAt === null ? (
<>
Catalog never synced — cannot reach {catalog.masterUrl ?? 'the master dashboard'}
{catalog.lastError && <><br />{catalog.lastError}</>}
<br />
<button
onClick={syncCatalogNow}
className="mt-2 px-3 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md not-italic transition-colors"
>
Retry
</button>
</>
) : (
<>No firms yet — add them on the master dashboard</>
)}
</td>
</tr>
) : (
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 (
<FirmRows
key={cfg.id}
state={state}
firm={cfg}
deleteMode={deleteMode}
selected={selected.has(cfg.id)}
onToggle={() => toggleSelected(cfg.id)}
hideDead={hideDead}
privacy={privacy}
sortKey={sortKey}
sortDir={sortDir}
tradeSymbol={tradeSymbol}
onChanged={fetchConfig}
/>
);
})
+49
View File
@@ -26,15 +26,40 @@ export default function SettingsPage() {
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
const [tradingHours, setTradingHours] = useState<string>('full_cme');
const [masterUrl, setMasterUrl] = useState<string>('');
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<string>('');
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"
/>
</div>
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Firm catalog</p>
<p className="text-xs text-slate-400 mt-0.5">
{catalog
? `v${catalog.version} · ${catalog.firmCount} firm${catalog.firmCount === 1 ? '' : 's'} · ${
catalog.lastSyncAt
? `last synced ${new Date(catalog.lastSyncAt).toLocaleTimeString()}`
: 'never synced'
}`
: 'Loading…'}
</p>
{catalog?.lastError && (
<p className="text-xs text-red-600 mt-0.5">{catalog.lastError}</p>
)}
</div>
<button
onClick={syncCatalogNow}
disabled={syncing}
className="text-sm px-3 py-1.5 rounded-lg font-medium bg-slate-100 hover:bg-slate-200 text-slate-700 disabled:opacity-50 transition-colors"
>
{syncing ? 'Syncing…' : 'Sync now'}
</button>
</div>
</div>
{/* ── Instruments ── */}
+192
View File
@@ -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 };
+19 -52
View File
@@ -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;
+8 -1
View File
@@ -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',
BIN
View File
Binary file not shown.
+103 -159
View File
@@ -6,167 +6,138 @@ const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS firms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS account_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE,
prefix TEXT NOT NULL,
profit_target REAL NOT NULL DEFAULT 3000,
consistency REAL NOT NULL DEFAULT 0.5,
min_day_pnl REAL NOT NULL DEFAULT -999,
min_trading_days INTEGER NOT NULL DEFAULT 5
);
CREATE TABLE IF NOT EXISTS instruments (
symbol TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
`);
// Migration: add account_size column if it doesn't exist yet
try {
db.exec('ALTER TABLE account_configs ADD COLUMN account_size REAL NOT NULL DEFAULT 50000');
} catch {
// Column already exists
}
// ── Machine-owned firm state ─────────────────────────────────────────────────
//
// Firms and account types themselves come from the master catalog (see
// lib/firm-catalog.ts) and are cached as one JSON blob in `settings`. These two
// tables hold the parts that are genuinely per-machine and must survive the
// catalog changing underneath them.
//
// Both reference firm ids as plain TEXT with NO foreign key on purpose. This
// file sets `foreign_keys = ON`, so an ON DELETE CASCADE here would wipe the
// Tradovate credentials the moment a firm was removed on the master — and
// mirroring master's deletions is routine, not exceptional. They must be
// orphan-tolerant: a firm that disappears and comes back finds its credentials
// and assignments still here.
db.exec(`
CREATE TABLE IF NOT EXISTS firm_credentials (
firm_id TEXT PRIMARY KEY,
firm_name TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
updated_at TEXT NOT NULL
);
// Migration: add max_loss column (0 = no max loss limit)
try {
db.exec('ALTER TABLE account_configs ADD COLUMN max_loss REAL NOT NULL DEFAULT 0');
} catch {
// Column already exists
}
CREATE TABLE IF NOT EXISTS account_assignments (
account_id INTEGER PRIMARY KEY,
firm_id TEXT NOT NULL,
account_type_id TEXT,
manual INTEGER NOT NULL DEFAULT 0,
resolved_at TEXT NOT NULL
);
`);
// Migration: add max_position_size column (0 = no limit)
try {
db.exec('ALTER TABLE account_configs ADD COLUMN max_position_size INTEGER NOT NULL DEFAULT 0');
} catch {
// Column already exists
}
// The legacy `firms`, `account_configs` and `firm_banned_symbols` tables are
// dead: firms now come from the master catalog. They are left on disk rather
// than dropped because `firms` is the only table that ever held plaintext
// Tradovate credentials, and a silent DROP would be unrecoverable if this
// change had to be rolled back. Safe to drop by hand once it has stuck.
// Migration: add target_same_equity flag
try {
db.exec('ALTER TABLE account_configs ADD COLUMN target_same_equity INTEGER NOT NULL DEFAULT 1');
} catch {
// Column already exists
}
// ── Firm credentials ─────────────────────────────────────────────────────────
// Migration: add withdrawal_stages JSON array
try {
db.exec("ALTER TABLE account_configs ADD COLUMN withdrawal_stages TEXT NOT NULL DEFAULT '[]'");
} catch {
// Column already exists
}
// ── Interfaces ─────────────────────────────────────────────────────────────
export interface AccountConfigRow {
id: number;
firm_id: number;
prefix: string;
profit_target: number;
consistency: number;
min_day_pnl: number;
min_trading_days: number;
account_size: number;
max_loss: number;
max_position_size: number;
target_same_equity: number; // 0 | 1
withdrawal_stages: string; // JSON { profit: number; consistency: number; minTradingDays: number }[]
}
export interface FirmRow {
id: number;
name: string;
export interface FirmCredentialRow {
firm_id: string;
firm_name: string;
username: string;
password: string;
updated_at: string;
}
export interface FirmWithAccounts extends FirmRow {
accounts: AccountConfigRow[];
export function getCredentials(firmId: string): { username: string; password: string } | undefined {
const row = db.prepare('SELECT username, password FROM firm_credentials WHERE firm_id = ?').get(firmId) as
{ username: string; password: string } | undefined;
return row;
}
// ── Firms ───────────────────────────────────────────────────────────────────
export function getFirms(): FirmWithAccounts[] {
const firms = db.prepare('SELECT * FROM firms ORDER BY id').all() as FirmRow[];
const getAccounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id');
return firms.map((firm) => ({
...firm,
accounts: getAccounts.all(firm.id) as AccountConfigRow[],
}));
export function listCredentials(): FirmCredentialRow[] {
return db.prepare('SELECT * FROM firm_credentials').all() as FirmCredentialRow[];
}
export function getFirmById(id: number): FirmWithAccounts | undefined {
const firm = db.prepare('SELECT * FROM firms WHERE id = ?').get(id) as FirmRow | undefined;
if (!firm) return undefined;
const accounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id').all(id) as AccountConfigRow[];
return { ...firm, accounts };
/** firm_name is stored alongside the id so credentials can be re-keyed by name
* if the master's database is ever rebuilt and the firms come back with new
* ids. See rekeyCredentialsByName in lib/firm-catalog.ts. */
export function setCredentials(firmId: string, firmName: string, username: string, password: string): void {
db.prepare(`
INSERT INTO firm_credentials (firm_id, firm_name, username, password, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(firm_id) DO UPDATE SET
firm_name = excluded.firm_name,
username = excluded.username,
password = excluded.password,
updated_at = excluded.updated_at
`).run(firmId, firmName, username, password, new Date().toISOString());
}
export function createFirm(name: string, username: string, password: string): FirmRow {
const stmt = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)');
const result = stmt.run(name, username, password);
return db.prepare('SELECT * FROM firms WHERE id = ?').get(result.lastInsertRowid) as FirmRow;
export function clearCredentials(firmId: string): boolean {
return db.prepare('DELETE FROM firm_credentials WHERE firm_id = ?').run(firmId).changes > 0;
}
export function deleteFirm(id: number): boolean {
const result = db.prepare('DELETE FROM firms WHERE id = ?').run(id);
return result.changes > 0;
export function rekeyCredentials(fromFirmId: string, toFirmId: string): void {
db.prepare('UPDATE firm_credentials SET firm_id = ? WHERE firm_id = ?').run(toFirmId, fromFirmId);
db.prepare('UPDATE account_assignments SET firm_id = ? WHERE firm_id = ?').run(toFirmId, fromFirmId);
}
// ── Account Configs ─────────────────────────────────────────────────────────
// ── Account type assignments ─────────────────────────────────────────────────
export function createAccountConfig(firmId: number, data: {
prefix: string;
profitTarget: number;
consistency: number;
minDayPnL: number;
minTradingDays: number;
accountSize: number;
maxLoss: number;
maxPositionSize: number;
targetSameEquity?: boolean;
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
}): AccountConfigRow {
const stmt = db.prepare(
'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size, max_loss, max_position_size, target_same_equity, withdrawal_stages) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(firmId, data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []));
return db.prepare('SELECT * FROM account_configs WHERE id = ?').get(result.lastInsertRowid) as AccountConfigRow;
export interface AssignmentRow {
account_id: number;
firm_id: string;
account_type_id: string | null;
manual: number; // 0 | 1
resolved_at: string;
}
export function deleteAccountConfig(id: number): boolean {
const result = db.prepare('DELETE FROM account_configs WHERE id = ?').run(id);
return result.changes > 0;
export function getAssignment(accountId: number): AssignmentRow | undefined {
return db.prepare('SELECT * FROM account_assignments WHERE account_id = ?').get(accountId) as AssignmentRow | undefined;
}
export function updateAccountConfig(id: number, data: {
prefix: string;
profitTarget: number;
consistency: number;
minDayPnL: number;
minTradingDays: number;
accountSize: number;
maxLoss: number;
maxPositionSize: number;
targetSameEquity?: boolean;
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
}): boolean {
const result = db.prepare(`
UPDATE account_configs
SET prefix = ?, profit_target = ?, consistency = ?, min_day_pnl = ?, min_trading_days = ?, account_size = ?, max_loss = ?, max_position_size = ?, target_same_equity = ?, withdrawal_stages = ?
WHERE id = ?
`).run(data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []), id);
return result.changes > 0;
export function listAssignments(firmId: string): AssignmentRow[] {
return db.prepare('SELECT * FROM account_assignments WHERE firm_id = ?').all(firmId) as AssignmentRow[];
}
export function setAssignment(
accountId: number,
firmId: string,
accountTypeId: string | null,
manual: boolean,
): void {
db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, new Date().toISOString());
}
/** Writes an auto-resolved assignment, leaving any manual override alone.
* The `manual = 0` guard is what makes a user's pick stick across syncs. */
export function setAutoAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
const existing = getAssignment(accountId);
if (existing?.manual === 1) return;
setAssignment(accountId, firmId, accountTypeId, false);
}
export function clearAssignment(accountId: number): boolean {
return db.prepare('DELETE FROM account_assignments WHERE account_id = ?').run(accountId).changes > 0;
}
// ── Settings ─────────────────────────────────────────────────────────────────
@@ -307,33 +278,6 @@ export function loadAccountMeta(accountId: number, key: string): string | null {
return row?.value ?? null;
}
// ── Firm banned symbols ───────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS firm_banned_symbols (
firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE,
symbol TEXT NOT NULL,
PRIMARY KEY (firm_id, symbol)
);
`);
export function getBannedSymbols(firmId: number): string[] {
return (db.prepare('SELECT symbol FROM firm_banned_symbols WHERE firm_id = ? ORDER BY symbol').all(firmId) as { symbol: string }[])
.map((r) => r.symbol);
}
export function isSymbolBanned(firmId: number, symbol: string): boolean {
return !!db.prepare('SELECT 1 FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').get(firmId, symbol);
}
export function setBannedSymbol(firmId: number, symbol: string, banned: boolean): void {
if (banned) {
db.prepare('INSERT OR IGNORE INTO firm_banned_symbols (firm_id, symbol) VALUES (?, ?)').run(firmId, symbol);
} else {
db.prepare('DELETE FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').run(firmId, symbol);
}
}
// ── AutoBuyer captures ───────────────────────────────────────────────────────
db.exec(`
+306
View File
@@ -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<string, CatalogAccountType>;
}
const g = global as typeof globalThis & {
__firmCatalog?: CatalogCache;
__firmCatalogTimer?: ReturnType<typeof setTimeout>;
__firmCatalogColdDelay?: number;
};
function indexPayload(payload: FirmCatalogPayload | null): CatalogCache {
const typesById = new Map<string, CatalogAccountType>();
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<string, unknown>).profit === 'number'
&& typeof (s as Record<string, unknown>).consistency === 'number'
&& typeof (s as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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;
}
+59
View File
@@ -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' }
+49 -16
View File
@@ -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<string | null, AccountTypeStats>();
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<void> {
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 }),
+25 -2
View File
@@ -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[];