@backtest-kit/cli 5.11.0 → 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.
- package/README.md +90 -0
- package/build/index.cjs +106 -18
- package/build/index.mjs +108 -20
- package/package.json +1 -1
- package/template/project/CLAUDE.md +158 -0
- package/template/project/content/feb_2026.strategy.ts +114 -0
- package/template/project/docs/backtest_actions.md +158 -0
- package/template/project/docs/backtest_graph_multiple_outputs.md +137 -0
- package/template/project/docs/backtest_graph_pattern.md +235 -0
- package/template/project/docs/backtest_logging_jsonl.md +246 -0
- package/template/project/docs/backtest_pinets_usage.md +190 -0
- package/template/project/docs/backtest_risk_async.md +153 -0
- package/template/project/docs/backtest_strategy_structure.md +250 -0
- package/template/project/docs/pine_debug.md +174 -0
- package/template/project/docs/pine_indicator_warmup.md +88 -0
- package/template/project/gitignore.mustache +30 -0
- package/template/project/math/feb_2026.pine +120 -0
- package/template/project/modules/dump.module.ts +37 -0
- package/template/project/modules/pine.module.ts +37 -0
- package/template/project/package.mustache +27 -0
- package/template/project/report/feb_2026.md +78 -0
- package/template/project/scripts/fetch_docs.mjs +58 -0
- /package/template/project/{.gitkeep → docs/lib/.gitkeep} +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
## Guide
|
|
2
|
+
|
|
3
|
+
### How to Write a Strategy
|
|
4
|
+
|
|
5
|
+
**What NOT to do**
|
|
6
|
+
|
|
7
|
+
- Don't read all project files and bloat the context.
|
|
8
|
+
|
|
9
|
+
Strategies are written as simple `.pine` files; the command to run them is below.
|
|
10
|
+
|
|
11
|
+
- Don't brute-force iterate.
|
|
12
|
+
|
|
13
|
+
The worst thing you can do is start incrementally writing into an existing project file. That's not how this works — you need market analysis, not work for the sake of work.
|
|
14
|
+
|
|
15
|
+
- Don't sacrifice efficiency for universality.
|
|
16
|
+
|
|
17
|
+
Markets change. By building a universal solution you lose the optimization that is the competitive edge actually generating profit at any given moment.
|
|
18
|
+
|
|
19
|
+
- Don't write `.pine` files with side effects.
|
|
20
|
+
|
|
21
|
+
You don't need `var` and `na` in PineScript — compute all values on every iteration. This makes errors and unpredictable behavior more likely to surface before going to production. Keep the code easy to understand; avoid premature optimization.
|
|
22
|
+
|
|
23
|
+
- Don't use hacks in trading strategy code.
|
|
24
|
+
|
|
25
|
+
You cannot disguise the absence of an SL by using ATR when the exit keeps shifting relative to the close price on every iteration. Trailing criteria must be finite — you cannot keep shifting the stop loss forever hoping for a bounce or a drop. Avoid HOLD in any form.
|
|
26
|
+
|
|
27
|
+
- Don't build strategies that produce one signal every few days.
|
|
28
|
+
|
|
29
|
+
Three profitable signals is not a successful trading strategy — it's luck. To evaluate a strategy statistically you need at least one signal per day.
|
|
30
|
+
|
|
31
|
+
**What TO do**
|
|
32
|
+
|
|
33
|
+
- Every strategy is written for a single calendar month.
|
|
34
|
+
|
|
35
|
+
Follow the naming pattern or refuse to work. The money is in optimizing for current market conditions; a backtest spanning two or more months is mathematically meaningless because the final balance will wipe out profit through commission whipsaw.
|
|
36
|
+
|
|
37
|
+
* `./math/jan_2026.pine`, `./content/jan_2026.strategy.ts`
|
|
38
|
+
* `./math/feb_2026.pine`, `./content/feb_2026.strategy.ts`
|
|
39
|
+
* `./math/march_2026.pine`, `./content/march_2026.strategy.ts`
|
|
40
|
+
* `./math/apr_2026.pine`, `./content/apr_2026.strategy.ts`
|
|
41
|
+
* `./math/may_2026.pine`, `./content/may_2026.strategy.ts`
|
|
42
|
+
|
|
43
|
+
- Read the news background for the chosen time period.
|
|
44
|
+
|
|
45
|
+
The focus should ALWAYS be on negative news. Searching for the Bitcoin price gives you marketing trash. Searching for analytics gives you SEO garbage. Use queries like:
|
|
46
|
+
|
|
47
|
+
* Bitcoin negative news March 2026 price drop regulatory problems…
|
|
48
|
+
* bitcoin price February 5 2024 current level forecast analytics BTC
|
|
49
|
+
* bitcoin negative news February 2024 problems regulator crackdown bitcoin
|
|
50
|
+
* bitcoin negative news March 2026 regulatory problems bans
|
|
51
|
+
* bitcoin security hackers fraud regulation negative news problems
|
|
52
|
+
|
|
53
|
+
- Create a `--dump` to output candles.
|
|
54
|
+
|
|
55
|
+
You need to see where the money actually is in the market. Identify the general trend: if it's bearish, protect against LONGs; if it's bullish, protect against SHORTs. There may be a short-term bounce or panic driven by geopolitical news.
|
|
56
|
+
|
|
57
|
+
- The market may be ranging (sideways).
|
|
58
|
+
|
|
59
|
+
There are cases when no position should be opened at all — your analysis must account for this.
|
|
60
|
+
|
|
61
|
+
- TP/SL should be dynamic, but not scalping.
|
|
62
|
+
|
|
63
|
+
The exchange charges 0.2% to enter and 0.2% to exit. You may think the strategy is profitable, but it's whipsaw. Minimum TP: 1%.
|
|
64
|
+
|
|
65
|
+
- Don't try to build an all-weather strategy.
|
|
66
|
+
|
|
67
|
+
I need to understand where the money is in the market only within the specified time period. If the strategy stops being profitable I'll simply ask you to run the analysis again.
|
|
68
|
+
|
|
69
|
+
- Don't build HOLD strategies.
|
|
70
|
+
|
|
71
|
+
I need to find where the money actually is in the market, not sit in a position hoping for luck. The criterion for "where the money is" must be expressed as a formula that finds effective entry points that lead to profit directly.
|
|
72
|
+
|
|
73
|
+
- Don't brut force strategies.
|
|
74
|
+
|
|
75
|
+
Use fresh strategies with different concepts. Do not edit existing strategy one cause this will give you a loop even if you coded it. I need concept engineering
|
|
76
|
+
|
|
77
|
+
### Market Candle Dump
|
|
78
|
+
|
|
79
|
+
File `BTCUSDT_500_15m_1772236800000.jsonl` will be created at `./dump/BTCUSDT_500_15m_1772236800000.jsonl`
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
npm start -- --dump --timeframe 15m --limit 500 --when "2026-02-28T00:00:00.000Z" --jsonl
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Running `.pine` Files
|
|
86
|
+
|
|
87
|
+
File `impulse_trend_15m.jsonl` will be created at `./math/dump/impulse_trend_15m.jsonl`
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
npm start -- --pine ./math/impulse_trend_15m.pine --timeframe 15m --limit 500 --when "2026-02-28T00:00:00.000Z" --jsonl
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Algorithm
|
|
94
|
+
|
|
95
|
+
**Planning the Work**
|
|
96
|
+
|
|
97
|
+
1. Read the `.pine` file from the previous month if one exists.
|
|
98
|
+
|
|
99
|
+
2. Read news from the internet for the current month with a focus on negative news.
|
|
100
|
+
|
|
101
|
+
3. Correlate the news background with the candle dump. News sources must visibly influence the candle data for the chosen time period: price bounce, sideways range, neutral trend, decline, or rally.
|
|
102
|
+
|
|
103
|
+
4. Understand why the previous month's file stopped working by interpreting its logic in the context of the new news background.
|
|
104
|
+
|
|
105
|
+
5. In addition to news, review the candle dump independently: assess volatility, market gaps, trading volumes, and risks.
|
|
106
|
+
|
|
107
|
+
**Writing the Strategy**
|
|
108
|
+
|
|
109
|
+
1. Create NEW files for the current month and write them from scratch. Do not copy-paste and do not attempt to brute-force parameters. New month — new strategy.
|
|
110
|
+
|
|
111
|
+
2. Run the `.pine` file and review the output. The acceptance criterion is a profitable trading strategy, not code for the sake of code. Do not stop until profit is achieved.
|
|
112
|
+
|
|
113
|
+
3. After obtaining a profitable strategy, ALWAYS save the knowledge base used to build it into a markdown file with fundamental market analysis, following the naming pattern:
|
|
114
|
+
|
|
115
|
+
* `./report/jan_2026.md`
|
|
116
|
+
* `./report/feb_2026.md`
|
|
117
|
+
* `./report/march_2026.md`
|
|
118
|
+
|
|
119
|
+
4. Run a code review as a separate agent.
|
|
120
|
+
|
|
121
|
+
The code review must check the strategy for perpetual hold without strict exit conditions — for example, a trailing SL that shifts forever relative to the close of the last candle. I need not just to make money, but to mathematically identify where the money is in the market in order to avoid large portfolio liquidity drawdowns.
|
|
122
|
+
|
|
123
|
+
5. If the code review fails, incorporate the findings and rebuild the strategy from scratch following this guide.
|
|
124
|
+
|
|
125
|
+
### Recommendations
|
|
126
|
+
|
|
127
|
+
- Search the internet for ideas.
|
|
128
|
+
|
|
129
|
+
I welcome borrowing trading ideas from other people via internet search rather than brute-forcing options. It's important not only to find someone else's concept but also to verify in practice that it is actually profitable.
|
|
130
|
+
|
|
131
|
+
- Analyze market structure.
|
|
132
|
+
|
|
133
|
+
Looking at the candles reveals the structures present in the current month: sideways range, neutral trend, bullish trend, bearish trend, high volatility. Think through how to identify and how to act in each case.
|
|
134
|
+
|
|
135
|
+
- Use a TODO list.
|
|
136
|
+
|
|
137
|
+
This guide has many steps. Form a TODO list and work through it step by step, marking each item complete. Include news research, candle `--dump` analysis, pine strategy analysis — as granularly as possible.
|
|
138
|
+
|
|
139
|
+
- Use deep research.
|
|
140
|
+
|
|
141
|
+
The guide calls for intelligent market analysis, not code for the sake of code. Think carefully; don't cut corners on tokens.
|
|
142
|
+
|
|
143
|
+
- Think logically.
|
|
144
|
+
|
|
145
|
+
A 1% target cannot be reached in less than 4 hours. A target below 1% is uninteresting because slippage will knock the position out before achieving an effective Risk/Reward. I don't need a formal reply — I need market analysis.
|
|
146
|
+
|
|
147
|
+
### Deliverable
|
|
148
|
+
|
|
149
|
+
A `.pine` file free of marketing fluff:
|
|
150
|
+
|
|
151
|
+
- Forbidden: TP=0.5% SL=-10% and any similar asymmetric nonsense. Risk management must be sound and must rule out holding on luck.
|
|
152
|
+
- Clearly described and commented operating modes with references to the time period on which they were tested.
|
|
153
|
+
- An honest profitability summary in the file header as a comment.
|
|
154
|
+
- An honest average daily signal count in the file header.
|
|
155
|
+
- An honest `sharpeRatio`, `avgPnl`, `stdDev` in the file header.
|
|
156
|
+
- One or more signals per day — more is better.
|
|
157
|
+
|
|
158
|
+
If it is impossible to make money, do not try to fudge the results. Write it as it is, without embellishment.
|
|
@@ -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.
|