@backtest-kit/cli 5.10.2 → 5.11.1

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.
@@ -0,0 +1,158 @@
1
+ # Actions (addActionSchema / IPublicAction / pingActive)
2
+
3
+ ## Overview
4
+
5
+ Actions are lifecycle hooks that run while a position is open. They are registered with `addActionSchema` and attached to a strategy via `actions` array. The most common use is `pingActive` — called on every tick while a pending signal exists.
6
+
7
+ ```ts
8
+ import {
9
+ addActionSchema,
10
+ commitClosePending,
11
+ getPendingSignal,
12
+ IPublicAction,
13
+ ActivePingContract,
14
+ } from "backtest-kit";
15
+ ```
16
+
17
+ ---
18
+
19
+ ## `addActionSchema`
20
+
21
+ ```ts
22
+ addActionSchema({
23
+ actionName: "my_action",
24
+ handler: class implements IPublicAction {
25
+ async pingActive(event: ActivePingContract) {
26
+ // called every tick while a position is open
27
+ }
28
+ },
29
+ });
30
+ ```
31
+
32
+ Attach to strategy:
33
+
34
+ ```ts
35
+ addStrategySchema({
36
+ strategyName: "my_strategy",
37
+ interval: "15m",
38
+ getSignal: ...,
39
+ actions: ["my_action"],
40
+ });
41
+ ```
42
+
43
+ ---
44
+
45
+ ## `ActivePingContract`
46
+
47
+ Fields available inside `pingActive`:
48
+
49
+ | Field | Type | Description |
50
+ |---|---|---|
51
+ | `symbol` | `string` | Trading pair (e.g. `"BTCUSDT"`) |
52
+
53
+ ---
54
+
55
+ ## `getPendingSignal(symbol)`
56
+
57
+ Returns the currently active pending signal. Use it to read `position`, `priceOpen`, `priceTakeProfit`, `priceStopLoss`.
58
+
59
+ ```ts
60
+ const pendingSignal = await getPendingSignal(event.symbol);
61
+ pendingSignal.position // "long" | "short"
62
+ pendingSignal.priceOpen // entry price
63
+ pendingSignal.priceTakeProfit
64
+ pendingSignal.priceStopLoss
65
+ ```
66
+
67
+ ---
68
+
69
+ ## `commitClosePending(symbol)`
70
+
71
+ Closes the active pending signal immediately (market close).
72
+
73
+ ```ts
74
+ await commitClosePending(event.symbol);
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Pattern: Close on trend reversal
80
+
81
+ Close an open position when the trend indicator reverses against it.
82
+
83
+ ```ts
84
+ addActionSchema({
85
+ actionName: "trend_reversal_close",
86
+ handler: class implements IPublicAction {
87
+ async pingActive(event: ActivePingContract) {
88
+ const shouldClose = await resolve(exitSignal);
89
+ if (shouldClose) {
90
+ await commitClosePending(event.symbol);
91
+ }
92
+ }
93
+ },
94
+ });
95
+ ```
96
+
97
+ Where `exitSignal` is an output node that returns `boolean`:
98
+
99
+ ```ts
100
+ const exitSignal = outputNode(
101
+ async ([trend, pendingSignal]) => {
102
+ if (!pendingSignal) return false;
103
+ if (pendingSignal.position === "long" && trend.position === -1) return true;
104
+ if (pendingSignal.position === "short" && trend.position === 1) return true;
105
+ return false;
106
+ },
107
+ masterTrendSource,
108
+ pendingSignalSource,
109
+ );
110
+ ```
111
+
112
+ See `backtest_graph_multiple_outputs.md` for the full pattern with `enterSignal` / `exitSignal`.
113
+
114
+ ---
115
+
116
+ ## Other `IPublicAction` lifecycle methods
117
+
118
+ Beyond `pingActive`, actions can implement:
119
+
120
+ | Method | When called |
121
+ |---|---|
122
+ | `pingActive(event)` | Every tick while position is open |
123
+ | `onOpen(event)` | Position just opened |
124
+ | `onClose(event)` | Position closed (TP/SL/manual) |
125
+ | `onIdle(event)` | Every tick when no position is open |
126
+
127
+ All methods are optional — implement only what you need.
128
+
129
+ ---
130
+
131
+ ## Pattern: DCA (dollar-cost averaging)
132
+
133
+ From `feb_2024.strategy.ts`:
134
+
135
+ ```ts
136
+ addActionSchema({
137
+ actionName: "long_dollar_cost_averaging",
138
+ handler: class implements IPublicAction {
139
+ async pingActive(event: ActivePingContract) {
140
+ const pendingSignal = await getPendingSignal(event.symbol);
141
+ if (pendingSignal.position !== "long") return;
142
+ if (pendingSignal.totalEntries > 5) return;
143
+ const currentPrice = await getAveragePrice(event.symbol);
144
+ if (currentPrice > pendingSignal.originalPriceOpen) return;
145
+ if (await getPositionEntryOverlap(event.symbol, currentPrice, {
146
+ lowerPercent: 0.5, upperPercent: 0.5,
147
+ })) return;
148
+ await commitAverageBuy(event.symbol);
149
+ }
150
+ },
151
+ });
152
+ ```
153
+
154
+ Key guards before DCA:
155
+ 1. `position !== "long"` — skip if wrong direction
156
+ 2. `totalEntries > 5` — cap at 5 entries
157
+ 3. `currentPrice > originalPriceOpen` — only average down, not up
158
+ 4. `getPositionEntryOverlap` — avoid averaging too close to an existing entry
@@ -0,0 +1,137 @@
1
+ # Graph Pattern: Multiple Output Nodes (enterSignal / exitSignal)
2
+
3
+ ## Why Two Output Nodes
4
+
5
+ A strategy can have separate output nodes for entry and exit logic. This keeps concerns separated and allows `exitSignal` to be reused in both an action (`pingActive`) and risk validators independently.
6
+
7
+ ```
8
+ masterTrendSource ──┬──► enterSignal → getSignal()
9
+ └──► exitSignal → pingActive() in action
10
+ fundamentalSource ──► enterSignal
11
+ pendingSignalSource ──► exitSignal
12
+ garchSource ────────► risk validator (resolve directly)
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Full Pattern
18
+
19
+ ```ts
20
+ import { sourceNode, outputNode, resolve } from "@backtest-kit/graph";
21
+ import { getPendingSignal, commitClosePending } from "backtest-kit";
22
+
23
+ // --- Source nodes ---
24
+
25
+ const masterTrendSource = sourceNode(
26
+ Cache.fn(
27
+ async (symbol: string) => {
28
+ const plots = await run(
29
+ File.fromPath("master_trend_15m.pine", "../../math"),
30
+ { symbol, timeframe: "15m", limit: 180 },
31
+ );
32
+ return extract(plots, {
33
+ position: "Position", // -1 | 0 | 1
34
+ close: "Close",
35
+ });
36
+ },
37
+ { interval: "15m", key: ([symbol]: [string]) => symbol },
38
+ ),
39
+ );
40
+
41
+ // Raw source node — no cache, reads live state every tick
42
+ const pendingSignalSource = sourceNode(
43
+ async (symbol) => await getPendingSignal(symbol),
44
+ );
45
+
46
+ // --- Output node: entry ---
47
+
48
+ const enterSignal = outputNode(
49
+ async ([trend, fundamental]) => {
50
+ if (fundamental.position === "wait") return null;
51
+ if (trend.position === 0) return null;
52
+ const fundamentalDir = fundamental.position === "long" ? 1 : -1;
53
+ if (fundamentalDir !== trend.position) return null;
54
+
55
+ const position = fundamentalDir === 1 ? "long" : "short";
56
+ const price = trend.close;
57
+ return {
58
+ id: randomString(),
59
+ position,
60
+ priceTakeProfit: position === "long" ? price * 1.05 : price * 0.95,
61
+ priceStopLoss: position === "long" ? price * 0.95 : price * 1.05,
62
+ minuteEstimatedTime: 480,
63
+ } as const;
64
+ },
65
+ masterTrendSource,
66
+ fundamentalSource,
67
+ );
68
+
69
+ // --- Output node: exit ---
70
+
71
+ const exitSignal = outputNode(
72
+ async ([trend, pendingSignal]) => {
73
+ if (!pendingSignal) return false;
74
+ if (pendingSignal.position === "long" && trend.position === -1) return true;
75
+ if (pendingSignal.position === "short" && trend.position === 1) return true;
76
+ return false;
77
+ },
78
+ masterTrendSource, // shared with enterSignal — resolved once per tick
79
+ pendingSignalSource,
80
+ );
81
+
82
+ // --- Action uses exitSignal ---
83
+
84
+ addActionSchema({
85
+ actionName: "trend_reversal_close",
86
+ handler: class implements IPublicAction {
87
+ async pingActive(event: ActivePingContract) {
88
+ const shouldClose = await resolve(exitSignal);
89
+ if (shouldClose) {
90
+ await commitClosePending(event.symbol);
91
+ }
92
+ }
93
+ },
94
+ });
95
+
96
+ // --- Strategy wires enterSignal ---
97
+
98
+ addStrategySchema({
99
+ strategyName: "feb_2026_strategy",
100
+ interval: "15m",
101
+ getSignal: () => resolve(enterSignal),
102
+ actions: ["trend_reversal_close"],
103
+ });
104
+ ```
105
+
106
+ ---
107
+
108
+ ## Shared Node Deduplication
109
+
110
+ `masterTrendSource` is a dependency of both `enterSignal` and `exitSignal`. When both are resolved in the same tick, `@backtest-kit/graph` deduplicates by reference — the Pine script runs **once**, not twice.
111
+
112
+ This is the main reason to split logic into multiple output nodes rather than one large node: each source is computed once regardless of how many output nodes depend on it.
113
+
114
+ ---
115
+
116
+ ## Raw sourceNode (no Cache.fn)
117
+
118
+ `pendingSignalSource` has no `Cache.fn` wrapper:
119
+
120
+ ```ts
121
+ const pendingSignalSource = sourceNode(
122
+ async (symbol) => await getPendingSignal(symbol),
123
+ );
124
+ ```
125
+
126
+ Use this pattern when:
127
+ - The data changes every tick and must not be cached (live position state)
128
+ - The source is cheap to fetch (single in-memory lookup)
129
+ - Caching would return stale data within the same candle
130
+
131
+ Contrast with `masterTrendSource` which uses `Cache.fn` with `interval: "15m"` — Pine runs once per 15m bar and the result is reused for all ticks within that bar.
132
+
133
+ ---
134
+
135
+ ## `exitSignal` return type
136
+
137
+ `exitSignal` returns `boolean`, not `ISignalDto | null`. Output nodes are not limited to signal shapes — they can return any value that the consumer needs. The action reads `boolean`, the risk validator could read a number, etc.
@@ -0,0 +1,235 @@
1
+ # Graph Pattern (sourceNode / outputNode / resolve)
2
+
3
+ ## Purpose
4
+
5
+ `@backtest-kit/graph` provides a typed directed acyclic graph (DAG) for composing strategy logic. Instead of a single monolithic `getSignal` function, you define reusable **source nodes** (data fetchers) and **output nodes** (computations that combine them).
6
+
7
+ Benefits:
8
+ - Each node caches independently via `Cache.fn`
9
+ - Type-safe: TypeScript infers value types through the graph
10
+ - Parallel resolution: sibling nodes resolve concurrently
11
+
12
+ ---
13
+
14
+ ## Core API
15
+
16
+ ```ts
17
+ import { sourceNode, outputNode, resolve } from "@backtest-kit/graph";
18
+ import { Cache } from "backtest-kit";
19
+ ```
20
+
21
+ ### `sourceNode(fetch)`
22
+
23
+ A leaf node with no dependencies. Fetches data from an external source.
24
+
25
+ ```ts
26
+ const mySource = sourceNode(
27
+ Cache.fn(
28
+ async (symbol: string) => {
29
+ const candles = await getCandles(symbol, "15m", 500);
30
+ return garch.predictRange(candles, "15m", 32);
31
+ },
32
+ { interval: "15m", key: ([symbol]) => symbol },
33
+ ),
34
+ );
35
+ ```
36
+
37
+ - `fetch` signature: `(symbol: string, when: Date, exchangeName: string) => Promise<T>`
38
+ - `Cache.fn` wraps the fetch function with interval-based caching (see [Cache section](#cachefn))
39
+ - `T` can be any non-undefined value: `number`, `string`, `boolean`, `object`, `null`
40
+
41
+ ### `outputNode(compute, ...nodes)`
42
+
43
+ A computation node that receives resolved values from its dependencies.
44
+
45
+ ```ts
46
+ const strategySignal = outputNode(
47
+ async ([trend, volume, reversal]) => {
48
+ // trend, volume, reversal are the resolved values of the three source nodes
49
+ if (!volume.reliable) return null;
50
+ return { position: "long", priceTakeProfit: volume.upperPrice, ... };
51
+ },
52
+ masterTrendSource, // dependency 1 → trend
53
+ rangeSource, // dependency 2 → volume
54
+ reversalSource, // dependency 3 → reversal
55
+ );
56
+ ```
57
+
58
+ - `compute` receives a tuple of resolved values **in the same order as the nodes**
59
+ - Return type is inferred automatically
60
+ - Can return `null` (no signal)
61
+ - Can be nested: an output node can be a dependency of another output node
62
+
63
+ ### `resolve(node)`
64
+
65
+ Executes the graph: resolves all dependencies recursively, then calls `compute`.
66
+
67
+ ```ts
68
+ addStrategySchema({
69
+ strategyName: "my_strategy",
70
+ interval: "15m",
71
+ getSignal: () => resolve(strategySignal),
72
+ });
73
+ ```
74
+
75
+ - Dependencies are resolved **in parallel** (Promise.all internally)
76
+ - The `symbol` and `when` context is injected automatically from the strategy runtime
77
+ - Returns the output node's computed value
78
+
79
+ ---
80
+
81
+ ## Cache.fn
82
+
83
+ Wraps any async function to cache results per candle interval and per cache key.
84
+
85
+ ```ts
86
+ const cachedFn = Cache.fn(
87
+ async (symbol: string) => {
88
+ // expensive operation
89
+ return fetchSomething(symbol);
90
+ },
91
+ {
92
+ interval: "15m", // invalidate every 15m candle boundary
93
+ key: ([symbol]) => symbol, // separate cache entry per symbol
94
+ },
95
+ );
96
+ ```
97
+
98
+ **How invalidation works:** The cache aligns to candle boundaries. At `interval="15m"`, the cache is valid within the same 15-minute bar and invalidated when a new bar opens. This means multiple calls within one bar return the same result without re-fetching.
99
+
100
+ **Without `key`:** single cache entry — all calls share one result regardless of arguments.
101
+ **With `key`:** separate cache entry per key — `"BTCUSDT"` and `"ETHUSDT"` computed independently.
102
+
103
+ Cache.fn is designed to be passed directly as the `fetch` argument to `sourceNode`.
104
+
105
+ ---
106
+
107
+ ## Full Pattern Example
108
+
109
+ ```ts
110
+ import { extract, run, File } from "@backtest-kit/pinets";
111
+ import { getCandles, getAggregatedTrades, Cache } from "backtest-kit";
112
+ import { sourceNode, outputNode, resolve } from "@backtest-kit/graph";
113
+ import * as garch from "garch";
114
+ import * as anomaly from "volume-anomaly";
115
+
116
+ // === Source nodes ===
117
+
118
+ const masterTrendSource = sourceNode(
119
+ Cache.fn(
120
+ async (symbol) => {
121
+ const plots = await run(
122
+ File.fromPath("master_trend_15m.pine", "../math"),
123
+ { symbol, timeframe: "15m", limit: 180 },
124
+ );
125
+ return extract(plots, {
126
+ position: "Position",
127
+ close: "Close",
128
+ });
129
+ },
130
+ { interval: "15m", key: ([symbol]) => symbol },
131
+ ),
132
+ );
133
+
134
+ const rangeSource = sourceNode(
135
+ Cache.fn(
136
+ async (symbol) => {
137
+ const candles = await getCandles(symbol, "15m", 1_000);
138
+ return garch.predictRange(candles, "15m", 32);
139
+ },
140
+ { interval: "15m", key: ([symbol]) => symbol },
141
+ ),
142
+ );
143
+
144
+ const reversalSource = sourceNode(
145
+ Cache.fn(
146
+ async (symbol) => {
147
+ const all = await getAggregatedTrades(symbol, 1400);
148
+ return anomaly.predict(all.slice(0, 1200), all.slice(1200), 0.75);
149
+ },
150
+ { interval: "15m", key: ([symbol]) => symbol },
151
+ ),
152
+ );
153
+
154
+ // === Output node (signal logic) ===
155
+
156
+ const strategySignal = outputNode(
157
+ async ([trend, volume, reversal]) => {
158
+ if (!volume.reliable || volume.movePercent < 0.7) return null;
159
+ if (trend.position === 0) return null;
160
+ if (!reversal.anomaly) return null;
161
+
162
+ let position: "long" | "short" | null = null;
163
+ if (trend.position === 1 && reversal.direction === "long") position = "long";
164
+ if (trend.position === -1 && reversal.direction === "short") position = "short";
165
+ if (!position) return null;
166
+
167
+ return {
168
+ id: randomString(),
169
+ position,
170
+ priceTakeProfit: position === "long" ? volume.upperPrice : volume.lowerPrice,
171
+ priceStopLoss: position === "long" ? volume.lowerPrice : volume.upperPrice,
172
+ minuteEstimatedTime: 480,
173
+ } as const;
174
+ },
175
+ masterTrendSource,
176
+ rangeSource,
177
+ reversalSource,
178
+ );
179
+
180
+ // === Wire into strategy ===
181
+
182
+ addStrategySchema({
183
+ strategyName: "bounce_strategy",
184
+ interval: "15m",
185
+ getSignal: () => resolve(strategySignal),
186
+ });
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Cache Interval Selection
192
+
193
+ Choose `Cache.fn` interval to match the data's natural update frequency:
194
+
195
+ | Data source | Recommended interval |
196
+ |---|---|
197
+ | Pine indicator (15m timeframe) | `"15m"` |
198
+ | `garch.predictRange` on 15m candles | `"15m"` |
199
+ | `garch.predict` on 5m candles | `"5m"` |
200
+ | `anomaly.predict` (trade stream) | `"5m"` — refreshed every 5m bar |
201
+ | Funding rate | `"1h"` |
202
+ | Order book snapshot | `"1m"` |
203
+
204
+ Setting interval too short wastes compute; too long means stale data spanning multiple bars. Match to the smallest timeframe of the data being fetched.
205
+
206
+ ---
207
+
208
+ ## Type Safety
209
+
210
+ The graph is fully typed. TypeScript infers the compute callback parameter types from the node declarations:
211
+
212
+ ```ts
213
+ // If rangeSource returns garch.PredictionResult:
214
+ const rangeSource = sourceNode(
215
+ Cache.fn(async (symbol) => garch.predictRange(candles, "15m", 32), { interval: "15m", key: ([s]) => s }),
216
+ );
217
+ // TS knows rangeSource is SourceNode<PredictionResult>
218
+
219
+ const out = outputNode(
220
+ async ([volume]) => {
221
+ volume.movePercent // ✓ typed as number
222
+ volume.upperPrice // ✓ typed as number
223
+ volume.nonExistent // ✗ TS error
224
+ },
225
+ rangeSource,
226
+ );
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Notes
232
+
233
+ - `resolve()` is called **inside** `getSignal`, not at module level. This ensures the graph runs per-tick with the correct symbol/when context.
234
+ - Each source node in the graph is resolved **once per tick** even if referenced by multiple output nodes (deduplication by reference).
235
+ - The graph does **not** support cycles.