Add master dashboard reporter and settings UI
- New reporter module that pushes firm stats (total accounts, accounts traded, in trade) to a configurable master dashboard every 30 seconds - Add Instance Name and Dashboard URL fields to the settings page - Register master_dashboard_url and instance_name in settings API - Seed default (empty) values for new settings in db Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
15ef2d7adc
commit
f112118090
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd",
|
"runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd",
|
||||||
"runtimeArgs": ["run", "dev", "--", "--webpack"],
|
"runtimeArgs": ["run", "dev", "--", "--webpack"],
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"cwd": "D:\\Development\\market-dev\\autotrader-firms\\autotrader"
|
"cwd": "."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getSetting, setSetting } from '@/lib/db';
|
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];
|
type SettingKey = typeof VALID_KEYS[number];
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|||||||
+48
-1
@@ -17,12 +17,16 @@ interface ResolvedContract {
|
|||||||
|
|
||||||
interface AppSettings {
|
interface AppSettings {
|
||||||
max_concurrent_accounts: string | null;
|
max_concurrent_accounts: string | null;
|
||||||
|
master_dashboard_url: string | null;
|
||||||
|
instance_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [instruments, setInstruments] = useState<Instrument[]>([]);
|
const [instruments, setInstruments] = useState<Instrument[]>([]);
|
||||||
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
|
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
|
||||||
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
|
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
|
||||||
|
const [masterUrl, setMasterUrl] = useState<string>('');
|
||||||
|
const [instanceName, setInstanceName] = useState<string>('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
@@ -37,6 +41,12 @@ export default function SettingsPage() {
|
|||||||
if (s.max_concurrent_accounts != null) {
|
if (s.max_concurrent_accounts != null) {
|
||||||
setMaxConcurrent(s.max_concurrent_accounts);
|
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
|
// Load cached contracts; if cache is empty, auto-resolve
|
||||||
@@ -91,7 +101,7 @@ export default function SettingsPage() {
|
|||||||
await fetch('/api/settings', {
|
await fetch('/api/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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);
|
setSaving(false);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
@@ -148,6 +158,43 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Master Dashboard ── */}
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||||
|
Master Dashboard
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-800">Instance Name</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Identifier for this autotrader on the master dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. VPS-1"
|
||||||
|
value={instanceName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-800">Dashboard URL</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Reports state to this URL every 30 seconds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. http://master-ip:4000"
|
||||||
|
value={masterUrl}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Instruments ── */}
|
{/* ── Instruments ── */}
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||||
Instruments
|
Instruments
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TradovateClient } from './tradovate-class';
|
import { TradovateClient } from './tradovate-class';
|
||||||
import { getFirms, getInstruments } from './db';
|
import { getFirms, getInstruments } from './db';
|
||||||
import { resolveContracts } from './contract-resolver';
|
import { resolveContracts } from './contract-resolver';
|
||||||
|
import { startReporter } from './reporter';
|
||||||
|
|
||||||
// Use global to persist the client pool across HMR reloads in dev mode
|
// Use global to persist the client pool across HMR reloads in dev mode
|
||||||
const g = global as typeof globalThis & {
|
const g = global as typeof globalThis & {
|
||||||
@@ -59,6 +60,9 @@ export function getClients(): Map<number, TradovateClient> {
|
|||||||
|
|
||||||
// Schedule daily resolve at midnight
|
// Schedule daily resolve at midnight
|
||||||
scheduleDailyResolve();
|
scheduleDailyResolve();
|
||||||
|
|
||||||
|
// Start master dashboard reporter
|
||||||
|
startReporter();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[clients] Failed to initialize clients', err);
|
console.error('[clients] Failed to initialize clients', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ db.exec(`
|
|||||||
const seedSetting = db.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`);
|
const seedSetting = db.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`);
|
||||||
seedSetting.run('max_concurrent_accounts', '5');
|
seedSetting.run('max_concurrent_accounts', '5');
|
||||||
seedSetting.run('tick_interval_seconds', '60');
|
seedSetting.run('tick_interval_seconds', '60');
|
||||||
|
seedSetting.run('master_dashboard_url', '');
|
||||||
|
seedSetting.run('instance_name', '');
|
||||||
|
|
||||||
export function getSetting(key: string): string | null {
|
export function getSetting(key: string): string | null {
|
||||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||||
|
|||||||
+103
@@ -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<typeof setInterval> };
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user