Skip to content

feat(trading-strategies): add declarative strategy graphs (GraphStrategy)#1190

Open
bennycode wants to merge 2 commits into
fix/backtest-executor-strategy-initfrom
feat/strategy-graph
Open

feat(trading-strategies): add declarative strategy graphs (GraphStrategy)#1190
bennycode wants to merge 2 commits into
fix/backtest-executor-strategy-initfrom
feat/strategy-graph

Conversation

@bennycode

Copy link
Copy Markdown
Owner

Stacked on #1189 — merge that first; GitHub will retarget this PR to main automatically.

What

Strategies expressed as data instead of code: a versioned, Zod-validated JSON graph of typed building blocks, executed by GraphStrategy — an interpreter implementing TradingSessionStrategy. 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)

Node Role
source:candle emits the incoming 1m candle
batcher rolls candles up to a configurable timeframe; emits only on bar close
field extracts open/high/low/close/volume/medianPrice
indicator SMA / EMA / RSI with configurable period; silent until warmed up
if compares two values; onChange trigger = crossover edge detection
advice emits a market OrderAdvice when its trigger fires

Design highlights

  • Warm-up safety: nodes emit only when stable (getResultOrThrow() strictly behind the isStable gate); held values between ticks make mixed timeframes structurally lookahead-free — a 5m indicator holds its last closed bar between batch closes.
  • Extension point: registerNodeType() lets third parties add nodes (news signals, on-chain data). Evaluators support async evaluate() and an async init() 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.
  • Validation: node-addressable errors, cycle detection (Kahn), port type checking, single-writer inputs, sink requirement.

Proof

The SMA crossover template graph produces advice-for-advice identical output to the hand-written SmaCrossoverStrategy across shared- and mixed-timeframe configs, and a balance-identical backtest to 8 decimal places (same trades, fees, P&L) via BacktestExecutor.

Test plan

21 new tests: equivalence (3 configs + full backtest), validation errors, trigger semantics (onChange vs always), advice payloads, custom node registration incl. async evaluate/init. Full suite: 283 green, lint clean, build clean.

…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 GraphStrategy to 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.

Comment on lines +157 to +167
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[] = [];
Comment on lines +186 to +189
// 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');
}
Comment on lines +99 to +103
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
@bennycode

Copy link
Copy Markdown
Owner Author

Addressed all three review findings in dbdec89:

  • Deterministic evaluation order: node ids and dependents are now sorted in the topological sort, so evaluation (and the first-advice-wins rule) depends only on graph structure — added a regression test racing two advice nodes under reversed JSON key/connection ordering
  • Empty-path Zod issues (refinements) no longer produce a leading : in config errors
  • Missing-sink error reworded to match the actual check, since registerNodeType() allows custom sinks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants