@backtest-kit/graph 14.1.0 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,254 +1,254 @@
1
- <img src="https://github.com/tripolskypetr/backtest-kit/raw/refs/heads/master/assets/assignation.svg" height="45px" align="right">
2
-
3
- # 🕸️ @backtest-kit/graph
4
-
5
- > Compose [backtest-kit](https://www.npmjs.com/package/backtest-kit) computations as a typed directed acyclic graph. Declare **source nodes** that fetch market data and **output nodes** that derive values from them — then resolve the whole graph in topological order, in parallel, fully type-inferred.
6
-
7
- ![screenshot](https://raw.githubusercontent.com/tripolskypetr/backtest-kit/HEAD/assets/screenshots/screenshot16.png)
8
-
9
- [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/tripolskypetr/backtest-kit)
10
- [![npm](https://img.shields.io/npm/v/@backtest-kit/graph.svg?style=flat-square)](https://npmjs.org/package/@backtest-kit/graph)
11
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue)]()
12
-
13
- 📚 **[Docs](https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html)** · 🌟 **[Reference implementation](https://github.com/tripolskypetr/backtest-kit/tree/master/example)** · 🐙 **[GitHub](https://github.com/tripolskypetr/backtest-kit)**
14
-
15
- ```bash
16
- npm install @backtest-kit/graph backtest-kit
17
- ```
18
-
19
- ---
20
-
21
- ## What it's for
22
-
23
- A trading signal is rarely one number — it's a small computation: pull 4h trend, pull 15m entry, combine them, maybe gate on RSI. Written inline, that becomes a tangle of `await`s with hand-managed ordering. This package lets you declare it as a **graph of typed nodes**: leaves (`sourceNode`) fetch data, branches (`outputNode`) compute from their children, and `resolve()` walks the tree bottom-up — resolving every node's dependencies **in parallel** (`Promise.all`) before computing the node itself. Swapping a timeframe or adding a filter node needs no change to the strategy wiring, and TypeScript infers the value type through the entire graph.
24
-
25
- It plugs straight into a `getSignal` and runs inside backtest-kit's execution context, so a `sourceNode`'s `fetch` automatically receives `(symbol, when, currentPrice, exchangeName)` — the same look-ahead-safe "now" the rest of the engine sees.
26
-
27
- ---
28
-
29
- ## Multi-timeframe Pine strategy (the canonical example)
30
-
31
- A two-timeframe strategy: a **4h Pine Script** acts as a trend filter, a **15m Pine Script** generates the entry. The output node combines them and returns `null` whenever the trend disagrees with the entry — so a long signal is suppressed in a downtrend and vice-versa.
32
-
33
- <details>
34
- <summary>The Code</summary>
35
-
36
- ```typescript
37
- import { extract, run, toSignalDto, File } from '@backtest-kit/pinets';
38
- import { addStrategySchema, Cache } from 'backtest-kit';
39
- import { randomString } from 'functools-kit';
40
- import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
41
-
42
- // SourceNode — 4h trend filter, cached per candle interval
43
- const higherTimeframe = sourceNode(Cache.fn(async (symbol) => {
44
- const plots = await run(File.fromPath('timeframe_4h.pine'), { symbol, timeframe: '4h', limit: 100 });
45
- return extract(plots, { allowLong: 'AllowLong', allowShort: 'AllowShort', noTrades: 'NoTrades' });
46
- }, { interval: '4h', key: ([symbol]) => symbol }));
47
-
48
- // SourceNode — 15m entry signal, cached per candle interval
49
- const lowerTimeframe = sourceNode(Cache.fn(async (symbol) => {
50
- const plots = await run(File.fromPath('timeframe_15m.pine'), { symbol, timeframe: '15m', limit: 100 });
51
- return extract(plots, { position: 'Signal', priceOpen: 'Close', priceTakeProfit: 'TakeProfit', priceStopLoss: 'StopLoss', minuteEstimatedTime: 'EstimatedTime' });
52
- }, { interval: '15m', key: ([symbol]) => symbol }));
53
-
54
- // OutputNode — applies the MTF filter, returns ISignalDto or null
55
- const mtfSignal = outputNode(async ([higher, lower]) => {
56
- if (higher.noTrades) return null;
57
- if (lower.position === 0) return null;
58
- if (higher.allowShort && lower.position === 1) return null; // long blocked in downtrend
59
- if (higher.allowLong && lower.position === -1) return null; // short blocked in uptrend
60
- return toSignalDto(randomString(), lower, null);
61
- }, higherTimeframe, lowerTimeframe);
62
-
63
- addStrategySchema({
64
- strategyName: 'mtf_graph_strategy', interval: '5m',
65
- getSignal: (symbol) => resolve(mtfSignal),
66
- actions: ['partial_profit_action', 'breakeven_action'],
67
- });
68
- ```
69
-
70
- Both Pine nodes resolve **in parallel**; their typed results flow into `compute`. Replacing either script — or adding a third filter node — requires no change to the strategy registration.
71
-
72
- </details>
73
-
74
- ---
75
-
76
- ## The model
77
-
78
- Two layers, by design: a **low-level runtime interface** (`INode`) that serializes to a DB and reconstructs without builders, and a **high-level authoring API** (`TypedNode` + `sourceNode`/`outputNode`) that gives full type inference. You write with the builders; the runtime and storage use the interface.
79
-
80
- - 📊 **DAG execution** — nodes resolve bottom-up in topological order with `Promise.all` parallelism.
81
- - 🔒 **Type-safe values** — TypeScript infers each node's return type through the graph via generics (`OutputNode<[SourceNode<number>, SourceNode<string>], …>`).
82
- - 💾 **DB-ready** — `serialize`/`deserialize` convert the graph to a flat `IFlatNode[]` list with `id`/`nodeIds`.
83
- - 🔌 **Context-aware fetch** — `SourceNode.fetch` receives `(symbol, when, currentPrice, exchangeName)` from the execution context automatically.
84
-
85
- ---
86
-
87
- ## Authoring API
88
-
89
- <details>
90
- <summary>Builder API — sourceNode / outputNode / resolve</summary>
91
-
92
- `outputNode` infers the type of `values` in `compute` from the nodes you pass:
93
-
94
- ```typescript
95
- import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
96
-
97
- const closePrice = sourceNode(async (symbol, when, currentPrice, exchangeName) => {
98
- const candles = await getCandles(symbol, '1h', 1, exchangeName);
99
- return candles[0].close; // SourceNode<number>
100
- });
101
- const volume = sourceNode(async (symbol, when, currentPrice, exchangeName) => {
102
- const candles = await getCandles(symbol, '1h', 1, exchangeName);
103
- return candles[0].volume; // SourceNode<number>
104
- });
105
-
106
- const vwap = outputNode(([price, vol]) => price * vol, closePrice, volume); // price, vol: number
107
- const result = await resolve(vwap); // Promise<number>, inside a strategy
108
- ```
109
-
110
- </details>
111
-
112
- <details>
113
- <summary>Mixed types — heterogeneous inference by position</summary>
114
-
115
- ```typescript
116
- const price = sourceNode(async (symbol) => 42); // SourceNode<number>
117
- const name = sourceNode(async (symbol) => 'BTCUSDT'); // SourceNode<string>
118
- const flag = sourceNode(async (symbol) => true); // SourceNode<boolean>
119
-
120
- const result = outputNode(
121
- ([p, n, f]) => `${n}: ${p} (active: ${f})`, // p: number, n: string, f: boolean
122
- price, name, flag,
123
- ); // OutputNode<[SourceNode<number>, SourceNode<string>, SourceNode<boolean>], string>
124
- ```
125
-
126
- </details>
127
-
128
- <details>
129
- <summary>Inline anonymous composition — a single object literal</summary>
130
-
131
- ```typescript
132
- import { NodeType, TypedNode, resolve } from '@backtest-kit/graph';
133
-
134
- const signal: TypedNode = {
135
- type: NodeType.OutputNode,
136
- nodes: [
137
- { type: NodeType.SourceNode, fetch: async (symbol) => extract(await run(File.fromPath('timeframe_4h.pine'), { symbol, timeframe: '4h', limit: 100 }), { allowLong: 'AllowLong', allowShort: 'AllowShort', noTrades: 'NoTrades' }) },
138
- { type: NodeType.SourceNode, fetch: async (symbol) => extract(await run(File.fromPath('timeframe_15m.pine'), { symbol, timeframe: '15m', limit: 100 }), { position: 'Signal', priceOpen: 'Close', priceTakeProfit: 'TakeProfit', priceStopLoss: 'StopLoss' }) },
139
- ],
140
- compute: ([higher, lower]) => {
141
- if (higher.noTrades || lower.position === 0) return null;
142
- if (higher.allowShort && lower.position === 1) return null;
143
- if (higher.allowLong && lower.position === -1) return null;
144
- return lower.position;
145
- },
146
- };
147
- const result = await resolve(signal);
148
- ```
149
-
150
- </details>
151
-
152
- <details>
153
- <summary>Inside a backtest-kit strategy</summary>
154
-
155
- ```typescript
156
- import { addStrategy } from 'backtest-kit';
157
- import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
158
-
159
- const rsi = sourceNode(async (symbol, when, currentPrice, exchangeName) => 55.2 /* compute RSI */);
160
- const signal = outputNode(([rsiValue]) => rsiValue < 30 ? 1 : rsiValue > 70 ? -1 : 0, rsi);
161
-
162
- addStrategy({
163
- strategyName: 'graph-rsi', interval: '1h', riskName: 'demo',
164
- getSignal: async (symbol) => {
165
- const direction = await resolve(signal); // 1 | -1 | 0
166
- return direction === 1 ? { position: 'long', /* … */ } : null;
167
- },
168
- });
169
- ```
170
-
171
- </details>
172
-
173
- ---
174
-
175
- ## Runtime interface & storage
176
-
177
- <details>
178
- <summary>Low-level INode — manual construction (post-deserialize / DI)</summary>
179
-
180
- ```typescript
181
- import { INode, Value } from '@backtest-kit/graph';
182
- import NodeType from '@backtest-kit/graph/enum/NodeType';
183
-
184
- const priceNode: INode = { type: NodeType.SourceNode, description: 'Close price', fetch: async (symbol, when, currentPrice, exchangeName) => 42 };
185
- const doubled: INode = { type: NodeType.OutputNode, description: 'Doubled price', nodes: [priceNode], compute: ([price]) => (price as number) * 2 };
186
- ```
187
-
188
- `INode` has no generic parameters — `values` in `compute` is typed as `Value[]` (`string | number | boolean | null`). Use `TypedNode` + builders for full IntelliSense.
189
-
190
- </details>
191
-
192
- <details>
193
- <summary>DB serialization — serialize / deserialize</summary>
194
-
195
- `serialize` flattens the graph into `IFlatNode[]`, replacing object references in `nodes` with `nodeIds`; `deserialize` rebuilds the tree:
196
-
197
- ```typescript
198
- import { serialize, deserialize, IFlatNode } from '@backtest-kit/graph';
199
-
200
- const flat: IFlatNode[] = serialize([vwap]);
201
- // [ { id:'abc', type:'source_node', nodeIds:[] }, // closePrice
202
- // { id:'def', type:'source_node', nodeIds:[] }, // volume
203
- // { id:'ghi', type:'output_node', nodeIds:['abc','def'] } ] // vwap
204
- await db.collection('nodes').insertMany(flat);
205
-
206
- const stored = await db.collection('nodes').find().toArray();
207
- const roots: INode[] = deserialize(stored); // nodes[] re-wired from nodeIds
208
- ```
209
-
210
- `fetch` and `compute` are **not** stored — restore them on the application side after `deserialize`.
211
-
212
- </details>
213
-
214
- <details>
215
- <summary>deepFlat — topological traversal</summary>
216
-
217
- Returns all nodes in topological order (dependencies before parents), deduplicated by reference:
218
-
219
- ```typescript
220
- import { deepFlat } from '@backtest-kit/graph';
221
- const all = deepFlat([vwap]); // [closePrice, volume, vwap]
222
- all.forEach(node => console.log(node.description));
223
- ```
224
-
225
- </details>
226
-
227
- ---
228
-
229
- ## API reference
230
-
231
- | Export | Description |
232
- |--------|-------------|
233
- | `sourceNode(fetch)` | Builder — typed source (leaf) node; `fetch(symbol, when, currentPrice, exchangeName)` |
234
- | `outputNode(compute, ...nodes)` | Builder — typed output node; infers `values` types from `nodes` |
235
- | `resolve(node)` | Recursively resolves a graph within backtest-kit execution context |
236
- | `serialize(roots)` | Flattens a node tree into `IFlatNode[]` for DB storage |
237
- | `deserialize(flat)` | Reconstructs a node tree from `IFlatNode[]`, returns root nodes |
238
- | `deepFlat(nodes)` | Returns all nodes in topological order (dependencies first) |
239
- | `INode` | Base runtime interface (untyped; used internally and for serialization) |
240
- | `TypedNode` | Discriminated union for authoring with full IntelliSense |
241
- | `IFlatNode` | Serialized node shape for DB storage (`id`, `type`, `nodeIds`) |
242
- | `NodeType` | Enum — `SourceNode` (`source_node`) / `OutputNode` (`output_node`) |
243
- | `Value` | `string \| number \| boolean \| null` |
244
- | `ExchangeName` | Exchange-name type alias passed to `fetch` |
245
-
246
- > **Complete source map.** `enum/NodeType.ts` · `helpers/{node,resolve,serialize,deepFlat}.ts` · `interfaces/{Node,TypedNode,FlatNode}.interface.ts` · `model/ExchangeName.model.ts` · `index.ts` — every export above is one of these files; nothing in `src/` is undocumented.
247
-
248
- ## 🤝 Contribute
249
-
250
- Fork / PR on [GitHub](https://github.com/tripolskypetr/backtest-kit).
251
-
252
- ## 📜 License
253
-
254
- MIT © [tripolskypetr](https://github.com/tripolskypetr)
1
+ <img src="https://github.com/tripolskypetr/backtest-kit/raw/refs/heads/master/assets/assignation.svg" height="45px" align="right">
2
+
3
+ # 🕸️ @backtest-kit/graph
4
+
5
+ > Compose [backtest-kit](https://www.npmjs.com/package/backtest-kit) computations as a typed directed acyclic graph. Declare **source nodes** that fetch market data and **output nodes** that derive values from them — then resolve the whole graph in topological order, in parallel, fully type-inferred.
6
+
7
+ ![screenshot](https://raw.githubusercontent.com/tripolskypetr/backtest-kit/HEAD/assets/screenshots/screenshot16.png)
8
+
9
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/tripolskypetr/backtest-kit)
10
+ [![npm](https://img.shields.io/npm/v/@backtest-kit/graph.svg?style=flat-square)](https://npmjs.org/package/@backtest-kit/graph)
11
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue)]()
12
+
13
+ 📚 **[Docs](https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html)** · 🌟 **[Reference implementation](https://github.com/tripolskypetr/backtest-kit/tree/master/example)** · 🐙 **[GitHub](https://github.com/tripolskypetr/backtest-kit)**
14
+
15
+ ```bash
16
+ npm install @backtest-kit/graph backtest-kit
17
+ ```
18
+
19
+ ---
20
+
21
+ ## What it's for
22
+
23
+ A trading signal is rarely one number — it's a small computation: pull 4h trend, pull 15m entry, combine them, maybe gate on RSI. Written inline, that becomes a tangle of `await`s with hand-managed ordering. This package lets you declare it as a **graph of typed nodes**: leaves (`sourceNode`) fetch data, branches (`outputNode`) compute from their children, and `resolve()` walks the tree bottom-up — resolving every node's dependencies **in parallel** (`Promise.all`) before computing the node itself. Swapping a timeframe or adding a filter node needs no change to the strategy wiring, and TypeScript infers the value type through the entire graph.
24
+
25
+ It plugs straight into a `getSignal` and runs inside backtest-kit's execution context, so a `sourceNode`'s `fetch` automatically receives `(symbol, when, currentPrice, exchangeName)` — the same look-ahead-safe "now" the rest of the engine sees.
26
+
27
+ ---
28
+
29
+ ## Multi-timeframe Pine strategy (the canonical example)
30
+
31
+ A two-timeframe strategy: a **4h Pine Script** acts as a trend filter, a **15m Pine Script** generates the entry. The output node combines them and returns `null` whenever the trend disagrees with the entry — so a long signal is suppressed in a downtrend and vice-versa.
32
+
33
+ <details>
34
+ <summary>The Code</summary>
35
+
36
+ ```typescript
37
+ import { extract, run, toSignalDto, File } from '@backtest-kit/pinets';
38
+ import { addStrategySchema, Cache } from 'backtest-kit';
39
+ import { randomString } from 'functools-kit';
40
+ import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
41
+
42
+ // SourceNode — 4h trend filter, cached per candle interval
43
+ const higherTimeframe = sourceNode(Cache.fn(async (symbol) => {
44
+ const plots = await run(File.fromPath('timeframe_4h.pine'), { symbol, timeframe: '4h', limit: 100 });
45
+ return extract(plots, { allowLong: 'AllowLong', allowShort: 'AllowShort', noTrades: 'NoTrades' });
46
+ }, { interval: '4h', key: ([symbol]) => symbol }));
47
+
48
+ // SourceNode — 15m entry signal, cached per candle interval
49
+ const lowerTimeframe = sourceNode(Cache.fn(async (symbol) => {
50
+ const plots = await run(File.fromPath('timeframe_15m.pine'), { symbol, timeframe: '15m', limit: 100 });
51
+ return extract(plots, { position: 'Signal', priceOpen: 'Close', priceTakeProfit: 'TakeProfit', priceStopLoss: 'StopLoss', minuteEstimatedTime: 'EstimatedTime' });
52
+ }, { interval: '15m', key: ([symbol]) => symbol }));
53
+
54
+ // OutputNode — applies the MTF filter, returns ISignalDto or null
55
+ const mtfSignal = outputNode(async ([higher, lower]) => {
56
+ if (higher.noTrades) return null;
57
+ if (lower.position === 0) return null;
58
+ if (higher.allowShort && lower.position === 1) return null; // long blocked in downtrend
59
+ if (higher.allowLong && lower.position === -1) return null; // short blocked in uptrend
60
+ return toSignalDto(randomString(), lower, null);
61
+ }, higherTimeframe, lowerTimeframe);
62
+
63
+ addStrategySchema({
64
+ strategyName: 'mtf_graph_strategy', interval: '5m',
65
+ getSignal: (symbol) => resolve(mtfSignal),
66
+ actions: ['partial_profit_action', 'breakeven_action'],
67
+ });
68
+ ```
69
+
70
+ Both Pine nodes resolve **in parallel**; their typed results flow into `compute`. Replacing either script — or adding a third filter node — requires no change to the strategy registration.
71
+
72
+ </details>
73
+
74
+ ---
75
+
76
+ ## The model
77
+
78
+ Two layers, by design: a **low-level runtime interface** (`INode`) that serializes to a DB and reconstructs without builders, and a **high-level authoring API** (`TypedNode` + `sourceNode`/`outputNode`) that gives full type inference. You write with the builders; the runtime and storage use the interface.
79
+
80
+ - 📊 **DAG execution** — nodes resolve bottom-up in topological order with `Promise.all` parallelism.
81
+ - 🔒 **Type-safe values** — TypeScript infers each node's return type through the graph via generics (`OutputNode<[SourceNode<number>, SourceNode<string>], …>`).
82
+ - 💾 **DB-ready** — `serialize`/`deserialize` convert the graph to a flat `IFlatNode[]` list with `id`/`nodeIds`.
83
+ - 🔌 **Context-aware fetch** — `SourceNode.fetch` receives `(symbol, when, currentPrice, exchangeName)` from the execution context automatically.
84
+
85
+ ---
86
+
87
+ ## Authoring API
88
+
89
+ <details>
90
+ <summary>Builder API — sourceNode / outputNode / resolve</summary>
91
+
92
+ `outputNode` infers the type of `values` in `compute` from the nodes you pass:
93
+
94
+ ```typescript
95
+ import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
96
+
97
+ const closePrice = sourceNode(async (symbol, when, currentPrice, exchangeName) => {
98
+ const candles = await getCandles(symbol, '1h', 1, exchangeName);
99
+ return candles[0].close; // SourceNode<number>
100
+ });
101
+ const volume = sourceNode(async (symbol, when, currentPrice, exchangeName) => {
102
+ const candles = await getCandles(symbol, '1h', 1, exchangeName);
103
+ return candles[0].volume; // SourceNode<number>
104
+ });
105
+
106
+ const vwap = outputNode(([price, vol]) => price * vol, closePrice, volume); // price, vol: number
107
+ const result = await resolve(vwap); // Promise<number>, inside a strategy
108
+ ```
109
+
110
+ </details>
111
+
112
+ <details>
113
+ <summary>Mixed types — heterogeneous inference by position</summary>
114
+
115
+ ```typescript
116
+ const price = sourceNode(async (symbol) => 42); // SourceNode<number>
117
+ const name = sourceNode(async (symbol) => 'BTCUSDT'); // SourceNode<string>
118
+ const flag = sourceNode(async (symbol) => true); // SourceNode<boolean>
119
+
120
+ const result = outputNode(
121
+ ([p, n, f]) => `${n}: ${p} (active: ${f})`, // p: number, n: string, f: boolean
122
+ price, name, flag,
123
+ ); // OutputNode<[SourceNode<number>, SourceNode<string>, SourceNode<boolean>], string>
124
+ ```
125
+
126
+ </details>
127
+
128
+ <details>
129
+ <summary>Inline anonymous composition — a single object literal</summary>
130
+
131
+ ```typescript
132
+ import { NodeType, TypedNode, resolve } from '@backtest-kit/graph';
133
+
134
+ const signal: TypedNode = {
135
+ type: NodeType.OutputNode,
136
+ nodes: [
137
+ { type: NodeType.SourceNode, fetch: async (symbol) => extract(await run(File.fromPath('timeframe_4h.pine'), { symbol, timeframe: '4h', limit: 100 }), { allowLong: 'AllowLong', allowShort: 'AllowShort', noTrades: 'NoTrades' }) },
138
+ { type: NodeType.SourceNode, fetch: async (symbol) => extract(await run(File.fromPath('timeframe_15m.pine'), { symbol, timeframe: '15m', limit: 100 }), { position: 'Signal', priceOpen: 'Close', priceTakeProfit: 'TakeProfit', priceStopLoss: 'StopLoss' }) },
139
+ ],
140
+ compute: ([higher, lower]) => {
141
+ if (higher.noTrades || lower.position === 0) return null;
142
+ if (higher.allowShort && lower.position === 1) return null;
143
+ if (higher.allowLong && lower.position === -1) return null;
144
+ return lower.position;
145
+ },
146
+ };
147
+ const result = await resolve(signal);
148
+ ```
149
+
150
+ </details>
151
+
152
+ <details>
153
+ <summary>Inside a backtest-kit strategy</summary>
154
+
155
+ ```typescript
156
+ import { addStrategy } from 'backtest-kit';
157
+ import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
158
+
159
+ const rsi = sourceNode(async (symbol, when, currentPrice, exchangeName) => 55.2 /* compute RSI */);
160
+ const signal = outputNode(([rsiValue]) => rsiValue < 30 ? 1 : rsiValue > 70 ? -1 : 0, rsi);
161
+
162
+ addStrategy({
163
+ strategyName: 'graph-rsi', interval: '1h', riskName: 'demo',
164
+ getSignal: async (symbol) => {
165
+ const direction = await resolve(signal); // 1 | -1 | 0
166
+ return direction === 1 ? { position: 'long', /* … */ } : null;
167
+ },
168
+ });
169
+ ```
170
+
171
+ </details>
172
+
173
+ ---
174
+
175
+ ## Runtime interface & storage
176
+
177
+ <details>
178
+ <summary>Low-level INode — manual construction (post-deserialize / DI)</summary>
179
+
180
+ ```typescript
181
+ import { INode, Value } from '@backtest-kit/graph';
182
+ import NodeType from '@backtest-kit/graph/enum/NodeType';
183
+
184
+ const priceNode: INode = { type: NodeType.SourceNode, description: 'Close price', fetch: async (symbol, when, currentPrice, exchangeName) => 42 };
185
+ const doubled: INode = { type: NodeType.OutputNode, description: 'Doubled price', nodes: [priceNode], compute: ([price]) => (price as number) * 2 };
186
+ ```
187
+
188
+ `INode` has no generic parameters — `values` in `compute` is typed as `Value[]` (`string | number | boolean | null`). Use `TypedNode` + builders for full IntelliSense.
189
+
190
+ </details>
191
+
192
+ <details>
193
+ <summary>DB serialization — serialize / deserialize</summary>
194
+
195
+ `serialize` flattens the graph into `IFlatNode[]`, replacing object references in `nodes` with `nodeIds`; `deserialize` rebuilds the tree:
196
+
197
+ ```typescript
198
+ import { serialize, deserialize, IFlatNode } from '@backtest-kit/graph';
199
+
200
+ const flat: IFlatNode[] = serialize([vwap]);
201
+ // [ { id:'abc', type:'source_node', nodeIds:[] }, // closePrice
202
+ // { id:'def', type:'source_node', nodeIds:[] }, // volume
203
+ // { id:'ghi', type:'output_node', nodeIds:['abc','def'] } ] // vwap
204
+ await db.collection('nodes').insertMany(flat);
205
+
206
+ const stored = await db.collection('nodes').find().toArray();
207
+ const roots: INode[] = deserialize(stored); // nodes[] re-wired from nodeIds
208
+ ```
209
+
210
+ `fetch` and `compute` are **not** stored — restore them on the application side after `deserialize`.
211
+
212
+ </details>
213
+
214
+ <details>
215
+ <summary>deepFlat — topological traversal</summary>
216
+
217
+ Returns all nodes in topological order (dependencies before parents), deduplicated by reference:
218
+
219
+ ```typescript
220
+ import { deepFlat } from '@backtest-kit/graph';
221
+ const all = deepFlat([vwap]); // [closePrice, volume, vwap]
222
+ all.forEach(node => console.log(node.description));
223
+ ```
224
+
225
+ </details>
226
+
227
+ ---
228
+
229
+ ## API reference
230
+
231
+ | Export | Description |
232
+ |--------|-------------|
233
+ | `sourceNode(fetch)` | Builder — typed source (leaf) node; `fetch(symbol, when, currentPrice, exchangeName)` |
234
+ | `outputNode(compute, ...nodes)` | Builder — typed output node; infers `values` types from `nodes` |
235
+ | `resolve(node)` | Recursively resolves a graph within backtest-kit execution context |
236
+ | `serialize(roots)` | Flattens a node tree into `IFlatNode[]` for DB storage |
237
+ | `deserialize(flat)` | Reconstructs a node tree from `IFlatNode[]`, returns root nodes |
238
+ | `deepFlat(nodes)` | Returns all nodes in topological order (dependencies first) |
239
+ | `INode` | Base runtime interface (untyped; used internally and for serialization) |
240
+ | `TypedNode` | Discriminated union for authoring with full IntelliSense |
241
+ | `IFlatNode` | Serialized node shape for DB storage (`id`, `type`, `nodeIds`) |
242
+ | `NodeType` | Enum — `SourceNode` (`source_node`) / `OutputNode` (`output_node`) |
243
+ | `Value` | `string \| number \| boolean \| null` |
244
+ | `ExchangeName` | Exchange-name type alias passed to `fetch` |
245
+
246
+ > **Complete source map.** `enum/NodeType.ts` · `helpers/{node,resolve,serialize,deepFlat}.ts` · `interfaces/{Node,TypedNode,FlatNode}.interface.ts` · `model/ExchangeName.model.ts` · `index.ts` — every export above is one of these files; nothing in `src/` is undocumented.
247
+
248
+ ## 🤝 Contribute
249
+
250
+ Fork / PR on [GitHub](https://github.com/tripolskypetr/backtest-kit).
251
+
252
+ ## 📜 License
253
+
254
+ MIT © [tripolskypetr](https://github.com/tripolskypetr)
package/build/index.cjs CHANGED
@@ -1,21 +1,33 @@
1
1
  'use strict';
2
2
 
3
- var backtestKit = require('backtest-kit');
4
3
  var functoolsKit = require('functools-kit');
4
+ var backtestKit = require('backtest-kit');
5
5
 
6
- var NodeType;
6
+ exports.NodeType = void 0;
7
7
  (function (NodeType) {
8
8
  NodeType["SourceNode"] = "source_node";
9
9
  NodeType["OutputNode"] = "output_node";
10
- })(NodeType || (NodeType = {}));
11
- var NodeType$1 = NodeType;
10
+ })(exports.NodeType || (exports.NodeType = {}));
11
+ var NodeType = exports.NodeType;
12
12
 
13
+ /**
14
+ * Создаёт SourceNode с проставленным идентификатором.
15
+ * Для стабильности между перезапусками процесса (JSON round-trip)
16
+ * перезапишите id своим значением после создания.
17
+ */
13
18
  const sourceNode = (fetch) => ({
14
- type: NodeType$1.SourceNode,
19
+ type: NodeType.SourceNode,
20
+ id: functoolsKit.randomString(),
15
21
  fetch,
16
22
  });
23
+ /**
24
+ * Создаёт OutputNode с проставленным идентификатором.
25
+ * Для стабильности между перезапусками процесса (JSON round-trip)
26
+ * перезапишите id своим значением после создания.
27
+ */
17
28
  const outputNode = (compute, ...nodes) => ({
18
- type: NodeType$1.OutputNode,
29
+ type: NodeType.OutputNode,
30
+ id: functoolsKit.randomString(),
19
31
  nodes,
20
32
  compute,
21
33
  });
@@ -40,6 +52,32 @@ const deepFlat = (arr = []) => {
40
52
  return result;
41
53
  };
42
54
 
55
+ /**
56
+ * Проверяет граф на циклы (DFS с раскраской: path — серые, done — чёрные).
57
+ * Линейная сложность за счёт done-набора; общие зависимости (ромбы) не
58
+ * обходятся повторно. Циклы возможны только у графов, собранных вручную
59
+ * или восстановленных через deserialize — хелперы sourceNode/outputNode
60
+ * создать цикл не позволяют.
61
+ */
62
+ const CHECK_CYCLES_FN = (root) => {
63
+ const path = new Set();
64
+ const done = new Set();
65
+ const walk = (node) => {
66
+ if (done.has(node)) {
67
+ return;
68
+ }
69
+ if (path.has(node)) {
70
+ throw new Error("graph resolve: cycle detected in node graph");
71
+ }
72
+ path.add(node);
73
+ if (node.type === NodeType.OutputNode) {
74
+ (node.nodes ?? []).forEach(walk);
75
+ }
76
+ path.delete(node);
77
+ done.add(node);
78
+ };
79
+ walk(root);
80
+ };
43
81
  async function resolve(node) {
44
82
  if (!backtestKit.ExecutionContextService.hasContext()) {
45
83
  throw new Error("Execution context is required to resolve graph nodes. Please ensure that resolve() is called within a valid execution context.");
@@ -47,52 +85,95 @@ async function resolve(node) {
47
85
  if (!backtestKit.MethodContextService.hasContext()) {
48
86
  throw new Error("Method context is required to resolve graph nodes. Please ensure that resolve() is called within a valid method context.");
49
87
  }
50
- if (node.type === NodeType$1.SourceNode) {
51
- const { symbol, when } = backtestKit.lib.executionContextService.context;
52
- const { exchangeName } = backtestKit.lib.methodContextService.context;
53
- const currentPrice = await backtestKit.getAveragePrice(symbol);
54
- return await node.fetch(symbol, when, currentPrice, exchangeName);
55
- }
56
- const values = await Promise.all(node.nodes.map(resolve));
57
- return await node.compute(values);
88
+ CHECK_CYCLES_FN(node);
89
+ const { symbol, when } = backtestKit.lib.executionContextService.context;
90
+ const { exchangeName } = backtestKit.lib.methodContextService.context;
91
+ // Одна цена на весь проход: без повторных запросов на каждый SourceNode
92
+ // и без рассинхрона значений между источниками одного вычисления.
93
+ let currentPricePromise = null;
94
+ const GET_PRICE_FN = () => {
95
+ if (!currentPricePromise) {
96
+ currentPricePromise = backtestKit.getAveragePrice(symbol);
97
+ }
98
+ return currentPricePromise;
99
+ };
100
+ const memo = new Map();
101
+ const resolveNode = (target) => {
102
+ const cached = memo.get(target);
103
+ if (cached) {
104
+ return cached;
105
+ }
106
+ const promise = (async () => {
107
+ if (target.type === NodeType.SourceNode) {
108
+ const currentPrice = await GET_PRICE_FN();
109
+ return await target.fetch(symbol, when, currentPrice, exchangeName);
110
+ }
111
+ const values = await Promise.all((target.nodes ?? []).map(resolveNode));
112
+ return await target.compute(values);
113
+ })();
114
+ memo.set(target, promise);
115
+ return promise;
116
+ };
117
+ return await resolveNode(node);
58
118
  }
59
119
 
120
+ /**
121
+ * Нормализация на входе пайплайна: гарантирует каждому узлу идентификатор.
122
+ * Узлы из sourceNode/outputNode приходят уже с id; рукописным INode id
123
+ * доштамповывается прямо на объект — повторный serialize того же графа
124
+ * даёт те же идентификаторы.
125
+ */
126
+ const ENSURE_ID_FN = (node) => {
127
+ if (!node.id) {
128
+ node.id = functoolsKit.randomString();
129
+ }
130
+ return node;
131
+ };
60
132
  /**
61
133
  * Преобразует древовидный граф в плоский массив IFlatNode для хранения в БД.
62
- * Каждому узлу присваивается уникальный id (если не задан),
63
- * объектные ссылки nodes заменяются на массив nodeIds.
134
+ * Объектные ссылки nodes заменяются на массив nodeIds.
135
+ *
136
+ * Задавайте узлам стабильные id, если планируете восстанавливать fetch/compute
137
+ * после JSON-сериализации: функции в JSON не переживают round-trip, и найти
138
+ * узел для повторной привязки можно только по известному id (случайный id
139
+ * не переживает перезапуск процесса).
64
140
  */
65
141
  const serialize = (roots) => {
66
- const flat = deepFlat(roots);
67
- // Первый проход: назначаем id каждому уникальному узлу
68
- const idMap = new Map();
142
+ // Вход пайплайна: все узлы получают гарантированный id
143
+ const flat = deepFlat(roots).map(ENSURE_ID_FN);
144
+ const usedIds = new Set();
69
145
  flat.forEach((node) => {
70
- const id = functoolsKit.randomString();
71
- idMap.set(node, id);
146
+ if (usedIds.has(node.id)) {
147
+ throw new Error(`graph serialize: duplicate node id "${node.id}"`);
148
+ }
149
+ usedIds.add(node.id);
72
150
  });
73
- // Второй проход: строим IFlatNode с nodeIds вместо nodes
74
151
  return flat.map((node) => {
75
152
  const flatNode = {
76
- id: idMap.get(node),
153
+ id: node.id,
77
154
  type: node.type,
78
155
  description: node.description,
79
156
  fetch: node.fetch,
80
157
  compute: node.compute,
81
- nodeIds: node.nodes?.map((child) => idMap.get(child)),
158
+ nodeIds: node.nodes?.map((child) => ENSURE_ID_FN(child).id),
82
159
  };
83
160
  return flatNode;
84
161
  });
85
162
  };
86
163
  /**
87
164
  * Восстанавливает древовидный граф из плоского массива IFlatNode.
88
- * nodes каждого узла заполняется по nodeIds.
165
+ * nodes каждого узла заполняется по nodeIds; id сохраняется на узле,
166
+ * так что повторный serialize даёт те же идентификаторы.
167
+ * Ссылка на неизвестный nodeId — ошибка: contract compute(values)
168
+ * позиционный, тихое выпадение элемента сдвинуло бы чужие значения.
89
169
  * Возвращает корневые узлы (те, на которые никто не ссылается).
90
170
  */
91
171
  const deserialize = (flat) => {
92
- // Первый проход: создаём INode-объекты, индексируем по id
172
+ // Первый проход: создаём узлы, индексируем по id
93
173
  const byId = new Map();
94
174
  flat.forEach((flatNode) => {
95
175
  const node = {
176
+ id: flatNode.id,
96
177
  type: flatNode.type,
97
178
  description: flatNode.description,
98
179
  fetch: flatNode.fetch,
@@ -100,13 +181,18 @@ const deserialize = (flat) => {
100
181
  };
101
182
  byId.set(flatNode.id, node);
102
183
  });
103
- // Второй проход: проставляем nodes[] по nodeIds
184
+ // Второй проход: проставляем nodes[] по nodeIds (включая пустой массив —
185
+ // OutputNode без зависимостей легален и должен получить nodes: [])
104
186
  flat.forEach((flatNode) => {
105
- if (flatNode.nodeIds?.length) {
187
+ if (flatNode.nodeIds) {
106
188
  const node = byId.get(flatNode.id);
107
- node.nodes = flatNode.nodeIds
108
- .map((id) => byId.get(id))
109
- .filter((n) => n !== undefined);
189
+ node.nodes = flatNode.nodeIds.map((id) => {
190
+ const child = byId.get(id);
191
+ if (!child) {
192
+ throw new Error(`graph deserialize: node "${flatNode.id}" references unknown nodeId "${id}"`);
193
+ }
194
+ return child;
195
+ });
110
196
  }
111
197
  });
112
198
  // Корневые узлы — те, на которые не ссылается никто другой
package/build/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { ExecutionContextService, MethodContextService, lib, getAveragePrice } from 'backtest-kit';
2
1
  import { randomString } from 'functools-kit';
2
+ import { ExecutionContextService, MethodContextService, lib, getAveragePrice } from 'backtest-kit';
3
3
 
4
4
  var NodeType;
5
5
  (function (NodeType) {
@@ -8,12 +8,24 @@ var NodeType;
8
8
  })(NodeType || (NodeType = {}));
9
9
  var NodeType$1 = NodeType;
10
10
 
11
+ /**
12
+ * Создаёт SourceNode с проставленным идентификатором.
13
+ * Для стабильности между перезапусками процесса (JSON round-trip)
14
+ * перезапишите id своим значением после создания.
15
+ */
11
16
  const sourceNode = (fetch) => ({
12
17
  type: NodeType$1.SourceNode,
18
+ id: randomString(),
13
19
  fetch,
14
20
  });
21
+ /**
22
+ * Создаёт OutputNode с проставленным идентификатором.
23
+ * Для стабильности между перезапусками процесса (JSON round-trip)
24
+ * перезапишите id своим значением после создания.
25
+ */
15
26
  const outputNode = (compute, ...nodes) => ({
16
27
  type: NodeType$1.OutputNode,
28
+ id: randomString(),
17
29
  nodes,
18
30
  compute,
19
31
  });
@@ -38,6 +50,32 @@ const deepFlat = (arr = []) => {
38
50
  return result;
39
51
  };
40
52
 
53
+ /**
54
+ * Проверяет граф на циклы (DFS с раскраской: path — серые, done — чёрные).
55
+ * Линейная сложность за счёт done-набора; общие зависимости (ромбы) не
56
+ * обходятся повторно. Циклы возможны только у графов, собранных вручную
57
+ * или восстановленных через deserialize — хелперы sourceNode/outputNode
58
+ * создать цикл не позволяют.
59
+ */
60
+ const CHECK_CYCLES_FN = (root) => {
61
+ const path = new Set();
62
+ const done = new Set();
63
+ const walk = (node) => {
64
+ if (done.has(node)) {
65
+ return;
66
+ }
67
+ if (path.has(node)) {
68
+ throw new Error("graph resolve: cycle detected in node graph");
69
+ }
70
+ path.add(node);
71
+ if (node.type === NodeType$1.OutputNode) {
72
+ (node.nodes ?? []).forEach(walk);
73
+ }
74
+ path.delete(node);
75
+ done.add(node);
76
+ };
77
+ walk(root);
78
+ };
41
79
  async function resolve(node) {
42
80
  if (!ExecutionContextService.hasContext()) {
43
81
  throw new Error("Execution context is required to resolve graph nodes. Please ensure that resolve() is called within a valid execution context.");
@@ -45,52 +83,95 @@ async function resolve(node) {
45
83
  if (!MethodContextService.hasContext()) {
46
84
  throw new Error("Method context is required to resolve graph nodes. Please ensure that resolve() is called within a valid method context.");
47
85
  }
48
- if (node.type === NodeType$1.SourceNode) {
49
- const { symbol, when } = lib.executionContextService.context;
50
- const { exchangeName } = lib.methodContextService.context;
51
- const currentPrice = await getAveragePrice(symbol);
52
- return await node.fetch(symbol, when, currentPrice, exchangeName);
53
- }
54
- const values = await Promise.all(node.nodes.map(resolve));
55
- return await node.compute(values);
86
+ CHECK_CYCLES_FN(node);
87
+ const { symbol, when } = lib.executionContextService.context;
88
+ const { exchangeName } = lib.methodContextService.context;
89
+ // Одна цена на весь проход: без повторных запросов на каждый SourceNode
90
+ // и без рассинхрона значений между источниками одного вычисления.
91
+ let currentPricePromise = null;
92
+ const GET_PRICE_FN = () => {
93
+ if (!currentPricePromise) {
94
+ currentPricePromise = getAveragePrice(symbol);
95
+ }
96
+ return currentPricePromise;
97
+ };
98
+ const memo = new Map();
99
+ const resolveNode = (target) => {
100
+ const cached = memo.get(target);
101
+ if (cached) {
102
+ return cached;
103
+ }
104
+ const promise = (async () => {
105
+ if (target.type === NodeType$1.SourceNode) {
106
+ const currentPrice = await GET_PRICE_FN();
107
+ return await target.fetch(symbol, when, currentPrice, exchangeName);
108
+ }
109
+ const values = await Promise.all((target.nodes ?? []).map(resolveNode));
110
+ return await target.compute(values);
111
+ })();
112
+ memo.set(target, promise);
113
+ return promise;
114
+ };
115
+ return await resolveNode(node);
56
116
  }
57
117
 
118
+ /**
119
+ * Нормализация на входе пайплайна: гарантирует каждому узлу идентификатор.
120
+ * Узлы из sourceNode/outputNode приходят уже с id; рукописным INode id
121
+ * доштамповывается прямо на объект — повторный serialize того же графа
122
+ * даёт те же идентификаторы.
123
+ */
124
+ const ENSURE_ID_FN = (node) => {
125
+ if (!node.id) {
126
+ node.id = randomString();
127
+ }
128
+ return node;
129
+ };
58
130
  /**
59
131
  * Преобразует древовидный граф в плоский массив IFlatNode для хранения в БД.
60
- * Каждому узлу присваивается уникальный id (если не задан),
61
- * объектные ссылки nodes заменяются на массив nodeIds.
132
+ * Объектные ссылки nodes заменяются на массив nodeIds.
133
+ *
134
+ * Задавайте узлам стабильные id, если планируете восстанавливать fetch/compute
135
+ * после JSON-сериализации: функции в JSON не переживают round-trip, и найти
136
+ * узел для повторной привязки можно только по известному id (случайный id
137
+ * не переживает перезапуск процесса).
62
138
  */
63
139
  const serialize = (roots) => {
64
- const flat = deepFlat(roots);
65
- // Первый проход: назначаем id каждому уникальному узлу
66
- const idMap = new Map();
140
+ // Вход пайплайна: все узлы получают гарантированный id
141
+ const flat = deepFlat(roots).map(ENSURE_ID_FN);
142
+ const usedIds = new Set();
67
143
  flat.forEach((node) => {
68
- const id = randomString();
69
- idMap.set(node, id);
144
+ if (usedIds.has(node.id)) {
145
+ throw new Error(`graph serialize: duplicate node id "${node.id}"`);
146
+ }
147
+ usedIds.add(node.id);
70
148
  });
71
- // Второй проход: строим IFlatNode с nodeIds вместо nodes
72
149
  return flat.map((node) => {
73
150
  const flatNode = {
74
- id: idMap.get(node),
151
+ id: node.id,
75
152
  type: node.type,
76
153
  description: node.description,
77
154
  fetch: node.fetch,
78
155
  compute: node.compute,
79
- nodeIds: node.nodes?.map((child) => idMap.get(child)),
156
+ nodeIds: node.nodes?.map((child) => ENSURE_ID_FN(child).id),
80
157
  };
81
158
  return flatNode;
82
159
  });
83
160
  };
84
161
  /**
85
162
  * Восстанавливает древовидный граф из плоского массива IFlatNode.
86
- * nodes каждого узла заполняется по nodeIds.
163
+ * nodes каждого узла заполняется по nodeIds; id сохраняется на узле,
164
+ * так что повторный serialize даёт те же идентификаторы.
165
+ * Ссылка на неизвестный nodeId — ошибка: contract compute(values)
166
+ * позиционный, тихое выпадение элемента сдвинуло бы чужие значения.
87
167
  * Возвращает корневые узлы (те, на которые никто не ссылается).
88
168
  */
89
169
  const deserialize = (flat) => {
90
- // Первый проход: создаём INode-объекты, индексируем по id
170
+ // Первый проход: создаём узлы, индексируем по id
91
171
  const byId = new Map();
92
172
  flat.forEach((flatNode) => {
93
173
  const node = {
174
+ id: flatNode.id,
94
175
  type: flatNode.type,
95
176
  description: flatNode.description,
96
177
  fetch: flatNode.fetch,
@@ -98,13 +179,18 @@ const deserialize = (flat) => {
98
179
  };
99
180
  byId.set(flatNode.id, node);
100
181
  });
101
- // Второй проход: проставляем nodes[] по nodeIds
182
+ // Второй проход: проставляем nodes[] по nodeIds (включая пустой массив —
183
+ // OutputNode без зависимостей легален и должен получить nodes: [])
102
184
  flat.forEach((flatNode) => {
103
- if (flatNode.nodeIds?.length) {
185
+ if (flatNode.nodeIds) {
104
186
  const node = byId.get(flatNode.id);
105
- node.nodes = flatNode.nodeIds
106
- .map((id) => byId.get(id))
107
- .filter((n) => n !== undefined);
187
+ node.nodes = flatNode.nodeIds.map((id) => {
188
+ const child = byId.get(id);
189
+ if (!child) {
190
+ throw new Error(`graph deserialize: node "${flatNode.id}" references unknown nodeId "${id}"`);
191
+ }
192
+ return child;
193
+ });
108
194
  }
109
195
  });
110
196
  // Корневые узлы — те, на которые не ссылается никто другой
@@ -114,4 +200,4 @@ const deserialize = (flat) => {
114
200
  .map(([, node]) => node);
115
201
  };
116
202
 
117
- export { deepFlat, deserialize, outputNode, resolve, serialize, sourceNode };
203
+ export { NodeType, deepFlat, deserialize, outputNode, resolve, serialize, sourceNode };
package/package.json CHANGED
@@ -1,86 +1,83 @@
1
- {
2
- "name": "@backtest-kit/graph",
3
- "version": "14.1.0",
4
- "description": "Compose backtest-kit computations as a typed directed acyclic graph. Define source nodes that fetch market data and output nodes that compute derived values — then resolve the whole graph in topological order.",
5
- "author": {
6
- "name": "Petr Tripolsky",
7
- "email": "tripolskypetr@gmail.com",
8
- "url": "https://github.com/tripolskypetr"
9
- },
10
- "funding": {
11
- "type": "individual",
12
- "url": "http://paypal.me/tripolskypetr"
13
- },
14
- "license": "MIT",
15
- "homepage": "https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html",
16
- "keywords": [
17
- "graph",
18
- "dag",
19
- "directed-acyclic-graph",
20
- "computation-graph",
21
- "dataflow",
22
- "trading-bot",
23
- "algorithmic-trading",
24
- "backtest",
25
- "backtesting",
26
- "cryptocurrency",
27
- "forex",
28
- "strategy",
29
- "typescript",
30
- "type-safe",
31
- "serialization"
32
- ],
33
- "files": [
34
- "build",
35
- "types.d.ts",
36
- "README.md"
37
- ],
38
- "repository": {
39
- "type": "git",
40
- "url": "https://github.com/tripolskypetr/backtest-kit",
41
- "documentation": "https://github.com/tripolskypetr/backtest-kit/tree/master/docs"
42
- },
43
- "bugs": {
44
- "url": "https://github.com/tripolskypetr/backtest-kit/issues"
45
- },
46
- "scripts": {
47
- "build": "rollup -c"
48
- },
49
- "main": "build/index.cjs",
50
- "module": "build/index.mjs",
51
- "source": "src/index.ts",
52
- "types": "./types.d.ts",
53
- "exports": {
54
- "require": "./build/index.cjs",
55
- "types": "./types.d.ts",
56
- "import": "./build/index.mjs",
57
- "default": "./build/index.cjs"
58
- },
59
- "devDependencies": {
60
- "@rollup/plugin-typescript": "11.1.6",
61
- "@types/node": "22.9.0",
62
- "glob": "11.0.1",
63
- "rimraf": "6.0.1",
64
- "rollup": "3.29.5",
65
- "rollup-plugin-dts": "6.1.1",
66
- "rollup-plugin-peer-deps-external": "2.2.4",
67
- "ts-morph": "27.0.2",
68
- "tslib": "2.7.0",
69
- "typedoc": "0.27.9",
70
- "backtest-kit": "14.1.0",
71
- "worker-testbed": "2.1.0"
72
- },
73
- "peerDependencies": {
74
- "backtest-kit": "^14.1.0",
75
- "typescript": "^5.0.0"
76
- },
77
- "dependencies": {
78
- "di-kit": "^1.1.1",
79
- "di-scoped": "^1.0.21",
80
- "functools-kit": "^3.0.0",
81
- "get-moment-stamp": "^2.0.0"
82
- },
83
- "publishConfig": {
84
- "access": "public"
85
- }
86
- }
1
+ {
2
+ "name": "@backtest-kit/graph",
3
+ "version": "15.0.0",
4
+ "description": "Compose backtest-kit computations as a typed directed acyclic graph. Define source nodes that fetch market data and output nodes that compute derived values — then resolve the whole graph in topological order.",
5
+ "author": {
6
+ "name": "Petr Tripolsky",
7
+ "email": "tripolskypetr@gmail.com",
8
+ "url": "https://github.com/tripolskypetr"
9
+ },
10
+ "funding": {
11
+ "type": "individual",
12
+ "url": "http://paypal.me/tripolskypetr"
13
+ },
14
+ "license": "MIT",
15
+ "homepage": "https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html",
16
+ "keywords": [
17
+ "graph",
18
+ "dag",
19
+ "directed-acyclic-graph",
20
+ "computation-graph",
21
+ "dataflow",
22
+ "trading-bot",
23
+ "algorithmic-trading",
24
+ "backtest",
25
+ "backtesting",
26
+ "cryptocurrency",
27
+ "forex",
28
+ "strategy",
29
+ "typescript",
30
+ "type-safe",
31
+ "serialization"
32
+ ],
33
+ "files": [
34
+ "build",
35
+ "types.d.ts",
36
+ "README.md"
37
+ ],
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/tripolskypetr/backtest-kit",
41
+ "documentation": "https://github.com/tripolskypetr/backtest-kit/tree/master/docs"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/tripolskypetr/backtest-kit/issues"
45
+ },
46
+ "scripts": {
47
+ "build": "rollup -c"
48
+ },
49
+ "main": "build/index.cjs",
50
+ "module": "build/index.mjs",
51
+ "source": "src/index.ts",
52
+ "types": "./types.d.ts",
53
+ "exports": {
54
+ "require": "./build/index.cjs",
55
+ "types": "./types.d.ts",
56
+ "import": "./build/index.mjs",
57
+ "default": "./build/index.cjs"
58
+ },
59
+ "devDependencies": {
60
+ "@rollup/plugin-typescript": "11.1.6",
61
+ "@types/node": "22.9.0",
62
+ "glob": "11.0.1",
63
+ "rimraf": "6.0.1",
64
+ "rollup": "3.29.5",
65
+ "rollup-plugin-dts": "6.1.1",
66
+ "rollup-plugin-peer-deps-external": "2.2.4",
67
+ "ts-morph": "27.0.2",
68
+ "tslib": "2.7.0",
69
+ "typedoc": "0.27.9",
70
+ "backtest-kit": "15.0.0",
71
+ "worker-testbed": "3.0.0"
72
+ },
73
+ "peerDependencies": {
74
+ "backtest-kit": "^15.0.0",
75
+ "typescript": "^5.0.0"
76
+ },
77
+ "dependencies": {
78
+ "functools-kit": "^4.0.0"
79
+ },
80
+ "publishConfig": {
81
+ "access": "public"
82
+ }
83
+ }
package/types.d.ts CHANGED
@@ -19,6 +19,14 @@ interface INode {
19
19
  * Тип узла для логического ветвления при исполнении графа
20
20
  */
21
21
  type: NodeType;
22
+ /**
23
+ * Стабильный идентификатор узла. Хелперы sourceNode/outputNode
24
+ * проставляют его при создании; для рукописных INode он будет
25
+ * доштампован при первом serialize. Задавайте свой id, если после
26
+ * JSON round-trip нужно повторно привязать fetch/compute к узлам
27
+ * (случайный id не переживает перезапуск процесса).
28
+ */
29
+ id?: string;
22
30
  /**
23
31
  * Человеко-читаемое описание узла, не влияет на исполнение графа.
24
32
  */
@@ -41,6 +49,15 @@ interface INode {
41
49
  nodes?: INode[];
42
50
  }
43
51
 
52
+ /**
53
+ * Внутренний тип: узел с гарантированно проставленным идентификатором.
54
+ * В таком виде узлы существуют после входа в пайплайн (хелперы
55
+ * sourceNode/outputNode, serialize, deserialize).
56
+ */
57
+ interface INodeInternal extends INode {
58
+ id: string;
59
+ }
60
+
44
61
  /**
45
62
  * Маппинг tuple нод в tuple их resolved-значений.
46
63
  * Сохраняет позиционную структуру: [SourceNode<number>, SourceNode<string>] → [number, string].
@@ -59,6 +76,12 @@ type InferNodeValue<T extends TypedNode> = T extends SourceNode<infer V> ? V : T
59
76
  */
60
77
  type SourceNode<T extends Value = Value> = {
61
78
  type: NodeType.SourceNode;
79
+ /**
80
+ * Идентификатор узла. Хелпер sourceNode проставляет случайный id при
81
+ * создании; перезапишите своим стабильным значением, если узел должен
82
+ * переживать JSON round-trip (повторная привязка fetch по id).
83
+ */
84
+ id: string;
62
85
  description?: string;
63
86
  fetch: (symbol: string, when: Date, currentPrice: number, exchangeName: ExchangeName) => Promise<T> | T;
64
87
  };
@@ -69,6 +92,12 @@ type SourceNode<T extends Value = Value> = {
69
92
  */
70
93
  type OutputNode<TNodes extends TypedNode[] = TypedNode[], TResult extends Value = Value> = {
71
94
  type: NodeType.OutputNode;
95
+ /**
96
+ * Идентификатор узла. Хелпер outputNode проставляет случайный id при
97
+ * создании; перезапишите своим стабильным значением, если узел должен
98
+ * переживать JSON round-trip (повторная привязка compute по id).
99
+ */
100
+ id: string;
72
101
  description?: string;
73
102
  nodes: TNodes;
74
103
  compute: (values: InferValues<TNodes>) => Promise<TResult> | TResult;
@@ -79,7 +108,17 @@ type OutputNode<TNodes extends TypedNode[] = TypedNode[], TResult extends Value
79
108
  */
80
109
  type TypedNode = SourceNode<Value> | OutputNode<TypedNode[], Value>;
81
110
 
111
+ /**
112
+ * Создаёт SourceNode с проставленным идентификатором.
113
+ * Для стабильности между перезапусками процесса (JSON round-trip)
114
+ * перезапишите id своим значением после создания.
115
+ */
82
116
  declare const sourceNode: <T extends Value>(fetch: (symbol: string, when: Date, currentPrice: number, exchangeName: ExchangeName) => Promise<T> | T) => SourceNode<T>;
117
+ /**
118
+ * Создаёт OutputNode с проставленным идентификатором.
119
+ * Для стабильности между перезапусками процесса (JSON round-trip)
120
+ * перезапишите id своим значением после создания.
121
+ */
83
122
  declare const outputNode: <TNodes extends TypedNode[], TResult extends Value = Value>(compute: (values: InferValues<TNodes>) => Promise<TResult> | TResult, ...nodes: TNodes) => OutputNode<TNodes, TResult>;
84
123
 
85
124
  /**
@@ -94,6 +133,13 @@ declare const deepFlat: (arr?: INode[]) => INode[];
94
133
  * Для SourceNode вызывает fetch().
95
134
  * Для OutputNode сначала резолвит все дочерние nodes параллельно,
96
135
  * затем передаёт их типизированные значения в compute().
136
+ *
137
+ * Гарантии одного прохода:
138
+ * - каждый узел вычисляется ровно один раз (мемоизация по ссылке):
139
+ * общая зависимость в «ромбе» даёт один fetch и одно согласованное
140
+ * значение для всех потребителей;
141
+ * - currentPrice запрашивается один раз и передаётся всем SourceNode;
142
+ * - цикл в графе даёт понятную ошибку, а не переполнение стека.
97
143
  */
98
144
  declare function resolve<V extends Value>(node: SourceNode<V>): Promise<V>;
99
145
  declare function resolve<TNodes extends TypedNode[], V extends Value>(node: OutputNode<TNodes, V>): Promise<V>;
@@ -134,15 +180,22 @@ interface IFlatNode {
134
180
 
135
181
  /**
136
182
  * Преобразует древовидный граф в плоский массив IFlatNode для хранения в БД.
137
- * Каждому узлу присваивается уникальный id (если не задан),
138
- * объектные ссылки nodes заменяются на массив nodeIds.
183
+ * Объектные ссылки nodes заменяются на массив nodeIds.
184
+ *
185
+ * Задавайте узлам стабильные id, если планируете восстанавливать fetch/compute
186
+ * после JSON-сериализации: функции в JSON не переживают round-trip, и найти
187
+ * узел для повторной привязки можно только по известному id (случайный id
188
+ * не переживает перезапуск процесса).
139
189
  */
140
190
  declare const serialize: (roots: INode[]) => IFlatNode[];
141
191
  /**
142
192
  * Восстанавливает древовидный граф из плоского массива IFlatNode.
143
- * nodes каждого узла заполняется по nodeIds.
193
+ * nodes каждого узла заполняется по nodeIds; id сохраняется на узле,
194
+ * так что повторный serialize даёт те же идентификаторы.
195
+ * Ссылка на неизвестный nodeId — ошибка: contract compute(values)
196
+ * позиционный, тихое выпадение элемента сдвинуло бы чужие значения.
144
197
  * Возвращает корневые узлы (те, на которые никто не ссылается).
145
198
  */
146
- declare const deserialize: (flat: IFlatNode[]) => INode[];
199
+ declare const deserialize: (flat: IFlatNode[]) => INodeInternal[];
147
200
 
148
- export { type IFlatNode, type INode, type TypedNode, type Value, deepFlat, deserialize, outputNode, resolve, serialize, sourceNode };
201
+ export { type IFlatNode, type INode, type INodeInternal, NodeType, type TypedNode, type Value, deepFlat, deserialize, outputNode, resolve, serialize, sourceNode };