tradelab 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # tradelab
2
+
3
+ `tradelab` is a candle-based backtesting toolkit for Node.js. It is built for two use cases:
4
+
5
+ - you already have candles and want a solid execution/backtest engine
6
+ - you want to fetch Yahoo Finance data or import CSVs and backtest with minimal setup
7
+
8
+ The package stays focused on historical research. It does not try to be a broker adapter or a live trading framework.
9
+
10
+ ## Features
11
+
12
+ - Backtest engine with pending entries, OCO exits, scale-outs, pyramiding, cooldowns, daily risk limits, and optional replay data
13
+ - Yahoo Finance historical downloader with local caching
14
+ - Flexible CSV import for common OHLCV layouts
15
+ - Metrics for positions and realized legs
16
+ - CSV trade export
17
+ - Self-contained HTML report export
18
+ - Utility indicators and session helpers for strategy code
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install tradelab
24
+ ```
25
+
26
+ Node `18+` is required.
27
+
28
+ ## Quick Start
29
+
30
+ ```js
31
+ import { backtest, ema, exportBacktestArtifacts } from "tradelab";
32
+
33
+ const result = backtest({
34
+ candles,
35
+ symbol: "BTC-USD",
36
+ interval: "5m",
37
+ range: "60d",
38
+ equity: 10_000,
39
+ riskPct: 1,
40
+ signal({ candles: history }) {
41
+ if (history.length < 50) return null;
42
+
43
+ const closes = history.map((bar) => bar.close);
44
+ const fast = ema(closes, 10);
45
+ const slow = ema(closes, 30);
46
+ const last = closes.length - 1;
47
+
48
+ if (fast[last - 1] <= slow[last - 1] && fast[last] > slow[last]) {
49
+ const entry = history[last].close;
50
+ const stop = Math.min(...history.slice(-15).map((bar) => bar.low));
51
+ const risk = entry - stop;
52
+ if (risk <= 0) return null;
53
+
54
+ return {
55
+ side: "long",
56
+ entry,
57
+ stop,
58
+ rr: 2,
59
+ };
60
+ }
61
+
62
+ return null;
63
+ },
64
+ });
65
+
66
+ exportBacktestArtifacts({
67
+ result,
68
+ outDir: "./output",
69
+ });
70
+ ```
71
+
72
+ ## Getting Historical Data
73
+
74
+ The simplest entry point is `getHistoricalCandles()`.
75
+
76
+ ### Yahoo Finance
77
+
78
+ ```js
79
+ import { getHistoricalCandles, backtest } from "tradelab";
80
+
81
+ const candles = await getHistoricalCandles({
82
+ source: "yahoo",
83
+ symbol: "SPY",
84
+ interval: "1d",
85
+ period: "2y",
86
+ cache: true,
87
+ });
88
+
89
+ const result = backtest({
90
+ candles,
91
+ symbol: "SPY",
92
+ interval: "1d",
93
+ range: "2y",
94
+ signal,
95
+ });
96
+ ```
97
+
98
+ Supported period examples: `5d`, `60d`, `6mo`, `1y`.
99
+
100
+ ### CSV
101
+
102
+ ```js
103
+ import { getHistoricalCandles } from "tradelab";
104
+
105
+ const candles = await getHistoricalCandles({
106
+ source: "csv",
107
+ symbol: "BTC-USD",
108
+ interval: "5m",
109
+ csvPath: "./data/btc-5m.csv",
110
+ csv: {
111
+ timeCol: "time",
112
+ openCol: "open",
113
+ highCol: "high",
114
+ lowCol: "low",
115
+ closeCol: "close",
116
+ volumeCol: "volume",
117
+ },
118
+ });
119
+ ```
120
+
121
+ If you pass `csvPath` and omit `source`, the loader will auto-detect CSV mode.
122
+
123
+ ## Signal Contract
124
+
125
+ Your strategy function receives:
126
+
127
+ ```js
128
+ {
129
+ candles, // history through the current bar
130
+ index, // current index in the original candle array
131
+ bar, // current candle
132
+ equity, // realized equity
133
+ openPosition, // null or current position
134
+ pendingOrder // null or current pending entry
135
+ }
136
+ ```
137
+
138
+ Return `null` for no trade, or a signal object:
139
+
140
+ ```js
141
+ {
142
+ side: "long" | "short",
143
+ entry: Number,
144
+ stop: Number,
145
+ takeProfit: Number
146
+ }
147
+ ```
148
+
149
+ Quality-of-life behavior:
150
+
151
+ - `side` also accepts `buy` and `sell`
152
+ - `entry` can be omitted and will default to the current bar close
153
+ - `takeProfit` can be omitted if `rr` or `_rr` is provided
154
+ - `qty` or `size` can override risk-based sizing
155
+ - `riskPct` or `riskFraction` can override the global risk setting per signal
156
+
157
+ Optional engine hints:
158
+
159
+ - `_entryExpiryBars`
160
+ - `_cooldownBars`
161
+ - `_breakevenAtR`
162
+ - `_trailAfterR`
163
+ - `_maxBarsInTrade`
164
+ - `_maxHoldMin`
165
+ - `_rr`
166
+ - `_initRisk`
167
+ - `_imb`
168
+
169
+ ## Result Shape
170
+
171
+ `backtest()` returns:
172
+
173
+ - `trades`: every realized leg, including scale-outs
174
+ - `positions`: completed positions only
175
+ - `metrics`: aggregate performance stats
176
+ - `eqSeries`: realized equity history
177
+ - `replay`: chart-friendly frame and event data
178
+
179
+ ## Main Exports
180
+
181
+ - `backtest(options)`
182
+ - `backtestHistorical({ data, backtestOptions })`
183
+ - `getHistoricalCandles(options)`
184
+ - `fetchHistorical(symbol, interval, period)`
185
+ - `loadCandlesFromCSV(filePath, options)`
186
+ - `saveCandlesToCache(candles, meta)`
187
+ - `loadCandlesFromCache(symbol, interval, period, outDir)`
188
+ - `exportBacktestArtifacts({ result, outDir })`
189
+
190
+ ## Reports
191
+
192
+ The HTML report is self-contained apart from the Plotly CDN script. Report markup, CSS, and client-side chart code live under `templates/`, not inline in the report renderer.
193
+
194
+ Export helpers default CSV output to completed positions. Use `csvSource: "trades"` if you want every realized leg in the CSV.
195
+
196
+ ## Examples
197
+
198
+ ```bash
199
+ node examples/emaCross.js
200
+ node examples/yahooEmaCross.js SPY 1d 1y
201
+ ```
202
+
203
+ ## Local Scripts
204
+
205
+ ```bash
206
+ npm run prefetch -- --symbol SPY --interval 1d --period 1y
207
+ npm run import-csv -- ./data/sample.csv --symbol BTC-USD --interval 5m
208
+ npm test
209
+ ```
210
+
211
+ ## Publishing
212
+
213
+ Validate the package contents before publishing:
214
+
215
+ ```bash
216
+ npm test
217
+ npm pack --dry-run
218
+ ```
219
+
220
+ Publish when the dry run looks correct:
221
+
222
+ ```bash
223
+ npm publish
224
+ ```
225
+
226
+ ## Notes
227
+
228
+ - Yahoo downloads can be cached under `output/data` by default.
229
+ - The engine is intended for historical research, not brokerage execution.
230
+ - File output only happens through the reporting and cache helpers.
@@ -0,0 +1,108 @@
1
+ import path from "path";
2
+ import { fileURLToPath } from "url";
3
+
4
+ import { backtest, ema, exportBacktestArtifacts } from "../src/index.js";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ function formatNumber(value, digits = 2) {
10
+ if (!Number.isFinite(value)) return value > 0 ? "Inf" : "0";
11
+ return value.toFixed(digits);
12
+ }
13
+
14
+ function generateCandles(count = 900) {
15
+ const candles = [];
16
+ const start = Date.UTC(2025, 0, 2, 14, 30, 0);
17
+ let price = 100;
18
+
19
+ for (let index = 0; index < count; index += 1) {
20
+ const drift = Math.sin(index / 24) * 0.3 + Math.cos(index / 11) * 0.15;
21
+ const shock = Math.sin(index / 7) * 0.6;
22
+ const close = Math.max(20, price + drift + shock);
23
+ const open = price;
24
+ const high = Math.max(open, close) + 0.35 + Math.abs(Math.sin(index / 5)) * 0.2;
25
+ const low = Math.min(open, close) - 0.35 - Math.abs(Math.cos(index / 6)) * 0.2;
26
+
27
+ candles.push({
28
+ time: start + index * 5 * 60 * 1000,
29
+ open,
30
+ high,
31
+ low,
32
+ close,
33
+ volume: 1_000 + index,
34
+ });
35
+
36
+ price = close;
37
+ }
38
+
39
+ return candles;
40
+ }
41
+
42
+ const candles = generateCandles();
43
+
44
+ const result = backtest({
45
+ candles,
46
+ symbol: "DEMO",
47
+ interval: "5m",
48
+ range: "synthetic",
49
+ equity: 25_000,
50
+ riskPct: 0.5,
51
+ collectEqSeries: true,
52
+ collectReplay: true,
53
+ signal({ candles: history }) {
54
+ if (history.length < 60) return null;
55
+
56
+ const closes = history.map((bar) => bar.close);
57
+ const fast = ema(closes, 12);
58
+ const slow = ema(closes, 26);
59
+ const lastIndex = closes.length - 1;
60
+
61
+ const fastNow = fast[lastIndex];
62
+ const slowNow = slow[lastIndex];
63
+ const fastPrev = fast[lastIndex - 1];
64
+ const slowPrev = slow[lastIndex - 1];
65
+
66
+ const crossedUp = fastPrev <= slowPrev && fastNow > slowNow;
67
+ const crossedDown = fastPrev >= slowPrev && fastNow < slowNow;
68
+ if (!crossedUp && !crossedDown) return null;
69
+
70
+ const recentBars = history.slice(-20);
71
+ const entry = history[lastIndex].close;
72
+ const stop = crossedUp
73
+ ? Math.min(...recentBars.map((bar) => bar.low))
74
+ : Math.max(...recentBars.map((bar) => bar.high));
75
+ const risk = Math.abs(entry - stop);
76
+ if (!Number.isFinite(risk) || risk <= 0) return null;
77
+
78
+ return {
79
+ side: crossedUp ? "long" : "short",
80
+ entry,
81
+ stop,
82
+ takeProfit: crossedUp ? entry + risk * 2.2 : entry - risk * 2.2,
83
+ _rr: 2.2,
84
+ _entryExpiryBars: 2,
85
+ _breakevenAtR: 1,
86
+ _trailAfterR: 1.5,
87
+ _cooldownBars: 6,
88
+ };
89
+ },
90
+ });
91
+
92
+ const outputDir = path.join(__dirname, "output");
93
+ const artifacts = exportBacktestArtifacts({
94
+ result,
95
+ outDir: outputDir,
96
+ });
97
+
98
+ console.table({
99
+ trades: result.metrics.trades,
100
+ winRate: `${(result.metrics.winRate * 100).toFixed(1)}%`,
101
+ profitFactor: formatNumber(result.metrics.profitFactor),
102
+ totalPnL: formatNumber(result.metrics.totalPnL),
103
+ returnPct: `${(result.metrics.returnPct * 100).toFixed(2)}%`,
104
+ maxDrawdownPct: `${(result.metrics.maxDrawdownPct * 100).toFixed(2)}%`,
105
+ });
106
+
107
+ console.log("CSV:", artifacts.csv);
108
+ console.log("HTML:", artifacts.html);
@@ -0,0 +1,88 @@
1
+ import path from "path";
2
+ import { fileURLToPath } from "url";
3
+
4
+ import {
5
+ backtest,
6
+ ema,
7
+ exportBacktestArtifacts,
8
+ getHistoricalCandles,
9
+ } from "../src/index.js";
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+
14
+ function formatNumber(value, digits = 2) {
15
+ if (!Number.isFinite(value)) return value > 0 ? "Inf" : "0";
16
+ return value.toFixed(digits);
17
+ }
18
+
19
+ const symbol = process.argv[2] || "SPY";
20
+ const interval = process.argv[3] || "1d";
21
+ const period = process.argv[4] || "1y";
22
+
23
+ const candles = await getHistoricalCandles({
24
+ source: "yahoo",
25
+ symbol,
26
+ interval,
27
+ period,
28
+ cache: true,
29
+ });
30
+
31
+ const result = backtest({
32
+ candles,
33
+ symbol,
34
+ interval,
35
+ range: period,
36
+ equity: 25_000,
37
+ riskPct: 1,
38
+ collectEqSeries: true,
39
+ collectReplay: true,
40
+ warmupBars: 50,
41
+ signal({ candles: history }) {
42
+ if (history.length < 50) return null;
43
+
44
+ const closes = history.map((bar) => bar.close);
45
+ const fast = ema(closes, 10);
46
+ const slow = ema(closes, 20);
47
+ const last = closes.length - 1;
48
+
49
+ const crossedUp = fast[last - 1] <= slow[last - 1] && fast[last] > slow[last];
50
+ const crossedDown = fast[last - 1] >= slow[last - 1] && fast[last] < slow[last];
51
+ if (!crossedUp && !crossedDown) return null;
52
+
53
+ const lookback = history.slice(-12);
54
+ const entry = history[last].close;
55
+ const stop = crossedUp
56
+ ? Math.min(...lookback.map((bar) => bar.low))
57
+ : Math.max(...lookback.map((bar) => bar.high));
58
+ const risk = Math.abs(entry - stop);
59
+ if (!(risk > 0)) return null;
60
+
61
+ return {
62
+ side: crossedUp ? "long" : "short",
63
+ entry,
64
+ stop,
65
+ rr: 2,
66
+ _entryExpiryBars: 1,
67
+ };
68
+ },
69
+ });
70
+
71
+ const outDir = path.join(__dirname, "output");
72
+ const artifacts = exportBacktestArtifacts({
73
+ result,
74
+ outDir,
75
+ });
76
+
77
+ console.table({
78
+ symbol,
79
+ candles: candles.length,
80
+ trades: result.metrics.trades,
81
+ winRate: `${(result.metrics.winRate * 100).toFixed(1)}%`,
82
+ profitFactor: formatNumber(result.metrics.profitFactor),
83
+ totalPnL: formatNumber(result.metrics.totalPnL),
84
+ returnPct: `${(result.metrics.returnPct * 100).toFixed(2)}%`,
85
+ });
86
+
87
+ console.log("CSV:", artifacts.csv);
88
+ console.log("HTML:", artifacts.html);
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "tradelab",
3
+ "version": "0.1.0",
4
+ "description": "Reusable trading and backtesting engine for candle-based strategies",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "license": "MIT",
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "sideEffects": false,
12
+ "exports": {
13
+ ".": "./src/index.js",
14
+ "./data": "./src/data/index.js",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "templates",
20
+ "examples/*.js",
21
+ "scripts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test",
27
+ "prefetch": "node scripts/prefetch.js",
28
+ "import-csv": "node scripts/import-csv.js",
29
+ "example:ema": "node examples/emaCross.js",
30
+ "example:yahoo": "node examples/yahooEmaCross.js"
31
+ },
32
+ "keywords": [
33
+ "trading",
34
+ "backtesting",
35
+ "algorithmic-trading",
36
+ "ohlcv",
37
+ "quant"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ candleStats,
5
+ loadCandlesFromCSV,
6
+ saveCandlesToCache,
7
+ } from "../src/index.js";
8
+
9
+ function parseArgs(argv) {
10
+ const args = {};
11
+ for (let index = 0; index < argv.length; index += 1) {
12
+ const token = argv[index];
13
+ if (!token.startsWith("--")) {
14
+ if (!args.file) args.file = token;
15
+ continue;
16
+ }
17
+
18
+ const key = token.slice(2);
19
+ const next = argv[index + 1];
20
+ if (next && !next.startsWith("--")) {
21
+ args[key] = next;
22
+ index += 1;
23
+ } else {
24
+ args[key] = "true";
25
+ }
26
+ }
27
+ return args;
28
+ }
29
+
30
+ const args = parseArgs(process.argv.slice(2));
31
+
32
+ if (!args.file || !args.symbol) {
33
+ console.log(
34
+ "Usage: node scripts/import-csv.js <file.csv> --symbol BTC-USD [--interval 5m] [--period 90d]"
35
+ );
36
+ process.exit(1);
37
+ }
38
+
39
+ try {
40
+ const candles = loadCandlesFromCSV(args.file, {
41
+ delimiter: args.delimiter || ",",
42
+ timeCol: args.timeCol || "time",
43
+ openCol: args.openCol || "open",
44
+ highCol: args.highCol || "high",
45
+ lowCol: args.lowCol || "low",
46
+ closeCol: args.closeCol || "close",
47
+ volumeCol: args.volumeCol || "volume",
48
+ startDate: args.startDate,
49
+ endDate: args.endDate,
50
+ });
51
+
52
+ const stats = candleStats(candles);
53
+ const interval = args.interval || "1d";
54
+ const period = args.period || `${Math.max(1, Math.ceil(stats?.durationDays || 1))}d`;
55
+ const outputPath = saveCandlesToCache(candles, {
56
+ symbol: args.symbol,
57
+ interval,
58
+ period,
59
+ outDir: args.outDir || "output/data",
60
+ source: "csv",
61
+ });
62
+
63
+ console.log(`Loaded ${stats?.count ?? 0} candles`);
64
+ console.log(`Range: ${stats?.firstTime ?? "—"} -> ${stats?.lastTime ?? "—"}`);
65
+ console.log(`Saved cache: ${outputPath}`);
66
+ } catch (error) {
67
+ console.error(error.message);
68
+ process.exit(1);
69
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { getHistoricalCandles, saveCandlesToCache } from "../src/index.js";
4
+
5
+ function parseArgs(argv) {
6
+ const args = {};
7
+ for (let index = 0; index < argv.length; index += 1) {
8
+ const token = argv[index];
9
+ if (!token.startsWith("--")) continue;
10
+
11
+ const key = token.slice(2);
12
+ const next = argv[index + 1];
13
+ if (next && !next.startsWith("--")) {
14
+ args[key] = next;
15
+ index += 1;
16
+ } else {
17
+ args[key] = "true";
18
+ }
19
+ }
20
+ return args;
21
+ }
22
+
23
+ const args = parseArgs(process.argv.slice(2));
24
+ const symbol = args.symbol || "SPY";
25
+ const interval = args.interval || "1d";
26
+ const period = args.period || "1y";
27
+ const outDir = args.outDir || "output/data";
28
+
29
+ async function main() {
30
+ const candles = await getHistoricalCandles({
31
+ source: "yahoo",
32
+ symbol,
33
+ interval,
34
+ period,
35
+ cache: false,
36
+ });
37
+
38
+ const outputPath = saveCandlesToCache(candles, {
39
+ symbol,
40
+ interval,
41
+ period,
42
+ outDir,
43
+ source: "yahoo",
44
+ });
45
+
46
+ console.log(`Saved ${candles.length} candles to ${outputPath}`);
47
+ }
48
+
49
+ main().catch((error) => {
50
+ console.error(error.message);
51
+ process.exit(1);
52
+ });