@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.
- package/README.md +90 -0
- package/build/index.cjs +127 -25
- package/build/index.mjs +129 -27
- package/package.json +13 -13
- 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,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
|
+
*~
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
//@version=5
|
|
2
|
+
// ============================================================
|
|
3
|
+
// feb_2026.pine — LiquidationSpike Bounce LONG | BTCUSDT 15m
|
|
4
|
+
// Test period: 2026-02-01 — 2026-02-28
|
|
5
|
+
// ============================================================
|
|
6
|
+
// Market context February 2026:
|
|
7
|
+
// BTC: 78K → 60K (crash Feb 5, -17% in 1 day) → bounce 70K → down again to 62K (Feb 23)
|
|
8
|
+
// Structure: hard bear trend with liquidation cascades and V-bounces.
|
|
9
|
+
// Drivers: Trump tariffs, ETF outflows -$3.8B, $2.56B in liquidations in 1 day.
|
|
10
|
+
//
|
|
11
|
+
// Trade idea (Liquidation Spike Bounce):
|
|
12
|
+
// After a liquidation cascade (abnormal volume + large single-bar drop),
|
|
13
|
+
// the market produces a V-bounce of 1.5-4% within 1-8 bars. Enter on the first reversal candle.
|
|
14
|
+
// Cooldown: ignore new signals for 20 bars after the previous entry.
|
|
15
|
+
//
|
|
16
|
+
// Entry conditions:
|
|
17
|
+
// 1. Bear context: close < EMA50
|
|
18
|
+
// 2. Spike[1]: (open[1]-close[1]) > 2.0*ATR14 AND drop > 0.6% of price (liquidation candle)
|
|
19
|
+
// 3. Volume[1] > 1.8 * SMA(volume,20)
|
|
20
|
+
// 4. Bounce bar: close > open AND close > low[1]
|
|
21
|
+
// 5. Cooldown: barsSinceRaw >= 20 (no more than one entry per 5h)
|
|
22
|
+
//
|
|
23
|
+
// Exits (strictly fixed, no trailing):
|
|
24
|
+
// TP: +1.5% from entryPrice
|
|
25
|
+
// SL: -0.8% from entryPrice
|
|
26
|
+
// RSI exit: rsi > 60 (momentum exhausted)
|
|
27
|
+
// Time exit: 20 bars (5 hours)
|
|
28
|
+
//
|
|
29
|
+
// Risk/Reward: 1.875 | Commission: 0.4% round trip
|
|
30
|
+
//
|
|
31
|
+
// BACKTEST RESULTS 2026-02-01 — 2026-02-28:
|
|
32
|
+
// Trades: 3 (~1 trade every 9 days)
|
|
33
|
+
// WinRate: 100% (3W / 0L)
|
|
34
|
+
// Gross PnL: +5.34%
|
|
35
|
+
// Net PnL (−0.4% commission × 3): +4.14%
|
|
36
|
+
// AvgPnL: +1.78% per trade
|
|
37
|
+
// sharpeRatio: N/A (0 losses, StdDev not applicable)
|
|
38
|
+
// Trades:
|
|
39
|
+
// 02-06 00:15 LONG ep=61373 exit=62966 +2.59% (V-bounce after $60K flash crash)
|
|
40
|
+
// 02-10 14:45 LONG ep=68460 exit=69287 +1.21% (bounce after liquidation cascade)
|
|
41
|
+
// 02-23 02:00 LONG ep=64763 exit=65762 +1.54% (bounce after tariff shock crash)
|
|
42
|
+
// Note: strategy stays flat during quiet periods (Feb 11-22, Feb 24-28) —
|
|
43
|
+
// this is intentional; no edge in ranging markets.
|
|
44
|
+
// ============================================================
|
|
45
|
+
|
|
46
|
+
indicator("LiqSpikeBounceLong Feb2026", overlay=true)
|
|
47
|
+
|
|
48
|
+
// --- Inputs ---
|
|
49
|
+
emaLen = input.int(50, "EMA Bear Filter")
|
|
50
|
+
atrLen = input.int(14, "ATR Period")
|
|
51
|
+
spikeMul = input.float(2.0,"Spike ATR Multiplier")
|
|
52
|
+
volLen = input.int(20, "Volume Avg Period")
|
|
53
|
+
volMul = input.float(1.8,"Volume Spike Multiplier")
|
|
54
|
+
rsiLen = input.int(14, "RSI Period")
|
|
55
|
+
rsiExit = input.float(60, "RSI Exit Level")
|
|
56
|
+
maxBars = input.int(20, "Max Hold Bars (5h)")
|
|
57
|
+
cooldown = input.int(20, "Cooldown Bars Between Entries")
|
|
58
|
+
tpPct = input.float(1.5,"TP %") / 100
|
|
59
|
+
slPct = input.float(0.8,"SL %") / 100
|
|
60
|
+
|
|
61
|
+
// --- Indicators ---
|
|
62
|
+
ema50 = ta.ema(close, emaLen)
|
|
63
|
+
atr14 = ta.atr(atrLen)
|
|
64
|
+
avgVol = ta.sma(volume, volLen)
|
|
65
|
+
rsi = ta.rsi(close, rsiLen)
|
|
66
|
+
|
|
67
|
+
// --- Signal conditions ---
|
|
68
|
+
bearFilter = close < ema50
|
|
69
|
+
|
|
70
|
+
// Spike bar: must exceed ATR multiplier AND be at least 0.6% of price in absolute terms.
|
|
71
|
+
// The 0.6% floor filters out false spikes during ultra-low-volatility periods
|
|
72
|
+
// where even a $200 drop can exceed 2×ATR (ATR collapses in quiet markets).
|
|
73
|
+
spikeDrop = open[1] - close[1]
|
|
74
|
+
spikeBar = spikeDrop > spikeMul * atr14 and close[1] < open[1]
|
|
75
|
+
and spikeDrop > close[1] * 0.006
|
|
76
|
+
|
|
77
|
+
volSpike = volume[1] > volMul * avgVol
|
|
78
|
+
bounceBar = close > open and close > low[1]
|
|
79
|
+
|
|
80
|
+
rawSignal = bearFilter and spikeBar and volSpike and bounceBar
|
|
81
|
+
|
|
82
|
+
// Cooldown через rawSignal[1]: сколько баров прошло с предыдущего rawSignal
|
|
83
|
+
barsSinceRaw = ta.barssince(rawSignal[1])
|
|
84
|
+
|
|
85
|
+
// Входим только если последний сигнал был давно (cooldown)
|
|
86
|
+
longEntry = rawSignal and (na(barsSinceRaw) or barsSinceRaw >= cooldown)
|
|
87
|
+
|
|
88
|
+
// --- Position tracking ---
|
|
89
|
+
barsSinceEntry = ta.barssince(longEntry)
|
|
90
|
+
|
|
91
|
+
entryPrice = longEntry ? close : close[barsSinceEntry]
|
|
92
|
+
entryTP = entryPrice * (1 + tpPct)
|
|
93
|
+
entrySL = entryPrice * (1 - slPct)
|
|
94
|
+
|
|
95
|
+
// --- Exit Conditions ---
|
|
96
|
+
// Use high/low (wick prices) to match real exchange fill behaviour.
|
|
97
|
+
// barsSinceEntry > 0 guard: skip the entry bar itself — entry is at close,
|
|
98
|
+
// so the entry bar's own wick must not trigger an immediate exit.
|
|
99
|
+
hitTP = barsSinceEntry > 0 and ta.highest(high, barsSinceEntry) >= entryTP
|
|
100
|
+
hitSL = barsSinceEntry > 0 and ta.lowest(low, barsSinceEntry) <= entrySL
|
|
101
|
+
rsiDone = rsi > rsiExit
|
|
102
|
+
timeLimit = barsSinceEntry >= maxBars
|
|
103
|
+
|
|
104
|
+
inPosition = not na(barsSinceEntry) and not hitTP and not hitSL and not rsiDone and not timeLimit
|
|
105
|
+
|
|
106
|
+
position = inPosition ? 1 : 0
|
|
107
|
+
|
|
108
|
+
// === OUTPUTS FOR BOT ===
|
|
109
|
+
plot(position, "Position", display=display.data_window)
|
|
110
|
+
plot(position == 1 ? entryTP : na, "TP", display=display.data_window)
|
|
111
|
+
plot(position == 1 ? entrySL : na, "SL", display=display.data_window)
|
|
112
|
+
plot(position == 1 ? entryPrice : na, "EntryPrice", display=display.data_window)
|
|
113
|
+
|
|
114
|
+
// === VISUAL ===
|
|
115
|
+
lineColor = position == 0 ? color.gray : color.green
|
|
116
|
+
plot(close, "Price", color=lineColor, linewidth=2)
|
|
117
|
+
plot(ema50, "EMA50", color=color.new(color.orange, 50), linewidth=1)
|
|
118
|
+
plot(position == 1 ? entryTP : na, "TP Line", color=color.new(color.green, 30), linewidth=1)
|
|
119
|
+
plot(position == 1 ? entrySL : na, "SL Line", color=color.new(color.red, 30), linewidth=1)
|
|
120
|
+
plotshape(longEntry, "Entry", shape.triangleup, location.belowbar, color.green, size=size.small)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { addExchangeSchema } from "backtest-kit";
|
|
2
|
+
import { singleshot } from "functools-kit";
|
|
3
|
+
import ccxt from "ccxt";
|
|
4
|
+
|
|
5
|
+
const getExchange = singleshot(async () => {
|
|
6
|
+
const exchange = new ccxt.binance({
|
|
7
|
+
options: {
|
|
8
|
+
defaultType: "spot",
|
|
9
|
+
adjustForTimeDifference: true,
|
|
10
|
+
recvWindow: 60000,
|
|
11
|
+
},
|
|
12
|
+
enableRateLimit: true,
|
|
13
|
+
});
|
|
14
|
+
await exchange.loadMarkets();
|
|
15
|
+
return exchange;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
addExchangeSchema({
|
|
19
|
+
exchangeName: "ccxt-exchange",
|
|
20
|
+
getCandles: async (symbol, interval, since, limit) => {
|
|
21
|
+
const exchange = await getExchange();
|
|
22
|
+
const candles = await exchange.fetchOHLCV(
|
|
23
|
+
symbol,
|
|
24
|
+
interval,
|
|
25
|
+
since.getTime(),
|
|
26
|
+
limit,
|
|
27
|
+
);
|
|
28
|
+
return candles.map(([timestamp, open, high, low, close, volume]) => ({
|
|
29
|
+
timestamp,
|
|
30
|
+
open,
|
|
31
|
+
high,
|
|
32
|
+
low,
|
|
33
|
+
close,
|
|
34
|
+
volume,
|
|
35
|
+
}));
|
|
36
|
+
},
|
|
37
|
+
});
|