feat(trading-strategies): add declarative strategy graphs (GraphStrategy) - #1190
feat(trading-strategies): add declarative strategy graphs (GraphStrategy)#1190bennycode wants to merge 2 commits into
Conversation
…egy) 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
There was a problem hiding this comment.
Pull request overview
Adds a new “strategy graph” subsystem to packages/trading-strategies, allowing strategies to be represented as versioned, Zod-validated JSON graphs executed by a GraphStrategy interpreter, with built-in node types and a registry for extensions.
Changes:
- Introduces
GraphStrategyto compile/validate and execute declarative strategy graphs via node evaluators. - Adds graph schema + built-in node definitions/registry (batching, fields, indicators, conditionals, advice sinks) and a SMA crossover template graph.
- Exposes the new graph API from the package entrypoint and adds a comprehensive Vitest suite (validation, equivalence, triggers, custom nodes).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/trading-strategies/src/strategy-graph/templates.ts | Adds a reusable SMA-crossover starter/fixture graph definition. |
| packages/trading-strategies/src/strategy-graph/NodeRegistry.ts | Defines built-in node types, Zod config schemas, and the extensible node registry. |
| packages/trading-strategies/src/strategy-graph/index.ts | Public exports for the strategy-graph module (strategy, schema, registry, templates). |
| packages/trading-strategies/src/strategy-graph/GraphStrategy.ts | Implements graph compilation, validation, topological ordering, and per-candle interpretation. |
| packages/trading-strategies/src/strategy-graph/GraphStrategy.test.ts | Adds validation, equivalence, trigger semantics, advice payload, and custom node tests. |
| packages/trading-strategies/src/strategy-graph/GraphSchema.ts | Introduces the versioned Zod schema for graph JSON inputs. |
| packages/trading-strategies/src/index.ts | Re-exports the strategy-graph module from the package root API. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const nodeIds = Object.keys(graph.nodes); | ||
| const dependents = new Map<string, string[]>(nodeIds.map(id => [id, []])); | ||
| const pendingInputs = new Map<string, number>(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[] = []; |
| // 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'); | ||
| } |
| 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}`); | ||
| } |
- 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
|
Addressed all three review findings in dbdec89:
|
There was a problem hiding this comment.
🔵 Needs a closer look
There is a compile-blocking syntax issue in the if node’s emitted outputs, and an internal keying approach in GraphStrategy that can collide for valid user-provided ids/ports.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
packages/trading-strategies/src/strategy-graph/GraphStrategy.ts:17
outputKey()concatenates node id and port with:; since graph node ids (and custom port names) are only validated as non-empty strings, values containing:can collide (e.g. node "a:b" port "c" vs node "a" port "b:c"). Use an unambiguous encoding for the composite key.
const outputKey = (node: string, port: string) => `${node}:${port}`;
packages/trading-strategies/src/strategy-graph/NodeRegistry.ts:244
- This return statement uses
{true: ...}/{false: ...}as unquoted object keys, which is invalid JavaScript/TypeScript syntax and will fail to compile. Use a computed property name (or quoted keys) to emit either the "true" or "false" output port.
return {outputs: outcome ? {true: true} : {false: true}};
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
What
Strategies expressed as data instead of code: a versioned, Zod-validated JSON graph of typed building blocks, executed by
GraphStrategy— an interpreter implementingTradingSessionStrategy. Graphs backtest and trade through the exact same executors as hand-written strategies; there is no separate toy runtime whose results could diverge.Building blocks (v1)
source:candlebatcherfieldindicatorifonChangetrigger = crossover edge detectionadviceOrderAdvicewhen its trigger firesDesign highlights
getResultOrThrow()strictly behind theisStablegate); held values between ticks make mixed timeframes structurally lookahead-free — a 5m indicator holds its last closed bar between batch closes.registerNodeType()lets third parties add nodes (news signals, on-chain data). Evaluators support asyncevaluate()and an asyncinit()hook (wired to the session/backtest init from fix(trading-strategies): call strategy init in BacktestExecutor to mirror TradingSession #1189) for loading external datasets deterministically.Proof
The SMA crossover template graph produces advice-for-advice identical output to the hand-written
SmaCrossoverStrategyacross shared- and mixed-timeframe configs, and a balance-identical backtest to 8 decimal places (same trades, fees, P&L) viaBacktestExecutor.Test plan
21 new tests: equivalence (3 configs + full backtest), validation errors, trigger semantics (
onChangevsalways), advice payloads, custom node registration incl. async evaluate/init. Full suite: 283 green, lint clean, build clean.