@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,153 @@
|
|
|
1
|
+
# Async Risk Validation with resolve()
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Risk validators can be async and call `resolve()` on any graph node. This lets you reuse the same source nodes (garch, trend, fear & greed) in both signal generation and risk filtering — without duplicate fetches.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Sync vs async validation
|
|
10
|
+
|
|
11
|
+
`backtest_strategy_structure.md` shows the basic sync pattern:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// Sync — receives pre-computed payload fields
|
|
15
|
+
validations: [
|
|
16
|
+
{
|
|
17
|
+
validate: ({ currentSignal, currentPrice }) => {
|
|
18
|
+
if (currentSignal.position === "short" && currentPrice > 50000) {
|
|
19
|
+
throw new Error("Price too high for short");
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Async pattern — resolve a graph node:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// Async — fetch fresh data at validation time
|
|
30
|
+
validations: [
|
|
31
|
+
async () => {
|
|
32
|
+
const garch = await resolve(garchSource);
|
|
33
|
+
if (!garch.reliable || garch.movePercent < 1.0) {
|
|
34
|
+
throw new Error(`GARCH volatility too low: ${garch.movePercent.toFixed(2)}%`);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Both forms can coexist in the same `validations` array.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## GARCH volatility gate
|
|
45
|
+
|
|
46
|
+
The canonical use case: block signals when predicted volatility is too low to cover transaction costs and generate profit.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { predict } from "garch";
|
|
50
|
+
import { sourceNode, resolve } from "@backtest-kit/graph";
|
|
51
|
+
import { Cache, getCandles, addRiskSchema } from "backtest-kit";
|
|
52
|
+
|
|
53
|
+
const CANDLES_FOR_GARCH = 1_000;
|
|
54
|
+
const GARCH_CONFIDENCE = 0.95;
|
|
55
|
+
const MIN_MOVE_PERCENT = 1.0;
|
|
56
|
+
|
|
57
|
+
const garchSource = sourceNode(
|
|
58
|
+
Cache.fn(
|
|
59
|
+
async (symbol: string) => {
|
|
60
|
+
const candles = await getCandles(symbol, "8h", CANDLES_FOR_GARCH);
|
|
61
|
+
return predict(candles, "8h", null, GARCH_CONFIDENCE);
|
|
62
|
+
},
|
|
63
|
+
{ interval: "8h", key: ([symbol]: [string]) => symbol },
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
addRiskSchema({
|
|
68
|
+
riskName: "garch_volatility_risk",
|
|
69
|
+
validations: [
|
|
70
|
+
async () => {
|
|
71
|
+
const garch = await resolve(garchSource);
|
|
72
|
+
if (!garch.reliable || garch.movePercent < MIN_MOVE_PERCENT) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`GARCH volatility too low: ${garch.movePercent.toFixed(2)}% (need ≥${MIN_MOVE_PERCENT}%, confidence=${GARCH_CONFIDENCE})`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**`garch.reliable`** — false when the model didn't converge or has insufficient data. Always check this before using `movePercent`.
|
|
83
|
+
|
|
84
|
+
**`garch.movePercent`** — predicted price move as percentage of current price over one candle (one 8h bar here). At confidence=0.95 (±1.96σ), a value of 1.0 means the model predicts ≥1% move with 95% probability.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Fear & Greed gate (directional)
|
|
89
|
+
|
|
90
|
+
From `feb_2024.strategy.ts` — block longs in fear and shorts in greed:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
addRiskSchema({
|
|
94
|
+
riskName: "fear_greed_directional",
|
|
95
|
+
validations: [
|
|
96
|
+
async ({ currentSignal }) => {
|
|
97
|
+
const fearGreed = await resolve(fearGreedSource);
|
|
98
|
+
if (currentSignal.position === "short" && fearGreed > 50) {
|
|
99
|
+
throw new Error(`Still greed (${fearGreed}): short not allowed`);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
async ({ currentSignal }) => {
|
|
103
|
+
const fearGreed = await resolve(fearGreedSource);
|
|
104
|
+
if (currentSignal.position === "long" && fearGreed < 50) {
|
|
105
|
+
throw new Error(`Still fear (${fearGreed}): long not allowed`);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`fearGreedSource` uses `Cache.file` with `interval: "8h"` — fetched once per 8h from `api.alternative.me`.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Node deduplication in risk
|
|
117
|
+
|
|
118
|
+
When `garchSource` is a dependency of both `enterSignal` (output node) and the risk validator, the graph deduplicates resolution within the same tick. If `getSignal()` already resolved `garchSource`, the risk validator gets the cached result — no second fetch.
|
|
119
|
+
|
|
120
|
+
This only holds if the validator uses `resolve(garchSource)` with the **same node reference**. Do not create a new `sourceNode` inside the validator — that would be a different node with its own cache.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## `validate` vs bare async function
|
|
125
|
+
|
|
126
|
+
Both forms are accepted in `validations`:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// Object form — supports `note` field
|
|
130
|
+
validations: [
|
|
131
|
+
{
|
|
132
|
+
note: "GARCH volatility must be at least 1%",
|
|
133
|
+
validate: async () => {
|
|
134
|
+
const garch = await resolve(garchSource);
|
|
135
|
+
if (!garch.reliable || garch.movePercent < 1.0) {
|
|
136
|
+
throw new Error("...");
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
// Bare async function — more concise
|
|
143
|
+
validations: [
|
|
144
|
+
async () => {
|
|
145
|
+
const garch = await resolve(garchSource);
|
|
146
|
+
if (!garch.reliable || garch.movePercent < 1.0) {
|
|
147
|
+
throw new Error("...");
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Throw `Error` to reject the signal. Return normally (or return `void`) to allow it.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Strategy Structure Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
A backtest-kit strategy file registers four schemas and wires them together. The CLI reads all `.ts` / `.mjs` files in the target directory and calls the registered schemas at runtime.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
addExchangeSchema — data source (candles, trades, order book)
|
|
9
|
+
addFrameSchema — backtest time window
|
|
10
|
+
addStrategySchema — signal generation logic
|
|
11
|
+
addRiskSchema — optional position filters
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Minimal Strategy File
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { addExchangeSchema, addFrameSchema, addStrategySchema, getCandles, Log } from "backtest-kit";
|
|
20
|
+
import { randomString } from "functools-kit";
|
|
21
|
+
|
|
22
|
+
// 1. Data source
|
|
23
|
+
addExchangeSchema({
|
|
24
|
+
exchangeName: "ccxt-exchange",
|
|
25
|
+
getCandles: async (symbol, interval, since, limit) => { ... },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 2. Backtest window
|
|
29
|
+
addFrameSchema({
|
|
30
|
+
frameName: "feb_2024_frame",
|
|
31
|
+
interval: "1m", // tick granularity
|
|
32
|
+
startDate: new Date("2024-02-01T00:00:00Z"),
|
|
33
|
+
endDate: new Date("2024-02-29T23:59:59Z"),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// 3. Signal logic
|
|
37
|
+
addStrategySchema({
|
|
38
|
+
strategyName: "my_strategy",
|
|
39
|
+
interval: "15m", // getSignal call frequency
|
|
40
|
+
getSignal: async (symbol, when) => {
|
|
41
|
+
// ... compute signal
|
|
42
|
+
return {
|
|
43
|
+
id: randomString(),
|
|
44
|
+
position: "long",
|
|
45
|
+
priceTakeProfit: 50000,
|
|
46
|
+
priceStopLoss: 48000,
|
|
47
|
+
minuteEstimatedTime: 480,
|
|
48
|
+
};
|
|
49
|
+
// return null → no signal this tick
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## ISignalDto — Signal Fields
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
interface ISignalDto {
|
|
60
|
+
id?: string; // auto-generated UUID if omitted
|
|
61
|
+
position: "long" | "short";
|
|
62
|
+
priceTakeProfit: number; // for long: > priceOpen; for short: < priceOpen
|
|
63
|
+
priceStopLoss: number; // for long: < priceOpen; for short: > priceOpen
|
|
64
|
+
minuteEstimatedTime: number; // expected hold duration in minutes
|
|
65
|
+
priceOpen?: number; // omit → opens immediately at current price
|
|
66
|
+
// provide → creates a scheduled signal
|
|
67
|
+
note?: string; // human-readable description
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Immediate vs scheduled signal:**
|
|
72
|
+
- `priceOpen` omitted → position opens at current VWAP immediately
|
|
73
|
+
- `priceOpen` set → signal waits until price reaches that level, then opens
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Signal Lifecycle
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
idle
|
|
81
|
+
│ getSignal() returns ISignalDto (no priceOpen)
|
|
82
|
+
▼
|
|
83
|
+
opened → active → closed (TP or SL hit, or time expired)
|
|
84
|
+
↓
|
|
85
|
+
pnlPercentage in result
|
|
86
|
+
|
|
87
|
+
│ getSignal() returns ISignalDto (with priceOpen)
|
|
88
|
+
▼
|
|
89
|
+
scheduled → waiting → opened → active → closed
|
|
90
|
+
↓
|
|
91
|
+
cancelled (if price never reached)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Lifecycle callbacks (all optional):
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
addStrategySchema({
|
|
98
|
+
strategyName: "...",
|
|
99
|
+
interval: "15m",
|
|
100
|
+
getSignal: ...,
|
|
101
|
+
callbacks: {
|
|
102
|
+
onOpen: (symbol, signal, price, backtest) => { ... },
|
|
103
|
+
onClose: (symbol, signal, closePrice, backtest) => { ... },
|
|
104
|
+
onActive: (symbol, signal, price, backtest) => { ... },
|
|
105
|
+
onIdle: (symbol, price, backtest) => { ... },
|
|
106
|
+
onCancel: (symbol, signal, price, backtest) => { ... },
|
|
107
|
+
onSchedule:(symbol, signal, price, backtest) => { ... },
|
|
108
|
+
onPartialProfit: (symbol, signal, price, revenuePercent, backtest) => { ... },
|
|
109
|
+
onPartialLoss: (symbol, signal, price, lossPercent, backtest) => { ... },
|
|
110
|
+
onBreakeven: (symbol, signal, price, backtest) => { ... },
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Interval Types
|
|
118
|
+
|
|
119
|
+
| Schema | Parameter | Valid values |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `addFrameSchema` | `interval` | `"1m"` `"3m"` `"5m"` `"15m"` `"30m"` `"1h"` `"2h"` `"4h"` `"6h"` `"8h"` `"12h"` `"1d"` `"3d"` |
|
|
122
|
+
| `addStrategySchema` | `interval` | `"1m"` `"3m"` `"5m"` `"15m"` `"30m"` `"1h"` |
|
|
123
|
+
| `getCandles` / `addExchangeSchema` | `interval` | `"1m"` `"3m"` `"5m"` `"15m"` `"30m"` `"1h"` `"2h"` `"4h"` `"6h"` `"8h"` |
|
|
124
|
+
|
|
125
|
+
**Rule:** `frameSchema.interval` controls tick granularity; `strategySchema.interval` throttles how often `getSignal` is called. Strategy interval must be ≥ frame interval.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Exchange Schema — Binance via CCXT
|
|
130
|
+
|
|
131
|
+
Full boilerplate with candles + aggregated trades:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { singleshot } from "functools-kit";
|
|
135
|
+
import ccxt from "ccxt";
|
|
136
|
+
|
|
137
|
+
const getExchange = singleshot(async () => {
|
|
138
|
+
const exchange = new ccxt.binance({
|
|
139
|
+
options: {
|
|
140
|
+
defaultType: "spot",
|
|
141
|
+
adjustForTimeDifference: true,
|
|
142
|
+
recvWindow: 60000,
|
|
143
|
+
},
|
|
144
|
+
enableRateLimit: true,
|
|
145
|
+
});
|
|
146
|
+
await exchange.loadMarkets();
|
|
147
|
+
return exchange;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
addExchangeSchema({
|
|
151
|
+
exchangeName: "ccxt-exchange",
|
|
152
|
+
getCandles: async (symbol, interval, since, limit) => {
|
|
153
|
+
const exchange = await getExchange();
|
|
154
|
+
const candles = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
|
|
155
|
+
return candles.map(([timestamp, open, high, low, close, volume]) => ({
|
|
156
|
+
timestamp, open, high, low, close, volume,
|
|
157
|
+
}));
|
|
158
|
+
},
|
|
159
|
+
getAggregatedTrades: async (symbol, from, to) => {
|
|
160
|
+
const exchange = await getExchange();
|
|
161
|
+
const response = await exchange.publicGetAggTrades({
|
|
162
|
+
symbol,
|
|
163
|
+
startTime: from.getTime(),
|
|
164
|
+
endTime: to.getTime(),
|
|
165
|
+
});
|
|
166
|
+
return response.map((t: any) => ({
|
|
167
|
+
id: String(t.a),
|
|
168
|
+
price: parseFloat(t.p),
|
|
169
|
+
qty: parseFloat(t.q),
|
|
170
|
+
timestamp: t.T,
|
|
171
|
+
isBuyerMaker: t.m,
|
|
172
|
+
}));
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Notes:
|
|
178
|
+
- `singleshot` from `functools-kit` memoizes the exchange init — called once, reused everywhere
|
|
179
|
+
- `getAggregatedTrades` is optional; only needed if strategy uses `volume-anomaly`
|
|
180
|
+
- In backtest mode the `backtest: boolean` flag is passed as 4th arg — use it to switch between live API and cached data
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Risk Schema
|
|
185
|
+
|
|
186
|
+
Filters that run before a signal is accepted. If any validation returns a rejection string, the signal is blocked.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { addRiskSchema } from "backtest-kit";
|
|
190
|
+
|
|
191
|
+
addRiskSchema({
|
|
192
|
+
riskName: "max_positions",
|
|
193
|
+
validations: [
|
|
194
|
+
{
|
|
195
|
+
note: "Allow max 1 active position",
|
|
196
|
+
validate: ({ activePositionCount }) => {
|
|
197
|
+
if (activePositionCount >= 1) return "Too many open positions";
|
|
198
|
+
return null; // null = allowed
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Attach to strategy:
|
|
205
|
+
addStrategySchema({
|
|
206
|
+
strategyName: "my_strategy",
|
|
207
|
+
interval: "15m",
|
|
208
|
+
riskName: "max_positions", // single risk profile
|
|
209
|
+
getSignal: ...,
|
|
210
|
+
});
|
|
211
|
+
// or multiple:
|
|
212
|
+
// riskList: ["max_positions", "funding_filter"]
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`IRiskValidationPayload` fields available in `validate`:
|
|
216
|
+
|
|
217
|
+
| Field | Type | Description |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| `currentSignal` | `IRiskSignalRow` | Signal about to open |
|
|
220
|
+
| `activePositionCount` | `number` | Currently open positions |
|
|
221
|
+
| `activePositions` | `IRiskActivePosition[]` | Details of each open position |
|
|
222
|
+
| `currentPrice` | `number` | Current market price |
|
|
223
|
+
| `symbol` | `string` | Trading pair |
|
|
224
|
+
| `backtest` | `boolean` | Whether running in backtest mode |
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## PNL Calculation
|
|
229
|
+
|
|
230
|
+
Transaction costs are automatically applied:
|
|
231
|
+
- Slippage: **0.1%** per side
|
|
232
|
+
- Fee: **0.1%** per side
|
|
233
|
+
- Round-trip cost: **0.4%**
|
|
234
|
+
|
|
235
|
+
`priceOpen` and `priceClose` in `IStrategyPnL` are **adjusted** (slippage + fee already subtracted). `pnlPercentage` is the net result.
|
|
236
|
+
|
|
237
|
+
Minimum `movePercent` to cover costs: `> 0.4%`. Recommended filter: `> 0.7%` to leave margin.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## File Naming Convention
|
|
242
|
+
|
|
243
|
+
Strategy files must be importable by the CLI. Use either:
|
|
244
|
+
- `content/my_strategy.ts` (TypeScript, requires compilation)
|
|
245
|
+
- `strategies/my_strategy/index.mjs` (compiled ESM)
|
|
246
|
+
|
|
247
|
+
The CLI entry point from `package.json`:
|
|
248
|
+
```
|
|
249
|
+
npx @backtest-kit/cli ./strategies/feb_2024/index.mjs --backtest --ui --noCache
|
|
250
|
+
```
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Pine Script Debugging Guide
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
This project runs Pine Script indicators via `@backtest-kit/pinets` against real exchange data fetched through `ccxt`. Use `@backtest-kit/cli --pine` to execute any `.pine` file and dump results to a JSONL file for inspection.
|
|
6
|
+
|
|
7
|
+
Key files:
|
|
8
|
+
- `math/*.pine` — Pine Script indicators
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Debug Workflow
|
|
13
|
+
|
|
14
|
+
### Step 1 — Add debug plots to Pine
|
|
15
|
+
|
|
16
|
+
Add named `plot()` calls for internal variables you want to inspect. Use `display=display.data_window` to mark bot-facing outputs (convention only — the CLI captures all named plots regardless):
|
|
17
|
+
|
|
18
|
+
```pine
|
|
19
|
+
// === OUTPUTS FOR BOT ===
|
|
20
|
+
plot(close, "Close", display=display.data_window)
|
|
21
|
+
plot(active_signal, "Signal", display=display.data_window)
|
|
22
|
+
|
|
23
|
+
// === DEBUG ===
|
|
24
|
+
plot(ema_fast, "EmaFast", display=display.data_window)
|
|
25
|
+
plot(ema_slow, "EmaSlow", display=display.data_window)
|
|
26
|
+
plot(ema_fast - ema_slow, "EmaGap", display=display.data_window)
|
|
27
|
+
plot(bars_since_signal, "BarsSinceSignal", display=display.data_window)
|
|
28
|
+
plot(last_signal, "LastSignal", display=display.data_window)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Step 2 — Run and dump to JSONL
|
|
32
|
+
|
|
33
|
+
Use `--jsonl` to write output to a file instead of stdout. JSONL is preferred over Markdown for large outputs — each row is a self-contained JSON object, so an AI agent can read only the rows it needs without loading the full table into context.
|
|
34
|
+
|
|
35
|
+
Output is written to `<pine-dir>/dump/<output>.jsonl` — the directory is created automatically. By default `<output>` equals the `.pine` file name (without extension). Override with `--output`.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx @backtest-kit/cli --pine ./math/my_indicator.pine \
|
|
39
|
+
--symbol BTCUSDT \
|
|
40
|
+
--timeframe 15m \
|
|
41
|
+
--limit 180 \
|
|
42
|
+
--when "2025-09-24T12:00:00.000Z" \
|
|
43
|
+
--jsonl
|
|
44
|
+
# → ./math/dump/my_indicator.jsonl
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Override the output name:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx @backtest-kit/cli --pine ./math/my_indicator.pine \
|
|
51
|
+
--jsonl \
|
|
52
|
+
--output debug
|
|
53
|
+
# → ./math/dump/debug.jsonl
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or add to `package.json`:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"scripts": {
|
|
61
|
+
"pine:debug": "npx @backtest-kit/cli --pine ./math/my_indicator.pine --symbol BTCUSDT --timeframe 15m --limit 180 --jsonl"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm run pine:debug
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Step 3 — Read the JSONL file
|
|
71
|
+
|
|
72
|
+
Each line is one bar:
|
|
73
|
+
|
|
74
|
+
```jsonl
|
|
75
|
+
{"Close":112871.28,"EmaFast":112500.10,"EmaGap":123.45,"BarsSinceSignal":0,"LastSignal":1,"Signal":1,"timestamp":"2025-09-22T15:00:00.000Z"}
|
|
76
|
+
{"Close":112666.69,"EmaFast":112480.55,"EmaGap":98.12,"BarsSinceSignal":1,"LastSignal":1,"Signal":1,"timestamp":"2025-09-22T15:15:00.000Z"}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Scan rows where `BarsSinceSignal == 0` to find signal transitions — that's where the crossover fired.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## CLI Flags
|
|
84
|
+
|
|
85
|
+
| Flag | Type | Description |
|
|
86
|
+
|------|------|-------------|
|
|
87
|
+
| `--pine` | boolean | Enable PineScript execution mode |
|
|
88
|
+
| `--symbol` | string | Trading pair (default: `"BTCUSDT"`) |
|
|
89
|
+
| `--timeframe` | string | Candle interval (default: `"15m"`) |
|
|
90
|
+
| `--limit` | string | Number of candles to fetch (default: `250`) |
|
|
91
|
+
| `--when` | string | End date for candle window — ISO 8601 or Unix ms (default: now) |
|
|
92
|
+
| `--exchange` | string | Exchange name (default: first registered, falls back to CCXT Binance) |
|
|
93
|
+
| `--output` | string | Output file base name without extension (default: `.pine` file name) |
|
|
94
|
+
| `--jsonl` | boolean | Write plots as JSONL (one row per line) to `<pine-dir>/dump/{output}.jsonl` — **preferred for debug** |
|
|
95
|
+
| `--json` | boolean | Write plots as a JSON array to `<pine-dir>/dump/{output}.json` |
|
|
96
|
+
| `--markdown` | boolean | Write Markdown table to `<pine-dir>/dump/{output}.md` |
|
|
97
|
+
|
|
98
|
+
**Important:** `limit` must cover indicator warmup bars — rows before warmup completes will show `N/A`.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Exchange Configuration (pine.module)
|
|
103
|
+
|
|
104
|
+
By default the CLI registers CCXT Binance automatically — no setup needed for Binance spot.
|
|
105
|
+
|
|
106
|
+
To use a different exchange, create `modules/pine.module.ts` next to the `.pine` file (or at project root as fallback):
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
math/
|
|
110
|
+
├── my_indicator.pine
|
|
111
|
+
└── modules/
|
|
112
|
+
└── pine.module.ts ← loaded automatically before running
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// modules/pine.module.ts
|
|
117
|
+
import { addExchangeSchema } from "backtest-kit";
|
|
118
|
+
import ccxt from "ccxt";
|
|
119
|
+
|
|
120
|
+
addExchangeSchema({
|
|
121
|
+
exchangeName: "my-exchange",
|
|
122
|
+
getCandles: async (symbol, interval, since, limit) => {
|
|
123
|
+
const exchange = new ccxt.bybit({ enableRateLimit: true });
|
|
124
|
+
const ohlcv = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
|
|
125
|
+
return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({
|
|
126
|
+
timestamp, open, high, low, close, volume,
|
|
127
|
+
}));
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Then pass `--exchange my-exchange` to the CLI.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Common Patterns
|
|
137
|
+
|
|
138
|
+
### N/A values
|
|
139
|
+
EMA of length N returns `N/A` for the first `N-1` bars — this is expected warmup behavior. `EmaFast` (len=8) starts at bar 8, `EmaSlow` (len=21) starts at bar 21.
|
|
140
|
+
|
|
141
|
+
### Diagnosing whipsaw
|
|
142
|
+
Look at `EmaGap` at the moment `LastSignal` changes (i.e. `BarsSinceSignal == 0`). If `|EmaGap|` is small (e.g. < 15), the crossover happened in a flat/noisy zone — likely a false signal.
|
|
143
|
+
|
|
144
|
+
### Diagnosing stale signals
|
|
145
|
+
`active_signal` goes to 0 when `bars_since_signal > signal_valid_bars`. If `Signal` is 0 but `LastSignal` is non-zero, the signal expired. Increase `signal_valid_bars` or check why the crossover didn't sustain.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Adding Filters
|
|
150
|
+
|
|
151
|
+
Filters go into the entry condition expressions:
|
|
152
|
+
|
|
153
|
+
```pine
|
|
154
|
+
min_gap = input.float(15.0, "Min EMA Gap Filter", minval=0.0)
|
|
155
|
+
|
|
156
|
+
long_cond = ta.crossover(ema_fast, ema_slow) and math.abs(ema_gap) >= min_gap
|
|
157
|
+
short_cond = ta.crossunder(ema_fast, ema_slow) and math.abs(ema_gap) >= min_gap
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Add the filter threshold as a debug plot, then check in JSONL: when `BarsSinceSignal` resets to 0, does `EmaGap` meet the threshold?
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Pine Variables Worth Plotting for Debug
|
|
165
|
+
|
|
166
|
+
| Variable | Purpose |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `ema_fast - ema_slow` | Gap magnitude — key for noise filtering |
|
|
169
|
+
| `bars_since_signal` | How many bars since last crossover |
|
|
170
|
+
| `last_signal` | Raw last direction (1/-1/0), ignores expiry |
|
|
171
|
+
| `active_signal` | Final output after expiry window |
|
|
172
|
+
| `ta.rsi(close, 14)` | Momentum context |
|
|
173
|
+
| `ta.atr(14)` | Volatility context for dynamic thresholds |
|
|
174
|
+
| `volume` | Volume spike confirmation |
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Pine Script Warmup & Limit Calculation
|
|
2
|
+
|
|
3
|
+
## The Problem
|
|
4
|
+
|
|
5
|
+
Pine functions that look back over N bars (`ta.supertrend`, `ta.ema`, `ta.highest`, etc.) return `na` / `null` until they have enough bars to compute. If `limit` is too small, the entire output is N/A.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Warmup Formula
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
warmup_bars = max_lookback_period + any_secondary_lookback
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
// EMA golden cross
|
|
19
|
+
warmup = max(ema_slow_len=21) = 21 bars
|
|
20
|
+
|
|
21
|
+
// MasterTrend
|
|
22
|
+
warmup = atrPeriod(15) + confirmBars(15) = 30 bars
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Rule: `limit` must be at least `warmup + desired_output_bars`.
|
|
26
|
+
|
|
27
|
+
For a meaningful output of ~150 bars:
|
|
28
|
+
```
|
|
29
|
+
limit = warmup + 150
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Timeframe → Limit Reference
|
|
35
|
+
|
|
36
|
+
Warmup for `master_trend_15m.pine` (atrPeriod=15, confirmBars=15 → warmup=30):
|
|
37
|
+
|
|
38
|
+
| Timeframe | Min limit (warmup only) | Limit for ~150 output bars | Real time covered |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| 5m | 30 | 180 | ~15h |
|
|
41
|
+
| **15m** | 30 | **180** | **~45h** |
|
|
42
|
+
| 1h | 30 | 180 | ~7.5 days |
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Diagnosing N/A Output
|
|
47
|
+
|
|
48
|
+
Symptom: all columns except `Close` show `N/A` from bar 0.
|
|
49
|
+
|
|
50
|
+
Check: count warmup bars in output — the index of the first non-null value.
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
const posData = plots["Position"].data;
|
|
54
|
+
const firstValid = posData.findIndex((d) => d.value !== null && !isNaN(d.value));
|
|
55
|
+
console.log("Warmup bars:", firstValid);
|
|
56
|
+
// if firstValid === posData.length → limit too small, zero valid output
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
If `firstValid === posData.length` → increase `limit` by at least `warmup - limit + desired_output`.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Stacked Lookbacks
|
|
64
|
+
|
|
65
|
+
When multiple lookback functions are chained, warmup is additive:
|
|
66
|
+
|
|
67
|
+
```pine
|
|
68
|
+
[supertrend, direction] = ta.supertrend(factor, atrPeriod) // needs atrPeriod bars
|
|
69
|
+
// confirmBars counter needs confirmBars more bars after supertrend is valid
|
|
70
|
+
// total warmup = atrPeriod + confirmBars = 15 + 15 = 30
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`ta.supertrend` itself uses ATR internally, so warmup = atrPeriod. The `confirmBars` confirmation window adds another `confirmBars` bars before `trend` stabilizes.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Pine inputs cannot be set via run()
|
|
78
|
+
|
|
79
|
+
The `inputs` parameter in `run()` is silently ignored — Pine `input.int()` / `input.float()` defaults are always used.
|
|
80
|
+
|
|
81
|
+
To test different period values, change the defaults in the `.pine` file directly:
|
|
82
|
+
|
|
83
|
+
```pine
|
|
84
|
+
// change this line in the .pine file
|
|
85
|
+
atrPeriod = input(15, "ATR Period") // → input(20, "ATR Period")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This means warmup changes when tuning params — recalculate `limit` after any period change.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules
|
|
3
|
+
|
|
4
|
+
# Environment variables
|
|
5
|
+
.env
|
|
6
|
+
.env.local
|
|
7
|
+
.env.*.local
|
|
8
|
+
|
|
9
|
+
# Logs
|
|
10
|
+
logs
|
|
11
|
+
*.log
|
|
12
|
+
npm-debug.log*
|
|
13
|
+
error.txt
|
|
14
|
+
|
|
15
|
+
# Runtime data
|
|
16
|
+
tmp
|
|
17
|
+
generated
|
|
18
|
+
dump
|
|
19
|
+
wwwroot
|
|
20
|
+
|
|
21
|
+
# OS files
|
|
22
|
+
.DS_Store
|
|
23
|
+
Thumbs.db
|
|
24
|
+
|
|
25
|
+
# IDE
|
|
26
|
+
.vscode
|
|
27
|
+
.idea
|
|
28
|
+
*.swp
|
|
29
|
+
*.swo
|
|
30
|
+
*~
|