@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,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) |
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Async Risk Validation with resolve()
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Risk validators can be async and call `resolve()` on any graph node. This lets you reuse the same source nodes (garch, trend, fear & greed) in both signal generation and risk filtering — without duplicate fetches.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Sync vs async validation
|
|
10
|
+
|
|
11
|
+
`backtest_strategy_structure.md` shows the basic sync pattern:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// Sync — receives pre-computed payload fields
|
|
15
|
+
validations: [
|
|
16
|
+
{
|
|
17
|
+
validate: ({ currentSignal, currentPrice }) => {
|
|
18
|
+
if (currentSignal.position === "short" && currentPrice > 50000) {
|
|
19
|
+
throw new Error("Price too high for short");
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Async pattern — resolve a graph node:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// Async — fetch fresh data at validation time
|
|
30
|
+
validations: [
|
|
31
|
+
async () => {
|
|
32
|
+
const garch = await resolve(garchSource);
|
|
33
|
+
if (!garch.reliable || garch.movePercent < 1.0) {
|
|
34
|
+
throw new Error(`GARCH volatility too low: ${garch.movePercent.toFixed(2)}%`);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Both forms can coexist in the same `validations` array.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## GARCH volatility gate
|
|
45
|
+
|
|
46
|
+
The canonical use case: block signals when predicted volatility is too low to cover transaction costs and generate profit.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { predict } from "garch";
|
|
50
|
+
import { sourceNode, resolve } from "@backtest-kit/graph";
|
|
51
|
+
import { Cache, getCandles, addRiskSchema } from "backtest-kit";
|
|
52
|
+
|
|
53
|
+
const CANDLES_FOR_GARCH = 1_000;
|
|
54
|
+
const GARCH_CONFIDENCE = 0.95;
|
|
55
|
+
const MIN_MOVE_PERCENT = 1.0;
|
|
56
|
+
|
|
57
|
+
const garchSource = sourceNode(
|
|
58
|
+
Cache.fn(
|
|
59
|
+
async (symbol: string) => {
|
|
60
|
+
const candles = await getCandles(symbol, "8h", CANDLES_FOR_GARCH);
|
|
61
|
+
return predict(candles, "8h", null, GARCH_CONFIDENCE);
|
|
62
|
+
},
|
|
63
|
+
{ interval: "8h", key: ([symbol]: [string]) => symbol },
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
addRiskSchema({
|
|
68
|
+
riskName: "garch_volatility_risk",
|
|
69
|
+
validations: [
|
|
70
|
+
async () => {
|
|
71
|
+
const garch = await resolve(garchSource);
|
|
72
|
+
if (!garch.reliable || garch.movePercent < MIN_MOVE_PERCENT) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`GARCH volatility too low: ${garch.movePercent.toFixed(2)}% (need ≥${MIN_MOVE_PERCENT}%, confidence=${GARCH_CONFIDENCE})`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**`garch.reliable`** — false when the model didn't converge or has insufficient data. Always check this before using `movePercent`.
|
|
83
|
+
|
|
84
|
+
**`garch.movePercent`** — predicted price move as percentage of current price over one candle (one 8h bar here). At confidence=0.95 (±1.96σ), a value of 1.0 means the model predicts ≥1% move with 95% probability.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Fear & Greed gate (directional)
|
|
89
|
+
|
|
90
|
+
From `feb_2024.strategy.ts` — block longs in fear and shorts in greed:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
addRiskSchema({
|
|
94
|
+
riskName: "fear_greed_directional",
|
|
95
|
+
validations: [
|
|
96
|
+
async ({ currentSignal }) => {
|
|
97
|
+
const fearGreed = await resolve(fearGreedSource);
|
|
98
|
+
if (currentSignal.position === "short" && fearGreed > 50) {
|
|
99
|
+
throw new Error(`Still greed (${fearGreed}): short not allowed`);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
async ({ currentSignal }) => {
|
|
103
|
+
const fearGreed = await resolve(fearGreedSource);
|
|
104
|
+
if (currentSignal.position === "long" && fearGreed < 50) {
|
|
105
|
+
throw new Error(`Still fear (${fearGreed}): long not allowed`);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`fearGreedSource` uses `Cache.file` with `interval: "8h"` — fetched once per 8h from `api.alternative.me`.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Node deduplication in risk
|
|
117
|
+
|
|
118
|
+
When `garchSource` is a dependency of both `enterSignal` (output node) and the risk validator, the graph deduplicates resolution within the same tick. If `getSignal()` already resolved `garchSource`, the risk validator gets the cached result — no second fetch.
|
|
119
|
+
|
|
120
|
+
This only holds if the validator uses `resolve(garchSource)` with the **same node reference**. Do not create a new `sourceNode` inside the validator — that would be a different node with its own cache.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## `validate` vs bare async function
|
|
125
|
+
|
|
126
|
+
Both forms are accepted in `validations`:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// Object form — supports `note` field
|
|
130
|
+
validations: [
|
|
131
|
+
{
|
|
132
|
+
note: "GARCH volatility must be at least 1%",
|
|
133
|
+
validate: async () => {
|
|
134
|
+
const garch = await resolve(garchSource);
|
|
135
|
+
if (!garch.reliable || garch.movePercent < 1.0) {
|
|
136
|
+
throw new Error("...");
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
// Bare async function — more concise
|
|
143
|
+
validations: [
|
|
144
|
+
async () => {
|
|
145
|
+
const garch = await resolve(garchSource);
|
|
146
|
+
if (!garch.reliable || garch.movePercent < 1.0) {
|
|
147
|
+
throw new Error("...");
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Throw `Error` to reject the signal. Return normally (or return `void`) to allow it.
|