Four features: - Backup: lib/backup-sync.ts pushes the full settings table and firm credentials (the deliberate exception to "credentials never leave the machine") to the master's bearer-gated /api/instances/backup — every 5 minutes when the content hash changed, within seconds of a settings or credential edit, and on the new backup-now command. Enabled by the master_backup_token setting. - Restore: a dead VPS's replacement pulls the backup and adopts its uuid/name, so roster, payouts and card links carry on. Pre-boot via seed-settings.js --restore-from (setup-linux.sh prompts for it) or the Settings page's restore picker (lib/backup-restore.ts). Stop the old machine first; the master 409s a live-name rename. - Proxy: the proxy_url setting routes BROKER traffic only — TopstepX REST + SignalR hub, Tradovate REST + websocket, Yahoo quotes — via lib/outbound-proxy.ts (axios agents, undici dispatcher, ws agent). The master link, reporter, catalog and backup push stay direct, so a dead proxy cannot take down the control channel. Restart to apply. - Multi-instance: AUTOFIRMER_DB moves the SQLite file so one checkout runs N instances; scripts/instances.json (per-device, gitignored) drives start-all/update-check, and add-instance.sh / remove-instance.sh manage the extra PM2 processes. Absent registry = single-instance behavior, unchanged. Instance 1 keeps port 3000 for the clicker and extension. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
222 lines
8.9 KiB
TypeScript
222 lines
8.9 KiB
TypeScript
import { TradovateClient } from './tradovate-class';
|
|
import { TopstepXClient } from './topstepx-class';
|
|
import type { PlatformClient } from './platform-client';
|
|
import { getInstruments } from './db';
|
|
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
|
import { resolveFirmAssignments } from './account-type-resolver';
|
|
import { resolveContracts } from './contract-resolver';
|
|
import { startReporter, sendReport } from './reporter';
|
|
import { startBackupSync } from './backup-sync';
|
|
import { applyBrokerProxy } from './outbound-proxy';
|
|
import { startMasterLink } from './master-link';
|
|
import { resumeSchedulerIfPersisted } from './auto-trade';
|
|
|
|
// Use global to persist the client pool across HMR reloads in dev mode.
|
|
// Keyed by the master's firm id, which is a uuid string.
|
|
const g = global as typeof globalThis & {
|
|
__tradovateClients?: Map<string, PlatformClient>;
|
|
__tradovateClientsInitialized?: boolean;
|
|
__tradovateClientCreds?: Map<string, string>;
|
|
__contractResolverTimer?: ReturnType<typeof setInterval>;
|
|
};
|
|
|
|
function ensureMap(): Map<string, PlatformClient> {
|
|
if (!g.__tradovateClients) {
|
|
g.__tradovateClients = new Map();
|
|
}
|
|
return g.__tradovateClients;
|
|
}
|
|
|
|
/** Tracks which credentials each live client was built with, so a password
|
|
* change can be noticed and the client rebuilt. */
|
|
function credsMap(): Map<string, string> {
|
|
if (!g.__tradovateClientCreds) g.__tradovateClientCreds = new Map();
|
|
return g.__tradovateClientCreds;
|
|
}
|
|
|
|
function credsSignature(firm: { platform: string; username: string; password: string; apiKey: string }): string {
|
|
return `${firm.platform} ${firm.username} ${firm.password} ${firm.apiKey}`;
|
|
}
|
|
|
|
export function initClient(
|
|
id: string,
|
|
firmName: string,
|
|
platform: 'tradovate' | 'topstepx',
|
|
creds: { username: string; password: string; apiKey: string },
|
|
): PlatformClient {
|
|
const map = ensureMap();
|
|
const onSyncComplete = async () => {
|
|
const c = map.get(id);
|
|
if (!c) return;
|
|
console.log(`[${firmName}] sync complete — ${c.accountList.length} account(s)`);
|
|
// Every sync is a chance to bind accounts whose funding history has
|
|
// finally arrived. The first pass can already resolve untraded accounts
|
|
// from their exact cash balance; traded ones wait for the separate
|
|
// reports fetch that produces fund transactions.
|
|
try {
|
|
const counts = resolveFirmAssignments(id, c);
|
|
console.log(
|
|
`[resolver] ${firmName}: ${counts.assigned} assigned, `
|
|
+ `${counts.unassigned} unassigned, ${counts.manual} manual`,
|
|
);
|
|
} catch (err) {
|
|
console.error(`[resolver] ${firmName} failed`, err);
|
|
}
|
|
};
|
|
|
|
const client: PlatformClient = platform === 'topstepx'
|
|
? new TopstepXClient(creds.username, creds.apiKey, onSyncComplete)
|
|
: new TradovateClient(creds.username, creds.password, onSyncComplete);
|
|
map.set(id, client);
|
|
credsMap().set(id, credsSignature({ platform, ...creds }));
|
|
return client;
|
|
}
|
|
|
|
export function removeClient(id: string): void {
|
|
const client = ensureMap().get(id);
|
|
client?.disconnect();
|
|
ensureMap().delete(id);
|
|
credsMap().delete(id);
|
|
}
|
|
|
|
/** Disconnect all clients and clear the pool so they are recreated on next getClients() call. */
|
|
export function resetClients(): void {
|
|
const map = g.__tradovateClients;
|
|
if (map) {
|
|
for (const client of map.values()) {
|
|
try { client.disconnect(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
g.__tradovateClients = undefined;
|
|
g.__tradovateClientCreds = undefined;
|
|
g.__tradovateClientsInitialized = false;
|
|
}
|
|
|
|
/**
|
|
* Brings the client pool in line with the catalog and the stored credentials.
|
|
*
|
|
* Called at boot, whenever credentials are saved, and after the catalog
|
|
* changes — a firm can appear, disappear, or have its password corrected long
|
|
* after startup, none of which the old one-shot initialization could handle.
|
|
*/
|
|
export function reconcileClients(): void {
|
|
const map = ensureMap();
|
|
const firms = getTradableFirms();
|
|
const wanted = new Set(firms.map((f) => f.id));
|
|
|
|
// Firms removed on the master, or whose credentials were cleared.
|
|
for (const id of [...map.keys()]) {
|
|
if (!wanted.has(id)) {
|
|
console.log(`[clients] dropping client for firm ${id} — no longer tradable`);
|
|
removeClient(id);
|
|
}
|
|
}
|
|
|
|
for (const firm of firms) {
|
|
const signature = credsSignature(firm);
|
|
const existing = map.get(firm.id);
|
|
if (existing) {
|
|
if (credsMap().get(firm.id) === signature) continue;
|
|
console.log(`[clients] credentials changed for ${firm.name} — reconnecting`);
|
|
removeClient(firm.id);
|
|
}
|
|
console.log(`[clients] starting ${firm.platform} client for ${firm.name}`);
|
|
initClient(firm.id, firm.name, firm.platform, firm);
|
|
}
|
|
}
|
|
|
|
export function getClients(): Map<string, PlatformClient> {
|
|
const map = ensureMap();
|
|
if (!g.__tradovateClientsInitialized) {
|
|
g.__tradovateClientsInitialized = true;
|
|
try {
|
|
// Before anything dials out: put the operator's proxy (if any)
|
|
// under the broker clients. Master traffic stays direct by design.
|
|
applyBrokerProxy();
|
|
|
|
// Pull the catalog before anything else — without it there are no
|
|
// firms to start clients for. Subsequent changes re-reconcile and
|
|
// re-resolve, so a new account type picks up accounts that were
|
|
// sitting Unassigned without waiting for a Tradovate sync.
|
|
startCatalogSync(() => {
|
|
reconcileClients();
|
|
for (const [firmId, client] of ensureMap()) {
|
|
try {
|
|
resolveFirmAssignments(firmId, client);
|
|
} catch (err) {
|
|
console.error(`[resolver] re-resolve failed for ${firmId}`, err);
|
|
}
|
|
}
|
|
});
|
|
|
|
reconcileClients();
|
|
console.log(`[clients] Initialized ${map.size} platform client(s)`);
|
|
|
|
// Auto-resolve contracts after clients have time to authenticate
|
|
setTimeout(() => triggerContractResolve(), 15_000);
|
|
|
|
// Schedule daily resolve at midnight
|
|
scheduleDailyResolve();
|
|
|
|
// Start master dashboard reporter
|
|
startReporter();
|
|
|
|
// Push settings+credentials backups to the master, so a dead VPS
|
|
// can be replaced and this identity adopted.
|
|
startBackupSync();
|
|
|
|
// ...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) {
|
|
console.error('[clients] Failed to initialize clients', err);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// ── Contract auto-resolve ────────────────────────────────────────────────────
|
|
|
|
function triggerContractResolve(): void {
|
|
const map = ensureMap();
|
|
// Find first client with a valid access token (the Yahoo-driven resolver
|
|
// confirms contract ids against Tradovate; TopstepX clients resolve their
|
|
// own contracts but still reuse the resolver's front-month names).
|
|
let accessToken: string | null = null;
|
|
for (const [, c] of map) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const token = (c as any).accessInfo?.accessToken;
|
|
if (token) { accessToken = token; break; }
|
|
}
|
|
if (!accessToken) {
|
|
console.log('[contract-resolver] No authenticated Tradovate client yet — skipping resolve');
|
|
return;
|
|
}
|
|
|
|
const symbols = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
|
console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`);
|
|
resolveContracts(symbols, accessToken)
|
|
.then((results) => {
|
|
const rolled = Object.entries(results).filter(([, v]) => v?.alternative);
|
|
console.log(`[contract-resolver] Done — ${rolled.length} contract(s) rolled via price match`);
|
|
})
|
|
.catch((err) => console.error('[contract-resolver] Resolve failed:', err));
|
|
}
|
|
|
|
function scheduleDailyResolve(): void {
|
|
// Clear any existing timer (HMR safety)
|
|
if (g.__contractResolverTimer) clearInterval(g.__contractResolverTimer);
|
|
|
|
// Check every minute if it's midnight (00:00)
|
|
g.__contractResolverTimer = setInterval(() => {
|
|
const now = new Date();
|
|
if (now.getHours() === 0 && now.getMinutes() === 0) {
|
|
console.log('[contract-resolver] Midnight resolve triggered');
|
|
triggerContractResolve();
|
|
}
|
|
}, 60_000);
|
|
}
|