- lib/instrument-specs.ts (pure, tested): the CME specs — point value, tick size, one-tick spread, Yahoo ticker — replacing POINT_VALUES; the draw's maths; the 12-hour hold cap; the micro for drain and extra-day legs; reading a firm's list (the master's, or the old rules against an older master: this machine's enabled set less bans, the symbol map). - lib/instruments.ts: the one module the trading paths ask. Measures every future's daily move from Yahoo's hourly candles once per trade date, and shows entry cost (a tick of spread plus Tradovate's fee parameters or TopstepX's last measured fill) without ever choosing by it. - Futures Auto draws one root per batch from the batch firm's list: movement per dollar of spread early in the session with a 10% floor each, sliding to the fastest movers by the last entry; nothing expected to hold 12h or more; micros only if nothing else is listed. Every draw logs its odds. - The batch is chosen before its instrument, and a group that can't trade anything yields to the next. Copy trade matches the held contract against the listed roots and won't copy an instrument it can't identify. Manual legs, manual orders and drain/extra-day legs follow the lists. - DXtrade firms rank their own list whatever root was drawn. - Capabilities, the dashboard's trade bar and contract resolves follow the lists; the trade bar and scheduler default to Auto. The master link reconnects when master_dashboard_url changes, and the catalog drops ?since after a switch so two masters' versions can't collide. - Reports carry each futures firm's instrumentStats for the master's Firms page; GET /api/instruments shows them (?simulate=N runs the draw, no orders); the Settings page shows the lists read-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
/**
|
|
* 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, setSetting, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
|
|
import { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade';
|
|
import { FIRMS, automationKey } from './automations';
|
|
import { CME_ROOTS, CME_SPECS } from './instrument-specs';
|
|
import { futuresRootsHere } from './instruments';
|
|
import { queueRun } from './queue-run';
|
|
import { pushBackup } from './backup-sync';
|
|
import { getBuildVersion } from './version';
|
|
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;
|
|
/** The master_dashboard_url the socket was opened for. */
|
|
url: string | 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, url: 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 {
|
|
// The roots this machine's futures firms list (the master's per-firm
|
|
// instrument lists); none listed yet means offer everything we can price.
|
|
const listed = futuresRootsHere();
|
|
|
|
// 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: listed.length > 0 ? listed : [...CME_ROOTS],
|
|
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' && !CME_SPECS[command.symbol]) {
|
|
throw new Error(`Unknown symbol: ${command.symbol}`);
|
|
}
|
|
// Same order as POST /api/trade: (re)start the scheduler first,
|
|
// then fire once now — the scheduler picks up accounts that were
|
|
// busy. Starting it first means a batch that throws or runs long
|
|
// can never leave it unstarted. It can't double-fire against this
|
|
// batch: the tick waits for flat positions, skips while this batch
|
|
// holds the trade mutex, and never picks an account in flight.
|
|
startScheduler(command.action, command.symbol, command.stopAfterAll ?? false);
|
|
const results = await runTrade(command.action, command.symbol);
|
|
|
|
const filled = results
|
|
.flatMap((r) => r.results)
|
|
.filter((r) => 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 };
|
|
|
|
case 'backup-now':
|
|
return pushBackup(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;
|
|
}
|
|
}
|
|
|
|
/** A changed master_dashboard_url drops the socket, and the reconnect dials
|
|
* the new master — otherwise reports and commands would stay with the old
|
|
* one until a restart, while the catalog followed the setting. */
|
|
function followMasterUrl(): void {
|
|
const s = state();
|
|
if (!s.socket || s.url === getSetting('master_dashboard_url')) return;
|
|
console.log('[master-link] master_dashboard_url changed — reconnecting');
|
|
try { s.socket.close(1000, 'master changed'); } catch { /* the close handler reconnects */ }
|
|
}
|
|
|
|
/** True when the report went up the socket, so the caller can skip the POST. */
|
|
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
|
|
followMasterUrl();
|
|
return send({
|
|
type: 'report',
|
|
firms: firms as never,
|
|
scheduler: scheduler as never,
|
|
capabilities: collectCapabilities(),
|
|
version: getBuildVersion() ?? undefined,
|
|
});
|
|
}
|
|
|
|
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.url = masterUrl;
|
|
s.ready = false;
|
|
|
|
socket.addEventListener('open', () => {
|
|
// The stored uuid, once the master has issued one — it, not the name,
|
|
// is who this instance is, so renaming instance_name keeps the same
|
|
// person (and their accounting history) on the master.
|
|
const instanceId = getSetting('instance_id');
|
|
socket.send(JSON.stringify({
|
|
type: 'hello',
|
|
instance,
|
|
id: instanceId || undefined,
|
|
capabilities: collectCapabilities(),
|
|
version: getBuildVersion() ?? undefined,
|
|
} 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;
|
|
if (msg.personId && msg.personId !== getSetting('instance_id')) {
|
|
setSetting('instance_id', msg.personId);
|
|
}
|
|
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');
|
|
}
|