@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,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) |
|