@backtest-kit/cli 5.11.0 โ†’ 6.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.
@@ -0,0 +1,207 @@
1
+ # ๐Ÿงฟ Backtest Kit Project
2
+
3
+ > A TypeScript framework for backtesting and live trading strategies on multi-asset, crypto, forex or [DEX (peer-to-peer marketplace)](https://en.wikipedia.org/wiki/Decentralized_finance#Decentralized_exchanges), spot, futures with crash-safe persistence, signal validation, and AI optimization.
4
+
5
+ ![screenshot](https://raw.githubusercontent.com/tripolskypetr/backtest-kit/HEAD/assets/screenshots/screenshot16.png)
6
+
7
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/tripolskypetr/backtest-kit)
8
+ [![npm](https://img.shields.io/npm/v/backtest-kit.svg?style=flat-square)](https://npmjs.org/package/backtest-kit)
9
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue)]()
10
+ [![Build](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml/badge.svg)](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml)
11
+
12
+ A minimal project scaffold for [backtest-kit](https://github.com/tripolskypetr/backtest-kit). All infrastructure (exchange registration, candle caching, runner, UI, Telegram) is handled by `@backtest-kit/cli` โ€” this project contains only your strategy files.
13
+
14
+ ## ๐Ÿ“‹ Quick Start
15
+
16
+ ```bash
17
+ npm start # Run the CLI (append flags below)
18
+ npm run sync:lib # Refresh library docs in docs/lib/
19
+ ```
20
+
21
+ ## ๐Ÿƒ Running Modes
22
+
23
+ All modes are invoked via `npm start -- <flags> <entry-point>`.
24
+
25
+ ### ๐Ÿงช Backtest
26
+
27
+ Runs the strategy against historical candle data defined by a `FrameSchema`.
28
+
29
+ ```bash
30
+ npm start -- --backtest --symbol BTCUSDT --strategy feb_2026_strategy --exchange ccxt-exchange --frame feb_2026_frame ./content/feb_2026.strategy.ts
31
+ ```
32
+
33
+ | Flag | Type | Default | Description |
34
+ |------|------|---------|-------------|
35
+ | `--backtest` | boolean | โ€” | Enable backtest mode |
36
+ | `--symbol` | string | `BTCUSDT` | Trading pair |
37
+ | `--strategy` | string | first registered | Strategy name from `addStrategySchema` |
38
+ | `--exchange` | string | first registered | Exchange name from `addExchangeSchema` |
39
+ | `--frame` | string | first registered | Frame name from `addFrameSchema` |
40
+ | `--cacheInterval` | string | `1m, 15m, 30m, 1h, 4h` | Comma-separated intervals to pre-cache before the run |
41
+ | `--noCache` | boolean | `false` | Skip candle cache warming |
42
+ | `--verbose` | boolean | `false` | Log every candle fetch to stdout |
43
+ | `--ui` | boolean | `false` | Start web dashboard at `http://localhost:60050` |
44
+ | `--telegram` | boolean | `false` | Send trade notifications to Telegram |
45
+
46
+ Before the backtest starts, the CLI warms the candle cache for every interval in `--cacheInterval`. On subsequent runs the cache is used directly โ€” no extra API calls. Pass `--noCache` to skip this step.
47
+
48
+ Module file `./modules/backtest.module.ts` (or `.mjs`) is loaded automatically if it exists.
49
+
50
+ ### ๐Ÿ“„ Paper Trading
51
+
52
+ Connects to the live exchange but places no real orders. Identical code path to `--live` โ€” safe for strategy validation.
53
+
54
+ ```bash
55
+ npm start -- --paper --symbol BTCUSDT --strategy feb_2026_strategy --exchange ccxt-exchange ./content/feb_2026.strategy.ts
56
+ ```
57
+
58
+ | Flag | Type | Default | Description |
59
+ |------|------|---------|-------------|
60
+ | `--paper` | boolean | โ€” | Enable paper trading mode |
61
+ | `--symbol` | string | `BTCUSDT` | Trading pair |
62
+ | `--strategy` | string | first registered | Strategy name |
63
+ | `--exchange` | string | first registered | Exchange name |
64
+ | `--verbose` | boolean | `false` | Log every candle fetch to stdout |
65
+ | `--ui` | boolean | `false` | Start web dashboard |
66
+ | `--telegram` | boolean | `false` | Enable Telegram notifications |
67
+
68
+ Module file `./modules/paper.module.ts` is loaded automatically if it exists.
69
+
70
+ ### ๐Ÿ“ˆ Live Trading
71
+
72
+ Deploys a real trading bot. Requires exchange API keys in `.env`.
73
+
74
+ ```bash
75
+ npm start -- --live --symbol BTCUSDT --ui --telegram ./content/feb_2026.strategy.ts
76
+ ```
77
+
78
+ | Flag | Type | Default | Description |
79
+ |------|------|---------|-------------|
80
+ | `--live` | boolean | โ€” | Enable live trading mode |
81
+ | `--symbol` | string | `BTCUSDT` | Trading pair |
82
+ | `--strategy` | string | first registered | Strategy name |
83
+ | `--exchange` | string | first registered | Exchange name |
84
+ | `--verbose` | boolean | `false` | Log every candle fetch to stdout |
85
+ | `--ui` | boolean | `false` | Start web dashboard |
86
+ | `--telegram` | boolean | `false` | Enable Telegram notifications |
87
+
88
+ Module file `./modules/live.module.ts` is loaded automatically if it exists. Use it to register a `Broker` adapter that intercepts every trade mutation before internal state changes โ€” exchange rejection rolls back the operation atomically.
89
+
90
+ ## ๐ŸŒฒ Running PineScript Indicators (`--pine`)
91
+
92
+ Executes a local `.pine` file against a real exchange and prints the output as a Markdown table or saves it to a file.
93
+
94
+ ```bash
95
+ npm start -- --pine ./math/feb_2026.pine --timeframe 15m --limit 500 --when "2026-02-28T00:00:00.000Z" --jsonl
96
+ ```
97
+
98
+ Output file is created at `./math/dump/<name>.jsonl` (next to the `.pine` file).
99
+
100
+ | Flag | Type | Default | Description |
101
+ |------|------|---------|-------------|
102
+ | `--pine` | boolean | โ€” | Enable PineScript execution mode |
103
+ | `--symbol` | string | `BTCUSDT` | Trading pair |
104
+ | `--timeframe` | string | `15m` | Candle interval |
105
+ | `--limit` | string | `250` | Number of candles to fetch |
106
+ | `--when` | string | now | End date โ€” ISO 8601 or Unix ms |
107
+ | `--exchange` | string | first registered | Exchange name |
108
+ | `--output` | string | `.pine` file name | Output file base name (no extension) |
109
+ | `--json` | boolean | `false` | Save output as JSON array |
110
+ | `--jsonl` | boolean | `false` | Save output as JSONL (one row per line) |
111
+ | `--markdown` | boolean | `false` | Save output as Markdown table |
112
+
113
+ Module file `./modules/pine.module.ts` is loaded automatically. The project includes it pre-configured with CCXT Binance. Override it to use a different exchange.
114
+
115
+ Only `plot()` calls with `display=display.data_window` produce output columns:
116
+
117
+ ```pine
118
+ plot(close, "Close", display=display.data_window)
119
+ plot(position, "Position", display=display.data_window)
120
+ ```
121
+
122
+ ## ๐Ÿ’พ Dumping Raw Candles (`--dump`)
123
+
124
+ Fetches raw OHLCV candles from an exchange and saves them to a file.
125
+
126
+ ```bash
127
+ npm start -- --dump --timeframe 15m --limit 500 --when "2026-02-28T00:00:00.000Z" --jsonl
128
+ ```
129
+
130
+ Output file is created at `./dump/<name>.jsonl`.
131
+
132
+ | Flag | Type | Default | Description |
133
+ |------|------|---------|-------------|
134
+ | `--dump` | boolean | โ€” | Enable candle dump mode |
135
+ | `--symbol` | string | `BTCUSDT` | Trading pair |
136
+ | `--timeframe` | string | `15m` | Candle interval |
137
+ | `--limit` | string | `250` | Number of candles |
138
+ | `--when` | string | now | End date โ€” ISO 8601 or Unix ms |
139
+ | `--exchange` | string | first registered | Exchange name |
140
+ | `--output` | string | `{SYMBOL}_{LIMIT}_{TIMEFRAME}_{TIMESTAMP}` | Output file base name |
141
+ | `--json` | boolean | `false` | Save as JSON array |
142
+ | `--jsonl` | boolean | `false` | Save as JSONL |
143
+
144
+ Module file `./modules/dump.module.ts` is loaded automatically. The project includes it pre-configured with CCXT Binance.
145
+
146
+ ## ๐Ÿงฉ Module Hooks
147
+
148
+ | File | Loaded by mode | Purpose |
149
+ |------|----------------|---------|
150
+ | `modules/backtest.module.ts` | `--backtest` | Register a `Broker` adapter for backtest |
151
+ | `modules/paper.module.ts` | `--paper` | Register a `Broker` adapter for paper trading |
152
+ | `modules/live.module.ts` | `--live` | Register a `Broker` adapter for live trading |
153
+ | `modules/pine.module.ts` | `--pine` | Register an exchange schema for PineScript runs |
154
+ | `modules/dump.module.ts` | `--dump` | Register an exchange schema for candle dumps |
155
+
156
+ All files are optional โ€” a missing module is a soft warning, not an error. Extensions `.ts`, `.mjs`, `.cjs` are tried automatically.
157
+
158
+ ## ๐ŸŒ Environment Variables
159
+
160
+ Create a `.env` file in the project root:
161
+
162
+ ```env
163
+ # Telegram notifications (required for --telegram)
164
+ CC_TELEGRAM_TOKEN=your_bot_token_here
165
+ CC_TELEGRAM_CHANNEL=-100123456789
166
+
167
+ # Web UI server (optional, defaults shown)
168
+ CC_WWWROOT_HOST=0.0.0.0
169
+ CC_WWWROOT_PORT=60050
170
+ ```
171
+
172
+ | Variable | Default | Description |
173
+ |----------|---------|-------------|
174
+ | `CC_TELEGRAM_TOKEN` | โ€” | Telegram bot token (from @BotFather) |
175
+ | `CC_TELEGRAM_CHANNEL` | โ€” | Telegram channel or chat ID |
176
+ | `CC_WWWROOT_HOST` | `0.0.0.0` | UI server bind address |
177
+ | `CC_WWWROOT_PORT` | `60050` | UI server port |
178
+
179
+
180
+ ## ๐Ÿ—‚๏ธ Project Structure
181
+
182
+ ```
183
+ โ”œโ”€โ”€ content/ # Strategy entry points (.ts)
184
+ โ”‚ โ””โ”€โ”€ feb_2026.strategy.ts
185
+ โ”œโ”€โ”€ docs/ # Documentation
186
+ โ”‚ โ”œโ”€โ”€ lib/ # Auto-fetched library READMEs (via sync:lib)
187
+ โ”‚ โ””โ”€โ”€ *.md # Backtest Kit how-to guides
188
+ โ”œโ”€โ”€ math/ # PineScript indicator files (.pine)
189
+ โ”‚ โ””โ”€โ”€ feb_2026.pine
190
+ โ”œโ”€โ”€ modules/ # Side-effect module hooks (loaded automatically)
191
+ โ”‚ โ”œโ”€โ”€ dump.module.ts # Exchange schema for --dump mode
192
+ โ”‚ โ””โ”€โ”€ pine.module.ts # Exchange schema for --pine mode
193
+ โ”œโ”€โ”€ report/ # Strategy research reports (.md)
194
+ โ”‚ โ””โ”€โ”€ feb_2026.md
195
+ โ”œโ”€โ”€ scripts/
196
+ โ”‚ โ””โ”€โ”€ fetch_docs.mjs # Downloads library READMEs into docs/lib/
197
+ โ”œโ”€โ”€ CLAUDE.md # AI-agent guide for writing strategies
198
+ โ””โ”€โ”€ package.json
199
+ ```
200
+
201
+ ## ๐Ÿ“š Updating Library Documentation
202
+
203
+ ```bash
204
+ npm run sync:lib
205
+ ```
206
+
207
+ Downloads the latest README files for all bundled libraries into `docs/lib/`. Run this after updating package versions or when you want fresh documentation available to the AI agent.
@@ -0,0 +1,114 @@
1
+ import {
2
+ addExchangeSchema,
3
+ addFrameSchema,
4
+ addStrategySchema,
5
+ listenError,
6
+ Cache,
7
+ Log,
8
+ } from "backtest-kit";
9
+ import {
10
+ errorData,
11
+ getErrorMessage,
12
+ randomString,
13
+ singleshot,
14
+ } from "functools-kit";
15
+ import ccxt from "ccxt";
16
+ import { run, File, extract } from "@backtest-kit/pinets";
17
+ import { outputNode, resolve, sourceNode } from "@backtest-kit/graph";
18
+
19
+ const getExchange = singleshot(async () => {
20
+ const exchange = new ccxt.binance({
21
+ options: {
22
+ defaultType: "spot",
23
+ adjustForTimeDifference: true,
24
+ recvWindow: 60000,
25
+ },
26
+ enableRateLimit: true,
27
+ });
28
+ await exchange.loadMarkets();
29
+ return exchange;
30
+ });
31
+
32
+ const pineSource = sourceNode(
33
+ Cache.fn(
34
+ async (symbol) => {
35
+ const plots = await run(File.fromPath("feb_2026.pine", "../math"), {
36
+ symbol,
37
+ timeframe: "15m",
38
+ limit: 2688,
39
+ });
40
+
41
+ return await extract(plots, {
42
+ position: "Position",
43
+ entryPrice: "EntryPrice",
44
+ tp: "TP",
45
+ sl: "SL",
46
+ });
47
+ },
48
+ { interval: "15m", key: ([symbol]) => symbol },
49
+ ),
50
+ );
51
+
52
+ const signalOutput = outputNode(async ([pineSource]) => {
53
+ const position =
54
+ pineSource.position === -1
55
+ ? "short"
56
+ : pineSource.position === 1
57
+ ? "long"
58
+ : "wait";
59
+
60
+ if (position === "wait") {
61
+ return null;
62
+ }
63
+
64
+ return {
65
+ id: randomString(),
66
+ position,
67
+ priceOpen: pineSource.entryPrice,
68
+ priceTakeProfit: pineSource.tp,
69
+ priceStopLoss: pineSource.sl,
70
+ minuteEstimatedTime: Infinity,
71
+ } as const;
72
+ }, pineSource);
73
+
74
+ addExchangeSchema({
75
+ exchangeName: "ccxt-exchange",
76
+ getCandles: async (symbol, interval, since, limit) => {
77
+ const exchange = await getExchange();
78
+ const candles = await exchange.fetchOHLCV(
79
+ symbol,
80
+ interval,
81
+ since.getTime(),
82
+ limit,
83
+ );
84
+ return candles.map(([timestamp, open, high, low, close, volume]) => ({
85
+ timestamp,
86
+ open,
87
+ high,
88
+ low,
89
+ close,
90
+ volume,
91
+ }));
92
+ },
93
+ });
94
+
95
+ addFrameSchema({
96
+ frameName: "feb_2026_frame",
97
+ interval: "1m",
98
+ startDate: new Date("2026-02-01T00:00:00Z"),
99
+ endDate: new Date("2026-02-28T23:59:59Z"),
100
+ note: "February 2026",
101
+ });
102
+
103
+ addStrategySchema({
104
+ strategyName: "feb_2026_strategy",
105
+ interval: "1m",
106
+ getSignal: async () => await resolve(signalOutput),
107
+ });
108
+
109
+ listenError((error) => {
110
+ Log.debug("error", {
111
+ error: errorData(error),
112
+ message: getErrorMessage(error),
113
+ });
114
+ });
@@ -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.