tradelab 0.5.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.
- package/README.md +89 -41
- package/bin/tradelab.js +276 -30
- package/dist/cjs/data.cjs +134 -104
- package/dist/cjs/index.cjs +378 -177
- package/dist/cjs/live.cjs +3350 -0
- package/docs/README.md +21 -9
- package/docs/api-reference.md +87 -29
- package/docs/backtest-engine.md +37 -53
- package/docs/data-reporting-cli.md +60 -34
- package/docs/examples.md +6 -12
- package/docs/live-trading.md +186 -0
- package/examples/yahooEmaCross.js +1 -6
- package/package.json +18 -3
- package/src/data/csv.js +24 -14
- package/src/data/index.js +1 -5
- package/src/data/yahoo.js +6 -19
- package/src/engine/backtest.js +137 -144
- package/src/engine/backtestTicks.js +89 -37
- package/src/engine/barSystemRunner.js +182 -118
- package/src/engine/execution.js +11 -39
- package/src/engine/portfolio.js +54 -6
- package/src/engine/walkForward.js +37 -14
- package/src/index.js +2 -11
- package/src/live/broker/alpaca.js +254 -0
- package/src/live/broker/binance.js +351 -0
- package/src/live/broker/coinbase.js +339 -0
- package/src/live/broker/interactiveBrokers.js +123 -0
- package/src/live/broker/interface.js +74 -0
- package/src/live/clock.js +56 -0
- package/src/live/engine/candleAggregator.js +154 -0
- package/src/live/engine/liveEngine.js +694 -0
- package/src/live/engine/paperEngine.js +453 -0
- package/src/live/engine/riskManager.js +185 -0
- package/src/live/engine/stateManager.js +112 -0
- package/src/live/events.js +48 -0
- package/src/live/feed/brokerFeed.js +35 -0
- package/src/live/feed/interface.js +28 -0
- package/src/live/feed/pollingFeed.js +105 -0
- package/src/live/index.js +27 -0
- package/src/live/logger.js +82 -0
- package/src/live/orchestrator.js +133 -0
- package/src/live/storage/interface.js +36 -0
- package/src/live/storage/jsonFileStorage.js +112 -0
- package/src/metrics/buildMetrics.js +18 -41
- package/src/reporting/exportBacktestArtifacts.js +1 -4
- package/src/reporting/exportTradesCsv.js +2 -7
- package/src/reporting/renderHtmlReport.js +8 -13
- package/src/utils/indicators.js +1 -2
- package/src/utils/positionSizing.js +16 -2
- package/src/utils/time.js +4 -12
- package/templates/report.html +23 -9
- package/templates/report.js +83 -69
- package/types/index.d.ts +21 -3
- package/types/live.d.ts +382 -0
package/src/engine/portfolio.js
CHANGED
|
@@ -6,6 +6,12 @@ function asWeight(value) {
|
|
|
6
6
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
function describeValue(value) {
|
|
10
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
11
|
+
if (value === null) return "null";
|
|
12
|
+
return typeof value;
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
|
|
10
16
|
return {
|
|
11
17
|
time,
|
|
@@ -20,6 +26,27 @@ function stableSystemOrder(left, right) {
|
|
|
20
26
|
return left.index - right.index;
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
function hashedOrderScore(index, time, seed) {
|
|
30
|
+
let value = (Number(time) ^ Math.imul(index + 1, 0x9e3779b1) ^ (seed | 0)) >>> 0;
|
|
31
|
+
value = Math.imul(value ^ (value >>> 16), 0x85ebca6b) >>> 0;
|
|
32
|
+
value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35) >>> 0;
|
|
33
|
+
return (value ^ (value >>> 16)) >>> 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function orderActiveSystems(active, nextTime, processingOrder, shuffleSeed) {
|
|
37
|
+
if (processingOrder !== "shuffle") {
|
|
38
|
+
active.sort(stableSystemOrder);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
active.sort((left, right) => {
|
|
43
|
+
const leftScore = hashedOrderScore(left.index, nextTime, shuffleSeed);
|
|
44
|
+
const rightScore = hashedOrderScore(right.index, nextTime, shuffleSeed);
|
|
45
|
+
if (leftScore !== rightScore) return leftScore - rightScore;
|
|
46
|
+
return stableSystemOrder(left, right);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
23
50
|
function combineReplay(systemResults, eqSeries, collectReplay) {
|
|
24
51
|
if (!collectReplay) {
|
|
25
52
|
return { frames: [], events: [] };
|
|
@@ -115,7 +142,8 @@ function forceExitAll(runners, time) {
|
|
|
115
142
|
*
|
|
116
143
|
* Existing allocation weights are preserved as default per-system capital caps,
|
|
117
144
|
* but capital is only locked when a fill actually occurs. Systems therefore
|
|
118
|
-
* compete for the same remaining capital at fill time.
|
|
145
|
+
* compete for the same remaining capital at fill time. `processingOrder` can be
|
|
146
|
+
* set to `"shuffle"` for fairness testing when multiple systems act on the same bar.
|
|
119
147
|
*/
|
|
120
148
|
export function backtestPortfolio({
|
|
121
149
|
systems = [],
|
|
@@ -124,9 +152,18 @@ export function backtestPortfolio({
|
|
|
124
152
|
collectEqSeries = true,
|
|
125
153
|
collectReplay = false,
|
|
126
154
|
maxDailyLossPct = 0,
|
|
155
|
+
processingOrder = "sequential",
|
|
156
|
+
shuffleSeed = 0,
|
|
127
157
|
} = {}) {
|
|
128
158
|
if (!Array.isArray(systems) || systems.length === 0) {
|
|
129
|
-
throw new Error(
|
|
159
|
+
throw new Error(
|
|
160
|
+
`backtestPortfolio() requires a non-empty systems array, got ${describeValue(systems)}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (processingOrder !== "sequential" && processingOrder !== "shuffle") {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`backtestPortfolio() processingOrder must be "sequential" or "shuffle", got ${processingOrder}`
|
|
166
|
+
);
|
|
130
167
|
}
|
|
131
168
|
|
|
132
169
|
const weights =
|
|
@@ -136,7 +173,9 @@ export function backtestPortfolio({
|
|
|
136
173
|
const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
|
|
137
174
|
|
|
138
175
|
if (!(totalWeight > 0)) {
|
|
139
|
-
throw new Error(
|
|
176
|
+
throw new Error(
|
|
177
|
+
`backtestPortfolio() requires positive allocation weights, got allocation=${allocation}`
|
|
178
|
+
);
|
|
140
179
|
}
|
|
141
180
|
|
|
142
181
|
const runners = systems.map((system, index) => {
|
|
@@ -178,7 +217,7 @@ export function backtestPortfolio({
|
|
|
178
217
|
while (true) {
|
|
179
218
|
const { nextTime, active } = findNextTimeAndActive(runners);
|
|
180
219
|
if (!Number.isFinite(nextTime)) break;
|
|
181
|
-
active
|
|
220
|
+
orderActiveSystems(active, nextTime, processingOrder, shuffleSeed);
|
|
182
221
|
|
|
183
222
|
const dayKey = dayKeyET(nextTime);
|
|
184
223
|
if (currentDay === null || dayKey !== currentDay) {
|
|
@@ -201,8 +240,10 @@ export function backtestPortfolio({
|
|
|
201
240
|
canTrade: !portfolioHalted,
|
|
202
241
|
resolveEntrySize({ desiredSize, entryPrice }) {
|
|
203
242
|
const maxLeverage = Math.max(1, systemEntry.runner.options.maxLeverage || 1);
|
|
204
|
-
const byAvailable =
|
|
205
|
-
|
|
243
|
+
const byAvailable =
|
|
244
|
+
(availableCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
|
|
245
|
+
const bySystemCap =
|
|
246
|
+
(systemRemainingCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
|
|
206
247
|
return Math.min(desiredSize, byAvailable, bySystemCap);
|
|
207
248
|
},
|
|
208
249
|
});
|
|
@@ -257,6 +298,12 @@ export function backtestPortfolio({
|
|
|
257
298
|
}))
|
|
258
299
|
)
|
|
259
300
|
.sort((left, right) => left.exit.time - right.exit.time);
|
|
301
|
+
const openPositions = systemResults.flatMap((run) =>
|
|
302
|
+
(run.result.openPositions || []).map((position) => ({
|
|
303
|
+
...position,
|
|
304
|
+
symbol: position.symbol || run.symbol,
|
|
305
|
+
}))
|
|
306
|
+
);
|
|
260
307
|
const replay = combineReplay(systemResults, eqSeries, collectReplay);
|
|
261
308
|
const allCandles = systems.flatMap((system) => system.candles || []);
|
|
262
309
|
const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
|
|
@@ -275,6 +322,7 @@ export function backtestPortfolio({
|
|
|
275
322
|
range: undefined,
|
|
276
323
|
trades,
|
|
277
324
|
positions,
|
|
325
|
+
openPositions,
|
|
278
326
|
metrics,
|
|
279
327
|
eqSeries,
|
|
280
328
|
replay,
|
|
@@ -20,19 +20,19 @@ function stitchEquitySeries(target, source) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function canonicalParams(params) {
|
|
23
|
-
const entries = Object.entries(params || {}).sort(([left], [right]) =>
|
|
24
|
-
left.localeCompare(right)
|
|
25
|
-
);
|
|
23
|
+
const entries = Object.entries(params || {}).sort(([left], [right]) => left.localeCompare(right));
|
|
26
24
|
return JSON.stringify(Object.fromEntries(entries));
|
|
27
25
|
}
|
|
28
26
|
|
|
27
|
+
function describeValue(value) {
|
|
28
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
29
|
+
if (value === null) return "null";
|
|
30
|
+
return typeof value;
|
|
31
|
+
}
|
|
32
|
+
|
|
29
33
|
function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
|
|
30
34
|
const ranges = [];
|
|
31
|
-
for (
|
|
32
|
-
let start = 0;
|
|
33
|
-
start + trainBars + testBars <= length;
|
|
34
|
-
start += stepBars
|
|
35
|
-
) {
|
|
35
|
+
for (let start = 0; start + trainBars + testBars <= length; start += stepBars) {
|
|
36
36
|
const trainStart = mode === "anchored" ? 0 : start;
|
|
37
37
|
const trainEnd = mode === "anchored" ? trainBars + start : start + trainBars;
|
|
38
38
|
const testStart = trainEnd;
|
|
@@ -62,8 +62,8 @@ function summarizeBestParams(windows) {
|
|
|
62
62
|
|
|
63
63
|
if (
|
|
64
64
|
index > 0 &&
|
|
65
|
-
(windows[index - 1].bestParamsSignature ??
|
|
66
|
-
|
|
65
|
+
(windows[index - 1].bestParamsSignature ?? canonicalParams(windows[index - 1].bestParams)) ===
|
|
66
|
+
signature
|
|
67
67
|
) {
|
|
68
68
|
adjacentRepeats += 1;
|
|
69
69
|
}
|
|
@@ -104,13 +104,19 @@ export function walkForwardOptimize({
|
|
|
104
104
|
backtestOptions = {},
|
|
105
105
|
} = {}) {
|
|
106
106
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
107
|
-
throw new Error(
|
|
107
|
+
throw new Error(
|
|
108
|
+
`walkForwardOptimize() requires a non-empty candles array, got ${describeValue(candles)}`
|
|
109
|
+
);
|
|
108
110
|
}
|
|
109
111
|
if (typeof signalFactory !== "function") {
|
|
110
|
-
throw new Error(
|
|
112
|
+
throw new Error(
|
|
113
|
+
`walkForwardOptimize() requires a signalFactory function, got ${describeValue(signalFactory)}`
|
|
114
|
+
);
|
|
111
115
|
}
|
|
112
116
|
if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
|
|
113
|
-
throw new Error(
|
|
117
|
+
throw new Error(
|
|
118
|
+
`walkForwardOptimize() requires parameterSets, got ${describeValue(parameterSets)}`
|
|
119
|
+
);
|
|
114
120
|
}
|
|
115
121
|
if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
|
|
116
122
|
throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
|
|
@@ -125,6 +131,12 @@ export function walkForwardOptimize({
|
|
|
125
131
|
const eqSeries = [];
|
|
126
132
|
let rollingEquity = backtestOptions.equity ?? 10_000;
|
|
127
133
|
const ranges = buildWindowRanges(candles.length, trainBars, testBars, stepBars, mode);
|
|
134
|
+
if (!ranges.length) {
|
|
135
|
+
const required = trainBars + testBars;
|
|
136
|
+
throw new Error(
|
|
137
|
+
`walkForwardOptimize() produced zero windows: need at least ${required} candles (trainBars=${trainBars} + testBars=${testBars}) but got ${candles.length}. Try reducing trainBars/testBars or adding more historical data.`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
128
140
|
const trainBacktestOptions = {
|
|
129
141
|
...backtestOptions,
|
|
130
142
|
collectEqSeries: false,
|
|
@@ -135,6 +147,13 @@ export function walkForwardOptimize({
|
|
|
135
147
|
for (const range of ranges) {
|
|
136
148
|
const trainSlice = candles.slice(range.trainStart, range.trainEnd);
|
|
137
149
|
const testSlice = candles.slice(range.testStart, range.testEnd);
|
|
150
|
+
if (!trainSlice.length || !testSlice.length) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`walkForwardOptimize() generated an empty window (train=${trainSlice.length}, test=${testSlice.length}, range=${JSON.stringify(
|
|
153
|
+
range
|
|
154
|
+
)})`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
138
157
|
|
|
139
158
|
let best = null;
|
|
140
159
|
for (const params of parameterSets) {
|
|
@@ -213,10 +232,14 @@ export function walkForwardOptimize({
|
|
|
213
232
|
windows,
|
|
214
233
|
trades: allTrades,
|
|
215
234
|
positions: allPositions,
|
|
235
|
+
openPositions: [],
|
|
216
236
|
metrics,
|
|
217
237
|
eqSeries,
|
|
218
238
|
replay: { frames: [], events: [] },
|
|
219
|
-
bestParams: Object.assign(
|
|
239
|
+
bestParams: Object.assign(
|
|
240
|
+
windows.map((window) => window.bestParams),
|
|
241
|
+
bestParamsSummary
|
|
242
|
+
),
|
|
220
243
|
bestParamsSummary: bestParamsSummary.stability,
|
|
221
244
|
};
|
|
222
245
|
}
|
package/src/index.js
CHANGED
|
@@ -18,10 +18,7 @@ export {
|
|
|
18
18
|
saveCandlesToCache,
|
|
19
19
|
} from "./data/index.js";
|
|
20
20
|
|
|
21
|
-
export {
|
|
22
|
-
renderHtmlReport,
|
|
23
|
-
exportHtmlReport,
|
|
24
|
-
} from "./reporting/renderHtmlReport.js";
|
|
21
|
+
export { renderHtmlReport, exportHtmlReport } from "./reporting/renderHtmlReport.js";
|
|
25
22
|
export { exportTradesCsv } from "./reporting/exportTradesCsv.js";
|
|
26
23
|
export { exportMetricsJSON } from "./reporting/exportMetricsJson.js";
|
|
27
24
|
export { exportBacktestArtifacts } from "./reporting/exportBacktestArtifacts.js";
|
|
@@ -38,10 +35,4 @@ export {
|
|
|
38
35
|
pct,
|
|
39
36
|
} from "./utils/indicators.js";
|
|
40
37
|
export { calculatePositionSize } from "./utils/positionSizing.js";
|
|
41
|
-
export {
|
|
42
|
-
offsetET,
|
|
43
|
-
minutesET,
|
|
44
|
-
isSession,
|
|
45
|
-
parseWindowsCSV,
|
|
46
|
-
inWindowsET,
|
|
47
|
-
} from "./utils/time.js";
|
|
38
|
+
export { offsetET, minutesET, isSession, parseWindowsCSV, inWindowsET } from "./utils/time.js";
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { URL } from "node:url";
|
|
2
|
+
|
|
3
|
+
import { normalizeCandles } from "../../data/csv.js";
|
|
4
|
+
import { BrokerAdapter } from "./interface.js";
|
|
5
|
+
|
|
6
|
+
function withQuery(url, query = {}) {
|
|
7
|
+
const target = new URL(url);
|
|
8
|
+
for (const [key, value] of Object.entries(query)) {
|
|
9
|
+
if (value === undefined || value === null) continue;
|
|
10
|
+
target.searchParams.set(key, String(value));
|
|
11
|
+
}
|
|
12
|
+
return target.toString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function mapOrderStatus(status) {
|
|
16
|
+
const normalized = String(status || "").toLowerCase();
|
|
17
|
+
if (normalized === "partially_filled") return "partially_filled";
|
|
18
|
+
if (normalized === "filled") return "filled";
|
|
19
|
+
if (normalized === "canceled" || normalized === "cancelled") return "canceled";
|
|
20
|
+
if (normalized === "rejected") return "rejected";
|
|
21
|
+
if (normalized === "expired") return "expired";
|
|
22
|
+
return "new";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mapOrderReceipt(order) {
|
|
26
|
+
return {
|
|
27
|
+
orderId: String(order.id),
|
|
28
|
+
clientOrderId: order.client_order_id,
|
|
29
|
+
status: mapOrderStatus(order.status),
|
|
30
|
+
filledQty: Number(order.filled_qty || 0),
|
|
31
|
+
avgFillPrice: Number.isFinite(Number(order.filled_avg_price))
|
|
32
|
+
? Number(order.filled_avg_price)
|
|
33
|
+
: undefined,
|
|
34
|
+
filledAt: order.filled_at ? Date.parse(order.filled_at) : undefined,
|
|
35
|
+
symbol: order.symbol,
|
|
36
|
+
side: order.side,
|
|
37
|
+
type: String(order.type || "").toLowerCase(),
|
|
38
|
+
qty: Number(order.qty || 0),
|
|
39
|
+
rejectReason: order.reject_reason,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Alpaca Markets broker adapter.
|
|
45
|
+
*/
|
|
46
|
+
export class AlpacaBroker extends BrokerAdapter {
|
|
47
|
+
constructor({ fetchImpl = globalThis.fetch } = {}) {
|
|
48
|
+
super();
|
|
49
|
+
this.fetch = fetchImpl;
|
|
50
|
+
this.connected = false;
|
|
51
|
+
this.config = {};
|
|
52
|
+
this.subscriptions = {
|
|
53
|
+
bars: new Map(),
|
|
54
|
+
quotes: new Map(),
|
|
55
|
+
trades: new Map(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async connect(config = {}) {
|
|
60
|
+
this.config = { ...config };
|
|
61
|
+
this.baseUrl =
|
|
62
|
+
config.baseUrl ||
|
|
63
|
+
(config.paper ? "https://paper-api.alpaca.markets" : "https://api.alpaca.markets");
|
|
64
|
+
this.dataUrl = config.dataUrl || "https://data.alpaca.markets";
|
|
65
|
+
this.connected = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async disconnect() {
|
|
69
|
+
this.connected = false;
|
|
70
|
+
this.subscriptions.bars.clear();
|
|
71
|
+
this.subscriptions.quotes.clear();
|
|
72
|
+
this.subscriptions.trades.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isConnected() {
|
|
76
|
+
return this.connected;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
supportsPaperNative() {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_headers(extra = {}) {
|
|
84
|
+
return {
|
|
85
|
+
"content-type": "application/json",
|
|
86
|
+
"APCA-API-KEY-ID": this.config.apiKey || "",
|
|
87
|
+
"APCA-API-SECRET-KEY": this.config.apiSecret || "",
|
|
88
|
+
...extra,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async _request(method, path, { query = null, body = null, dataApi = false } = {}) {
|
|
93
|
+
if (!this.fetch) throw new Error("global fetch is unavailable");
|
|
94
|
+
const base = dataApi ? this.dataUrl : this.baseUrl;
|
|
95
|
+
const url = withQuery(`${base}${path}`, query || {});
|
|
96
|
+
const response = await this.fetch(url, {
|
|
97
|
+
method,
|
|
98
|
+
headers: this._headers(),
|
|
99
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
100
|
+
});
|
|
101
|
+
const text = await response.text();
|
|
102
|
+
const payload = text ? JSON.parse(text) : {};
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
const message =
|
|
105
|
+
payload?.message || payload?.error || `alpaca request failed (${response.status})`;
|
|
106
|
+
throw new Error(message);
|
|
107
|
+
}
|
|
108
|
+
return payload;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async getAccount() {
|
|
112
|
+
const account = await this._request("GET", "/v2/account");
|
|
113
|
+
return {
|
|
114
|
+
equity: Number(account.equity || 0),
|
|
115
|
+
buyingPower: Number(account.buying_power || 0),
|
|
116
|
+
cash: Number(account.cash || 0),
|
|
117
|
+
currency: account.currency || "USD",
|
|
118
|
+
marginUsed: Number(account.initial_margin || 0),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async getPositions() {
|
|
123
|
+
const positions = await this._request("GET", "/v2/positions");
|
|
124
|
+
return positions.map((position) => ({
|
|
125
|
+
symbol: position.symbol,
|
|
126
|
+
side: String(position.side || "long").toLowerCase(),
|
|
127
|
+
qty: Number(position.qty || 0),
|
|
128
|
+
avgEntry: Number(position.avg_entry_price || 0),
|
|
129
|
+
marketValue: Number(position.market_value || 0),
|
|
130
|
+
unrealizedPnl: Number(position.unrealized_pl || 0),
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async getServerTime() {
|
|
135
|
+
const clock = await this._request("GET", "/v2/clock");
|
|
136
|
+
return clock.timestamp ? Date.parse(clock.timestamp) : Date.now();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async submitOrder(order) {
|
|
140
|
+
const payload = {
|
|
141
|
+
symbol: order.symbol,
|
|
142
|
+
side: order.side,
|
|
143
|
+
type: order.type,
|
|
144
|
+
qty: String(order.qty),
|
|
145
|
+
time_in_force: order.timeInForce || "day",
|
|
146
|
+
client_order_id: order.clientOrderId,
|
|
147
|
+
};
|
|
148
|
+
if (order.limitPrice !== undefined) payload.limit_price = String(order.limitPrice);
|
|
149
|
+
if (order.stopPrice !== undefined) payload.stop_price = String(order.stopPrice);
|
|
150
|
+
const response = await this._request("POST", "/v2/orders", { body: payload });
|
|
151
|
+
const receipt = mapOrderReceipt(response);
|
|
152
|
+
this.emit("order:submitted", receipt);
|
|
153
|
+
return receipt;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async cancelOrder(orderId) {
|
|
157
|
+
await this._request("DELETE", `/v2/orders/${orderId}`);
|
|
158
|
+
this.emit("order:canceled", { orderId });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async modifyOrder(orderId, changes) {
|
|
162
|
+
const payload = {};
|
|
163
|
+
if (changes.qty !== undefined) payload.qty = String(changes.qty);
|
|
164
|
+
if (changes.limitPrice !== undefined) payload.limit_price = String(changes.limitPrice);
|
|
165
|
+
if (changes.stopPrice !== undefined) payload.stop_price = String(changes.stopPrice);
|
|
166
|
+
const response = await this._request("PATCH", `/v2/orders/${orderId}`, { body: payload });
|
|
167
|
+
const receipt = mapOrderReceipt(response);
|
|
168
|
+
this.emit("order:modified", receipt);
|
|
169
|
+
return receipt;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async getOpenOrders() {
|
|
173
|
+
const orders = await this._request("GET", "/v2/orders", { query: { status: "open" } });
|
|
174
|
+
return orders.map(mapOrderReceipt);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async getOrderStatus(orderId) {
|
|
178
|
+
const order = await this._request("GET", `/v2/orders/${orderId}`);
|
|
179
|
+
return mapOrderReceipt(order);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async subscribeQuotes(symbol, handler) {
|
|
183
|
+
const key = symbol;
|
|
184
|
+
const list = this.subscriptions.quotes.get(key) || [];
|
|
185
|
+
list.push(handler);
|
|
186
|
+
this.subscriptions.quotes.set(key, list);
|
|
187
|
+
return {
|
|
188
|
+
unsubscribe: () => {
|
|
189
|
+
const current = this.subscriptions.quotes.get(key) || [];
|
|
190
|
+
this.subscriptions.quotes.set(
|
|
191
|
+
key,
|
|
192
|
+
current.filter((candidate) => candidate !== handler)
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async subscribeTrades(symbol, handler) {
|
|
199
|
+
const key = symbol;
|
|
200
|
+
const list = this.subscriptions.trades.get(key) || [];
|
|
201
|
+
list.push(handler);
|
|
202
|
+
this.subscriptions.trades.set(key, list);
|
|
203
|
+
return {
|
|
204
|
+
unsubscribe: () => {
|
|
205
|
+
const current = this.subscriptions.trades.get(key) || [];
|
|
206
|
+
this.subscriptions.trades.set(
|
|
207
|
+
key,
|
|
208
|
+
current.filter((candidate) => candidate !== handler)
|
|
209
|
+
);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async subscribeBars(symbol, interval, handler) {
|
|
215
|
+
const key = `${symbol}::${interval}`;
|
|
216
|
+
const list = this.subscriptions.bars.get(key) || [];
|
|
217
|
+
list.push(handler);
|
|
218
|
+
this.subscriptions.bars.set(key, list);
|
|
219
|
+
return {
|
|
220
|
+
unsubscribe: () => {
|
|
221
|
+
const current = this.subscriptions.bars.get(key) || [];
|
|
222
|
+
this.subscriptions.bars.set(
|
|
223
|
+
key,
|
|
224
|
+
current.filter((candidate) => candidate !== handler)
|
|
225
|
+
);
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async getHistoricalBars(symbol, interval, limit = 200) {
|
|
231
|
+
const response = await this._request("GET", `/v2/stocks/${symbol}/bars`, {
|
|
232
|
+
dataApi: true,
|
|
233
|
+
query: {
|
|
234
|
+
timeframe: interval,
|
|
235
|
+
limit,
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
const bars = Array.isArray(response?.bars)
|
|
239
|
+
? response.bars.map((bar) => ({
|
|
240
|
+
time: Date.parse(bar.t),
|
|
241
|
+
open: Number(bar.o),
|
|
242
|
+
high: Number(bar.h),
|
|
243
|
+
low: Number(bar.l),
|
|
244
|
+
close: Number(bar.c),
|
|
245
|
+
volume: Number(bar.v ?? 0),
|
|
246
|
+
}))
|
|
247
|
+
: [];
|
|
248
|
+
return normalizeCandles(bars);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function createAlpacaBroker(options) {
|
|
253
|
+
return new AlpacaBroker(options);
|
|
254
|
+
}
|