@backtest-kit/cli 5.11.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,235 @@
1
+ # Graph Pattern (sourceNode / outputNode / resolve)
2
+
3
+ ## Purpose
4
+
5
+ `@backtest-kit/graph` provides a typed directed acyclic graph (DAG) for composing strategy logic. Instead of a single monolithic `getSignal` function, you define reusable **source nodes** (data fetchers) and **output nodes** (computations that combine them).
6
+
7
+ Benefits:
8
+ - Each node caches independently via `Cache.fn`
9
+ - Type-safe: TypeScript infers value types through the graph
10
+ - Parallel resolution: sibling nodes resolve concurrently
11
+
12
+ ---
13
+
14
+ ## Core API
15
+
16
+ ```ts
17
+ import { sourceNode, outputNode, resolve } from "@backtest-kit/graph";
18
+ import { Cache } from "backtest-kit";
19
+ ```
20
+
21
+ ### `sourceNode(fetch)`
22
+
23
+ A leaf node with no dependencies. Fetches data from an external source.
24
+
25
+ ```ts
26
+ const mySource = sourceNode(
27
+ Cache.fn(
28
+ async (symbol: string) => {
29
+ const candles = await getCandles(symbol, "15m", 500);
30
+ return garch.predictRange(candles, "15m", 32);
31
+ },
32
+ { interval: "15m", key: ([symbol]) => symbol },
33
+ ),
34
+ );
35
+ ```
36
+
37
+ - `fetch` signature: `(symbol: string, when: Date, exchangeName: string) => Promise<T>`
38
+ - `Cache.fn` wraps the fetch function with interval-based caching (see [Cache section](#cachefn))
39
+ - `T` can be any non-undefined value: `number`, `string`, `boolean`, `object`, `null`
40
+
41
+ ### `outputNode(compute, ...nodes)`
42
+
43
+ A computation node that receives resolved values from its dependencies.
44
+
45
+ ```ts
46
+ const strategySignal = outputNode(
47
+ async ([trend, volume, reversal]) => {
48
+ // trend, volume, reversal are the resolved values of the three source nodes
49
+ if (!volume.reliable) return null;
50
+ return { position: "long", priceTakeProfit: volume.upperPrice, ... };
51
+ },
52
+ masterTrendSource, // dependency 1 → trend
53
+ rangeSource, // dependency 2 → volume
54
+ reversalSource, // dependency 3 → reversal
55
+ );
56
+ ```
57
+
58
+ - `compute` receives a tuple of resolved values **in the same order as the nodes**
59
+ - Return type is inferred automatically
60
+ - Can return `null` (no signal)
61
+ - Can be nested: an output node can be a dependency of another output node
62
+
63
+ ### `resolve(node)`
64
+
65
+ Executes the graph: resolves all dependencies recursively, then calls `compute`.
66
+
67
+ ```ts
68
+ addStrategySchema({
69
+ strategyName: "my_strategy",
70
+ interval: "15m",
71
+ getSignal: () => resolve(strategySignal),
72
+ });
73
+ ```
74
+
75
+ - Dependencies are resolved **in parallel** (Promise.all internally)
76
+ - The `symbol` and `when` context is injected automatically from the strategy runtime
77
+ - Returns the output node's computed value
78
+
79
+ ---
80
+
81
+ ## Cache.fn
82
+
83
+ Wraps any async function to cache results per candle interval and per cache key.
84
+
85
+ ```ts
86
+ const cachedFn = Cache.fn(
87
+ async (symbol: string) => {
88
+ // expensive operation
89
+ return fetchSomething(symbol);
90
+ },
91
+ {
92
+ interval: "15m", // invalidate every 15m candle boundary
93
+ key: ([symbol]) => symbol, // separate cache entry per symbol
94
+ },
95
+ );
96
+ ```
97
+
98
+ **How invalidation works:** The cache aligns to candle boundaries. At `interval="15m"`, the cache is valid within the same 15-minute bar and invalidated when a new bar opens. This means multiple calls within one bar return the same result without re-fetching.
99
+
100
+ **Without `key`:** single cache entry — all calls share one result regardless of arguments.
101
+ **With `key`:** separate cache entry per key — `"BTCUSDT"` and `"ETHUSDT"` computed independently.
102
+
103
+ Cache.fn is designed to be passed directly as the `fetch` argument to `sourceNode`.
104
+
105
+ ---
106
+
107
+ ## Full Pattern Example
108
+
109
+ ```ts
110
+ import { extract, run, File } from "@backtest-kit/pinets";
111
+ import { getCandles, getAggregatedTrades, Cache } from "backtest-kit";
112
+ import { sourceNode, outputNode, resolve } from "@backtest-kit/graph";
113
+ import * as garch from "garch";
114
+ import * as anomaly from "volume-anomaly";
115
+
116
+ // === Source nodes ===
117
+
118
+ const masterTrendSource = sourceNode(
119
+ Cache.fn(
120
+ async (symbol) => {
121
+ const plots = await run(
122
+ File.fromPath("master_trend_15m.pine", "../math"),
123
+ { symbol, timeframe: "15m", limit: 180 },
124
+ );
125
+ return extract(plots, {
126
+ position: "Position",
127
+ close: "Close",
128
+ });
129
+ },
130
+ { interval: "15m", key: ([symbol]) => symbol },
131
+ ),
132
+ );
133
+
134
+ const rangeSource = sourceNode(
135
+ Cache.fn(
136
+ async (symbol) => {
137
+ const candles = await getCandles(symbol, "15m", 1_000);
138
+ return garch.predictRange(candles, "15m", 32);
139
+ },
140
+ { interval: "15m", key: ([symbol]) => symbol },
141
+ ),
142
+ );
143
+
144
+ const reversalSource = sourceNode(
145
+ Cache.fn(
146
+ async (symbol) => {
147
+ const all = await getAggregatedTrades(symbol, 1400);
148
+ return anomaly.predict(all.slice(0, 1200), all.slice(1200), 0.75);
149
+ },
150
+ { interval: "15m", key: ([symbol]) => symbol },
151
+ ),
152
+ );
153
+
154
+ // === Output node (signal logic) ===
155
+
156
+ const strategySignal = outputNode(
157
+ async ([trend, volume, reversal]) => {
158
+ if (!volume.reliable || volume.movePercent < 0.7) return null;
159
+ if (trend.position === 0) return null;
160
+ if (!reversal.anomaly) return null;
161
+
162
+ let position: "long" | "short" | null = null;
163
+ if (trend.position === 1 && reversal.direction === "long") position = "long";
164
+ if (trend.position === -1 && reversal.direction === "short") position = "short";
165
+ if (!position) return null;
166
+
167
+ return {
168
+ id: randomString(),
169
+ position,
170
+ priceTakeProfit: position === "long" ? volume.upperPrice : volume.lowerPrice,
171
+ priceStopLoss: position === "long" ? volume.lowerPrice : volume.upperPrice,
172
+ minuteEstimatedTime: 480,
173
+ } as const;
174
+ },
175
+ masterTrendSource,
176
+ rangeSource,
177
+ reversalSource,
178
+ );
179
+
180
+ // === Wire into strategy ===
181
+
182
+ addStrategySchema({
183
+ strategyName: "bounce_strategy",
184
+ interval: "15m",
185
+ getSignal: () => resolve(strategySignal),
186
+ });
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Cache Interval Selection
192
+
193
+ Choose `Cache.fn` interval to match the data's natural update frequency:
194
+
195
+ | Data source | Recommended interval |
196
+ |---|---|
197
+ | Pine indicator (15m timeframe) | `"15m"` |
198
+ | `garch.predictRange` on 15m candles | `"15m"` |
199
+ | `garch.predict` on 5m candles | `"5m"` |
200
+ | `anomaly.predict` (trade stream) | `"5m"` — refreshed every 5m bar |
201
+ | Funding rate | `"1h"` |
202
+ | Order book snapshot | `"1m"` |
203
+
204
+ Setting interval too short wastes compute; too long means stale data spanning multiple bars. Match to the smallest timeframe of the data being fetched.
205
+
206
+ ---
207
+
208
+ ## Type Safety
209
+
210
+ The graph is fully typed. TypeScript infers the compute callback parameter types from the node declarations:
211
+
212
+ ```ts
213
+ // If rangeSource returns garch.PredictionResult:
214
+ const rangeSource = sourceNode(
215
+ Cache.fn(async (symbol) => garch.predictRange(candles, "15m", 32), { interval: "15m", key: ([s]) => s }),
216
+ );
217
+ // TS knows rangeSource is SourceNode<PredictionResult>
218
+
219
+ const out = outputNode(
220
+ async ([volume]) => {
221
+ volume.movePercent // ✓ typed as number
222
+ volume.upperPrice // ✓ typed as number
223
+ volume.nonExistent // ✗ TS error
224
+ },
225
+ rangeSource,
226
+ );
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Notes
232
+
233
+ - `resolve()` is called **inside** `getSignal`, not at module level. This ensures the graph runs per-tick with the correct symbol/when context.
234
+ - Each source node in the graph is resolved **once per tick** even if referenced by multiple output nodes (deduplication by reference).
235
+ - The graph does **not** support cycles.
@@ -0,0 +1,246 @@
1
+ # Logging & JSONL — Coefficient Tuning Guide
2
+
3
+ ## Purpose
4
+
5
+ `Log` from `backtest-kit` writes structured entries to a `.jsonl` file during backtests. Each line is a JSON object. After running a backtest, you analyse the file to understand why signals fired or were blocked, then adjust thresholds.
6
+
7
+ ---
8
+
9
+ ## Setup
10
+
11
+ Call `Log.useJsonl` **once at module level**, before any schema registrations:
12
+
13
+ ```ts
14
+ import { Log } from "backtest-kit";
15
+
16
+ Log.useJsonl("bounce_strategy", "./dump/log");
17
+ // Writes to: ./dump/log/bounce_strategy.jsonl
18
+ ```
19
+
20
+ Parameters:
21
+ - First arg: base filename (without extension)
22
+ - Second arg: directory path (created automatically if missing)
23
+
24
+ Each `Log.log / Log.info / Log.debug / Log.warn` call appends one JSON line to the file.
25
+
26
+ **Default mode is in-memory** — call `useJsonl` to activate file output. Other modes:
27
+ - `Log.useMemory()` — default, no file I/O
28
+ - `Log.useDummy()` — no-op, zero overhead
29
+ - `Log.usePersist()` — persistent adapter (db-backed)
30
+
31
+ ---
32
+
33
+ ## Log Levels
34
+
35
+ ```ts
36
+ Log.log(topic, ...args) // general events — use for signal opens
37
+ Log.debug(topic, ...args) // diagnostic details — use for filter rejections
38
+ Log.info(topic, ...args) // informational — use for per-tick snapshots
39
+ Log.warn(topic, ...args) // warnings — unexpected but non-fatal conditions
40
+ ```
41
+
42
+ `topic` is the first argument and becomes the log's searchable category. Always use a fixed string identifier. `args` are serialized as JSON in the `args` array of the log entry.
43
+
44
+ ---
45
+
46
+ ## ILogEntry Structure
47
+
48
+ Every written line has this shape:
49
+
50
+ ```ts
51
+ interface ILogEntry {
52
+ id: string; // unique entry id
53
+ type: "log" | "debug" | "info" | "warn";
54
+ timestamp: number; // unix ms (wall clock)
55
+ createdAt: string; // ISO date from backtest context
56
+ topic: string; // first argument to Log.xxx()
57
+ args: unknown[]; // remaining arguments
58
+ methodContext: IMethodContext | null;
59
+ executionContext: IExecutionContext | null;
60
+ }
61
+ ```
62
+
63
+ `createdAt` reflects the **backtest simulation time** (the bar's timestamp), not the wall clock. This is what you use to correlate log entries with price history.
64
+
65
+ ---
66
+
67
+ ## Recommended Logging Pattern for Coefficient Tuning
68
+
69
+ Three topic categories cover everything needed:
70
+
71
+ ### 1. `bar_snapshot` — every tick (Log.info)
72
+
73
+ Log all indicator values on **every** `getSignal` call, regardless of whether a signal fires. This gives you the full distribution of each metric.
74
+
75
+ ```ts
76
+ Log.info("bar_snapshot", {
77
+ // Pine extremes
78
+ totalHighs: extreme.totalHighs,
79
+ totalLows: extreme.totalLows,
80
+ balance: extreme.balance,
81
+ trend: extreme.trend,
82
+ // GARCH
83
+ movePercent: volume.movePercent,
84
+ sigma: volume.sigma,
85
+ modelType: volume.modelType,
86
+ reliable: volume.reliable,
87
+ // Volume anomaly
88
+ anomaly: reversal.anomaly,
89
+ confidence: reversal.confidence,
90
+ direction: reversal.direction,
91
+ imbalance: reversal.imbalance,
92
+ });
93
+ ```
94
+
95
+ After backtest: load the JSONL and look at the **percentile distribution** of each field. Example: if `movePercent` is > 0.7% only 5% of the time, your filter is very restrictive — consider lowering the threshold or you'll have very few trades.
96
+
97
+ ### 2. `filter_rejected` — per failed filter (Log.debug)
98
+
99
+ Log **which** filter blocked the signal and the exact value that failed:
100
+
101
+ ```ts
102
+ // Filter 1: volatility
103
+ if (!volume.reliable || volume.movePercent < MIN_MOVE_PERCENT) {
104
+ Log.debug("filter_rejected", {
105
+ reason: "garch_low_vol",
106
+ movePercent: volume.movePercent,
107
+ threshold: MIN_MOVE_PERCENT,
108
+ reliable: volume.reliable,
109
+ });
110
+ return null;
111
+ }
112
+
113
+ // Filter 2: extreme touches
114
+ if (extreme.totalHighs < MIN_TOTAL_TOUCHES && extreme.totalLows < MIN_TOTAL_TOUCHES) {
115
+ Log.debug("filter_rejected", {
116
+ reason: "not_enough_touches",
117
+ totalHighs: extreme.totalHighs,
118
+ totalLows: extreme.totalLows,
119
+ threshold: MIN_TOTAL_TOUCHES,
120
+ });
121
+ return null;
122
+ }
123
+
124
+ // Filter 3: anomaly
125
+ if (!reversal.anomaly) {
126
+ Log.debug("filter_rejected", {
127
+ reason: "no_anomaly",
128
+ confidence: reversal.confidence,
129
+ threshold: ANOMALY_CONFIDENCE,
130
+ });
131
+ return null;
132
+ }
133
+
134
+ // Filter 4: direction mismatch
135
+ if (!position) {
136
+ Log.debug("filter_rejected", {
137
+ reason: "direction_mismatch",
138
+ direction: reversal.direction,
139
+ isHighTest, isLowTest,
140
+ });
141
+ return null;
142
+ }
143
+ ```
144
+
145
+ After backtest: count entries per `reason`. The most frequent rejection is your bottleneck. If `garch_low_vol` dominates — lower `MIN_MOVE_PERCENT`. If `no_anomaly` dominates — lower `ANOMALY_CONFIDENCE`.
146
+
147
+ ### 3. `signal_open` — when a signal fires (Log.log)
148
+
149
+ Log the complete state at the moment a signal is created:
150
+
151
+ ```ts
152
+ Log.log("signal_open", {
153
+ position,
154
+ tp,
155
+ sl,
156
+ totalHighs: extreme.totalHighs,
157
+ totalLows: extreme.totalLows,
158
+ balance: extreme.balance,
159
+ confidence: reversal.confidence,
160
+ direction: reversal.direction,
161
+ imbalance: reversal.imbalance,
162
+ movePercent: volume.movePercent,
163
+ sigma: volume.sigma,
164
+ modelType: volume.modelType,
165
+ });
166
+ ```
167
+
168
+ After backtest: cross-reference `signal_open` entries with trade PNL to find which combination of values (e.g. `totalHighs >= 5 AND confidence > 0.85`) correlates with profitable trades.
169
+
170
+ ---
171
+
172
+ ## Analysing the JSONL File
173
+
174
+ The file at `./dump/log/bounce_strategy.jsonl` contains one JSON object per line.
175
+
176
+ **Quick shell inspection:**
177
+
178
+ ```bash
179
+ # Count entries per topic
180
+ cat ./dump/log/bounce_strategy.jsonl | grep -o '"topic":"[^"]*"' | sort | uniq -c
181
+
182
+ # Count filter rejection reasons
183
+ cat ./dump/log/bounce_strategy.jsonl | python3 -c "
184
+ import sys, json
185
+ from collections import Counter
186
+ reasons = []
187
+ for line in sys.stdin:
188
+ e = json.loads(line)
189
+ if e['topic'] == 'filter_rejected' and e['args']:
190
+ reasons.append(e['args'][0].get('reason','?'))
191
+ print(Counter(reasons))
192
+ "
193
+
194
+ # Extract all bar_snapshots as CSV for Excel
195
+ cat ./dump/log/bounce_strategy.jsonl | python3 -c "
196
+ import sys, json
197
+ rows = []
198
+ for line in sys.stdin:
199
+ e = json.loads(line)
200
+ if e['topic'] == 'bar_snapshot' and e['args']:
201
+ d = e['args'][0]
202
+ d['createdAt'] = e['createdAt']
203
+ rows.append(d)
204
+ import csv
205
+ if rows:
206
+ w = csv.DictWriter(sys.stdout, fieldnames=rows[0].keys())
207
+ w.writeheader()
208
+ w.writerows(rows)
209
+ "
210
+ ```
211
+
212
+ ---
213
+
214
+ ## CONFIG Constants Pattern
215
+
216
+ Keep all tunable thresholds as named constants at the top of the strategy file:
217
+
218
+ ```ts
219
+ // === CONFIG (tune before each backtest run) ===
220
+ const MIN_MOVE_PERCENT = 0.7; // garch filter: minimum 8h volatility
221
+ const RANGE_STEPS = 32; // predictRange horizon: 32 × 15m = 8h
222
+ const MIN_TOTAL_TOUCHES = 3; // extreme direction: min tests of high/low
223
+ const ANOMALY_CONFIDENCE = 0.75; // volume-anomaly composite threshold
224
+ const PINE_LIMIT = 300; // Pine script warmup + output bars
225
+ const CANDLES_FOR_GARCH = 1_000; // GARCH history length
226
+ const N_TRAIN = 1200; // volume-anomaly baseline window (trades)
227
+ const N_DETECT = 200; // volume-anomaly detection window (trades)
228
+ ```
229
+
230
+ **Tuning workflow:**
231
+ 1. Run backtest → check `filter_rejected` distribution in JSONL
232
+ 2. Identify the bottleneck (dominant rejection reason)
233
+ 3. Adjust the corresponding CONFIG constant
234
+ 4. Re-run backtest
235
+ 5. Repeat until signal frequency and PNL balance is acceptable
236
+
237
+ ---
238
+
239
+ ## Log File Location
240
+
241
+ | `useJsonl(name, dir)` call | Output path |
242
+ |---|---|
243
+ | `Log.useJsonl("bounce_strategy", "./dump/log")` | `./dump/log/bounce_strategy.jsonl` |
244
+ | `Log.useJsonl("debug", "./dump")` | `./dump/debug.jsonl` |
245
+
246
+ The directory is relative to the working directory where the CLI is invoked (project root).
@@ -0,0 +1,190 @@
1
+ # Pine Script Integration (@backtest-kit/pinets)
2
+
3
+ ## Overview
4
+
5
+ `@backtest-kit/pinets` runs `.pine` files against exchange data and returns named plot series. In a strategy file you use two functions: `run()` to execute the script and `extract()` to read the last bar's values.
6
+
7
+ ---
8
+
9
+ ## Key Difference: Strategy vs Standalone Runner
10
+
11
+ | Context | `exchangeName` | `when` |
12
+ |---|---|---|
13
+ | `content/*.strategy.ts` (backtest CLI) | **omit** — resolved from context | **omit** — uses current backtest tick |
14
+ | `scripts/run_*.mjs` (standalone) | **required** | **required** |
15
+
16
+ In strategy files, both arguments are injected automatically by the runtime. Do not pass them manually.
17
+
18
+ ---
19
+
20
+ ## `run(source, params)` — Execute Pine Script
21
+
22
+ ```ts
23
+ import { run, File } from "@backtest-kit/pinets";
24
+
25
+ const plots = await run(
26
+ File.fromPath("master_trend_15m.pine", "../math"),
27
+ {
28
+ symbol: "BTCUSDT", // injected from strategy context automatically
29
+ timeframe: "15m", // Pine script timeframe
30
+ limit: 180, // number of candles (warmup + output)
31
+ },
32
+ // exchangeName omitted — resolved from context in strategy files
33
+ // when omitted — uses current backtest tick time
34
+ );
35
+ ```
36
+
37
+ **`File.fromPath(filename, baseDir)`:**
38
+ - `filename` — path relative to `baseDir`, not cwd
39
+ - `baseDir` — optional, defaults to cwd
40
+ - Example: `File.fromPath("master_trend_15m.pine", "../math")` → resolves to `../math/master_trend_15m.pine`
41
+
42
+ **`params.limit`:**
43
+ - Must cover warmup + desired output bars
44
+ - See `pine_indicator_warmup.md` for how to calculate the correct limit
45
+
46
+ **Returns:** `PlotModel` — a record of plot name → `{ data: Array<{ time: number, value: number }> }`
47
+
48
+ ---
49
+
50
+ ## `extract(plots, mapping)` — Read Last Bar Values
51
+
52
+ `extract` reads the **last valid bar** from each named plot and returns a typed record.
53
+
54
+ ```ts
55
+ import { extract } from "@backtest-kit/pinets";
56
+
57
+ const result = await extract(plots, {
58
+ position: "Position", // JS key → Pine plot name (case-sensitive)
59
+ close: "Close",
60
+ });
61
+
62
+ // result.position → number (last bar value of "Position" plot)
63
+ // result.close → number
64
+ ```
65
+
66
+ **The mapping object:**
67
+ - Keys: JavaScript field names you want to use
68
+ - Values: exact Pine plot names as written in the `plot(value, "Name", ...)` call
69
+ - Plot names are **case-sensitive**
70
+
71
+ **Advanced mapping — read N bars back:**
72
+
73
+ ```ts
74
+ const result = await extract(plots, {
75
+ position: "Position",
76
+ prevPosition: { plot: "Position", barsBack: 1 }, // previous bar value
77
+ });
78
+ // result.prevPosition → value from 1 bar before last
79
+ ```
80
+
81
+ **With transform:**
82
+
83
+ ```ts
84
+ const result = await extract(plots, {
85
+ isLong: { plot: "Position", transform: (v) => v === 1 }, // returns boolean
86
+ });
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Pine Plot Convention
92
+
93
+ For `extract()` to work, the Pine script must expose plots with `display=display.data_window`:
94
+
95
+ ```pine
96
+ // === OUTPUTS FOR BOT ===
97
+ plot(close, "Close", display=display.data_window)
98
+ plot(position, "Position", display=display.data_window)
99
+ ```
100
+
101
+ Plots without `display=display.data_window` are not accessible via `extract()`.
102
+
103
+ ---
104
+
105
+ ## Using in a sourceNode
106
+
107
+ The standard pattern in a strategy — wrap in `Cache.fn` inside `sourceNode`:
108
+
109
+ ```ts
110
+ import { run, extract, File } from "@backtest-kit/pinets";
111
+ import { Cache } from "backtest-kit";
112
+ import { sourceNode } from "@backtest-kit/graph";
113
+
114
+ const masterTrendSource = sourceNode(
115
+ Cache.fn(
116
+ async (symbol) => {
117
+ const plots = await run(
118
+ File.fromPath("master_trend_15m.pine", "../math"),
119
+ {
120
+ symbol,
121
+ timeframe: "15m",
122
+ limit: 180, // warmup(30) + 150 output bars
123
+ },
124
+ );
125
+ return extract(plots, {
126
+ position: "Position",
127
+ close: "Close",
128
+ });
129
+ },
130
+ { interval: "15m", key: ([symbol]) => symbol },
131
+ ),
132
+ );
133
+ ```
134
+
135
+ - Cache interval matches the Pine timeframe (`"15m"`)
136
+ - `key` separates cache by symbol (important for multi-symbol backtests)
137
+ - `limit` must be set high enough — see `pine_indicator_warmup.md`
138
+
139
+ ---
140
+
141
+ ## `getSignal()` — Pine-Driven Signal
142
+
143
+ An alternative to manually computing signals in JS: let Pine compute the signal directly.
144
+
145
+ Pine script must expose specific plot names:
146
+
147
+ ```pine
148
+ plot(signal, "Signal", display=display.data_window) // 1=long, -1=short, 0=none
149
+ plot(close, "Close", display=display.data_window) // entry price
150
+ plot(sl_price, "StopLoss", display=display.data_window)
151
+ plot(tp_price, "TakeProfit", display=display.data_window)
152
+ plot(estimated_time, "EstimatedTime", display=display.data_window) // optional, default 240
153
+ ```
154
+
155
+ ```ts
156
+ import { getSignal, File } from "@backtest-kit/pinets";
157
+
158
+ const signal = await getSignal(
159
+ File.fromPath("my_signal.pine", "../math"),
160
+ { symbol, timeframe: "15m", limit: 200 },
161
+ );
162
+ // Returns ISignalDto | null
163
+ ```
164
+
165
+ Use `getSignal` when signal logic is simpler to express in Pine than in JS. Use `run` + `extract` when you need to combine Pine output with JS-side libraries (garch, volume-anomaly, etc.).
166
+
167
+ ---
168
+
169
+ ## Limit Calculation Quick Reference
170
+
171
+ See `pine_indicator_warmup.md` for full details. Short version:
172
+
173
+ ```
174
+ limit = max_lookback_period + desired_output_bars
175
+ ```
176
+
177
+ For `master_trend_15m.pine` with default params (atrPeriod=15, confirmBars=15):
178
+ - Warmup = atrPeriod + confirmBars = 15 + 15 = **30 bars**
179
+ - For 150 output bars: `limit = 30 + 150 = 180`
180
+
181
+ **Note:** Pine `input.int()` defaults are always used — the `inputs` parameter in `run()` is silently ignored. Change periods directly in the `.pine` file.
182
+
183
+ ---
184
+
185
+ ## Available Pine Outputs (master_trend_15m.pine)
186
+
187
+ | Plot name | Type | Description |
188
+ |---|---|---|
189
+ | `"Close"` | number | Current close price |
190
+ | `"Position"` | `-1 / 0 / 1` | Confirmed trend direction (0=pending confirmBars) |