@backtest-kit/cli 5.10.2 → 5.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ });