Repository Public
1// Signal runner for perp agents: `python -m spq.signal --contract 2` in a separate process with an almost empty environment2// (no secret, no database URL), a timeout and a bounded output, inside the OS sandbox of lab.ts (v8) — exactly as the spot runner (live.ts).3// Any deviation throws, and the engine holds.4import { existsSync } from 'node:fs';5import path from 'node:path';6import { labExec, RESEARCH_DIR, SIGNAL_MAX_RSS_MB } from './lab.ts';7import type { PerpInterval, PerpSeries } from './perp-data.ts';8import type { PerpSignal } from './perp-engine.ts';910const SIGNAL_TIMEOUT_MS = 60_000;1112/** signal-input-v2: candles + funding per perpetual; nothing dated after the last closed bar's open */13export function perpSignalInput(data: Record<string, PerpSeries>, interval: PerpInterval): { input: string; last: number } {14 const syms = Object.keys(data);15 if (!syms.length) throw new Error('no series');16 const first = data[syms[0]!]!, last = first.bars.at(-1)?.ts;17 if (last === undefined || syms.some((s) => data[s]!.bars.at(-1)?.ts !== last || data[s]!.bars.length !== first.bars.length)) throw new Error('series are not aligned on the same closed bars');18 const candles = Object.fromEntries(syms.map((s) => [s, data[s]!.bars.map((b) => [Math.floor(b.ts / 1000), b.open, b.high, b.low, b.close, b.volume])]));19 const funding = Object.fromEntries(syms.map((s) => [s, data[s]!.funding.filter((f) => f.ts <= last).map((f) => (f.premium === null ? [Math.floor(f.ts / 1000), f.rate] : [Math.floor(f.ts / 1000), f.rate, f.premium]))]));20 return { input: JSON.stringify({ candles, funding, interval, as_of: new Date(last).toISOString().replace('.000Z', 'Z') }), last };21}2223export async function runPerpSignal(strategy: { path: string; sha256: string }, data: Record<string, PerpSeries>, interval: PerpInterval): Promise<PerpSignal> {24 const dir = path.resolve(RESEARCH_DIR, strategy.path);25 if (!dir.startsWith(path.join(RESEARCH_DIR, 'strategies') + path.sep) || !existsSync(path.join(dir, 'strategy.py'))) throw new Error('strategy directory is not inside research/strategies');26 if (!/^[0-9a-f]{64}$/.test(strategy.sha256)) throw new Error('bad strategy hash');27 const { input, last } = perpSignalInput(data, interval);28 let r;29 try { r = await labExec(['spq.signal', '--contract', '2', '--strategy', dir, '--sha256', strategy.sha256, '--input', '-'], { timeoutMs: SIGNAL_TIMEOUT_MS, stdin: input, strategyDir: dir, maxOut: 100_000, onOverflow: 'kill', maxRssMb: SIGNAL_MAX_RSS_MB }); }30 catch (e) { throw new Error(`signal runner could not start: ${(e as Error).message}`); }31 if (r.killed === 'timeout') throw new Error('signal timed out');32 if (r.killed === 'output') throw new Error('signal output too large');33 if (r.killed === 'memory') throw new Error('signal used too much memory');34 if (r.code === null) throw new Error(`signal runner could not start: ${r.err.trim().split('\n').pop()?.slice(0, 200) ?? ''}`);35 if (r.code !== 0) throw new Error(`signal exit ${r.code}: ${r.err.trim().split('\n').pop()?.slice(0, 200) ?? ''}`);36 try {37 const j = JSON.parse(r.out) as { schema_version?: number; kind?: string; strategy_sha256?: string; as_of?: string; weights?: Record<string, number>; max_leverage?: number; max_gross_exposure?: number };38 if (j.schema_version !== 2 || j.kind !== 'perp') throw new Error('not a contract-2 (perp) signal');39 if (j.strategy_sha256 !== strategy.sha256) throw new Error('signal computed by another strategy hash');40 if (!j.as_of || Date.parse(j.as_of) !== last) throw new Error('signal is not for the last closed bar');41 if (!j.weights || typeof j.weights !== 'object' || typeof j.max_leverage !== 'number' || typeof j.max_gross_exposure !== 'number') throw new Error('no weights or no leverage bounds');42 return { weights: j.weights, maxLeverage: j.max_leverage, maxGrossExposure: j.max_gross_exposure };43 } catch (e) { throw new Error(`unreadable signal: ${(e as Error).message}`); }44}45