tradelab 0.4.0 → 1.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.
Files changed (54) hide show
  1. package/README.md +121 -52
  2. package/bin/tradelab.js +340 -49
  3. package/dist/cjs/data.cjs +210 -155
  4. package/dist/cjs/index.cjs +1782 -274
  5. package/dist/cjs/live.cjs +3350 -0
  6. package/docs/README.md +26 -9
  7. package/docs/api-reference.md +89 -26
  8. package/docs/backtest-engine.md +74 -60
  9. package/docs/data-reporting-cli.md +66 -36
  10. package/docs/examples.md +275 -0
  11. package/docs/live-trading.md +186 -0
  12. package/examples/yahooEmaCross.js +1 -6
  13. package/package.json +18 -3
  14. package/src/data/csv.js +24 -14
  15. package/src/data/index.js +1 -5
  16. package/src/data/yahoo.js +6 -19
  17. package/src/engine/backtest.js +137 -144
  18. package/src/engine/backtestTicks.js +481 -0
  19. package/src/engine/barSystemRunner.js +1027 -0
  20. package/src/engine/execution.js +11 -39
  21. package/src/engine/portfolio.js +237 -66
  22. package/src/engine/walkForward.js +132 -13
  23. package/src/index.js +3 -11
  24. package/src/live/broker/alpaca.js +254 -0
  25. package/src/live/broker/binance.js +351 -0
  26. package/src/live/broker/coinbase.js +339 -0
  27. package/src/live/broker/interactiveBrokers.js +123 -0
  28. package/src/live/broker/interface.js +74 -0
  29. package/src/live/clock.js +56 -0
  30. package/src/live/engine/candleAggregator.js +154 -0
  31. package/src/live/engine/liveEngine.js +694 -0
  32. package/src/live/engine/paperEngine.js +453 -0
  33. package/src/live/engine/riskManager.js +185 -0
  34. package/src/live/engine/stateManager.js +112 -0
  35. package/src/live/events.js +48 -0
  36. package/src/live/feed/brokerFeed.js +35 -0
  37. package/src/live/feed/interface.js +28 -0
  38. package/src/live/feed/pollingFeed.js +105 -0
  39. package/src/live/index.js +27 -0
  40. package/src/live/logger.js +82 -0
  41. package/src/live/orchestrator.js +133 -0
  42. package/src/live/storage/interface.js +36 -0
  43. package/src/live/storage/jsonFileStorage.js +112 -0
  44. package/src/metrics/buildMetrics.js +103 -100
  45. package/src/reporting/exportBacktestArtifacts.js +1 -4
  46. package/src/reporting/exportTradesCsv.js +2 -7
  47. package/src/reporting/renderHtmlReport.js +8 -13
  48. package/src/utils/indicators.js +1 -2
  49. package/src/utils/positionSizing.js +16 -2
  50. package/src/utils/time.js +4 -12
  51. package/templates/report.html +23 -9
  52. package/templates/report.js +83 -69
  53. package/types/index.d.ts +98 -4
  54. package/types/live.d.ts +382 -0
@@ -0,0 +1,275 @@
1
+ # Strategy examples
2
+
3
+ <small>[Back to main page](README.md)</small>
4
+
5
+ These are research templates. They show how to wire different kinds of data and execution assumptions into the engine without changing the output pipeline.
6
+
7
+ The five examples cover:
8
+
9
+ - single-symbol price research
10
+ - tick-level fills
11
+ - external feature overlays
12
+ - model-derived regime filters with walk-forward validation
13
+ - portfolio research with shared capital
14
+
15
+ ---
16
+
17
+ ## 1. Mean reversion pullback
18
+
19
+ Entry when price is stretched below its 20-bar mean. Exit via stop and take-profit.
20
+
21
+ ```js
22
+ import { backtest, getHistoricalCandles } from "tradelab";
23
+
24
+ function sma(values, period) {
25
+ if (values.length < period) return null;
26
+ return values.slice(-period).reduce((sum, v) => sum + v, 0) / period;
27
+ }
28
+
29
+ const candles = await getHistoricalCandles({
30
+ source: "yahoo",
31
+ symbol: "SPY",
32
+ interval: "1d",
33
+ period: "2y",
34
+ cache: true,
35
+ });
36
+
37
+ const result = backtest({
38
+ candles,
39
+ symbol: "SPY",
40
+ warmupBars: 25,
41
+ signal({ candles: history, bar }) {
42
+ const closes = history.map((c) => c.close);
43
+ const mean = sma(closes, 20);
44
+ if (!mean) return null;
45
+
46
+ const stretch = (bar.close - mean) / mean;
47
+ if (stretch > -0.03) return null;
48
+
49
+ return {
50
+ side: "long",
51
+ entry: bar.close,
52
+ stop: bar.low * 0.99,
53
+ rr: 1.5,
54
+ _maxBarsInTrade: 5,
55
+ };
56
+ },
57
+ });
58
+ ```
59
+
60
+ ---
61
+
62
+ ## 2. Opening-range breakout on ticks
63
+
64
+ Breakout logic where fill order matters. `backtestTicks()` resolves fills at tick resolution instead of bar close. The result shape is identical to `backtest()`.
65
+
66
+ ```js
67
+ import { backtestTicks } from "tradelab";
68
+
69
+ const result = backtestTicks({
70
+ ticks,
71
+ symbol: "NQ",
72
+ equity: 25_000,
73
+ queueFillProbability: 0.4,
74
+ costs: {
75
+ spreadBps: 0.5,
76
+ slippageByKind: {
77
+ market: 2,
78
+ stop: 4,
79
+ },
80
+ },
81
+ signal({ candles: history, bar, index }) {
82
+ if (index < 30) return null;
83
+
84
+ const openingRange = history.slice(0, 30);
85
+ const rangeHigh = Math.max(...openingRange.map((t) => t.high));
86
+ const rangeLow = Math.min(...openingRange.map((t) => t.low));
87
+
88
+ if (bar.close > rangeHigh) {
89
+ return { side: "long", entry: rangeHigh, stop: rangeLow, rr: 2 };
90
+ }
91
+
92
+ if (bar.close < rangeLow) {
93
+ return { side: "short", entry: rangeLow, stop: rangeHigh, rr: 2 };
94
+ }
95
+
96
+ return null;
97
+ },
98
+ });
99
+ ```
100
+
101
+ `queueFillProbability` controls what fraction of limit touches actually fill. Set it to `1` for optimistic fills, `0` to require the price to trade through.
102
+
103
+ ---
104
+
105
+ ## 3. Sentiment overlay on a candle strategy
106
+
107
+ Enrich candles with a second data source before the backtest starts. The engine does not care where extra fields come from.
108
+
109
+ ```js
110
+ import { backtest, getHistoricalCandles, ema } from "tradelab";
111
+
112
+ const candles = await getHistoricalCandles({
113
+ source: "yahoo",
114
+ symbol: "AAPL",
115
+ interval: "1d",
116
+ period: "2y",
117
+ });
118
+
119
+ const sentimentByDay = new Map([
120
+ ["2025-01-02", 0.75],
121
+ ["2025-01-03", -0.1],
122
+ // ...
123
+ ]);
124
+
125
+ const enriched = candles.map((bar) => ({
126
+ ...bar,
127
+ sentiment: sentimentByDay.get(new Date(bar.time).toISOString().slice(0, 10)) ?? 0,
128
+ }));
129
+
130
+ const result = backtest({
131
+ candles: enriched,
132
+ symbol: "AAPL",
133
+ warmupBars: 30,
134
+ signal({ candles: history, bar }) {
135
+ const closes = history.map((c) => c.close);
136
+ const fast = ema(closes, 10);
137
+ const slow = ema(closes, 30);
138
+ const last = closes.length - 1;
139
+
140
+ if (fast[last - 1] <= slow[last - 1] && fast[last] > slow[last] && bar.sentiment > 0.5) {
141
+ return {
142
+ side: "long",
143
+ entry: bar.close,
144
+ stop: Math.min(...history.slice(-10).map((c) => c.low)),
145
+ rr: 2,
146
+ };
147
+ }
148
+
149
+ return null;
150
+ },
151
+ });
152
+ ```
153
+
154
+ The same pattern works for any precomputed field - regime labels, macro scores, alternative data signals. Compute it outside the engine, attach it to the candle, read it in the signal function.
155
+
156
+ ---
157
+
158
+ ## 4. Precomputed regime filter with anchored walk-forward
159
+
160
+ LLM or model outputs work best as precomputed fields, not as live callers inside the signal function. Call the model once per bar outside the engine, store the result on the candle, then run a normal walk-forward on top of it.
161
+
162
+ ```js
163
+ import { walkForwardOptimize, getHistoricalCandles, ema } from "tradelab";
164
+
165
+ const candles = await getHistoricalCandles({
166
+ source: "yahoo",
167
+ symbol: "QQQ",
168
+ interval: "1d",
169
+ period: "3y",
170
+ });
171
+
172
+ // call model outside the engine - keep signal() synchronous
173
+ const labeled = await Promise.all(
174
+ candles.map(async (bar, index) => ({
175
+ ...bar,
176
+ regime:
177
+ index < 20
178
+ ? "neutral"
179
+ : await classifyRegime(candles.slice(index - 20, index).map((c) => c.close)),
180
+ }))
181
+ );
182
+
183
+ const wf = walkForwardOptimize({
184
+ candles: labeled,
185
+ mode: "anchored",
186
+ trainBars: 180,
187
+ testBars: 60,
188
+ stepBars: 60,
189
+ scoreBy: "profitFactor",
190
+ parameterSets: [
191
+ { fast: 10, slow: 30, regime: "trend" },
192
+ { fast: 20, slow: 50, regime: "trend" },
193
+ { fast: 10, slow: 30, regime: "mean-revert" },
194
+ ],
195
+ backtestOptions: {
196
+ warmupBars: 60,
197
+ flattenAtClose: false,
198
+ },
199
+ signalFactory(params) {
200
+ return ({ candles: history, bar }) => {
201
+ if (bar.regime !== params.regime) return null;
202
+
203
+ const closes = history.map((c) => c.close);
204
+ const fast = ema(closes, params.fast);
205
+ const slow = ema(closes, params.slow);
206
+ const last = closes.length - 1;
207
+
208
+ if (fast[last - 1] <= slow[last - 1] && fast[last] > slow[last]) {
209
+ return {
210
+ side: "long",
211
+ entry: bar.close,
212
+ stop: Math.min(...history.slice(-15).map((c) => c.low)),
213
+ rr: 2,
214
+ };
215
+ }
216
+
217
+ return null;
218
+ };
219
+ },
220
+ });
221
+ ```
222
+
223
+ Check `wf.bestParamsSummary` for parameter stability across windows. If the winning regime or EMA pair changes every window, the model output probably is not adding signal.
224
+
225
+ ---
226
+
227
+ ## 5. Cross-sectional momentum portfolio
228
+
229
+ One signal factory across three symbols. Fills compete for the same capital pool at fill time - a position on SPY reduces what QQQ and IWM can size into on the same bar.
230
+
231
+ ```js
232
+ import { backtestPortfolio, ema, getHistoricalCandles } from "tradelab";
233
+
234
+ function momentumSignal() {
235
+ return ({ candles: history }) => {
236
+ if (history.length < 60) return null;
237
+
238
+ const closes = history.map((c) => c.close);
239
+ const fast = ema(closes, 20);
240
+ const slow = ema(closes, 50);
241
+ const last = closes.length - 1;
242
+
243
+ if (fast[last - 1] <= slow[last - 1] && fast[last] > slow[last]) {
244
+ return {
245
+ side: "long",
246
+ entry: closes[last],
247
+ stop: Math.min(...history.slice(-20).map((c) => c.low)),
248
+ rr: 2,
249
+ };
250
+ }
251
+
252
+ return null;
253
+ };
254
+ }
255
+
256
+ const [spy, qqq, iwm] = await Promise.all([
257
+ getHistoricalCandles({ source: "yahoo", symbol: "SPY", interval: "1d", period: "2y" }),
258
+ getHistoricalCandles({ source: "yahoo", symbol: "QQQ", interval: "1d", period: "2y" }),
259
+ getHistoricalCandles({ source: "yahoo", symbol: "IWM", interval: "1d", period: "2y" }),
260
+ ]);
261
+
262
+ const result = backtestPortfolio({
263
+ equity: 100_000,
264
+ maxDailyLossPct: 3,
265
+ systems: [
266
+ { symbol: "SPY", candles: spy, signal: momentumSignal(), weight: 2, maxAllocationPct: 0.5 },
267
+ { symbol: "QQQ", candles: qqq, signal: momentumSignal(), weight: 2, maxAllocationPct: 0.5 },
268
+ { symbol: "IWM", candles: iwm, signal: momentumSignal(), weight: 1, maxAllocationPct: 0.3 },
269
+ ],
270
+ });
271
+ ```
272
+
273
+ `result.eqSeries` includes `lockedCapital` and `availableCapital` at each realized equity point. Use those to see how often the portfolio was fully deployed versus sitting partially idle.
274
+
275
+ <small>[Back to main page](README.md)</small>
@@ -0,0 +1,186 @@
1
+ # Live trading
2
+
3
+ <small>[Back to main page](README.md)</small>
4
+
5
+ This guide covers the `tradelab/live` module and the live CLI commands.
6
+
7
+ ## Overview
8
+
9
+ The live stack is built to reuse the same signal contract as backtesting:
10
+
11
+ - write and validate `signal()` with `backtest()`
12
+ - run the same signal in `LiveEngine` or `LiveOrchestrator`
13
+ - choose a real broker adapter or `PaperEngine`
14
+ - persist state with `JsonFileStorage` for restart safety
15
+
16
+ Import path:
17
+
18
+ ```js
19
+ import { LiveEngine, LiveOrchestrator, PaperEngine } from "tradelab/live";
20
+ ```
21
+
22
+ ## Module components
23
+
24
+ | Component | Purpose |
25
+ | -------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
26
+ | `LiveEngine` | Single-system live or paper execution loop |
27
+ | `LiveOrchestrator` | Multi-system live execution with shared broker and aggregated status |
28
+ | `PaperEngine` | In-process broker simulator implementing the broker adapter contract |
29
+ | `AlpacaBroker` / `BinanceBroker` / `CoinbaseBroker` / `InteractiveBrokersBroker` | Real broker adapters |
30
+ | `BrokerFeed` / `PollingFeed` | Feed adapters for streaming or polling operation |
31
+ | `RiskManager` | Session windows, daily loss gates, drawdown halts, position checks |
32
+ | `StateManager` / `JsonFileStorage` | Persisted state, trades, and equity curve |
33
+ | `EventBus` / `LiveLogger` | Event fanout and structured logging |
34
+
35
+ ## `LiveEngine` quick start
36
+
37
+ ```js
38
+ import { LiveEngine, PaperEngine, JsonFileStorage } from "tradelab/live";
39
+
40
+ const engine = new LiveEngine({
41
+ id: "aapl-1m",
42
+ symbol: "AAPL",
43
+ interval: "1m",
44
+ broker: new PaperEngine({ equity: 25_000 }),
45
+ storage: new JsonFileStorage({ baseDir: "./output/live-state" }),
46
+ riskPct: 1,
47
+ mode: "streaming",
48
+ signal({ bar, openPosition }) {
49
+ if (openPosition) return null;
50
+ return {
51
+ side: "long",
52
+ stop: bar.close - 1,
53
+ rr: 2,
54
+ };
55
+ },
56
+ });
57
+
58
+ await engine.start();
59
+ // ... run until shutdown condition
60
+ await engine.stop();
61
+ ```
62
+
63
+ Important behavior:
64
+
65
+ - `signal()` is called with the same context shape as backtesting
66
+ - market and limit/stop order lifecycles are tracked through broker events
67
+ - state is persisted after fills, order updates, and equity updates
68
+ - `getStatus()` returns runtime and risk state for health checks
69
+
70
+ ## `LiveOrchestrator` quick start
71
+
72
+ ```js
73
+ import { LiveOrchestrator, PaperEngine, JsonFileStorage } from "tradelab/live";
74
+
75
+ const orchestrator = new LiveOrchestrator({
76
+ broker: new PaperEngine({ equity: 100_000 }),
77
+ storage: new JsonFileStorage({ baseDir: "./output/live-state" }),
78
+ allocation: "weight",
79
+ systems: [
80
+ { id: "spy", symbol: "SPY", interval: "1m", weight: 2, signal: signalA },
81
+ { id: "qqq", symbol: "QQQ", interval: "1m", weight: 1, signal: signalB },
82
+ ],
83
+ });
84
+
85
+ await orchestrator.start();
86
+ const status = orchestrator.getStatus();
87
+ await orchestrator.stop();
88
+ ```
89
+
90
+ Use orchestrator when multiple systems should share one broker/account context.
91
+
92
+ ## CLI live commands
93
+
94
+ | Command | Purpose |
95
+ | ----------------- | -------------------------------------------- |
96
+ | `tradelab live` | Run live engine or orchestrator (`--config`) |
97
+ | `tradelab paper` | Shortcut for `live` with paper broker mode |
98
+ | `tradelab status` | Inspect persisted live state |
99
+
100
+ ### Single-system paper run
101
+
102
+ ```bash
103
+ tradelab paper \
104
+ --id aapl-1m \
105
+ --symbol AAPL \
106
+ --interval 1m \
107
+ --mode polling \
108
+ --once true \
109
+ --stateDir ./output/live-state
110
+ ```
111
+
112
+ ### Orchestrator run from config
113
+
114
+ ```bash
115
+ tradelab live \
116
+ --config ./live-portfolio.json \
117
+ --paper \
118
+ --mode polling \
119
+ --once true \
120
+ --stateDir ./output/live-state
121
+ ```
122
+
123
+ Example config:
124
+
125
+ ```json
126
+ {
127
+ "allocation": "weight",
128
+ "equity": 50000,
129
+ "systems": [
130
+ {
131
+ "id": "spy-system",
132
+ "symbol": "SPY",
133
+ "interval": "1m",
134
+ "strategy": "./strategies/spySignal.js",
135
+ "weight": 2
136
+ },
137
+ {
138
+ "id": "qqq-system",
139
+ "symbol": "QQQ",
140
+ "interval": "1m",
141
+ "strategy": "./strategies/qqqSignal.js",
142
+ "weight": 1
143
+ }
144
+ ]
145
+ }
146
+ ```
147
+
148
+ ### State inspection
149
+
150
+ ```bash
151
+ tradelab status --dir ./output/live-state
152
+ tradelab status --dir ./output/live-state --namespace spy-system
153
+ ```
154
+
155
+ ## State and recovery
156
+
157
+ Live state is namespaced and persisted as:
158
+
159
+ - `state.json` (latest engine state)
160
+ - `trades.jsonl` (append-only)
161
+ - `equity.jsonl` (append-only)
162
+
163
+ On restart, the engine loads persisted state and reconciles with broker positions.
164
+
165
+ ## Broker notes
166
+
167
+ - Alpaca and Binance adapters support native paper modes.
168
+ - Coinbase adapter is live API only; use `PaperEngine` for simulated Coinbase workflows.
169
+ - Interactive Brokers adapter requires `@stoqey/ib` to be installed.
170
+
171
+ For runtime compatibility and options, see [types/live.d.ts](../types/live.d.ts).
172
+
173
+ ## Eventing and logs
174
+
175
+ `EventBus` emits lifecycle and execution events such as:
176
+
177
+ - `connected`, `shutdown`
178
+ - `signal`
179
+ - `order:submitted`, `order:filled`, `order:rejected`, `order:canceled`
180
+ - `position:opened`, `position:closed`
181
+ - `equity:update`
182
+ - `risk:warning`, `risk:halt`
183
+
184
+ Attach `LiveLogger` for structured JSON logs.
185
+
186
+ <small>[Back to main page](README.md)</small>
@@ -1,12 +1,7 @@
1
1
  import path from "path";
2
2
  import { fileURLToPath } from "url";
3
3
 
4
- import {
5
- backtest,
6
- ema,
7
- exportBacktestArtifacts,
8
- getHistoricalCandles,
9
- } from "../src/index.js";
4
+ import { backtest, ema, exportBacktestArtifacts, getHistoricalCandles } from "../src/index.js";
10
5
 
11
6
  const __filename = fileURLToPath(import.meta.url);
12
7
  const __dirname = path.dirname(__filename);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tradelab",
3
- "version": "0.4.0",
3
+ "version": "1.0.0",
4
4
  "description": "Backtesting toolkit for Node.js with strategy simulation, historical data loading, and report generation",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",
@@ -33,6 +33,11 @@
33
33
  "import": "./src/data/index.js",
34
34
  "require": "./dist/cjs/data.cjs"
35
35
  },
36
+ "./live": {
37
+ "types": "./types/live.d.ts",
38
+ "import": "./src/live/index.js",
39
+ "require": "./dist/cjs/live.cjs"
40
+ },
36
41
  "./package.json": "./package.json"
37
42
  },
38
43
  "files": [
@@ -48,8 +53,12 @@
48
53
  ],
49
54
  "scripts": {
50
55
  "build": "node scripts/build-cjs.mjs",
56
+ "lint": "eslint .",
57
+ "lint:fix": "eslint . --fix",
58
+ "format": "prettier . --write",
59
+ "format:check": "prettier . --check",
60
+ "typecheck": "tsc --noEmit -p tsconfig.json",
51
61
  "prepare": "npm run build",
52
- "prepack": "npm run build",
53
62
  "test": "node --test"
54
63
  },
55
64
  "keywords": [
@@ -67,6 +76,12 @@
67
76
  "access": "public"
68
77
  },
69
78
  "devDependencies": {
70
- "esbuild": "^0.27.3"
79
+ "@eslint/js": "^9.25.1",
80
+ "@types/node": "^22.15.2",
81
+ "esbuild": "^0.27.3",
82
+ "eslint": "^9.25.1",
83
+ "globals": "^15.15.0",
84
+ "prettier": "^3.5.3",
85
+ "typescript": "^5.8.3"
71
86
  }
72
87
  }
package/src/data/csv.js CHANGED
@@ -21,7 +21,9 @@ function resolveDate(value, customDateParser) {
21
21
  if (Number.isFinite(time)) return time;
22
22
  }
23
23
 
24
- const raw = String(value).trim().replace(/^['"]|['"]$/g, "");
24
+ const raw = String(value)
25
+ .trim()
26
+ .replace(/^['"]|['"]$/g, "");
25
27
  const numeric = Number(raw);
26
28
  if (Number.isFinite(numeric)) {
27
29
  return numeric < 1e11 ? numeric * 1000 : numeric;
@@ -108,7 +110,7 @@ function normalizeDateBoundary(value, fallback) {
108
110
  export function normalizeCandles(candles) {
109
111
  if (!Array.isArray(candles)) return [];
110
112
 
111
- const normalized = candles
113
+ const parsed = candles
112
114
  .map((bar) => {
113
115
  try {
114
116
  const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
@@ -140,8 +142,18 @@ export function normalizeCandles(candles) {
140
142
  return null;
141
143
  }
142
144
  })
143
- .filter(Boolean)
144
- .sort((left, right) => left.time - right.time);
145
+ .filter(Boolean);
146
+
147
+ let reordered = false;
148
+ let duplicateCount = 0;
149
+ for (let index = 1; index < parsed.length; index += 1) {
150
+ const prev = parsed[index - 1].time;
151
+ const current = parsed[index].time;
152
+ if (current < prev) reordered = true;
153
+ if (current === prev) duplicateCount += 1;
154
+ }
155
+
156
+ const normalized = parsed.sort((left, right) => left.time - right.time);
145
157
 
146
158
  const deduped = [];
147
159
  let lastTime = null;
@@ -150,6 +162,12 @@ export function normalizeCandles(candles) {
150
162
  deduped.push(candle);
151
163
  lastTime = candle.time;
152
164
  }
165
+ const removedDuplicates = normalized.length - deduped.length;
166
+ if (reordered || removedDuplicates > 0 || duplicateCount > 0) {
167
+ console.warn(
168
+ `[tradelab] normalizeCandles() reordered or deduplicated candles (input=${candles.length}, valid=${parsed.length}, output=${deduped.length})`
169
+ );
170
+ }
153
171
  return deduped;
154
172
  }
155
173
 
@@ -197,16 +215,8 @@ export function loadCandlesFromCSV(filePath, options = {}) {
197
215
  const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
198
216
  const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
199
217
 
200
- if (
201
- timeIdx < 0 ||
202
- openIdx < 0 ||
203
- highIdx < 0 ||
204
- lowIdx < 0 ||
205
- closeIdx < 0
206
- ) {
207
- throw new Error(
208
- `Could not resolve required CSV columns in ${path.basename(filePath)}`
209
- );
218
+ if (timeIdx < 0 || openIdx < 0 || highIdx < 0 || lowIdx < 0 || closeIdx < 0) {
219
+ throw new Error(`Could not resolve required CSV columns in ${path.basename(filePath)}`);
210
220
  }
211
221
 
212
222
  const minTime = normalizeDateBoundary(startDate, -Infinity);
package/src/data/index.js CHANGED
@@ -97,11 +97,7 @@ export async function getHistoricalCandles(options = {}) {
97
97
  return candles;
98
98
  }
99
99
 
100
- export async function backtestHistorical({
101
- backtestOptions = {},
102
- data,
103
- ...legacy
104
- } = {}) {
100
+ export async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}) {
105
101
  const candles = await getHistoricalCandles(data || legacy);
106
102
  return runBacktest({
107
103
  candles,
package/src/data/yahoo.js CHANGED
@@ -1,7 +1,6 @@
1
1
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2
2
 
3
3
  const DAY_MS = 24 * 60 * 60 * 1000;
4
- const DAY_SEC = 24 * 60 * 60;
5
4
  const requestQueue = {
6
5
  lastRequestAt: 0,
7
6
  minDelayMs: 400,
@@ -111,8 +110,7 @@ async function rateLimitedFetch(url, options = {}) {
111
110
  return fetch(url, {
112
111
  ...options,
113
112
  headers: {
114
- "User-Agent":
115
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
113
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
116
114
  ...options.headers,
117
115
  },
118
116
  });
@@ -152,12 +150,7 @@ async function fetchYahooChart(symbol, { period1, period2, interval, includePreP
152
150
 
153
151
  const candles = [];
154
152
  for (let index = 0; index < timestamps.length; index += 1) {
155
- if (
156
- open[index] == null ||
157
- high[index] == null ||
158
- low[index] == null ||
159
- close[index] == null
160
- ) {
153
+ if (open[index] == null || high[index] == null || low[index] == null || close[index] == null) {
161
154
  continue;
162
155
  }
163
156
 
@@ -179,7 +172,7 @@ function formatYahooFailureMessage(symbol, interval, period, error, attempts) {
179
172
  return [
180
173
  `Unable to reach Yahoo Finance for ${symbol} ${interval} ${period} after ${attempts} attempts.`,
181
174
  `Last error: ${detail}`,
182
- "Try again later, or fall back to a local CSV/cache workflow with getHistoricalCandles({ source: \"csv\", ... }) or loadCandlesFromCache(...).",
175
+ 'Try again later, or fall back to a local CSV/cache workflow with getHistoricalCandles({ source: "csv", ... }) or loadCandlesFromCache(...).',
183
176
  ].join(" ");
184
177
  }
185
178
 
@@ -203,13 +196,7 @@ async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3)
203
196
  }
204
197
 
205
198
  throw new Error(
206
- formatYahooFailureMessage(
207
- symbol,
208
- params.interval,
209
- period,
210
- lastError,
211
- maxRetries
212
- )
199
+ formatYahooFailureMessage(symbol, params.interval, period, lastError, maxRetries)
213
200
  );
214
201
  }
215
202
 
@@ -253,10 +240,10 @@ export async function fetchHistorical(symbol, interval = "5m", period = "60d", o
253
240
  period
254
241
  );
255
242
  chunks.push(...candles);
256
- chunkEndMs = chunkStartMs - 1000;
257
243
  remainingMs -= takeMs;
244
+ chunkEndMs = chunkStartMs - 1000;
258
245
 
259
- if (chunks.length > 2_000_000) break;
246
+ if (chunkEndMs <= 0 || chunks.length > 2_000_000) break;
260
247
  }
261
248
 
262
249
  return sanitizeBars(chunks);