Let the master dashboard drive this instance; drop the AutoBuyer switch
Three changes that ride together because they touch the same surface. A control link to the master. lib/master-link.ts dials out to <master>/ws/instance and holds it open, so the dashboard can trigger a trade or queue an AutoBuyer run here. Commands call the same functions the local pages call — runTrade, startScheduler, queueRun — so nothing reaches the broker by a path the UI does not also use. Reports prefer the socket and fall back to the existing POST, which keeps older masters working unchanged. startMasterLink is idempotent, and that matters more than it looks: getClients() is lazy and starts the link, so the first command re-enters it from inside a command the live socket is waiting to ack. Tearing the socket down there loses the ack and reports a trade that actually executed as a failure — which invites pressing the button twice. queueRun is lifted out of the automations route so the socket and HTTP share one copy of the input clamping that decides how many times a purchase fires. Page capture loses its switch and gains a scope. It now runs every tick, but only against the active tab of the last-focused window that matches host_permissions — so it follows the page in front of you and stops when you switch away, where before it would fall back to any firm tab left open in the background. autobuyer_enabled is gone entirely. The AutoBuyer is ready whenever the runner is; a run happens because someone asked for one. That does mean a queued run executes as soon as clicker/runner.py claims it, with no second gate — stopping the runner is now the way to make a machine idle. Extension to 0.10.0; it needs a manual reload at chrome://extensions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0ba27161ce
commit
25289cb9b3
+6
-1
@@ -1,7 +1,8 @@
|
||||
import { TradovateClient } from './tradovate-class';
|
||||
import { getFirms, getInstruments } from './db';
|
||||
import { resolveContracts } from './contract-resolver';
|
||||
import { startReporter } from './reporter';
|
||||
import { startReporter, sendReport } from './reporter';
|
||||
import { startMasterLink } from './master-link';
|
||||
import { resumeSchedulerIfPersisted } from './auto-trade';
|
||||
|
||||
// Use global to persist the client pool across HMR reloads in dev mode
|
||||
@@ -65,6 +66,10 @@ export function getClients(): Map<number, TradovateClient> {
|
||||
// Start master dashboard reporter
|
||||
startReporter();
|
||||
|
||||
// ...and the socket it prefers to report over, which is also how
|
||||
// the master triggers trades and AutoBuyer runs here.
|
||||
startMasterLink(() => { void sendReport(); });
|
||||
|
||||
// Pick the auto-trade schedule back up if it was running before restart
|
||||
resumeSchedulerIfPersisted();
|
||||
} catch (err) {
|
||||
|
||||
@@ -341,8 +341,6 @@ db.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
seedSetting.run('autobuyer_enabled', '0');
|
||||
|
||||
export interface CaptureRow {
|
||||
id: number;
|
||||
url: string;
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Control link to the master dashboard.
|
||||
*
|
||||
* We dial out, not the other way round: this machine sits on a home network
|
||||
* behind NAT and the master is the only end with a public address. Once the
|
||||
* socket is up it carries traffic both ways — reports go up it instead of the
|
||||
* 30s HTTP POST, and trade / AutoBuyer commands come back down it.
|
||||
*
|
||||
* The link is optional. Without `master_dashboard_url` and `instance_name`
|
||||
* nothing starts, and if the master is unreachable the reporter falls back to
|
||||
* the HTTP endpoint it has always used.
|
||||
*
|
||||
* Neither end authenticates the other. Whatever is at `master_dashboard_url`
|
||||
* can place orders on every account this instance holds, so point it only at a
|
||||
* master you control, and prefer https so it cannot be redirected in transit.
|
||||
*/
|
||||
|
||||
import { getSetting, getInstruments, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
|
||||
import { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade';
|
||||
import { FIRMS, automationKey } from './automations';
|
||||
import { POINT_VALUES } from './trading-logic';
|
||||
import { queueRun } from './queue-run';
|
||||
import type {
|
||||
Command,
|
||||
InstanceCapabilities,
|
||||
InstanceMessage,
|
||||
MasterMessage,
|
||||
} from './master-protocol';
|
||||
|
||||
const WS_PATH = '/ws/instance';
|
||||
const RECONNECT_MIN_MS = 2_000;
|
||||
const RECONNECT_MAX_MS = 60_000;
|
||||
|
||||
interface LinkState {
|
||||
socket: WebSocket | null;
|
||||
/** Only true once the master has answered the hello. */
|
||||
ready: boolean;
|
||||
attempt: number;
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
// HMR-safe: a dev reload must not leave a second socket dialling.
|
||||
const _g = globalThis as typeof globalThis & { __masterLink?: LinkState };
|
||||
|
||||
function state(): LinkState {
|
||||
if (!_g.__masterLink) {
|
||||
_g.__masterLink = { socket: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false };
|
||||
}
|
||||
return _g.__masterLink;
|
||||
}
|
||||
|
||||
// ── capabilities ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** What this instance can be asked to do, as of right now. */
|
||||
export function collectCapabilities(): InstanceCapabilities {
|
||||
// Only symbols the operator has enabled here. An empty instruments table
|
||||
// means nothing has been configured yet, so offer everything we can price.
|
||||
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||||
|
||||
// Same test the AutoBuyer page uses: `at` is epoch ms from the last ping.
|
||||
const heartbeat = getRunnerHeartbeat();
|
||||
const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS;
|
||||
|
||||
return {
|
||||
symbols: enabled.length > 0 ? enabled : Object.keys(POINT_VALUES),
|
||||
automations: FIRMS.flatMap((firm) =>
|
||||
firm.automations
|
||||
.filter((a) => a.steps.length > 0)
|
||||
.map((a) => ({
|
||||
key: automationKey(firm.id, a.id),
|
||||
label: a.label,
|
||||
firm: firm.label,
|
||||
inputs: (a.inputs ?? []).map((i) => ({
|
||||
id: i.id,
|
||||
label: i.label,
|
||||
min: i.min,
|
||||
max: i.max,
|
||||
default: i.default,
|
||||
})),
|
||||
}))
|
||||
),
|
||||
runnerOnline,
|
||||
};
|
||||
}
|
||||
|
||||
// ── commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Runs one command and returns whatever the master should show the operator.
|
||||
* Throwing is fine — the caller turns it into a failed ack, which surfaces as
|
||||
* a red banner on the dashboard rather than silence.
|
||||
*/
|
||||
async function execute(command: Command): Promise<unknown> {
|
||||
switch (command.kind) {
|
||||
case 'trade': {
|
||||
if (command.symbol !== 'Auto' && !POINT_VALUES[command.symbol]) {
|
||||
throw new Error(`Unknown symbol: ${command.symbol}`);
|
||||
}
|
||||
// Same order as POST /api/trade: fire once now, then leave the
|
||||
// scheduler running to pick up accounts that were busy.
|
||||
const results = await runTrade(command.action, command.symbol);
|
||||
startScheduler(command.action, command.symbol, command.stopAfterAll ?? false);
|
||||
|
||||
const filled = results
|
||||
.flatMap((r: any) => r.results ?? [])
|
||||
.filter((r: any) => r.status === 'filled').length;
|
||||
return { filled, scheduler: getSchedulerStatus() };
|
||||
}
|
||||
|
||||
case 'stop-trading':
|
||||
stopScheduler();
|
||||
return { stopped: true, scheduler: getSchedulerStatus() };
|
||||
|
||||
case 'autobuyer-run':
|
||||
return queueRun(command.automationId, command.inputs ?? {});
|
||||
|
||||
case 'report-now':
|
||||
return { reported: true };
|
||||
|
||||
default: {
|
||||
// A newer master may know commands this build does not. Say so
|
||||
// rather than failing silently.
|
||||
const unknown = command as { kind?: string };
|
||||
throw new Error(`Unsupported command: ${unknown.kind ?? 'unnamed'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── link ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** `https://dash.example.com` → `wss://dash.example.com/ws/instance` */
|
||||
function toWsUrl(masterUrl: string): string | null {
|
||||
try {
|
||||
const url = new URL(masterUrl);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url.pathname = WS_PATH;
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function send(message: InstanceMessage): boolean {
|
||||
const s = state();
|
||||
if (!s.socket || !s.ready || s.socket.readyState !== WebSocket.OPEN) return false;
|
||||
try {
|
||||
s.socket.send(JSON.stringify(message));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the report went up the socket, so the caller can skip the POST. */
|
||||
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
|
||||
return send({
|
||||
type: 'report',
|
||||
firms: firms as never,
|
||||
scheduler: scheduler as never,
|
||||
capabilities: collectCapabilities(),
|
||||
});
|
||||
}
|
||||
|
||||
export function isLinked(): boolean {
|
||||
return state().ready;
|
||||
}
|
||||
|
||||
function scheduleReconnect(reason: string) {
|
||||
const s = state();
|
||||
if (s.stopped || s.reconnectTimer) return;
|
||||
|
||||
// Exponential backoff with jitter: a master restart brings every instance
|
||||
// back at once, and they should not all land on the same millisecond.
|
||||
const base = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** s.attempt);
|
||||
const delay = Math.round(base * (0.5 + Math.random() / 2));
|
||||
s.attempt++;
|
||||
|
||||
if (s.attempt <= 3 || s.attempt % 10 === 0) {
|
||||
console.log(`[master-link] ${reason} — retrying in ${Math.round(delay / 1000)}s`);
|
||||
}
|
||||
|
||||
s.reconnectTimer = setTimeout(() => {
|
||||
s.reconnectTimer = null;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const s = state();
|
||||
if (s.stopped || s.socket) return;
|
||||
|
||||
const masterUrl = getSetting('master_dashboard_url');
|
||||
const instance = getSetting('instance_name');
|
||||
if (!masterUrl || !instance) {
|
||||
// Not configured — check again later in case Settings gets filled in.
|
||||
scheduleReconnect('not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = toWsUrl(masterUrl);
|
||||
if (!url) {
|
||||
scheduleReconnect(`master_dashboard_url is not a URL: ${masterUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch (err: any) {
|
||||
scheduleReconnect(err?.message ?? 'could not open socket');
|
||||
return;
|
||||
}
|
||||
s.socket = socket;
|
||||
s.ready = false;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
instance,
|
||||
capabilities: collectCapabilities(),
|
||||
} satisfies InstanceMessage));
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
let msg: MasterMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'welcome') {
|
||||
s.ready = true;
|
||||
s.attempt = 0;
|
||||
console.log(`[master-link] connected to ${url} as "${instance}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type !== 'command') return;
|
||||
|
||||
const { id, command } = msg;
|
||||
console.log(`[master-link] command: ${command.kind}`);
|
||||
|
||||
// Deliberately not awaited: a trade walks every account and can take a
|
||||
// while, and the socket must stay free for a Stop in the meantime.
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await execute(command);
|
||||
send({ type: 'ack', id, ok: true, result });
|
||||
} catch (err: any) {
|
||||
console.error(`[master-link] command ${command.kind} failed:`, err?.message ?? err);
|
||||
send({ type: 'ack', id, ok: false, error: err?.message ?? String(err) });
|
||||
}
|
||||
// Whatever just happened, the dashboard's numbers are now stale.
|
||||
reportSoon();
|
||||
})();
|
||||
});
|
||||
|
||||
const dropped = (reason: string) => {
|
||||
if (s.socket !== socket) return; // superseded
|
||||
s.socket = null;
|
||||
s.ready = false;
|
||||
scheduleReconnect(reason);
|
||||
};
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
dropped(event.reason ? `closed: ${event.reason}` : 'connection closed');
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
// 'close' always follows, and carries the better message of the two.
|
||||
if (s.socket === socket && s.ready) console.warn('[master-link] socket error');
|
||||
});
|
||||
}
|
||||
|
||||
/** Set by startMasterLink so a command can trigger a report without a cycle. */
|
||||
let reportSoon: () => void = () => { };
|
||||
|
||||
export function startMasterLink(onReportRequested: () => void) {
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
console.warn('[master-link] no global WebSocket in this Node build — control link disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
reportSoon = onReportRequested;
|
||||
|
||||
const s = state();
|
||||
s.stopped = false;
|
||||
|
||||
// Idempotent on purpose. getClients() is lazy and starts this link, so the
|
||||
// first command that reaches it re-enters here from *inside* a command the
|
||||
// live socket is waiting to ack. Tearing the socket down there loses the
|
||||
// ack, and the master reports a trade that actually executed as a failure —
|
||||
// which invites pressing the button a second time. Already dialling or
|
||||
// connected means there is nothing to do.
|
||||
if (s.socket || s.reconnectTimer) return;
|
||||
|
||||
connect();
|
||||
console.log('[master-link] started');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Wire protocol for the control link to the master dashboard.
|
||||
*
|
||||
* Mirror of the block at the bottom of `autotrader-master/types.ts`, which is
|
||||
* the source of truth. The two repos deploy independently, so change both
|
||||
* together — and only ever by adding, since an instance and a master can be
|
||||
* running different versions of this file at the same time.
|
||||
*/
|
||||
|
||||
export interface FirmStats {
|
||||
firm: string;
|
||||
totalAccounts: number;
|
||||
accountsTraded: number;
|
||||
inTrade: number;
|
||||
}
|
||||
|
||||
export interface SchedulerState {
|
||||
running: boolean;
|
||||
action: 'Buy' | 'Sell' | 'Auto';
|
||||
symbol: string;
|
||||
lastRun: string | null;
|
||||
intervalSeconds: number;
|
||||
stopAfterAll?: boolean;
|
||||
}
|
||||
|
||||
export interface AutomationSummary {
|
||||
key: string;
|
||||
label: string;
|
||||
firm: string;
|
||||
inputs: { id: string; label: string; min: number; max: number; default: number }[];
|
||||
}
|
||||
|
||||
export interface InstanceCapabilities {
|
||||
symbols: string[];
|
||||
automations: AutomationSummary[];
|
||||
runnerOnline: boolean;
|
||||
}
|
||||
|
||||
export type Command =
|
||||
| { kind: 'trade'; action: 'Buy' | 'Sell' | 'Auto'; symbol: string; stopAfterAll?: boolean }
|
||||
| { kind: 'stop-trading' }
|
||||
| { kind: 'autobuyer-run'; automationId: string; inputs?: Record<string, number> }
|
||||
| { kind: 'report-now' };
|
||||
|
||||
export type MasterMessage =
|
||||
| { type: 'welcome' }
|
||||
| { type: 'command'; id: string; command: Command };
|
||||
|
||||
export type InstanceMessage =
|
||||
| { type: 'hello'; instance: string; capabilities: InstanceCapabilities }
|
||||
| { type: 'report'; firms: FirmStats[]; scheduler: SchedulerState; capabilities?: InstanceCapabilities }
|
||||
| { type: 'ack'; id: string; ok: boolean; result?: unknown; error?: string };
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Queuing an AutoBuyer run.
|
||||
*
|
||||
* Lifted out of POST /api/autobuyer/automations so the master dashboard's
|
||||
* control link can queue the same run over its WebSocket without going back
|
||||
* through HTTP — and, more to the point, without a second copy of the checks
|
||||
* that decide how many times a purchase fires.
|
||||
*
|
||||
* Queuing is always allowed. Nothing arms the AutoBuyer any more, so the only
|
||||
* thing standing between a queued run and a moving mouse is whether
|
||||
* clicker/runner.py is up to claim it.
|
||||
*/
|
||||
|
||||
import { createRun } from './db';
|
||||
import { findAutomation, resolveSteps } from './automations';
|
||||
|
||||
export class QueueRunError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message);
|
||||
this.name = 'QueueRunError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueuedRun {
|
||||
runId: number;
|
||||
totalSteps: number;
|
||||
inputs: Record<string, number>;
|
||||
}
|
||||
|
||||
export function queueRun(automationId: string, supplied: Record<string, unknown> = {}): QueuedRun {
|
||||
const found = findAutomation(automationId);
|
||||
if (!found) {
|
||||
throw new QueueRunError(`No automation "${automationId}"`, 404);
|
||||
}
|
||||
if (found.automation.steps.length === 0) {
|
||||
throw new QueueRunError(`"${found.automation.label}" has no steps yet`, 400);
|
||||
}
|
||||
// Clamp every input to what the automation declared. The count decides how
|
||||
// many times a purchase runs, so it is not taken on trust from the client.
|
||||
const inputs: Record<string, number> = {};
|
||||
for (const declared of found.automation.inputs ?? []) {
|
||||
const raw = Number(supplied[declared.id]);
|
||||
const value = Number.isFinite(raw) ? Math.floor(raw) : declared.default;
|
||||
inputs[declared.id] = Math.min(declared.max, Math.max(declared.min, value));
|
||||
}
|
||||
|
||||
let resolved;
|
||||
try {
|
||||
resolved = resolveSteps(found.firm, found.automation, inputs);
|
||||
} catch (err: any) {
|
||||
throw new QueueRunError(err?.message ?? 'Could not expand the steps', 400);
|
||||
}
|
||||
|
||||
const run = createRun(automationId, resolved.length, inputs);
|
||||
return { runId: run.id, totalSteps: run.total_steps, inputs };
|
||||
}
|
||||
+37
-23
@@ -1,11 +1,18 @@
|
||||
/**
|
||||
* Reporter module — pushes aggregated state to the master dashboard.
|
||||
*
|
||||
* Every 30 seconds, collects firm-level stats + scheduler state and POSTs
|
||||
* Every 30 seconds, collects firm-level stats + scheduler state and sends them
|
||||
* to the configured master_dashboard_url. Silently skips if not configured.
|
||||
*
|
||||
* Two transports, in that order of preference: the control WebSocket in
|
||||
* `master-link.ts` when it is up, and the original POST /api/report when it is
|
||||
* not. The socket already exists for commands, so reusing it saves a request
|
||||
* per tick — but a master on an older build has no socket to offer, and the
|
||||
* POST keeps those working unchanged.
|
||||
*/
|
||||
|
||||
import { getSetting } from './db';
|
||||
import { pushReport } from './master-link';
|
||||
import { getFirms } from './db';
|
||||
import { getClients } from './clients';
|
||||
import { getSchedulerStatus } from './auto-trade';
|
||||
@@ -69,35 +76,42 @@ function collectFirmStats() {
|
||||
// HMR-safe global to avoid duplicate intervals
|
||||
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
|
||||
|
||||
/**
|
||||
* Collect and send one report. Exported so the control link can fire an
|
||||
* off-cycle one the moment a command has changed something — waiting up to 30s
|
||||
* to see whether your own button worked is the wrong feedback loop.
|
||||
*/
|
||||
export async function sendReport(): Promise<void> {
|
||||
const url = getSetting('master_dashboard_url');
|
||||
const instanceName = getSetting('instance_name');
|
||||
|
||||
if (!url || !instanceName) return;
|
||||
|
||||
try {
|
||||
const firms = collectFirmStats();
|
||||
const scheduler = getSchedulerStatus();
|
||||
|
||||
if (pushReport(firms, scheduler)) return;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
_g.__reporterInterval = setInterval(sendReport, 30_000);
|
||||
|
||||
// Fire first report after a short delay to let clients sync
|
||||
setTimeout(report, 10_000);
|
||||
setTimeout(sendReport, 10_000);
|
||||
|
||||
console.log('[reporter] started — reporting every 30s');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user