Roid Runner
A frame-by-frame streaming execution engine that runs a compiled Slang script bar-by-bar without any graph UI — powering live signals, backtests, and chart overlays.
Overview
The Roid Runner is a headless execution engine. It takes a Slang source string, compiles it into an ordered array of CompiledSteps, and then calls each step's exec(memory, frameIdx) closure once per data frame. Source steps push one pre-fetched OHLCV bar into the shared RoidMemory; compute steps call an IncrementalRunner with the latest scalar values from their input channels.
Three execution modes are supported: synchronous (all frames, blocks), asynchronous frame-by-frame with configurable delay (for UI streaming), and implicit single-frame execution by calling step.exec directly for backtesting replay.
Incremental design
Each runner maintains its own rolling state (window buffers, EMA values, etc.). No O(n²) recalculation — every push() call is O(1) or O(period).
State reset on re-run
When frameIdx === 0, each CompiledStep recreates its runner via the factory function, clearing all accumulated state for a clean re-run.
Memory isolation
RoidMemory is a Map<varName, channels>. Each step reads its inputs from memory and writes its outputs back. Channels grow one element per frame.
Key Types
RoidMemorytype RoidMemory = Map<string, Record<string, unknown>>;
// key = variable name declared in the script (e.g. "sma", "pos")
// value = channel map: { values: number[], timestamps: number[], … }
// Arrays grow by one element per frame.Shared memory store. Passed by reference to every CompiledStep. Source steps initialise the channel arrays on frame 0 and push one value per frame. Compute steps do the same from their IncrementalRunner output.
CompiledStepinterface CompiledStep {
exec: (memory: RoidMemory, frameIdx: number) => void;
meta: CompiledStepMeta;
}
interface CompiledStepMeta {
varName: string;
funcName: string;
kind: 'source' | 'compute';
inputRefs: string[]; // variable names of input nodes
args: Record<string, string | number>; // baked param values
}A single execution unit. exec() is called once per frame. The meta object carries human-readable metadata for debugging and progress displays.
CompiledRoidinterface CompiledRoid {
steps: CompiledStep[];
totalFrames: number; // length of the first source's arrays
}The compiled output of compileRoid(). steps is in topological order matching the script. totalFrames is determined by the first source node's data length.
RoidRunResultinterface RoidRunResult {
success: boolean;
memory: RoidMemory;
errors: string[]; // e.g. "frame 42: TypeError: …"
}Returned by runRoid() and passed to onDone() in runRoidAsync(). The memory snapshot contains the full accumulated arrays for every variable.
IncrementalRunnerinterface IncrementalRunner {
push(inputs: Record<string, unknown>[]): Record<string, unknown>;
}
type RunnerFactory =
(args: Record<string, string | number>) => IncrementalRunner;The protocol every compute runner must implement. push() receives an array of channel maps (one per input variable) and returns an output channel map. The factory is called to create a fresh runner, and is re-called on frame 0 for state reset.
compileRoid()
export async function compileRoid( source: string, prefetchedData?: Record<string, unknown>, ): Promise<CompiledRoid>
The optional prefetchedData parameter allows callers to supply pre-loaded market data. When omitted, each source step calls the appropriate SOURCES function to fetch data asynchronously on compilation.
Compilation Steps
Reset position state
resetRoidPositionState() is called before processing any commands, clearing all position tracking variables from the previous run.
Parse
parse(source) tokenises the source. All parser modules (Source, Indicator, Logical, Risk, Action, Pattern, Utility, Math) must have been imported (side-effects) before this call.
Iterate commands
For each SlangCommand in topological order (as written in the script): look up the CommandDef, coerce parameters at compile time into bakedArgs, validate that all input refs have been declared earlier.
Source step
If SOURCES[funcName] exists, call the source function (async) to fetch the full data array. Bake closures that push one array element per frame. totalFrames is set to the first source's array length.
Compute step
Otherwise, look up the RunnerFactory in INDICATOR_RUNNERS, LOGICAL_RUNNERS, RISK_RUNNERS, ACTION_RUNNERS, PATTERN_RUNNERS, UTILITY_RUNNERS, or MATH_RUNNERS (checked in order). Bake a closure that calls runner.push() each frame and appends output channels to memory.
How compute steps extract inputs
Each frame, a compute step reads the latest value from every input channel:
// Inside the CompiledStep.exec closure for a compute step:
const bars = inputRefs.map(ref => {
const data = memory.get(ref)!;
const bar: Record<string, unknown> = {};
for (const ch of Object.keys(data)) {
const val = data[ch];
// Extract last element of arrays; pass scalars through as-is
bar[ch] = Array.isArray(val) ? val[val.length - 1] : val;
}
return bar;
});
// bars[0] = { c: 150.2, h: 151.0, l: 149.5, t: 1715000000 } ← latest bar
const out = runner.push(bars);runRoid() & runRoidAsync()
runRoid — synchronous
function runRoid(roid: CompiledRoid): RoidRunResult
Iterates all totalFrames in a tight loop. For each frame, every step is called in order. Errors within a step are caught per-frame and accumulated in the errors array rather than aborting the run. Returns the full memory after the final frame.
Best for: backtesting replay where all data is already available and speed matters.
runRoidAsync — frame-by-frame
function runRoidAsync( roid: CompiledRoid, delayMs: number, onProgress: (frame, total, memory) => void, onDone: (result) => void, ): () => void // cancel function
Advances one frame per setTimeout(tick, delayMs) tick. After each frame, a deep clone of memory (snapshotMemory()) is passed to onProgress for live UI updates. Returns a cancel function that sets an internal cancelled flag.
Best for: live signal streaming, chart playback overlays.
Memory snapshot
snapshotMemory() performs a shallow clone of each channel map, deep-cloning array channels via spread ([...val]). This guarantees that onProgress receives a stable snapshot even as the live memory continues to grow.
Runner Modules
Each runner module exports a Record<string, RunnerFactory> constant. The Roid Runner merges all of them with the ?? fallback chain to look up the correct factory by command name.
| Module | Exported constant | Commands covered |
|---|---|---|
| RoidRunnerSources.ts | SOURCES | STOCK, CRYPTO, FOREX — async data fetchers (return full arrays, not IncrementalRunners) |
| RoidIndicatorFunc.ts | INDICATOR_RUNNERS | All 200+ ta.* indicator commands (SMA, EMA, RSI, MACD, ICHIMOKU, BOLLINGER_BANDS, …) |
| RoidLogicalFunc.ts | LOGICAL_RUNNERS | COMPARE, BOOLEAN, CROSSOVER, NOT, WAIT |
| RoidRiskManagementFunc.ts | RISK_RUNNERS | TAKEPROFIT, STOPLOSS, KELLY, RISK_PER_TRADE |
| RoidActionFunc.ts | ACTION_RUNNERS | OPENPOSITION, CLOSEPOSITION |
| RoidPatternRunner.ts | PATTERN_RUNNERS | All candlestick pattern commands (DOJI, HAMMER, ENGULFING, …) |
| RoidUtilityRunner.ts | UTILITY_RUNNERS | GETVALUE, CANDLECOLOR, SERIALIZER, SHIFT |
| RoidMathFunc.ts | MATH_RUNNERS | ABS, ADD, DIVIDE, MULT, SMA windowed stats, NORMALIZE, SCALE, … |
Writing a RunnerFactory
A factory receives the baked parameter map and returns an IncrementalRunner. Stateful variables are captured in the factory's closure. Example — a simple SMA runner:
const SMA: RunnerFactory = (args) => {
const period = args.period as number;
const window: number[] = []; // rolling window — closed over
let sum = 0;
return {
push(inputs) {
const src = inputs[0];
const close = src.c as number;
window.push(close);
sum += close;
if (window.length > period) sum -= window.shift()!;
return {
values: window.length >= period ? sum / period : null,
timestamps: src.t,
};
},
};
};RoidPositionState
RoidPositionState.ts is a module-level singleton that tracks a single open position across the OPENPOSITION and CLOSEPOSITION runner closures within one Roid execution. It is reset by resetRoidPositionState() at the start of every compileRoid() call.
State variables
isOpenbooleanWhether a position is currently open.entryPricenumber | nullPrice at which the position was opened.frozenTpnumber | nullTake-profit level frozen at entry.frozenSlnumber | nullStop-loss level frozen at entry.lastCloseReasonstring"tp", "sl", "signal", or "" (consumed once by consumeCloseReason).priceHistory{ value, timestamp }[]Accumulated price bars pushed during the open position.Public API
isPositionOpen()Returns the open flag.getEntryPrice()Returns entry price or null.getFrozenTp()Returns frozen TP or null.getFrozenSl()Returns frozen SL or null.openPosition(entry, tp, sl)Marks position as open, sets entry/TP/SL.closePosition(reason)Marks position as closed, stores the close reason.consumeCloseReason()Returns the last close reason then resets it to "".pushPriceData(value, timestamp)Appends a price bar to history.getPriceHistory()Returns the price history array.resetRoidPositionState()Resets all state to initial values. Called by compileRoid().Position lifecycle within a Roid run
- OPENPOSITION runner checks
isPositionOpen()— if already open, skips. - When the signal fires (1), calls
openPosition(entryPrice, tp, sl). - Each subsequent frame, the runner checks if TP or SL has been hit. If so, calls
closePosition("tp")orclosePosition("sl"). - CLOSEPOSITION runner checks
isPositionOpen()— if the close signal fires, callsclosePosition("signal"). - At the start of the next
compileRoid()call,resetRoidPositionState()wipes all state for the new run.
RoidRunnerSources — SOURCES
The SOURCES map is keyed by command name (STOCK, CRYPTO, FOREX) and maps to an async function that accepts the baked args and returns a Record<string, unknown> containing the full OHLCV arrays. The Roid compiler calls this at compile time; subsequent frames just index into the pre-fetched arrays.
// Pattern of a SOURCES entry:
const SOURCES: Record<string, (args: Record<string, string | number>) => Promise<Record<string, unknown>>> = {
STOCK: async (args) => {
const { symbol, timespan, multiplier } = args;
// Fetch from API...
return { t: [...], o: [...], h: [...], l: [...], c: [...], v: [...] };
},
CRYPTO: async (args) => { … },
FOREX: async (args) => { … },
};The prefetchedData argument to compileRoid() bypasses the source function entirely, allowing callers (e.g. the backtesting engine) to inject pre-loaded data without additional network requests.
Usage Example
import { compileRoid, runRoid, runRoidAsync } from './roidRunner/RoidRunner';
const source = `
s = sc.STOCK("AAPL", "day", 1)
fast = ta.SMA(s, 10)
slow = ta.SMA(s, 50)
sig = dm.CROSSOVER(fast, slow)
`;
// ── Synchronous run ──────────────────────────────────────────────────
const roid = await compileRoid(source);
const result = runRoid(roid);
if (result.success) {
const sigValues = result.memory.get('sig')?.values as (number | null)[];
const lastSignal = sigValues?.at(-1);
console.log('Last signal:', lastSignal); // 1 = bullish crossover, 0 = not
}
// ── Async run with live progress ─────────────────────────────────────
const cancel = runRoidAsync(
roid,
16, // ~60fps
(frame, total, memory) => {
const pct = Math.round((frame / total) * 100);
console.log(`${pct}% — fast[${frame}] = ${
(memory.get('fast')?.values as number[]).at(-1)
}`);
},
(result) => {
console.log('Done. Errors:', result.errors);
},
);
// Optionally cancel mid-run:
// cancel();