@backtest-kit/graph 3.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 +304 -0
- package/build/index.cjs +129 -0
- package/build/index.mjs +122 -0
- package/package.json +85 -0
- package/types.d.ts +147 -0
package/README.md
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
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 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.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
[](https://deepwiki.com/tripolskypetr/backtest-kit)
|
|
10
|
+
[](https://npmjs.org/package/@backtest-kit/graph)
|
|
11
|
+
[]()
|
|
12
|
+
|
|
13
|
+
📚 **[Backtest Kit Docs](https://backtest-kit.github.io/documents/example_02_first_backtest.html)** | 🌟 **[GitHub](https://github.com/tripolskypetr/backtest-kit)**
|
|
14
|
+
|
|
15
|
+
## 🔥 Multi-timeframe Pine Script strategy
|
|
16
|
+
|
|
17
|
+
The graph below replicates a two-timeframe strategy: a 4h Pine Script acts as a trend filter, a 15m Pine Script generates the entry signal. `outputNode` combines them and returns `null` when the trend disagrees.
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { extract, run, toSignalDto, File } from '@backtest-kit/pinets';
|
|
21
|
+
import { addStrategySchema, Cache } from 'backtest-kit';
|
|
22
|
+
import { randomString } from 'functools-kit';
|
|
23
|
+
import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
|
|
24
|
+
|
|
25
|
+
// SourceNode — 4h trend filter, cached per candle interval
|
|
26
|
+
const higherTimeframe = sourceNode(
|
|
27
|
+
Cache.fn(
|
|
28
|
+
async (symbol) => {
|
|
29
|
+
const plots = await run(File.fromPath('timeframe_4h.pine'), {
|
|
30
|
+
symbol,
|
|
31
|
+
timeframe: '4h',
|
|
32
|
+
limit: 100,
|
|
33
|
+
});
|
|
34
|
+
return extract(plots, {
|
|
35
|
+
allowLong: 'AllowLong',
|
|
36
|
+
allowShort: 'AllowShort',
|
|
37
|
+
noTrades: 'NoTrades',
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
{ interval: '4h', key: ([symbol]) => symbol },
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// SourceNode — 15m entry signal, cached per candle interval
|
|
45
|
+
const lowerTimeframe = sourceNode(
|
|
46
|
+
Cache.fn(
|
|
47
|
+
async (symbol) => {
|
|
48
|
+
const plots = await run(File.fromPath('timeframe_15m.pine'), {
|
|
49
|
+
symbol,
|
|
50
|
+
timeframe: '15m',
|
|
51
|
+
limit: 100,
|
|
52
|
+
});
|
|
53
|
+
return extract(plots, {
|
|
54
|
+
position: 'Signal',
|
|
55
|
+
priceOpen: 'Close',
|
|
56
|
+
priceTakeProfit: 'TakeProfit',
|
|
57
|
+
priceStopLoss: 'StopLoss',
|
|
58
|
+
minuteEstimatedTime: 'EstimatedTime',
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
{ interval: '15m', key: ([symbol]) => symbol },
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// OutputNode — applies MTF filter, returns ISignalDto or null
|
|
66
|
+
const mtfSignal = outputNode(
|
|
67
|
+
async ([higher, lower]) => {
|
|
68
|
+
if (higher.noTrades) return null;
|
|
69
|
+
if (lower.position === 0) return null;
|
|
70
|
+
if (higher.allowShort && lower.position === 1) return null;
|
|
71
|
+
if (higher.allowLong && lower.position === -1) return null;
|
|
72
|
+
|
|
73
|
+
return toSignalDto(randomString(), lower, null);
|
|
74
|
+
},
|
|
75
|
+
higherTimeframe,
|
|
76
|
+
lowerTimeframe,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
addStrategySchema({
|
|
80
|
+
strategyName: 'mtf_graph_strategy',
|
|
81
|
+
interval: '5m',
|
|
82
|
+
getSignal: (symbol) => resolve(mtfSignal),
|
|
83
|
+
actions: ['partial_profit_action', 'breakeven_action'],
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The graph resolves both Pine Script nodes **in parallel** via `Promise.all`, then passes their typed results to `compute`. Replacing either timeframe script or adding a third filter node requires no changes to the strategy wiring.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## 🚀 Installation
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm install @backtest-kit/graph backtest-kit
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## ✨ Features
|
|
97
|
+
|
|
98
|
+
- 📊 **DAG execution**: Nodes are resolved bottom-up in topological order with `Promise.all` parallelism
|
|
99
|
+
- 🔒 **Type-safe values**: TypeScript infers the return type of every node through the graph via generics
|
|
100
|
+
- 🧱 **Two APIs**: Low-level `INode` for runtime/storage, high-level `TypedNode` + builders for authoring
|
|
101
|
+
- 💾 **DB-ready serialization**: `serialize` / `deserialize` convert the graph to a flat `IFlatNode[]` list with `id` / `nodeIds`
|
|
102
|
+
- 🔌 **Context-aware fetch**: `SourceNode.fetch` receives `(symbol, when, exchangeName)` from the execution context automatically
|
|
103
|
+
|
|
104
|
+
## 📖 Usage
|
|
105
|
+
|
|
106
|
+
### Quick Start — builder API
|
|
107
|
+
|
|
108
|
+
Use `sourceNode` and `outputNode` to define a typed computation graph. TypeScript infers the type of `values` in `compute` from the `nodes` passed to `outputNode`:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
|
|
112
|
+
|
|
113
|
+
// SourceNode<number> — fetch receives symbol, when, exchangeName from context
|
|
114
|
+
const closePrice = sourceNode(async (symbol, when, exchangeName) => {
|
|
115
|
+
const candles = await getCandles(symbol, '1h', 1, exchangeName);
|
|
116
|
+
return candles[0].close; // number
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// SourceNode<number>
|
|
120
|
+
const volume = sourceNode(async (symbol, when, exchangeName) => {
|
|
121
|
+
const candles = await getCandles(symbol, '1h', 1, exchangeName);
|
|
122
|
+
return candles[0].volume; // number
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// OutputNode<[SourceNode<number>, SourceNode<number>], number>
|
|
126
|
+
// price and vol are automatically number
|
|
127
|
+
const vwap = outputNode(
|
|
128
|
+
([price, vol]) => price * vol,
|
|
129
|
+
closePrice,
|
|
130
|
+
volume,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Resolve inside a backtest-kit strategy
|
|
134
|
+
const result = await resolve(vwap); // Promise<number>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Inline anonymous composition
|
|
138
|
+
|
|
139
|
+
The entire graph can be defined as a single object literal.
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import { NodeType } from '@backtest-kit/graph';
|
|
143
|
+
import { TypedNode, resolve } from '@backtest-kit/graph';
|
|
144
|
+
|
|
145
|
+
const signal: TypedNode = {
|
|
146
|
+
type: NodeType.OutputNode,
|
|
147
|
+
nodes: [
|
|
148
|
+
{
|
|
149
|
+
type: NodeType.SourceNode,
|
|
150
|
+
fetch: async (symbol, when, exchangeName) => {
|
|
151
|
+
const plots = await run(File.fromPath('timeframe_4h.pine'), { symbol, timeframe: '4h', limit: 100 });
|
|
152
|
+
return extract(plots, { allowLong: 'AllowLong', allowShort: 'AllowShort', noTrades: 'NoTrades' });
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
type: NodeType.SourceNode,
|
|
157
|
+
fetch: async (symbol, when, exchangeName) => {
|
|
158
|
+
const plots = await run(File.fromPath('timeframe_15m.pine'), { symbol, timeframe: '15m', limit: 100 });
|
|
159
|
+
return extract(plots, { position: 'Signal', priceOpen: 'Close', priceTakeProfit: 'TakeProfit', priceStopLoss: 'StopLoss' });
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
compute: ([higher, lower]) => {
|
|
164
|
+
if (higher.noTrades || lower.position === 0) return null;
|
|
165
|
+
if (higher.allowShort && lower.position === 1) return null;
|
|
166
|
+
if (higher.allowLong && lower.position === -1) return null;
|
|
167
|
+
return lower.position;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const result = await resolve(signal);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Mixed types
|
|
175
|
+
|
|
176
|
+
TypeScript correctly infers heterogeneous types by position in `nodes`:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
const price = sourceNode(async (symbol) => 42); // SourceNode<number>
|
|
180
|
+
const name = sourceNode(async (symbol) => 'BTCUSDT'); // SourceNode<string>
|
|
181
|
+
const flag = sourceNode(async (symbol) => true); // SourceNode<boolean>
|
|
182
|
+
|
|
183
|
+
const result = outputNode(
|
|
184
|
+
([p, n, f]) => `${n}: ${p} (active: ${f})`, // p: number, n: string, f: boolean
|
|
185
|
+
price,
|
|
186
|
+
name,
|
|
187
|
+
flag,
|
|
188
|
+
);
|
|
189
|
+
// OutputNode<[SourceNode<number>, SourceNode<string>, SourceNode<boolean>], string>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Using inside a backtest-kit strategy
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
import { addStrategy } from 'backtest-kit';
|
|
196
|
+
import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
|
|
197
|
+
|
|
198
|
+
const rsi = sourceNode(async (symbol, when, exchangeName) => {
|
|
199
|
+
// ... compute RSI
|
|
200
|
+
return 55.2;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const signal = outputNode(
|
|
204
|
+
([rsiValue]) => rsiValue < 30 ? 1 : rsiValue > 70 ? -1 : 0,
|
|
205
|
+
rsi,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
addStrategy({
|
|
209
|
+
strategyName: 'graph-rsi',
|
|
210
|
+
interval: '1h',
|
|
211
|
+
riskName: 'demo',
|
|
212
|
+
getSignal: async (symbol) => {
|
|
213
|
+
const direction = await resolve(signal); // 1 | -1 | 0
|
|
214
|
+
return direction === 1
|
|
215
|
+
? { position: 'long', ... }
|
|
216
|
+
: null;
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Low-level INode
|
|
222
|
+
|
|
223
|
+
For manual graph construction without builders (e.g. after deserialization or in a DI container):
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import { INode, Value } from '@backtest-kit/graph';
|
|
227
|
+
import NodeType from '@backtest-kit/graph/enum/NodeType';
|
|
228
|
+
|
|
229
|
+
const priceNode: INode = {
|
|
230
|
+
type: NodeType.SourceNode,
|
|
231
|
+
description: 'Close price',
|
|
232
|
+
fetch: async (symbol, when, exchangeName) => 42,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const outputNode: INode = {
|
|
236
|
+
type: NodeType.OutputNode,
|
|
237
|
+
description: 'Doubled price',
|
|
238
|
+
nodes: [priceNode],
|
|
239
|
+
compute: ([price]) => (price as number) * 2,
|
|
240
|
+
};
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
> `INode` has no generic parameters — `values` in `compute` is typed as `Value[]`. Use `TypedNode` and builders for full IntelliSense.
|
|
244
|
+
|
|
245
|
+
### DB serialization
|
|
246
|
+
|
|
247
|
+
`serialize` flattens the graph into an `IFlatNode[]` array, replacing object references in `nodes` with `nodeIds`. `deserialize` reconstructs the tree:
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
import { serialize, deserialize, IFlatNode } from '@backtest-kit/graph';
|
|
251
|
+
|
|
252
|
+
// Graph → flat array for DB
|
|
253
|
+
const flat: IFlatNode[] = serialize([vwap]);
|
|
254
|
+
// [
|
|
255
|
+
// { id: 'abc', type: 'source_node', nodeIds: [] }, // closePrice
|
|
256
|
+
// { id: 'def', type: 'source_node', nodeIds: [] }, // volume
|
|
257
|
+
// { id: 'ghi', type: 'output_node', nodeIds: ['abc', 'def'] }, // vwap
|
|
258
|
+
// ]
|
|
259
|
+
|
|
260
|
+
// Save to DB
|
|
261
|
+
await db.collection('nodes').insertMany(flat);
|
|
262
|
+
|
|
263
|
+
// Load from DB and reconstruct the graph
|
|
264
|
+
const stored: IFlatNode[] = await db.collection('nodes').find().toArray();
|
|
265
|
+
const roots: INode[] = deserialize(stored); // nodes[] is wired up from nodeIds
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
> `fetch` and `compute` are not stored in the DB — they must be restored on the application side after `deserialize`.
|
|
269
|
+
|
|
270
|
+
### deepFlat — traversal utility
|
|
271
|
+
|
|
272
|
+
`deepFlat` returns all nodes in topological order (dependencies before parents), deduplicated by reference:
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
import { deepFlat } from '@backtest-kit/graph';
|
|
276
|
+
|
|
277
|
+
const all = deepFlat([vwap]);
|
|
278
|
+
// [closePrice, volume, vwap] — dependencies first
|
|
279
|
+
|
|
280
|
+
all.forEach(node => console.log(node.description));
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## 📋 API Reference
|
|
284
|
+
|
|
285
|
+
| Export | Description |
|
|
286
|
+
|--------|-------------|
|
|
287
|
+
| **`sourceNode(fetch)`** | Builder — creates a typed source node |
|
|
288
|
+
| **`outputNode(compute, ...nodes)`** | Builder — creates a typed output node, infers `values` types from `nodes` |
|
|
289
|
+
| **`resolve(node)`** | Recursively resolves a node graph within backtest-kit execution context |
|
|
290
|
+
| **`serialize(roots)`** | Flattens a node tree into `IFlatNode[]` for DB storage |
|
|
291
|
+
| **`deserialize(flat)`** | Reconstructs a node tree from `IFlatNode[]`, returns root nodes |
|
|
292
|
+
| **`deepFlat(nodes)`** | Utility — returns all nodes in topological order (dependencies first) |
|
|
293
|
+
| **`INode`** | Base runtime interface (untyped, used internally and for serialization) |
|
|
294
|
+
| **`TypedNode`** | Discriminated union for authoring with full IntelliSense |
|
|
295
|
+
| **`IFlatNode`** | Serialized node shape for DB storage |
|
|
296
|
+
| **`Value`** | `string \| number \| boolean \| null` |
|
|
297
|
+
|
|
298
|
+
## 🤝 Contribute
|
|
299
|
+
|
|
300
|
+
Fork/PR on [GitHub](https://github.com/tripolskypetr/backtest-kit).
|
|
301
|
+
|
|
302
|
+
## 📜 License
|
|
303
|
+
|
|
304
|
+
MIT © [tripolskypetr](https://github.com/tripolskypetr)
|
package/build/index.cjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var backtestKit = require('backtest-kit');
|
|
4
|
+
var functoolsKit = require('functools-kit');
|
|
5
|
+
|
|
6
|
+
var NodeType;
|
|
7
|
+
(function (NodeType) {
|
|
8
|
+
NodeType["SourceNode"] = "source_node";
|
|
9
|
+
NodeType["OutputNode"] = "output_node";
|
|
10
|
+
})(NodeType || (NodeType = {}));
|
|
11
|
+
var NodeType$1 = NodeType;
|
|
12
|
+
|
|
13
|
+
const sourceNode = (fetch) => ({
|
|
14
|
+
type: NodeType$1.SourceNode,
|
|
15
|
+
fetch,
|
|
16
|
+
});
|
|
17
|
+
const outputNode = (compute, ...nodes) => ({
|
|
18
|
+
type: NodeType$1.OutputNode,
|
|
19
|
+
nodes,
|
|
20
|
+
compute,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Рекурсивно разворачивает граф узлов в плоский массив.
|
|
25
|
+
* Порядок: сначала зависимости (children), затем родитель — топологический порядок.
|
|
26
|
+
* Дубликаты (один узел может быть зависимостью нескольких) исключаются по ссылке.
|
|
27
|
+
*/
|
|
28
|
+
const deepFlat = (arr = []) => {
|
|
29
|
+
const result = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
const process = (entries = []) => entries.forEach((entry) => {
|
|
32
|
+
if (seen.has(entry)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
seen.add(entry);
|
|
36
|
+
process(entry.nodes ?? []);
|
|
37
|
+
result.push(entry);
|
|
38
|
+
});
|
|
39
|
+
process(arr);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Рекурсивно вычисляет значение узла графа.
|
|
45
|
+
* Для SourceNode вызывает fetch().
|
|
46
|
+
* Для OutputNode сначала резолвит все дочерние nodes параллельно,
|
|
47
|
+
* затем передаёт их типизированные значения в compute().
|
|
48
|
+
*/
|
|
49
|
+
const resolve = async (node) => {
|
|
50
|
+
if (!backtestKit.ExecutionContextService.hasContext()) {
|
|
51
|
+
throw new Error("Execution context is required to resolve graph nodes. Please ensure that resolve() is called within a valid execution context.");
|
|
52
|
+
}
|
|
53
|
+
if (!backtestKit.MethodContextService.hasContext()) {
|
|
54
|
+
throw new Error("Method context is required to resolve graph nodes. Please ensure that resolve() is called within a valid method context.");
|
|
55
|
+
}
|
|
56
|
+
if (node.type === NodeType$1.SourceNode) {
|
|
57
|
+
const { symbol, when } = backtestKit.lib.executionContextService.context;
|
|
58
|
+
const { exchangeName } = backtestKit.lib.methodContextService.context;
|
|
59
|
+
return node.fetch(symbol, when, exchangeName);
|
|
60
|
+
}
|
|
61
|
+
const values = await Promise.all(node.nodes.map(resolve));
|
|
62
|
+
return node.compute(values);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Преобразует древовидный граф в плоский массив IFlatNode для хранения в БД.
|
|
67
|
+
* Каждому узлу присваивается уникальный id (если не задан),
|
|
68
|
+
* объектные ссылки nodes заменяются на массив nodeIds.
|
|
69
|
+
*/
|
|
70
|
+
const serialize = (roots) => {
|
|
71
|
+
const flat = deepFlat(roots);
|
|
72
|
+
// Первый проход: назначаем id каждому уникальному узлу
|
|
73
|
+
const idMap = new Map();
|
|
74
|
+
flat.forEach((node) => {
|
|
75
|
+
const id = functoolsKit.randomString();
|
|
76
|
+
idMap.set(node, id);
|
|
77
|
+
});
|
|
78
|
+
// Второй проход: строим IFlatNode с nodeIds вместо nodes
|
|
79
|
+
return flat.map((node) => {
|
|
80
|
+
const flatNode = {
|
|
81
|
+
id: idMap.get(node),
|
|
82
|
+
type: node.type,
|
|
83
|
+
description: node.description,
|
|
84
|
+
fetch: node.fetch,
|
|
85
|
+
compute: node.compute,
|
|
86
|
+
nodeIds: node.nodes?.map((child) => idMap.get(child)),
|
|
87
|
+
};
|
|
88
|
+
return flatNode;
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Восстанавливает древовидный граф из плоского массива IFlatNode.
|
|
93
|
+
* nodes каждого узла заполняется по nodeIds.
|
|
94
|
+
* Возвращает корневые узлы (те, на которые никто не ссылается).
|
|
95
|
+
*/
|
|
96
|
+
const deserialize = (flat) => {
|
|
97
|
+
// Первый проход: создаём INode-объекты, индексируем по id
|
|
98
|
+
const byId = new Map();
|
|
99
|
+
flat.forEach((flatNode) => {
|
|
100
|
+
const node = {
|
|
101
|
+
type: flatNode.type,
|
|
102
|
+
description: flatNode.description,
|
|
103
|
+
fetch: flatNode.fetch,
|
|
104
|
+
compute: flatNode.compute,
|
|
105
|
+
};
|
|
106
|
+
byId.set(flatNode.id, node);
|
|
107
|
+
});
|
|
108
|
+
// Второй проход: проставляем nodes[] по nodeIds
|
|
109
|
+
flat.forEach((flatNode) => {
|
|
110
|
+
if (flatNode.nodeIds?.length) {
|
|
111
|
+
const node = byId.get(flatNode.id);
|
|
112
|
+
node.nodes = flatNode.nodeIds
|
|
113
|
+
.map((id) => byId.get(id))
|
|
114
|
+
.filter((n) => n !== undefined);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
// Корневые узлы — те, на которые не ссылается никто другой
|
|
118
|
+
const referenced = new Set(flat.flatMap((n) => n.nodeIds ?? []));
|
|
119
|
+
return [...byId.entries()]
|
|
120
|
+
.filter(([id]) => !referenced.has(id))
|
|
121
|
+
.map(([, node]) => node);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
exports.deepFlat = deepFlat;
|
|
125
|
+
exports.deserialize = deserialize;
|
|
126
|
+
exports.outputNode = outputNode;
|
|
127
|
+
exports.resolve = resolve;
|
|
128
|
+
exports.serialize = serialize;
|
|
129
|
+
exports.sourceNode = sourceNode;
|
package/build/index.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ExecutionContextService, MethodContextService, lib } from 'backtest-kit';
|
|
2
|
+
import { randomString } from 'functools-kit';
|
|
3
|
+
|
|
4
|
+
var NodeType;
|
|
5
|
+
(function (NodeType) {
|
|
6
|
+
NodeType["SourceNode"] = "source_node";
|
|
7
|
+
NodeType["OutputNode"] = "output_node";
|
|
8
|
+
})(NodeType || (NodeType = {}));
|
|
9
|
+
var NodeType$1 = NodeType;
|
|
10
|
+
|
|
11
|
+
const sourceNode = (fetch) => ({
|
|
12
|
+
type: NodeType$1.SourceNode,
|
|
13
|
+
fetch,
|
|
14
|
+
});
|
|
15
|
+
const outputNode = (compute, ...nodes) => ({
|
|
16
|
+
type: NodeType$1.OutputNode,
|
|
17
|
+
nodes,
|
|
18
|
+
compute,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Рекурсивно разворачивает граф узлов в плоский массив.
|
|
23
|
+
* Порядок: сначала зависимости (children), затем родитель — топологический порядок.
|
|
24
|
+
* Дубликаты (один узел может быть зависимостью нескольких) исключаются по ссылке.
|
|
25
|
+
*/
|
|
26
|
+
const deepFlat = (arr = []) => {
|
|
27
|
+
const result = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const process = (entries = []) => entries.forEach((entry) => {
|
|
30
|
+
if (seen.has(entry)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
seen.add(entry);
|
|
34
|
+
process(entry.nodes ?? []);
|
|
35
|
+
result.push(entry);
|
|
36
|
+
});
|
|
37
|
+
process(arr);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Рекурсивно вычисляет значение узла графа.
|
|
43
|
+
* Для SourceNode вызывает fetch().
|
|
44
|
+
* Для OutputNode сначала резолвит все дочерние nodes параллельно,
|
|
45
|
+
* затем передаёт их типизированные значения в compute().
|
|
46
|
+
*/
|
|
47
|
+
const resolve = async (node) => {
|
|
48
|
+
if (!ExecutionContextService.hasContext()) {
|
|
49
|
+
throw new Error("Execution context is required to resolve graph nodes. Please ensure that resolve() is called within a valid execution context.");
|
|
50
|
+
}
|
|
51
|
+
if (!MethodContextService.hasContext()) {
|
|
52
|
+
throw new Error("Method context is required to resolve graph nodes. Please ensure that resolve() is called within a valid method context.");
|
|
53
|
+
}
|
|
54
|
+
if (node.type === NodeType$1.SourceNode) {
|
|
55
|
+
const { symbol, when } = lib.executionContextService.context;
|
|
56
|
+
const { exchangeName } = lib.methodContextService.context;
|
|
57
|
+
return node.fetch(symbol, when, exchangeName);
|
|
58
|
+
}
|
|
59
|
+
const values = await Promise.all(node.nodes.map(resolve));
|
|
60
|
+
return node.compute(values);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Преобразует древовидный граф в плоский массив IFlatNode для хранения в БД.
|
|
65
|
+
* Каждому узлу присваивается уникальный id (если не задан),
|
|
66
|
+
* объектные ссылки nodes заменяются на массив nodeIds.
|
|
67
|
+
*/
|
|
68
|
+
const serialize = (roots) => {
|
|
69
|
+
const flat = deepFlat(roots);
|
|
70
|
+
// Первый проход: назначаем id каждому уникальному узлу
|
|
71
|
+
const idMap = new Map();
|
|
72
|
+
flat.forEach((node) => {
|
|
73
|
+
const id = randomString();
|
|
74
|
+
idMap.set(node, id);
|
|
75
|
+
});
|
|
76
|
+
// Второй проход: строим IFlatNode с nodeIds вместо nodes
|
|
77
|
+
return flat.map((node) => {
|
|
78
|
+
const flatNode = {
|
|
79
|
+
id: idMap.get(node),
|
|
80
|
+
type: node.type,
|
|
81
|
+
description: node.description,
|
|
82
|
+
fetch: node.fetch,
|
|
83
|
+
compute: node.compute,
|
|
84
|
+
nodeIds: node.nodes?.map((child) => idMap.get(child)),
|
|
85
|
+
};
|
|
86
|
+
return flatNode;
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Восстанавливает древовидный граф из плоского массива IFlatNode.
|
|
91
|
+
* nodes каждого узла заполняется по nodeIds.
|
|
92
|
+
* Возвращает корневые узлы (те, на которые никто не ссылается).
|
|
93
|
+
*/
|
|
94
|
+
const deserialize = (flat) => {
|
|
95
|
+
// Первый проход: создаём INode-объекты, индексируем по id
|
|
96
|
+
const byId = new Map();
|
|
97
|
+
flat.forEach((flatNode) => {
|
|
98
|
+
const node = {
|
|
99
|
+
type: flatNode.type,
|
|
100
|
+
description: flatNode.description,
|
|
101
|
+
fetch: flatNode.fetch,
|
|
102
|
+
compute: flatNode.compute,
|
|
103
|
+
};
|
|
104
|
+
byId.set(flatNode.id, node);
|
|
105
|
+
});
|
|
106
|
+
// Второй проход: проставляем nodes[] по nodeIds
|
|
107
|
+
flat.forEach((flatNode) => {
|
|
108
|
+
if (flatNode.nodeIds?.length) {
|
|
109
|
+
const node = byId.get(flatNode.id);
|
|
110
|
+
node.nodes = flatNode.nodeIds
|
|
111
|
+
.map((id) => byId.get(id))
|
|
112
|
+
.filter((n) => n !== undefined);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
// Корневые узлы — те, на которые не ссылается никто другой
|
|
116
|
+
const referenced = new Set(flat.flatMap((n) => n.nodeIds ?? []));
|
|
117
|
+
return [...byId.entries()]
|
|
118
|
+
.filter(([id]) => !referenced.has(id))
|
|
119
|
+
.map(([, node]) => node);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export { deepFlat, deserialize, outputNode, resolve, serialize, sourceNode };
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@backtest-kit/graph",
|
|
3
|
+
"version": "3.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/example_02_first_backtest.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
|
+
"worker-testbed": "1.0.12"
|
|
71
|
+
},
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"backtest-kit": "^3.0.18",
|
|
74
|
+
"typescript": "^5.0.0"
|
|
75
|
+
},
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"di-kit": "^1.0.18",
|
|
78
|
+
"di-scoped": "^1.0.21",
|
|
79
|
+
"functools-kit": "^1.0.95",
|
|
80
|
+
"get-moment-stamp": "^1.1.1"
|
|
81
|
+
},
|
|
82
|
+
"publishConfig": {
|
|
83
|
+
"access": "public"
|
|
84
|
+
}
|
|
85
|
+
}
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
declare enum NodeType {
|
|
2
|
+
SourceNode = "source_node",
|
|
3
|
+
OutputNode = "output_node"
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type ExchangeName = string;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Любое возможное вычисленное значение узла графа.
|
|
10
|
+
*/
|
|
11
|
+
type Value = string | number | boolean | null;
|
|
12
|
+
/**
|
|
13
|
+
* Плоский базовый интерфейс узла графа.
|
|
14
|
+
* Следует тому же паттерну, что IField в react-declarative:
|
|
15
|
+
* все свойства опциональны, type — обязателен.
|
|
16
|
+
*/
|
|
17
|
+
interface INode {
|
|
18
|
+
/**
|
|
19
|
+
* Тип узла для логического ветвления при исполнении графа
|
|
20
|
+
*/
|
|
21
|
+
type: NodeType;
|
|
22
|
+
/**
|
|
23
|
+
* Человеко-читаемое описание узла, не влияет на исполнение графа.
|
|
24
|
+
*/
|
|
25
|
+
description?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Источник данных для SourceNode.
|
|
28
|
+
* Вызывается при вычислении узла без входящих зависимостей.
|
|
29
|
+
*/
|
|
30
|
+
fetch?: (symbol: string, when: Date, exchangeName: ExchangeName) => Promise<Value> | Value;
|
|
31
|
+
/**
|
|
32
|
+
* Функция вычисления для OutputNode.
|
|
33
|
+
* Получает на вход массив значений, возвращённых fetch/compute
|
|
34
|
+
* из узлов массива nodes, в том же порядке.
|
|
35
|
+
*/
|
|
36
|
+
compute?: (values: Value[]) => Promise<Value> | Value;
|
|
37
|
+
/**
|
|
38
|
+
* Входящие зависимости для OutputNode.
|
|
39
|
+
* Значения этих узлов передаются в compute.
|
|
40
|
+
*/
|
|
41
|
+
nodes?: INode[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Маппинг tuple нод в tuple их resolved-значений.
|
|
46
|
+
* Сохраняет позиционную структуру: [SourceNode<number>, SourceNode<string>] → [number, string].
|
|
47
|
+
*/
|
|
48
|
+
type InferValues<TNodes extends TypedNode[]> = {
|
|
49
|
+
[K in keyof TNodes]: TNodes[K] extends TypedNode ? InferNodeValue<TNodes[K]> : never;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Извлекает тип возвращаемого значения из TypedNode.
|
|
53
|
+
* Используется InferValues и run() для типобезопасного резолвинга.
|
|
54
|
+
*/
|
|
55
|
+
type InferNodeValue<T extends TypedNode> = T extends SourceNode<infer V> ? V : T extends OutputNode<any, infer V> ? V : never;
|
|
56
|
+
/**
|
|
57
|
+
* Узел-источник данных. Не имеет входящих зависимостей.
|
|
58
|
+
* T — тип значения, возвращаемого fetch().
|
|
59
|
+
*/
|
|
60
|
+
type SourceNode<T extends Value = Value> = {
|
|
61
|
+
type: NodeType.SourceNode;
|
|
62
|
+
description?: string;
|
|
63
|
+
fetch: (symbol: string, when: Date, exchangeName: ExchangeName) => Promise<T> | T;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Узел вычисления. TNodes — tuple входящих зависимостей,
|
|
67
|
+
* TResult — тип возвращаемого значения compute().
|
|
68
|
+
* values в compute автоматически выводится из типов TNodes.
|
|
69
|
+
*/
|
|
70
|
+
type OutputNode<TNodes extends TypedNode[] = TypedNode[], TResult extends Value = Value> = {
|
|
71
|
+
type: NodeType.OutputNode;
|
|
72
|
+
description?: string;
|
|
73
|
+
nodes: TNodes;
|
|
74
|
+
compute: (values: InferValues<TNodes>) => Promise<TResult> | TResult;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Типизированный узел графа для прикладного программиста.
|
|
78
|
+
* Подставляется вместо INode для строгой проверки типов и IntelliSense.
|
|
79
|
+
*/
|
|
80
|
+
type TypedNode = SourceNode<Value> | OutputNode<TypedNode[], Value>;
|
|
81
|
+
|
|
82
|
+
declare const sourceNode: <T extends Value>(fetch: (symbol: string, when: Date, exchangeName: ExchangeName) => Promise<T> | T) => SourceNode<T>;
|
|
83
|
+
declare const outputNode: <TNodes extends TypedNode[], TResult extends Value = Value>(compute: (values: InferValues<TNodes>) => Promise<TResult> | TResult, ...nodes: TNodes) => OutputNode<TNodes, TResult>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Рекурсивно разворачивает граф узлов в плоский массив.
|
|
87
|
+
* Порядок: сначала зависимости (children), затем родитель — топологический порядок.
|
|
88
|
+
* Дубликаты (один узел может быть зависимостью нескольких) исключаются по ссылке.
|
|
89
|
+
*/
|
|
90
|
+
declare const deepFlat: (arr?: INode[]) => INode[];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Рекурсивно вычисляет значение узла графа.
|
|
94
|
+
* Для SourceNode вызывает fetch().
|
|
95
|
+
* Для OutputNode сначала резолвит все дочерние nodes параллельно,
|
|
96
|
+
* затем передаёт их типизированные значения в compute().
|
|
97
|
+
*/
|
|
98
|
+
declare const resolve: <T extends TypedNode>(node: T) => Promise<InferNodeValue<T>>;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Сериализованная (плоская) форма узла графа для хранения в БД.
|
|
102
|
+
* Объектные ссылки nodes заменены на массив идентификаторов nodeIds.
|
|
103
|
+
*/
|
|
104
|
+
interface IFlatNode {
|
|
105
|
+
/**
|
|
106
|
+
* Уникальный идентификатор узла.
|
|
107
|
+
*/
|
|
108
|
+
id: string;
|
|
109
|
+
/**
|
|
110
|
+
* Тип узла.
|
|
111
|
+
*/
|
|
112
|
+
type: NodeType;
|
|
113
|
+
/**
|
|
114
|
+
* Человеко-читаемое описание узла.
|
|
115
|
+
*/
|
|
116
|
+
description?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Идентификаторы входящих зависимостей.
|
|
119
|
+
* Порядок соответствует порядку значений в compute(values).
|
|
120
|
+
*/
|
|
121
|
+
nodeIds?: string[];
|
|
122
|
+
/**
|
|
123
|
+
* Источник данных для SourceNode — не сериализуется в БД,
|
|
124
|
+
* восстанавливается на стороне приложения.
|
|
125
|
+
*/
|
|
126
|
+
fetch?: (symbol: string, when: Date, exchangeName: ExchangeName) => Promise<Value> | Value;
|
|
127
|
+
/**
|
|
128
|
+
* Функция вычисления для OutputNode — не сериализуется в БД,
|
|
129
|
+
* восстанавливается на стороне приложения.
|
|
130
|
+
*/
|
|
131
|
+
compute?: (values: Value[]) => Promise<Value> | Value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Преобразует древовидный граф в плоский массив IFlatNode для хранения в БД.
|
|
136
|
+
* Каждому узлу присваивается уникальный id (если не задан),
|
|
137
|
+
* объектные ссылки nodes заменяются на массив nodeIds.
|
|
138
|
+
*/
|
|
139
|
+
declare const serialize: (roots: INode[]) => IFlatNode[];
|
|
140
|
+
/**
|
|
141
|
+
* Восстанавливает древовидный граф из плоского массива IFlatNode.
|
|
142
|
+
* nodes каждого узла заполняется по nodeIds.
|
|
143
|
+
* Возвращает корневые узлы (те, на которые никто не ссылается).
|
|
144
|
+
*/
|
|
145
|
+
declare const deserialize: (flat: IFlatNode[]) => INode[];
|
|
146
|
+
|
|
147
|
+
export { type IFlatNode, type INode, type TypedNode, type Value, deepFlat, deserialize, outputNode, resolve, serialize, sourceNode };
|