diff --git a/.claude/launch.json b/.claude/launch.json index 5d72c43..b90a691 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,7 +6,7 @@ "runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd", "runtimeArgs": ["run", "dev", "--", "--webpack"], "port": 3000, - "cwd": "D:\\Development\\market-dev\\autotrader-firms\\autotrader" + "cwd": "." } ] } diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index fbd69a9..6455186 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getSetting, setSetting } from '@/lib/db'; -const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds'] as const; +const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name'] as const; type SettingKey = typeof VALID_KEYS[number]; export async function GET() { diff --git a/app/settings/page.tsx b/app/settings/page.tsx index ee4e6a8..95fb242 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -17,12 +17,16 @@ interface ResolvedContract { interface AppSettings { max_concurrent_accounts: string | null; + master_dashboard_url: string | null; + instance_name: string | null; } export default function SettingsPage() { const [instruments, setInstruments] = useState([]); const [contracts, setContracts] = useState>({}); const [maxConcurrent, setMaxConcurrent] = useState('5'); + const [masterUrl, setMasterUrl] = useState(''); + const [instanceName, setInstanceName] = useState(''); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); @@ -37,6 +41,12 @@ export default function SettingsPage() { if (s.max_concurrent_accounts != null) { setMaxConcurrent(s.max_concurrent_accounts); } + if (s.master_dashboard_url != null) { + setMasterUrl(s.master_dashboard_url); + } + if (s.instance_name != null) { + setInstanceName(s.instance_name); + } }); // Load cached contracts; if cache is empty, auto-resolve @@ -91,7 +101,7 @@ export default function SettingsPage() { await fetch('/api/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ max_concurrent_accounts: maxConcurrent }), + body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, master_dashboard_url: masterUrl, instance_name: instanceName }), }); setSaving(false); setSaved(true); @@ -148,6 +158,43 @@ export default function SettingsPage() { + {/* ── Master Dashboard ── */} +

+ Master Dashboard +

+
+
+
+

Instance Name

+

+ Identifier for this autotrader on the master dashboard +

+
+ setInstanceName(e.target.value)} + className="w-48 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" + /> +
+
+
+

Dashboard URL

+

+ Reports state to this URL every 30 seconds +

+
+ setMasterUrl(e.target.value)} + 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" + /> +
+
+ {/* ── Instruments ── */}

Instruments diff --git a/lib/clients.ts b/lib/clients.ts index 3424d1e..80ec1a4 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -1,6 +1,7 @@ import { TradovateClient } from './tradovate-class'; import { getFirms, getInstruments } from './db'; import { resolveContracts } from './contract-resolver'; +import { startReporter } from './reporter'; // Use global to persist the client pool across HMR reloads in dev mode const g = global as typeof globalThis & { @@ -59,6 +60,9 @@ export function getClients(): Map { // Schedule daily resolve at midnight scheduleDailyResolve(); + + // Start master dashboard reporter + startReporter(); } catch (err) { console.error('[clients] Failed to initialize clients', err); } diff --git a/lib/db.ts b/lib/db.ts index 8a4431f..ef7448e 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -162,6 +162,8 @@ db.exec(` const seedSetting = db.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`); seedSetting.run('max_concurrent_accounts', '5'); seedSetting.run('tick_interval_seconds', '60'); +seedSetting.run('master_dashboard_url', ''); +seedSetting.run('instance_name', ''); export function getSetting(key: string): string | null { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined; diff --git a/lib/reporter.ts b/lib/reporter.ts new file mode 100644 index 0000000..7c0a325 --- /dev/null +++ b/lib/reporter.ts @@ -0,0 +1,103 @@ +/** + * Reporter module — pushes aggregated state to the master dashboard. + * + * Every 30 seconds, collects firm-level stats + scheduler state and POSTs + * to the configured master_dashboard_url. Silently skips if not configured. + */ + +import { getSetting } from './db'; +import { getFirms } from './db'; +import { getClients } from './clients'; +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)); +} + +function collectFirmStats() { + const firms = getFirms(); + 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 }; + } + + let totalAccounts = 0; + let accountsTraded = 0; + let inTrade = 0; + + 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; + + totalAccounts++; + + if (client.positions[acc.id]) { + inTrade++; + accountsTraded++; + continue; + } + + // Check if target was hit today + const cfg = getAccountConfig(acc.name, f.accounts); + if (cfg) { + const dailyPnL = client.dailyPnL[acc.id] ?? []; + const totalProfit = dailyPnL.reduce((sum: number, d: { pnl: number }) => sum + d.pnl, 0); + const target = computeDailyTarget(cfg.profit_target, cfg.consistency, totalProfit, dailyPnL, cfg.min_day_pnl, cfg.min_trading_days); + const targetHit = + (target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.min_trading_days) || + (cash.realizedPnL >= target.amount); + + if (targetHit || cash.realizedPnL !== 0) { + accountsTraded++; + } + } + } + + return { firm: f.name, totalAccounts, accountsTraded, inTrade }; + }); +} + +// HMR-safe global to avoid duplicate intervals +const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType }; + +export function startReporter() { + if (_g.__reporterInterval) { + clearInterval(_g.__reporterInterval); + } + + const report = async () => { + const url = getSetting('master_dashboard_url'); + const instanceName = getSetting('instance_name'); + + if (!url || !instanceName) return; + + try { + const firms = collectFirmStats(); + const scheduler = getSchedulerStatus(); + + await fetch(`${url}/api/report`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instance: instanceName, firms, scheduler }), + }); + } catch { + // Master might be down — silently continue + } + }; + + _g.__reporterInterval = setInterval(report, 30_000); + + // Fire first report after a short delay to let clients sync + setTimeout(report, 10_000); + + console.log('[reporter] started — reporting every 30s'); +}