import Database from 'better-sqlite3'; import path from 'path'; 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 } // 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 } // 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 } // 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 } // 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; username: string; password: string; } export interface FirmWithAccounts extends FirmRow { accounts: AccountConfigRow[]; } // ── 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 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 }; } 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 deleteFirm(id: number): boolean { const result = db.prepare('DELETE FROM firms WHERE id = ?').run(id); return result.changes > 0; } // ── Account Configs ───────────────────────────────────────────────────────── 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 function deleteAccountConfig(id: number): boolean { const result = db.prepare('DELETE FROM account_configs WHERE id = ?').run(id); return result.changes > 0; } 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; } // ── Settings ───────────────────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); `); // Seed defaults if missing 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', ''); seedSetting.run('trading_hours', 'full_cme'); export function getSetting(key: string): string | null { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined; return row?.value ?? null; } export function setSetting(key: string, value: string): void { db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value); } // ── Instruments ────────────────────────────────────────────────────────────── const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B']; // Seed instruments table if empty const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count; if (instrCount === 0) { const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, 1)'); for (const s of SYMBOLS) ins.run(s); } export interface InstrumentRow { symbol: string; enabled: boolean; } export function getInstruments(): InstrumentRow[] { return (db.prepare('SELECT symbol, enabled FROM instruments ORDER BY symbol').all() as { symbol: string; enabled: number }[]) .map((r) => ({ symbol: r.symbol, enabled: r.enabled === 1 })); } export function setInstrumentEnabled(symbol: string, enabled: boolean): boolean { const result = db.prepare('UPDATE instruments SET enabled = ? WHERE symbol = ?').run(enabled ? 1 : 0, symbol); return result.changes > 0; } // ── Daily P&L Cache ────────────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS daily_pnl ( account_id INTEGER NOT NULL, account_name TEXT NOT NULL, date TEXT NOT NULL, pnl REAL NOT NULL, PRIMARY KEY (account_id, date) ); `); export function saveDailyPnL(accountId: number, accountName: string, entries: { date: string; pnl: number }[]): void { const ins = db.prepare('INSERT OR REPLACE INTO daily_pnl (account_id, account_name, date, pnl) VALUES (?, ?, ?, ?)'); const txn = db.transaction(() => { for (const e of entries) { ins.run(accountId, accountName, e.date, e.pnl); } }); txn(); } export function loadDailyPnL(accountId: number): { date: string; pnl: number }[] { return (db.prepare('SELECT date, pnl FROM daily_pnl WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; pnl: number }[]); } // ── Fund Transactions Cache ─────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS fund_transactions ( account_id INTEGER NOT NULL, account_name TEXT NOT NULL, date TEXT NOT NULL, amount REAL NOT NULL, PRIMARY KEY (account_id, date) ); `); export function saveFundTransactions(accountId: number, accountName: string, entries: { date: string; amount: number }[]): void { const ins = db.prepare('INSERT OR REPLACE INTO fund_transactions (account_id, account_name, date, amount) VALUES (?, ?, ?, ?)'); const txn = db.transaction(() => { for (const e of entries) ins.run(accountId, accountName, e.date, e.amount); }); txn(); } export function loadFundTransactions(accountId: number): { date: string; amount: number }[] { return db.prepare('SELECT date, amount FROM fund_transactions WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; amount: number }[]; } // ── Account Meta ───────────────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS account_meta ( account_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (account_id, key) ); `); export function saveAccountMeta(accountId: number, key: string, value: string): void { db.prepare('INSERT OR REPLACE INTO account_meta (account_id, key, value) VALUES (?, ?, ?)').run(accountId, key, value); } export function loadAccountMeta(accountId: number, key: string): string | null { const row = db.prepare('SELECT value FROM account_meta WHERE account_id = ? AND key = ?').get(accountId, key) as { value: string } | undefined; 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(` CREATE TABLE IF NOT EXISTS autobuyer_captures ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', html TEXT NOT NULL, viewport TEXT NOT NULL DEFAULT '{}', elements TEXT NOT NULL DEFAULT '[]', captured_at INTEGER NOT NULL ); `); seedSetting.run('autobuyer_enabled', '0'); export interface CaptureRow { id: number; url: string; title: string; html: string; viewport: string; // JSON ViewportMetrics elements: string; // JSON ElementPosition[] captured_at: number; } /** Number of captures kept on disk — the extension overwrites constantly, so only * a short tail is useful and an unbounded table would grow by megabytes a minute. */ const CAPTURE_HISTORY = 5; export function saveCapture(c: Omit): void { db.prepare( 'INSERT INTO autobuyer_captures (url, title, html, viewport, elements, captured_at) VALUES (?, ?, ?, ?, ?, ?)' ).run(c.url, c.title, c.html, c.viewport, c.elements, c.captured_at); db.prepare(` DELETE FROM autobuyer_captures WHERE id NOT IN (SELECT id FROM autobuyer_captures ORDER BY id DESC LIMIT ?) `).run(CAPTURE_HISTORY); } export function getLatestCapture(): CaptureRow | undefined { return db.prepare('SELECT * FROM autobuyer_captures ORDER BY id DESC LIMIT 1').get() as CaptureRow | undefined; } export function clearCaptures(): void { db.prepare('DELETE FROM autobuyer_captures').run(); } // ── AutoBuyer locate/click requests ────────────────────────────────────────── // // The Python clicker can't see inside Chrome, and the extension can't move the // mouse. So they meet here: Python enqueues "where is ?", the extension // focuses the tab, measures the element and writes back desktop coordinates. db.exec(` CREATE TABLE IF NOT EXISTS autobuyer_locate ( id INTEGER PRIMARY KEY AUTOINCREMENT, selector TEXT NOT NULL, match_index INTEGER NOT NULL DEFAULT 0, url_pattern TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', result TEXT, error TEXT, created_at INTEGER NOT NULL, resolved_at INTEGER ); `); export interface LocateRow { id: number; selector: string; match_index: number; url_pattern: string; open_url: string; navigate_url: string; status: 'pending' | 'claimed' | 'done' | 'error'; result: string | null; error: string | null; created_at: number; resolved_at: number | null; } const LOCATE_HISTORY = 20; export function createLocateRequest(selector: string, matchIndex: number, urlPattern: string, openUrl = '', navigateUrl = ''): LocateRow { const res = db.prepare( 'INSERT INTO autobuyer_locate (selector, match_index, url_pattern, open_url, navigate_url, created_at) VALUES (?, ?, ?, ?, ?, ?)' ).run(selector, matchIndex, urlPattern, openUrl, navigateUrl, Date.now()); db.prepare(` DELETE FROM autobuyer_locate WHERE id NOT IN (SELECT id FROM autobuyer_locate ORDER BY id DESC LIMIT ?) `).run(LOCATE_HISTORY); return db.prepare('SELECT * FROM autobuyer_locate WHERE id = ?').get(res.lastInsertRowid) as LocateRow; } export function getLocateRequest(id: number): LocateRow | undefined { return db.prepare('SELECT * FROM autobuyer_locate WHERE id = ?').get(id) as LocateRow | undefined; } export function countPendingLocate(): number { const row = db.prepare("SELECT COUNT(*) AS n FROM autobuyer_locate WHERE status = 'pending'").get() as { n: number }; return row.n; } /** Hand the oldest pending request to the extension, marking it claimed so a * slow round-trip doesn't cause the same click to be served twice. */ export function claimLocateRequest(): LocateRow | undefined { const row = db.prepare("SELECT * FROM autobuyer_locate WHERE status = 'pending' ORDER BY id LIMIT 1").get() as LocateRow | undefined; if (!row) return undefined; db.prepare("UPDATE autobuyer_locate SET status = 'claimed' WHERE id = ?").run(row.id); return { ...row, status: 'claimed' }; } export function resolveLocateRequest(id: number, result: unknown | null, error: string | null): void { db.prepare('UPDATE autobuyer_locate SET status = ?, result = ?, error = ?, resolved_at = ? WHERE id = ?') .run(error ? 'error' : 'done', result ? JSON.stringify(result) : null, error, Date.now(), id); } // ── AutoBuyer automation runs ──────────────────────────────────────────────── // // The dashboard queues a run; the Python runner claims it and works through the // steps, reporting progress back so the page can show what is happening. db.exec(` CREATE TABLE IF NOT EXISTS autobuyer_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, automation_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued', step_index INTEGER NOT NULL DEFAULT 0, total_steps INTEGER NOT NULL DEFAULT 0, log TEXT NOT NULL DEFAULT '[]', inputs TEXT NOT NULL DEFAULT '{}', error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); `); // Migration: the page to send the tab to before locating. try { db.exec("ALTER TABLE autobuyer_locate ADD COLUMN navigate_url TEXT NOT NULL DEFAULT ''"); } catch { // Column already exists } // Migration: the page to open when no tab matches the pattern. try { db.exec("ALTER TABLE autobuyer_locate ADD COLUMN open_url TEXT NOT NULL DEFAULT ''"); } catch { // Column already exists } // Migration: per-run inputs the user set on the dashboard. try { db.exec("ALTER TABLE autobuyer_runs ADD COLUMN inputs TEXT NOT NULL DEFAULT '{}'"); } catch { // Column already exists } export type RunStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled'; export interface RunRow { id: number; automation_id: string; status: RunStatus; step_index: number; total_steps: number; log: string; // JSON { at: number; step: string; ok: boolean; detail?: string }[] inputs: string; // JSON Record error: string | null; created_at: number; updated_at: number; } const RUN_HISTORY = 30; export function createRun(automationId: string, totalSteps: number, inputs: Record = {}): RunRow { const now = Date.now(); const res = db.prepare( 'INSERT INTO autobuyer_runs (automation_id, total_steps, inputs, created_at, updated_at) VALUES (?, ?, ?, ?, ?)' ).run(automationId, totalSteps, JSON.stringify(inputs), now, now); db.prepare(` DELETE FROM autobuyer_runs WHERE id NOT IN (SELECT id FROM autobuyer_runs ORDER BY id DESC LIMIT ?) `).run(RUN_HISTORY); return db.prepare('SELECT * FROM autobuyer_runs WHERE id = ?').get(res.lastInsertRowid) as RunRow; } export function getRun(id: number): RunRow | undefined { return db.prepare('SELECT * FROM autobuyer_runs WHERE id = ?').get(id) as RunRow | undefined; } export function getRecentRuns(limit = 5): RunRow[] { return db.prepare('SELECT * FROM autobuyer_runs ORDER BY id DESC LIMIT ?').all(limit) as RunRow[]; } export function countActiveRuns(): number { const row = db.prepare("SELECT COUNT(*) AS n FROM autobuyer_runs WHERE status IN ('queued','running')").get() as { n: number }; return row.n; } /** Grace period before a 'running' run with nobody behind it is written off. * Long enough to cover the gap between the runner claiming a run and its next * heartbeat reporting busy. */ const ORPHAN_GRACE_MS = 30_000; /** Fail runs that were left mid-flight when their runner went away. * * Only one run executes at a time, so a stale 'running' row blocks every future * run — kill the daemon during a run (or restart it, which is the same thing) * and nothing would ever be claimed again. The heartbeat says whether anything * is actually executing: a runner that is gone, or up and idle, is not driving * this run no matter what its status column claims. */ export function reapOrphanedRuns(): number { const hb = getRunnerHeartbeat(); const alive = hb !== null && Date.now() - hb.at < RUNNER_TIMEOUT_MS; if (alive && hb!.busy) return 0; const now = Date.now(); return db.prepare(` UPDATE autobuyer_runs SET status = 'error', error = 'the runner stopped while this run was in progress', updated_at = ? WHERE status = 'running' AND updated_at < ? `).run(now, now - ORPHAN_GRACE_MS).changes; } /** The runner takes the oldest queued run. Only one runs at a time — two * processes driving the same physical mouse would interleave clicks. */ export function claimRun(): RunRow | undefined { reapOrphanedRuns(); const running = db.prepare("SELECT 1 FROM autobuyer_runs WHERE status = 'running'").get(); if (running) return undefined; const row = db.prepare("SELECT * FROM autobuyer_runs WHERE status = 'queued' ORDER BY id LIMIT 1").get() as RunRow | undefined; if (!row) return undefined; db.prepare("UPDATE autobuyer_runs SET status = 'running', updated_at = ? WHERE id = ?").run(Date.now(), row.id); return { ...row, status: 'running' }; } export function appendRunLog(id: number, entry: unknown, stepIndex: number): void { const row = getRun(id); if (!row) return; let log: unknown[]; try { log = JSON.parse(row.log); } catch { log = []; } log.push(entry); db.prepare('UPDATE autobuyer_runs SET log = ?, step_index = ?, updated_at = ? WHERE id = ?') .run(JSON.stringify(log), stepIndex, Date.now(), id); } export function finishRun(id: number, status: RunStatus, error: string | null): void { db.prepare('UPDATE autobuyer_runs SET status = ?, error = ?, updated_at = ? WHERE id = ?') .run(status, error, Date.now(), id); } /** Cancelling a queued run stops it starting; cancelling a running one is seen * by the runner between steps. */ export function cancelRun(id: number): boolean { const row = getRun(id); if (!row || row.status === 'done' || row.status === 'error' || row.status === 'cancelled') return false; finishRun(id, 'cancelled', null); return true; } // ── Runner heartbeat ───────────────────────────────────────────────────────── // // The runner is a desktop process, so the server can't tell whether it's alive. // It reports in every couple of seconds; if the beats stop, the dashboard greys // out the buttons rather than queueing runs nobody will execute. /** Three missed beats. Long enough to ride out a slow tick, short enough that a * killed runner shows as offline before you press anything. */ export const RUNNER_TIMEOUT_MS = 7000; /** The runner version this server's step vocabulary requires. A running process * doesn't reload when the source changes, so an older one silently fails on * steps it predates — the dashboard warns instead. */ export const RUNNER_EXPECTED_VERSION = '0.11.0'; export interface RunnerHeartbeat { at: number; host?: string; pid?: number; dryRun?: boolean; busy?: boolean; version?: string; } export function setRunnerHeartbeat(info: Omit): void { setSetting('runner_heartbeat', JSON.stringify({ ...info, at: Date.now() })); } export function getRunnerHeartbeat(): RunnerHeartbeat | null { const raw = getSetting('runner_heartbeat'); if (!raw) return null; try { return JSON.parse(raw) as RunnerHeartbeat; } catch { return null; } }