From 99b1d5891e2c9341a67f7016c5a7fc430c9009c2 Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Mon, 13 Jul 2026 00:25:52 +0200 Subject: [PATCH 1/2] feat(trading-strategies): add declarative strategy graphs (GraphStrategy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strategies can now be expressed as JSON graphs of typed building blocks (candle source, batcher, field, indicator, if, order advice) and executed by GraphStrategy, an interpreter implementing TradingSessionStrategy — so graphs backtest and trade through the same executors as hand-written strategies. - Zod-validated graph schema (versioned) with node-addressable errors, cycle detection, port type checking, and single-writer inputs - Node registry with registerNodeType() as a public extension point; evaluators support async evaluate() and an async init() hook for loading external datasets deterministically - Warm-up safety: nodes emit only when stable; held values between ticks make mixed timeframes structurally lookahead-free - Equivalence proven by tests: the SMA crossover template graph produces advice-for-advice and balance-identical backtests vs SmaCrossoverStrategy --- packages/trading-strategies/src/index.ts | 1 + .../src/strategy-graph/GraphSchema.ts | 31 ++ .../src/strategy-graph/GraphStrategy.test.ts | 406 ++++++++++++++++++ .../src/strategy-graph/GraphStrategy.ts | 193 +++++++++ .../src/strategy-graph/NodeRegistry.ts | 362 ++++++++++++++++ .../src/strategy-graph/index.ts | 27 ++ .../src/strategy-graph/templates.ts | 68 +++ 7 files changed, 1088 insertions(+) create mode 100644 packages/trading-strategies/src/strategy-graph/GraphSchema.ts create mode 100644 packages/trading-strategies/src/strategy-graph/GraphStrategy.test.ts create mode 100644 packages/trading-strategies/src/strategy-graph/GraphStrategy.ts create mode 100644 packages/trading-strategies/src/strategy-graph/NodeRegistry.ts create mode 100644 packages/trading-strategies/src/strategy-graph/index.ts create mode 100644 packages/trading-strategies/src/strategy-graph/templates.ts diff --git a/packages/trading-strategies/src/index.ts b/packages/trading-strategies/src/index.ts index bfd76a8eb..e13ffbcc4 100644 --- a/packages/trading-strategies/src/index.ts +++ b/packages/trading-strategies/src/index.ts @@ -1,5 +1,6 @@ export * from './backtest/index.js'; export * from './strategy/index.js'; +export * from './strategy-graph/index.js'; export * from './trader/index.js'; export {BuyOnceStrategy, BuyOnceSchema, type BuyOnceConfig} from './strategy-buy-once/BuyOnceStrategy.js'; /** @deprecated Use {@link BuyOnceStrategy} without `buyAt` instead. */ diff --git a/packages/trading-strategies/src/strategy-graph/GraphSchema.ts b/packages/trading-strategies/src/strategy-graph/GraphSchema.ts new file mode 100644 index 000000000..4a01666fa --- /dev/null +++ b/packages/trading-strategies/src/strategy-graph/GraphSchema.ts @@ -0,0 +1,31 @@ +import {z} from 'zod'; + +/** + * A strategy expressed as data instead of code: the same JSON drives the visual editor, + * validation, sharing, and the {@link GraphStrategy} interpreter, so what users see on + * the canvas is exactly what runs in a backtest or live session. + */ +export const StrategyGraphSchema = z.object({ + connections: z.array( + z.object({ + from: z.object({node: z.string().min(1), port: z.string().min(1).default('out')}), + to: z.object({node: z.string().min(1), port: z.string().min(1).default('in')}), + }) + ), + name: z.string().optional(), + nodes: z.record( + z.string().min(1), + z.object({ + config: z.record(z.string(), z.unknown()).optional(), + /** Canvas coordinates for the visual editor. Ignored by the interpreter. */ + position: z.object({x: z.number(), y: z.number()}).optional(), + type: z.string().min(1), + }) + ), + version: z.literal(1), +}); + +export type StrategyGraph = z.infer; +export type StrategyGraphInput = z.input; +export type GraphNode = StrategyGraph['nodes'][string]; +export type GraphConnection = StrategyGraph['connections'][number]; diff --git a/packages/trading-strategies/src/strategy-graph/GraphStrategy.test.ts b/packages/trading-strategies/src/strategy-graph/GraphStrategy.test.ts new file mode 100644 index 000000000..b90f6e74c --- /dev/null +++ b/packages/trading-strategies/src/strategy-graph/GraphStrategy.test.ts @@ -0,0 +1,406 @@ +import Big from 'big.js'; +import {describe, expect, it} from 'vitest'; +import {AlpacaBrokerMock, CandleBatcher, OrderSide, TradingPair} from '@typedtrader/exchange'; +import type {Candle, OneMinuteBatchedCandle} from '@typedtrader/exchange'; +import {OrderType} from '@typedtrader/exchange'; +import {BacktestExecutor} from '../backtest/BacktestExecutor.js'; +import {SmaCrossoverStrategy} from '../strategy-sma-crossover/SmaCrossoverStrategy.js'; +import {AllAvailableAmount} from '../trader/index.js'; +import type {OrderAdvice, TradingSessionState, TradingSessionStrategy} from '../trader/index.js'; +import {z} from 'zod'; +import {GraphStrategy} from './GraphStrategy.js'; +import type {StrategyGraphInput} from './GraphSchema.js'; +import {getNodeTypes, registerNodeType} from './NodeRegistry.js'; +import type {NodeTypeDefinition} from './NodeRegistry.js'; +import {createSmaCrossoverGraph} from './templates.js'; + +const pair = new TradingPair('AAPL', 'USD'); + +const mockState: TradingSessionState = { + baseBalance: new Big(0), + counterBalance: new Big(1000), + feeRates: { + [OrderType.LIMIT]: new Big('0.001'), + [OrderType.MARKET]: new Big('0.002'), + }, + tradingRules: { + base_increment: '0.01', + base_max_size: '10000', + base_min_size: '0.01', + counter_increment: '0.01', + counter_min_size: '1', + pair, + }, +}; + +const ONE_MINUTE_IN_MS = 60_000; +const START_TIME_IN_MS = 1735689600000; + +function makeExchangeCandle(close: number, index: number): Candle { + const closeStr = String(close); + return { + base: 'AAPL', + close: closeStr, + counter: 'USD', + high: String(close + 1), + low: String(close - 1), + open: closeStr, + openTimeInISO: new Date(START_TIME_IN_MS + index * ONE_MINUTE_IN_MS).toISOString(), + openTimeInMillis: START_TIME_IN_MS + index * ONE_MINUTE_IN_MS, + sizeInMillis: ONE_MINUTE_IN_MS, + volume: '1000', + }; +} + +function makeCandle(close: number, index: number): OneMinuteBatchedCandle { + return CandleBatcher.createOneMinuteBatchedCandle([makeExchangeCandle(close, index)]); +} + +async function feed(strategy: TradingSessionStrategy, prices: number[]): Promise { + const advices: OrderAdvice[] = []; + for (const [index, price] of prices.entries()) { + const advice = await strategy.onCandle(makeCandle(price, index), mockState); + if (advice) { + advices.push(advice); + } + } + return advices; +} + +/** Comparable essence of an advice — `reason` texts legitimately differ between the two strategies. */ +function essence(advice: OrderAdvice) { + return {amount: advice.amount, amountIn: advice.amountIn, side: advice.side, type: advice.type}; +} + +/** A long, wavy price series that produces many crossovers in both directions. */ +function wavyPrices(length: number): number[] { + return Array.from({length}, (_, index) => 100 + 10 * Math.sin(index / 5) + index * 0.05); +} + +describe('GraphStrategy', () => { + describe('validation', () => { + const minimalNodes: StrategyGraphInput['nodes'] = { + buy: {config: {side: 'BUY'}, type: 'advice'}, + candles: {type: 'source:candle'}, + close: {type: 'field'}, + crossover: {type: 'if'}, + fast: {config: {period: 2}, type: 'indicator'}, + slow: {config: {period: 3}, type: 'indicator'}, + }; + + const minimalConnections: StrategyGraphInput['connections'] = [ + {from: {node: 'candles'}, to: {node: 'close'}}, + {from: {node: 'close'}, to: {node: 'fast'}}, + {from: {node: 'close'}, to: {node: 'slow'}}, + {from: {node: 'fast'}, to: {node: 'crossover', port: 'a'}}, + {from: {node: 'slow'}, to: {node: 'crossover', port: 'b'}}, + {from: {node: 'crossover', port: 'true'}, to: {node: 'buy', port: 'when'}}, + ]; + + it('accepts a minimal valid graph', () => { + expect(() => new GraphStrategy({connections: minimalConnections, nodes: minimalNodes, version: 1})).not.toThrow(); + }); + + it('rejects an unknown node type', () => { + expect( + () => + new GraphStrategy({ + connections: [], + nodes: {mystery: {type: 'quantum-oracle'}}, + version: 1, + }) + ).toThrowError(/unknown node type "quantum-oracle"/); + }); + + it('rejects a type mismatch between connected ports', () => { + const connections = [...minimalConnections, {from: {node: 'candles'}, to: {node: 'crossover', port: 'a'}}]; + expect(() => new GraphStrategy({connections, nodes: minimalNodes, version: 1})).toThrowError( + /more than one incoming connection|Type mismatch/ + ); + // Directly: candle output into a number input + expect( + () => + new GraphStrategy({ + connections: [ + {from: {node: 'candles'}, to: {node: 'fast'}}, + ...minimalConnections.filter(connection => connection.to.node !== 'fast'), + ], + nodes: minimalNodes, + version: 1, + }) + ).toThrowError(/Type mismatch: "candles:out" emits candle but "fast:in" expects number/); + }); + + it('rejects an unconnected input port', () => { + const connections = minimalConnections.filter(connection => connection.to.node !== 'buy'); + expect(() => new GraphStrategy({connections, nodes: minimalNodes, version: 1})).toThrowError( + /Node "buy" \(Order Advice\): input port "when" is not connected/ + ); + }); + + it('rejects a graph without an advice node', () => { + const nodes = {...minimalNodes}; + delete (nodes as Record).buy; + const connections = minimalConnections.filter(connection => connection.to.node !== 'buy'); + expect(() => new GraphStrategy({connections, nodes, version: 1})).toThrowError(/no advice node/); + }); + + it('rejects an invalid node config with a node-addressable error', () => { + const nodes: StrategyGraphInput['nodes'] = { + ...minimalNodes, + fast: {config: {period: -5}, type: 'indicator'}, + }; + expect(() => new GraphStrategy({connections: minimalConnections, nodes, version: 1})).toThrowError( + /Node "fast": invalid config/ + ); + }); + + it('rejects a BUY advice sized in base with the full available amount', () => { + const nodes: StrategyGraphInput['nodes'] = { + ...minimalNodes, + buy: {config: {amount: 'ALL_AVAILABLE_AMOUNT', amountIn: 'base', side: 'BUY'}, type: 'advice'}, + }; + expect(() => new GraphStrategy({connections: minimalConnections, nodes, version: 1})).toThrowError( + /Node "buy": invalid config/ + ); + }); + + it('rejects a cyclic graph', () => { + // Two if-nodes feeding each other's inputs can never be evaluated. + const nodes: StrategyGraphInput['nodes'] = { + ...minimalNodes, + loopA: {type: 'indicator'}, + loopB: {type: 'indicator'}, + }; + const connections: StrategyGraphInput['connections'] = [ + ...minimalConnections, + {from: {node: 'loopA'}, to: {node: 'loopB'}}, + {from: {node: 'loopB'}, to: {node: 'loopA'}}, + ]; + expect(() => new GraphStrategy({connections, nodes, version: 1})).toThrowError(/cycle/); + }); + }); + + describe('equivalence with SmaCrossoverStrategy', () => { + const configurations = [ + {fastPeriod: 2, fastTimeframe: '1m', slowPeriod: 3, slowTimeframe: '1m'}, + {fastPeriod: 2, fastTimeframe: '1m', slowPeriod: 2, slowTimeframe: '2m'}, + {fastPeriod: 5, fastTimeframe: '1m', slowPeriod: 4, slowTimeframe: '3m'}, + ] as const; + + it.each(configurations)('produces identical advice to the hand-written strategy (%o)', async configuration => { + const prices = wavyPrices(240); + const handWritten = new SmaCrossoverStrategy(configuration); + const graph = new GraphStrategy(createSmaCrossoverGraph(configuration)); + + const [handWrittenAdvices, graphAdvices] = await Promise.all([feed(handWritten, prices), feed(graph, prices)]); + + expect(graphAdvices.length).toBeGreaterThan(2); + expect(graphAdvices.map(essence)).toEqual(handWrittenAdvices.map(essence)); + }); + + it('produces an identical backtest result to the hand-written strategy', async () => { + const candles = wavyPrices(240).map(makeExchangeCandle); + const tradingPair = new TradingPair('AAPL', 'USD'); + + const createBroker = () => + new AlpacaBrokerMock({ + balances: new Map([ + ['AAPL', {available: new Big(0), hold: new Big(0)}], + ['USD', {available: new Big(10000), hold: new Big(0)}], + ]), + }); + + const configuration = {fastPeriod: 5, fastTimeframe: '1m', slowPeriod: 10, slowTimeframe: '2m'}; + const [handWritten, graph] = await Promise.all([ + new BacktestExecutor({ + broker: createBroker(), + candles, + strategy: new SmaCrossoverStrategy(configuration), + tradingPair, + }).execute(), + new BacktestExecutor({ + broker: createBroker(), + candles, + strategy: new GraphStrategy(createSmaCrossoverGraph(configuration)), + tradingPair, + }).execute(), + ]); + + expect(graph.trades.length).toBeGreaterThan(0); + expect(graph.trades.length).toBe(handWritten.trades.length); + expect(graph.finalBaseBalance.toFixed(8)).toBe(handWritten.finalBaseBalance.toFixed(8)); + expect(graph.finalCounterBalance.toFixed(8)).toBe(handWritten.finalCounterBalance.toFixed(8)); + expect(graph.profitOrLoss.toFixed(8)).toBe(handWritten.profitOrLoss.toFixed(8)); + }); + }); + + describe('trigger semantics', () => { + function thresholdGraph(trigger: 'onChange' | 'always'): StrategyGraphInput { + // Buys whenever the close is above its SMA2 — a level condition, not a crossover. + return { + connections: [ + {from: {node: 'candles'}, to: {node: 'close'}}, + {from: {node: 'close'}, to: {node: 'sma'}}, + {from: {node: 'close'}, to: {node: 'condition', port: 'a'}}, + {from: {node: 'sma'}, to: {node: 'condition', port: 'b'}}, + {from: {node: 'condition', port: 'true'}, to: {node: 'buy', port: 'when'}}, + ], + nodes: { + buy: {config: {amountIn: 'counter', side: 'BUY'}, type: 'advice'}, + candles: {type: 'source:candle'}, + close: {type: 'field'}, + condition: {config: {operator: 'gt', trigger}, type: 'if'}, + sma: {config: {indicator: 'SMA', period: 2}, type: 'indicator'}, + }, + version: 1, + }; + } + + // Close > SMA2 from index 2 onward (rising), so the condition holds on many consecutive candles. + const risingPrices = [10, 11, 12, 13, 14, 15]; + + it('fires once at the flip with trigger "onChange"', async () => { + // The first evaluation only records the state; a BUY needs false → true, so dip first. + const advices = await feed(new GraphStrategy(thresholdGraph('onChange')), [10, 9, 8, 9, 12, 13, 14]); + expect(advices.map(advice => advice.side)).toEqual([OrderSide.BUY]); + }); + + it('fires on every matching candle with trigger "always"', async () => { + const advices = await feed(new GraphStrategy(thresholdGraph('always')), risingPrices); + expect(advices.length).toBeGreaterThan(1); + expect(advices.every(advice => advice.side === OrderSide.BUY)).toBe(true); + }); + }); + + describe('advice payload', () => { + it('uses the configured amount and amountIn', async () => { + const graph: StrategyGraphInput = { + connections: [ + {from: {node: 'candles'}, to: {node: 'close'}}, + {from: {node: 'close'}, to: {node: 'sma'}}, + {from: {node: 'close'}, to: {node: 'condition', port: 'a'}}, + {from: {node: 'sma'}, to: {node: 'condition', port: 'b'}}, + {from: {node: 'condition', port: 'true'}, to: {node: 'buy', port: 'when'}}, + ], + nodes: { + buy: {config: {amount: '250', amountIn: 'counter', reason: 'breakout', side: 'BUY'}, type: 'advice'}, + candles: {type: 'source:candle'}, + close: {type: 'field'}, + condition: {config: {operator: 'gt', trigger: 'always'}, type: 'if'}, + sma: {config: {period: 2}, type: 'indicator'}, + }, + version: 1, + }; + const [buy] = await feed(new GraphStrategy(graph), [10, 11, 12]); + expect(buy).toEqual({ + amount: '250', + amountIn: 'counter', + reason: 'breakout', + side: OrderSide.BUY, + type: OrderType.MARKET, + }); + }); + + it('defaults to the full available amount', async () => { + const graph = createSmaCrossoverGraph({fastPeriod: 2, fastTimeframe: '1m', slowPeriod: 3, slowTimeframe: '1m'}); + const advices = await feed(new GraphStrategy(graph), [10, 9, 8, 7, 6, 7, 9, 12, 15, 12, 9, 6]); + expect(advices.map(advice => advice.side)).toEqual([OrderSide.BUY, OrderSide.SELL]); + expect(advices[0].amount).toBe(AllAvailableAmount); + }); + }); + + describe('custom node types', () => { + function makeDefinition(overrides: Partial): NodeTypeDefinition { + return { + category: 'indicator', + configSchema: z.object({}), + createEvaluator: () => ({evaluate: () => ({})}), + description: 'Test node', + inputs: [{kind: 'candle', label: 'Candle', name: 'in'}], + label: 'Test Node', + outputs: [{kind: 'number', label: 'Value', name: 'out'}], + type: 'test:node', + ...overrides, + }; + } + + it('runs a registered node with an async evaluator inside a graph', async () => { + let initialized = false; + registerNodeType( + makeDefinition({ + createEvaluator: () => { + let count = 0; + return { + // Simulates an external-data node: async lookup, fires on every 3rd candle. + evaluate: async inputs => { + if (!inputs['in']?.isFresh) { + return {}; + } + count += 1; + await Promise.resolve(); + return count % 3 === 0 ? {outputs: {fire: true}} : {}; + }, + init: async () => { + await Promise.resolve(); + initialized = true; + }, + }; + }, + outputs: [{kind: 'trigger', label: 'Fire', name: 'fire'}], + type: 'test:async-pulse', + }) + ); + + const strategy = new GraphStrategy({ + connections: [ + {from: {node: 'candles'}, to: {node: 'pulse'}}, + {from: {node: 'pulse', port: 'fire'}, to: {node: 'buy', port: 'when'}}, + ], + nodes: { + buy: {config: {amountIn: 'counter', side: 'BUY'}, type: 'advice'}, + candles: {type: 'source:candle'}, + pulse: {type: 'test:async-pulse'}, + }, + version: 1, + }); + + await strategy.init({getRecentCandles: async () => []}, pair); + expect(initialized).toBe(true); + + const advices = await feed(strategy, [10, 11, 12, 13, 14, 15]); + expect(advices, 'fires on candle 3 and 6').toHaveLength(2); + expect(advices.every(advice => advice.side === OrderSide.BUY)).toBe(true); + }); + + it('appears in the node type list once registered', () => { + registerNodeType(makeDefinition({type: 'test:listed'})); + expect(getNodeTypes().map(definition => definition.type)).toContain('test:listed'); + }); + + it('rejects a duplicate type id', () => { + registerNodeType(makeDefinition({type: 'test:duplicate'})); + expect(() => registerNodeType(makeDefinition({type: 'test:duplicate'}))).toThrowError(/already registered/); + expect(() => registerNodeType(makeDefinition({type: 'batcher'}))).toThrowError(/already registered/); + }); + + it('rejects an unknown port kind', () => { + const definition = makeDefinition({ + outputs: [{kind: 'boolean' as never, label: 'Value', name: 'out'}], + type: 'test:bad-kind', + }); + expect(() => registerNodeType(definition)).toThrowError(/unknown kind "boolean"/); + }); + + it('rejects duplicate port names on the same side', () => { + const definition = makeDefinition({ + outputs: [ + {kind: 'number', label: 'A', name: 'out'}, + {kind: 'number', label: 'B', name: 'out'}, + ], + type: 'test:dup-port', + }); + expect(() => registerNodeType(definition)).toThrowError(/duplicate outputs port "out"/); + }); + }); +}); diff --git a/packages/trading-strategies/src/strategy-graph/GraphStrategy.ts b/packages/trading-strategies/src/strategy-graph/GraphStrategy.ts new file mode 100644 index 000000000..77ab55778 --- /dev/null +++ b/packages/trading-strategies/src/strategy-graph/GraphStrategy.ts @@ -0,0 +1,193 @@ +import type {MarketDataSource, OneMinuteBatchedCandle, TradingPair} from '@typedtrader/exchange'; +import type {OrderAdvice, TradingSessionState} from '../trader/index.js'; +import {Strategy} from '../strategy/Strategy.js'; +import type {StrategyGraph, StrategyGraphInput} from './GraphSchema.js'; +import {StrategyGraphSchema} from './GraphSchema.js'; +import {getNodeTypeDefinition} from './NodeRegistry.js'; +import type {NodeEvaluator, NodeInputState, NodeTypeDefinition} from './NodeRegistry.js'; + +interface CompiledNode { + id: string; + definition: NodeTypeDefinition; + evaluator: NodeEvaluator; + /** Which upstream output feeds each of this node's input ports. */ + inputSources: Map; +} + +const outputKey = (node: string, port: string) => `${node}:${port}`; + +/** + * Runs a declarative {@link StrategyGraph} as a real trading strategy. Because it satisfies the + * same contract as hand-written strategies, graphs built in the visual editor backtest and trade + * through the exact same executors — there is no separate "toy" runtime whose results could + * diverge from production behavior. + */ +export class GraphStrategy extends Strategy { + static override NAME = '@typedtrader/strategy-graph'; + + readonly #nodes: CompiledNode[]; + + /** + * Latest value each output port ever emitted. Holding values between ticks is what lets a + * fast 1m indicator compare against the last *closed* bar of a slow 5m indicator. + */ + readonly #heldValues = new Map(); + + constructor(graph: StrategyGraphInput) { + const parsed = StrategyGraphSchema.parse(graph); + super({config: parsed}); + this.#nodes = GraphStrategy.#compile(parsed); + } + + /** Forwards the session's init to every node evaluator, in evaluation order. */ + async init(market: Pick, pair: TradingPair): Promise { + for (const node of this.#nodes) { + await node.evaluator.init?.({market, pair}); + } + } + + protected override async processCandle( + candle: OneMinuteBatchedCandle, + _state: TradingSessionState + ): Promise { + const emittedThisTick = new Set(); + let advice: OrderAdvice | undefined = undefined; + + for (const node of this.#nodes) { + const inputs: Record = {}; + for (const port of node.definition.inputs) { + const source = node.inputSources.get(port.name); + if (source !== undefined && this.#heldValues.has(source)) { + inputs[port.name] = {isFresh: emittedThisTick.has(source), value: this.#heldValues.get(source)}; + } else { + inputs[port.name] = undefined; + } + } + + const evaluation = await node.evaluator.evaluate(inputs, {candle}); + + for (const [port, value] of Object.entries(evaluation.outputs ?? {})) { + const key = outputKey(node.id, port); + this.#heldValues.set(key, value); + emittedThisTick.add(key); + } + + // If several advice nodes fire on the same tick, the first in evaluation order wins. + if (evaluation.advice && !advice) { + advice = evaluation.advice; + } + } + + return advice; + } + + static #compile(graph: StrategyGraph): CompiledNode[] { + const nodeIds = Object.keys(graph.nodes); + if (nodeIds.length === 0) { + throw new Error('Graph has no nodes'); + } + + const definitions = new Map(); + const parsedConfigs = new Map(); + + for (const [id, node] of Object.entries(graph.nodes)) { + const definition = getNodeTypeDefinition(node.type); + if (!definition) { + throw new Error(`Node "${id}": unknown node type "${node.type}"`); + } + definitions.set(id, definition); + const result = definition.configSchema.safeParse(node.config ?? {}); + if (!result.success) { + const issues = result.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '); + throw new Error(`Node "${id}": invalid config — ${issues}`); + } + parsedConfigs.set(id, result.data); + } + + const inputSourcesByNode = new Map>(nodeIds.map(id => [id, new Map()])); + + for (const {from, to} of graph.connections) { + const fromDefinition = definitions.get(from.node); + const toDefinition = definitions.get(to.node); + if (!fromDefinition) { + throw new Error(`Connection references unknown node "${from.node}"`); + } + if (!toDefinition) { + throw new Error(`Connection references unknown node "${to.node}"`); + } + const output = fromDefinition.outputs.find(port => port.name === from.port); + if (!output) { + throw new Error(`Node "${from.node}" (${fromDefinition.label}) has no output port "${from.port}"`); + } + const input = toDefinition.inputs.find(port => port.name === to.port); + if (!input) { + throw new Error(`Node "${to.node}" (${toDefinition.label}) has no input port "${to.port}"`); + } + if (output.kind !== input.kind) { + throw new Error( + `Type mismatch: "${from.node}:${from.port}" emits ${output.kind} but "${to.node}:${to.port}" expects ${input.kind}` + ); + } + const inputSources = inputSourcesByNode.get(to.node)!; + if (inputSources.has(to.port)) { + throw new Error(`Node "${to.node}": input port "${to.port}" has more than one incoming connection`); + } + inputSources.set(to.port, outputKey(from.node, from.port)); + } + + for (const [id, definition] of definitions) { + const inputSources = inputSourcesByNode.get(id)!; + for (const port of definition.inputs) { + if (!inputSources.has(port.name)) { + throw new Error(`Node "${id}" (${definition.label}): input port "${port.name}" is not connected`); + } + } + } + + return GraphStrategy.#sortTopologically(graph, definitions).map(id => ({ + definition: definitions.get(id)!, + evaluator: definitions.get(id)!.createEvaluator(parsedConfigs.get(id)), + id, + inputSources: inputSourcesByNode.get(id)!, + })); + } + + /** Kahn's algorithm. Also the cycle detector: a cycle leaves nodes with unresolved inputs. */ + static #sortTopologically(graph: StrategyGraph, definitions: Map): string[] { + const nodeIds = Object.keys(graph.nodes); + const dependents = new Map(nodeIds.map(id => [id, []])); + const pendingInputs = new Map(nodeIds.map(id => [id, 0])); + + for (const {from, to} of graph.connections) { + dependents.get(from.node)!.push(to.node); + pendingInputs.set(to.node, pendingInputs.get(to.node)! + 1); + } + + const queue = nodeIds.filter(id => pendingInputs.get(id) === 0); + const order: string[] = []; + + while (queue.length > 0) { + const id = queue.shift()!; + order.push(id); + for (const dependent of dependents.get(id)!) { + const remaining = pendingInputs.get(dependent)! - 1; + pendingInputs.set(dependent, remaining); + if (remaining === 0) { + queue.push(dependent); + } + } + } + + if (order.length !== nodeIds.length) { + const stuck = nodeIds.filter(id => !order.includes(id)); + throw new Error(`Graph contains a cycle involving: ${stuck.map(id => `"${id}"`).join(', ')}`); + } + + // Guarantee at least one path can produce advice; a graph without a sink can never trade. + if (![...definitions.values()].some(definition => definition.category === 'sink')) { + throw new Error('Graph has no advice node — it can never produce a trade'); + } + + return order; + } +} diff --git a/packages/trading-strategies/src/strategy-graph/NodeRegistry.ts b/packages/trading-strategies/src/strategy-graph/NodeRegistry.ts new file mode 100644 index 000000000..27e152413 --- /dev/null +++ b/packages/trading-strategies/src/strategy-graph/NodeRegistry.ts @@ -0,0 +1,362 @@ +import type {StringValue} from 'ms'; +import {ms} from 'ms'; +import {z} from 'zod'; +import {CandleBatcher, OrderSide, OrderType} from '@typedtrader/exchange'; +import type {BatchedCandle, MarketDataSource, OneMinuteBatchedCandle, TradingPair} from '@typedtrader/exchange'; +import {EMA, RSI, SMA} from 'trading-signals'; +import {AllAvailableAmount} from '../trader/index.js'; +import type {OrderAdvice} from '../trader/index.js'; + +const ONE_MINUTE_IN_MS = 60_000; + +/** A duration in `ms` syntax (`"1m"`, `"5m"`, `"1d"`), no smaller than one candle (`"1m"`). */ +const timeframeString = z.string().refine(value => { + const millis = ms(value as StringValue); + return typeof millis === 'number' && Number.isFinite(millis) && millis >= ONE_MINUTE_IN_MS; +}, 'Must be a duration of at least "1m" in ms syntax, e.g. "1m", "5m", "1d"'); + +/** + * The kind of value that travels along a connection. The editor uses this to make + * mismatched connections impossible (a Scratch-style "wrong blocks don't snap" guarantee), + * and the interpreter validates it again when a graph is loaded from JSON. + */ +export type PortValueKind = 'candle' | 'number' | 'trigger'; + +export interface NodePortDefinition { + name: string; + kind: PortValueKind; + label: string; +} + +/** + * What a node sees on one of its input ports during a tick. `value` is the latest value the + * connected output ever emitted (its "held" value); `isFresh` marks whether it was emitted + * during the current tick. Holding stale values is what makes mixed timeframes safe: a 5m + * indicator keeps its last *closed* bar's value between batch closes instead of leaking + * mid-bar data. + */ +export interface NodeInputState { + value: unknown; + isFresh: boolean; +} + +export interface NodeTickContext { + candle: OneMinuteBatchedCandle; +} + +export interface NodeEvaluation { + /** Values emitted this tick, keyed by output port name. Omitted ports emit nothing. */ + outputs?: Record; + /** Set only by sink nodes when their trigger fired. */ + advice?: OrderAdvice; +} + +/** Passed to an evaluator's `init` hook when the strategy is attached to a session. */ +export interface NodeInitContext { + market: Pick; + pair: TradingPair; +} + +export interface NodeEvaluator { + /** + * One-time async setup before the first tick — the place to load external datasets + * (news archives, on-chain snapshots) so `evaluate` can stay a deterministic lookup. + */ + init?(context: NodeInitContext): void | Promise; + evaluate( + inputs: Record, + context: NodeTickContext + ): NodeEvaluation | Promise; +} + +export interface NodeTypeDefinition { + type: string; + label: string; + description: string; + category: 'source' | 'transform' | 'indicator' | 'logic' | 'sink'; + configSchema: z.ZodType; + inputs: NodePortDefinition[]; + outputs: NodePortDefinition[]; + /** `config` must already be parsed by `configSchema`. */ + createEvaluator(config: unknown): NodeEvaluator; +} + +const CandleSourceConfigSchema = z.object({}); + +const candleSourceDefinition: NodeTypeDefinition = { + category: 'source', + configSchema: CandleSourceConfigSchema, + createEvaluator: () => ({ + evaluate: (_inputs, context) => ({outputs: {out: context.candle}}), + }), + description: 'Emits the incoming 1-minute candle on every tick. Every graph starts here.', + inputs: [], + label: 'Candles', + outputs: [{kind: 'candle', label: 'Candle (1m)', name: 'out'}], + type: 'source:candle', +}; + +export const BatcherConfigSchema = z.object({ + /** Bar size in `ms` syntax (e.g. "1m", "5m", "1d"). */ + timeframe: timeframeString.default('5m'), +}); + +const batcherDefinition: NodeTypeDefinition = { + category: 'transform', + configSchema: BatcherConfigSchema, + createEvaluator: config => { + const {timeframe} = config as z.infer; + const batcher = new CandleBatcher(ms(timeframe as StringValue)); + return { + evaluate: inputs => { + const input = inputs['in']; + if (!input?.isFresh) { + return {}; + } + const batched = batcher.addToBatch(input.value as BatchedCandle); + return batched ? {outputs: {out: batched}} : {}; + }, + }; + }, + description: + 'Rolls 1-minute candles up to a larger timeframe. Emits only when a bar closes, so downstream nodes never see mid-bar values.', + inputs: [{kind: 'candle', label: 'Candle', name: 'in'}], + label: 'Candle Batcher', + outputs: [{kind: 'candle', label: 'Batched candle', name: 'out'}], + type: 'batcher', +}; + +export const FieldConfigSchema = z.object({ + field: z.enum(['open', 'high', 'low', 'close', 'volume', 'medianPrice']).default('close'), +}); + +const fieldDefinition: NodeTypeDefinition = { + category: 'transform', + configSchema: FieldConfigSchema, + createEvaluator: config => { + const {field} = config as z.infer; + return { + evaluate: inputs => { + const input = inputs['in']; + if (!input?.isFresh) { + return {}; + } + const candle = input.value as BatchedCandle; + return {outputs: {out: candle[field].toNumber()}}; + }, + }; + }, + description: 'Extracts a numeric field (like the close price) from a candle.', + inputs: [{kind: 'candle', label: 'Candle', name: 'in'}], + label: 'Candle Field', + outputs: [{kind: 'number', label: 'Value', name: 'out'}], + type: 'field', +}; + +/** The slice of the `trading-signals` indicator contract the indicator node relies on. */ +interface NumericIndicator { + isStable: boolean; + add(value: number): unknown; + getResultOrThrow(): number; +} + +const INDICATOR_FACTORIES = { + EMA: (period: number) => new EMA(period), + RSI: (period: number) => new RSI(period), + SMA: (period: number) => new SMA(period), +} satisfies Record NumericIndicator>; + +export type GraphIndicatorName = keyof typeof INDICATOR_FACTORIES; + +export const IndicatorConfigSchema = z.object({ + indicator: z.enum(['SMA', 'EMA', 'RSI']).default('SMA'), + period: z.number().int().positive().default(10), +}); + +const indicatorDefinition: NodeTypeDefinition = { + category: 'indicator', + configSchema: IndicatorConfigSchema, + createEvaluator: config => { + const {indicator, period} = config as z.infer; + const instance = INDICATOR_FACTORIES[indicator](period); + return { + evaluate: inputs => { + const input = inputs['in']; + if (!input?.isFresh) { + return {}; + } + instance.add(input.value as number); + // Emit only once warmed up — downstream nodes stay silent instead of acting on garbage. + return instance.isStable ? {outputs: {out: instance.getResultOrThrow()}} : {}; + }, + }; + }, + description: 'Feeds incoming values into a technical indicator. Stays silent until the indicator is warmed up.', + inputs: [{kind: 'number', label: 'Value', name: 'in'}], + label: 'Indicator', + outputs: [{kind: 'number', label: 'Result', name: 'out'}], + type: 'indicator', +}; + +const COMPARE_OPERATORS = { + eq: (a: number, b: number) => a === b, + gt: (a: number, b: number) => a > b, + gte: (a: number, b: number) => a >= b, + lt: (a: number, b: number) => a < b, + lte: (a: number, b: number) => a <= b, +} satisfies Record boolean>; + +export const IfConfigSchema = z.object({ + operator: z.enum(['gt', 'gte', 'lt', 'lte', 'eq']).default('gt'), + /** + * `onChange` fires only on the tick where the comparison flips (edge detection — this is + * what turns "fast > slow" into a *crossover*). `always` fires on every evaluated tick. + */ + trigger: z.enum(['onChange', 'always']).default('onChange'), +}); + +const ifDefinition: NodeTypeDefinition = { + category: 'logic', + configSchema: IfConfigSchema, + createEvaluator: config => { + const {operator, trigger} = config as z.infer; + const compare = COMPARE_OPERATORS[operator]; + let previousOutcome: boolean | undefined = undefined; + return { + evaluate: inputs => { + const a = inputs['a']; + const b = inputs['b']; + // Evaluate only when at least one side advanced this tick — otherwise nothing changed. + if (!a?.isFresh && !b?.isFresh) { + return {}; + } + if (a?.value === undefined || b?.value === undefined) { + return {}; + } + const outcome = compare(a.value as number, b.value as number); + if (trigger === 'onChange') { + const wasOutcome = previousOutcome; + previousOutcome = outcome; + if (wasOutcome === undefined || wasOutcome === outcome) { + return {}; + } + } + return {outputs: outcome ? {true: true} : {false: true}}; + }, + }; + }, + description: + 'Compares two values and fires its true/false branch. "On change" fires only when the outcome flips — a crossover.', + inputs: [ + {kind: 'number', label: 'A', name: 'a'}, + {kind: 'number', label: 'B', name: 'b'}, + ], + label: 'If', + outputs: [ + {kind: 'trigger', label: 'True', name: 'true'}, + {kind: 'trigger', label: 'False', name: 'false'}, + ], + type: 'if', +}; + +export const AdviceConfigSchema = z + .object({ + amount: z + .union([z.literal(AllAvailableAmount), z.string().regex(/^\d+(\.\d+)?$/, 'Must be a numeric amount')]) + .default(AllAvailableAmount), + amountIn: z.enum(['base', 'counter']).default('counter'), + reason: z.string().optional(), + side: z.enum(['BUY', 'SELL']).default('BUY'), + }) + .refine(config => !(config.side === 'BUY' && config.amountIn === 'base' && config.amount === AllAvailableAmount), { + message: 'A BUY sized in base cannot use the full available amount — set an explicit amount or size it in counter', + }); + +const adviceDefinition: NodeTypeDefinition = { + category: 'sink', + configSchema: AdviceConfigSchema, + createEvaluator: config => { + const {amount, amountIn, reason, side} = config as z.infer; + const buildAdvice = (): OrderAdvice => { + if (side === 'SELL') { + return {amount, amountIn, reason, side: OrderSide.SELL, type: OrderType.MARKET}; + } + if (amountIn === 'counter') { + return {amount, amountIn, reason, side: OrderSide.BUY, type: OrderType.MARKET}; + } + if (amount === AllAvailableAmount) { + // Unreachable: the config schema refinement rejects this combination. + throw new Error('A BUY sized in base requires an explicit amount'); + } + return {amount, amountIn, reason, side: OrderSide.BUY, type: OrderType.MARKET}; + }; + return { + evaluate: inputs => { + const when = inputs['when']; + if (!when?.isFresh || when.value !== true) { + return {}; + } + return {advice: buildAdvice()}; + }, + }; + }, + description: 'Emits a market order advice when its trigger fires.', + inputs: [{kind: 'trigger', label: 'When', name: 'when'}], + label: 'Order Advice', + outputs: [], + type: 'advice', +}; + +const BUILT_IN_NODE_TYPES: readonly NodeTypeDefinition[] = [ + candleSourceDefinition, + batcherDefinition, + fieldDefinition, + indicatorDefinition, + ifDefinition, + adviceDefinition, +]; + +const registry = new Map(BUILT_IN_NODE_TYPES.map(definition => [definition.type, definition])); + +const VALID_PORT_KINDS: ReadonlySet = new Set(['candle', 'number', 'trigger']); + +/** + * The extension point for third-party building blocks (news signals, on-chain data, + * custom math): register a definition once at startup and it becomes available to every + * graph and appears in the visual builder's palette — no engine or UI changes needed. + * + * Keep `evaluate` deterministic for a given candle stream: backtests replay graphs in the + * browser, so a node that queries live external state produces irreproducible results. + * Load external datasets in the evaluator's `init` hook (or inject them via config) and + * look them up by candle timestamp during evaluation. + */ +export function registerNodeType(definition: NodeTypeDefinition): void { + if (!definition.type) { + throw new Error('Node type must have a non-empty "type" id'); + } + if (registry.has(definition.type)) { + throw new Error(`Node type "${definition.type}" is already registered`); + } + for (const side of ['inputs', 'outputs'] as const) { + const seen = new Set(); + for (const port of definition[side]) { + if (!VALID_PORT_KINDS.has(port.kind)) { + throw new Error(`Node type "${definition.type}": ${side} port "${port.name}" has unknown kind "${port.kind}"`); + } + if (seen.has(port.name)) { + throw new Error(`Node type "${definition.type}": duplicate ${side} port "${port.name}"`); + } + seen.add(port.name); + } + } + registry.set(definition.type, definition); +} + +/** All available node types (built-ins plus registered extensions), e.g. for rendering a palette. */ +export function getNodeTypes(): NodeTypeDefinition[] { + return [...registry.values()]; +} + +export function getNodeTypeDefinition(type: string): NodeTypeDefinition | undefined { + return registry.get(type); +} diff --git a/packages/trading-strategies/src/strategy-graph/index.ts b/packages/trading-strategies/src/strategy-graph/index.ts new file mode 100644 index 000000000..b4b57473f --- /dev/null +++ b/packages/trading-strategies/src/strategy-graph/index.ts @@ -0,0 +1,27 @@ +export {GraphStrategy} from './GraphStrategy.js'; +export { + StrategyGraphSchema, + type StrategyGraph, + type StrategyGraphInput, + type GraphNode, + type GraphConnection, +} from './GraphSchema.js'; +export { + getNodeTypes, + getNodeTypeDefinition, + registerNodeType, + AdviceConfigSchema, + BatcherConfigSchema, + FieldConfigSchema, + IfConfigSchema, + IndicatorConfigSchema, + type NodeTypeDefinition, + type NodePortDefinition, + type NodeEvaluator, + type NodeEvaluation, + type NodeInitContext, + type NodeInputState, + type NodeTickContext, + type PortValueKind, +} from './NodeRegistry.js'; +export {createSmaCrossoverGraph, type SmaCrossoverGraphOptions} from './templates.js'; diff --git a/packages/trading-strategies/src/strategy-graph/templates.ts b/packages/trading-strategies/src/strategy-graph/templates.ts new file mode 100644 index 000000000..cfa88941d --- /dev/null +++ b/packages/trading-strategies/src/strategy-graph/templates.ts @@ -0,0 +1,68 @@ +import type {StrategyGraphInput} from './GraphSchema.js'; + +export interface SmaCrossoverGraphOptions { + fastPeriod?: number; + fastTimeframe?: string; + slowPeriod?: number; + slowTimeframe?: string; +} + +/** + * The classic dual-SMA crossover expressed as a graph — the same logic as + * `SmaCrossoverStrategy`, but built from generic nodes. Serves as the editor's starter + * template and as the fixture proving the graph interpreter matches a hand-written strategy. + */ +export function createSmaCrossoverGraph(options: SmaCrossoverGraphOptions = {}): StrategyGraphInput { + const {fastPeriod = 10, fastTimeframe = '1m', slowPeriod = 20, slowTimeframe = '5m'} = options; + + return { + connections: [ + {from: {node: 'candles', port: 'out'}, to: {node: 'fastBatcher', port: 'in'}}, + {from: {node: 'candles', port: 'out'}, to: {node: 'slowBatcher', port: 'in'}}, + {from: {node: 'fastBatcher', port: 'out'}, to: {node: 'fastClose', port: 'in'}}, + {from: {node: 'slowBatcher', port: 'out'}, to: {node: 'slowClose', port: 'in'}}, + {from: {node: 'fastClose', port: 'out'}, to: {node: 'fastSma', port: 'in'}}, + {from: {node: 'slowClose', port: 'out'}, to: {node: 'slowSma', port: 'in'}}, + {from: {node: 'fastSma', port: 'out'}, to: {node: 'crossover', port: 'a'}}, + {from: {node: 'slowSma', port: 'out'}, to: {node: 'crossover', port: 'b'}}, + {from: {node: 'crossover', port: 'true'}, to: {node: 'buy', port: 'when'}}, + {from: {node: 'crossover', port: 'false'}, to: {node: 'sell', port: 'when'}}, + ], + name: 'SMA Crossover', + nodes: { + buy: { + config: { + amount: 'ALL_AVAILABLE_AMOUNT', + amountIn: 'counter', + reason: 'Fast SMA crossed above slow SMA', + side: 'BUY', + }, + position: {x: 1240, y: 40}, + type: 'advice', + }, + candles: {position: {x: 40, y: 160}, type: 'source:candle'}, + crossover: { + config: {operator: 'gt', trigger: 'onChange'}, + position: {x: 1000, y: 160}, + type: 'if', + }, + fastBatcher: {config: {timeframe: fastTimeframe}, position: {x: 280, y: 40}, type: 'batcher'}, + fastClose: {config: {field: 'close'}, position: {x: 520, y: 40}, type: 'field'}, + fastSma: {config: {indicator: 'SMA', period: fastPeriod}, position: {x: 760, y: 40}, type: 'indicator'}, + sell: { + config: { + amount: 'ALL_AVAILABLE_AMOUNT', + amountIn: 'base', + reason: 'Fast SMA crossed below slow SMA', + side: 'SELL', + }, + position: {x: 1240, y: 280}, + type: 'advice', + }, + slowBatcher: {config: {timeframe: slowTimeframe}, position: {x: 280, y: 280}, type: 'batcher'}, + slowClose: {config: {field: 'close'}, position: {x: 520, y: 280}, type: 'field'}, + slowSma: {config: {indicator: 'SMA', period: slowPeriod}, position: {x: 760, y: 280}, type: 'indicator'}, + }, + version: 1, + }; +} From dbdec8958299f4dc35584791d0968666b305f206 Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Mon, 13 Jul 2026 16:41:31 +0200 Subject: [PATCH 2/2] fix(trading-strategies): address review findings on strategy graphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sort node ids and dependents in the topological sort so evaluation order (and the first-advice-wins rule) depends only on graph structure, never on JSON key or connection ordering — with a regression test racing two advice nodes under reversed serialization - Drop the leading ': ' from config errors produced by refinement issues with an empty path - Reword the missing-sink error to match the actual check (any sink node), since registerNodeType() allows custom sinks --- .../src/strategy-graph/GraphStrategy.test.ts | 43 ++++++++++++++++++- .../src/strategy-graph/GraphStrategy.ts | 17 ++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/trading-strategies/src/strategy-graph/GraphStrategy.test.ts b/packages/trading-strategies/src/strategy-graph/GraphStrategy.test.ts index b90f6e74c..42ea10264 100644 --- a/packages/trading-strategies/src/strategy-graph/GraphStrategy.test.ts +++ b/packages/trading-strategies/src/strategy-graph/GraphStrategy.test.ts @@ -142,7 +142,7 @@ describe('GraphStrategy', () => { const nodes = {...minimalNodes}; delete (nodes as Record).buy; const connections = minimalConnections.filter(connection => connection.to.node !== 'buy'); - expect(() => new GraphStrategy({connections, nodes, version: 1})).toThrowError(/no advice node/); + expect(() => new GraphStrategy({connections, nodes, version: 1})).toThrowError(/no sink node/); }); it('rejects an invalid node config with a node-addressable error', () => { @@ -160,11 +160,50 @@ describe('GraphStrategy', () => { ...minimalNodes, buy: {config: {amount: 'ALL_AVAILABLE_AMOUNT', amountIn: 'base', side: 'BUY'}, type: 'advice'}, }; + // Refinement issues have an empty path — the message must not degrade to "invalid config — : …". expect(() => new GraphStrategy({connections: minimalConnections, nodes, version: 1})).toThrowError( - /Node "buy": invalid config/ + /Node "buy": invalid config — A BUY sized in base/ ); }); + it('evaluates equivalent graphs identically regardless of JSON key order', async () => { + /* + * Two advice nodes race on the same trigger tick — "first advice wins" must not + * depend on how the JSON happens to order its keys or connections. + */ + const nodes: StrategyGraphInput['nodes'] = { + adviceA: {config: {amountIn: 'counter', reason: 'A', side: 'BUY'}, type: 'advice'}, + adviceB: {config: {amountIn: 'counter', reason: 'B', side: 'BUY'}, type: 'advice'}, + candles: {type: 'source:candle'}, + close: {type: 'field'}, + condition: {config: {operator: 'gt', trigger: 'always'}, type: 'if'}, + sma: {config: {period: 2}, type: 'indicator'}, + }; + const connections: StrategyGraphInput['connections'] = [ + {from: {node: 'candles'}, to: {node: 'close'}}, + {from: {node: 'close'}, to: {node: 'sma'}}, + {from: {node: 'close'}, to: {node: 'condition', port: 'a'}}, + {from: {node: 'sma'}, to: {node: 'condition', port: 'b'}}, + {from: {node: 'condition', port: 'true'}, to: {node: 'adviceA', port: 'when'}}, + {from: {node: 'condition', port: 'true'}, to: {node: 'adviceB', port: 'when'}}, + ]; + const reversed: StrategyGraphInput = { + connections: [...connections].reverse(), + nodes: Object.fromEntries(Object.entries(nodes).reverse()), + version: 1, + }; + + const prices = [10, 11, 12, 13]; + const [advices, reversedAdvices] = await Promise.all([ + feed(new GraphStrategy({connections, nodes, version: 1}), prices), + feed(new GraphStrategy(reversed), prices), + ]); + + expect(advices.length).toBeGreaterThan(0); + expect(advices.map(advice => advice.reason)).toEqual(reversedAdvices.map(advice => advice.reason)); + expect(advices.every(advice => advice.reason === 'A')).toBe(true); + }); + it('rejects a cyclic graph', () => { // Two if-nodes feeding each other's inputs can never be evaluated. const nodes: StrategyGraphInput['nodes'] = { diff --git a/packages/trading-strategies/src/strategy-graph/GraphStrategy.ts b/packages/trading-strategies/src/strategy-graph/GraphStrategy.ts index 77ab55778..4a7450b7c 100644 --- a/packages/trading-strategies/src/strategy-graph/GraphStrategy.ts +++ b/packages/trading-strategies/src/strategy-graph/GraphStrategy.ts @@ -98,7 +98,10 @@ export class GraphStrategy extends Strategy { definitions.set(id, definition); const result = definition.configSchema.safeParse(node.config ?? {}); if (!result.success) { - const issues = result.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '); + // Refinement issues carry an empty path — printing "…— : message" would just add noise. + const issues = result.error.issues + .map(issue => (issue.path.length > 0 ? `${issue.path.join('.')}: ${issue.message}` : issue.message)) + .join('; '); throw new Error(`Node "${id}": invalid config — ${issues}`); } parsedConfigs.set(id, result.data); @@ -154,7 +157,12 @@ export class GraphStrategy extends Strategy { /** Kahn's algorithm. Also the cycle detector: a cycle leaves nodes with unresolved inputs. */ static #sortTopologically(graph: StrategyGraph, definitions: Map): string[] { - const nodeIds = Object.keys(graph.nodes); + /* + * Sort ids (and dependents, below) so evaluation order — and with it the "first advice + * wins" rule — depends only on the graph's structure, never on JSON key or connection + * ordering. Equivalent graphs serialized differently must behave identically. + */ + const nodeIds = Object.keys(graph.nodes).sort(); const dependents = new Map(nodeIds.map(id => [id, []])); const pendingInputs = new Map(nodeIds.map(id => [id, 0])); @@ -162,6 +170,9 @@ export class GraphStrategy extends Strategy { dependents.get(from.node)!.push(to.node); pendingInputs.set(to.node, pendingInputs.get(to.node)! + 1); } + for (const list of dependents.values()) { + list.sort(); + } const queue = nodeIds.filter(id => pendingInputs.get(id) === 0); const order: string[] = []; @@ -185,7 +196,7 @@ export class GraphStrategy extends Strategy { // Guarantee at least one path can produce advice; a graph without a sink can never trade. if (![...definitions.values()].some(definition => definition.category === 'sink')) { - throw new Error('Graph has no advice node — it can never produce a trade'); + throw new Error('Graph has no sink node (such as an order advice node) — it can never produce a trade'); } return order;