@livefolio/sdk 0.2.3 → 0.2.5
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 +3 -0
- package/dist/market/client.d.ts.map +1 -1
- package/dist/market/client.js +28 -0
- package/dist/market/client.js.map +1 -1
- package/dist/market/index.d.ts +2 -0
- package/dist/market/index.d.ts.map +1 -1
- package/dist/market/index.js +4 -1
- package/dist/market/index.js.map +1 -1
- package/dist/market/trackedTickers.d.ts +4 -0
- package/dist/market/trackedTickers.d.ts.map +1 -0
- package/dist/market/trackedTickers.js +256 -0
- package/dist/market/trackedTickers.js.map +1 -0
- package/dist/market/types.d.ts +2 -0
- package/dist/market/types.d.ts.map +1 -1
- package/dist/strategy/backtest.d.ts +5 -2
- package/dist/strategy/backtest.d.ts.map +1 -1
- package/dist/strategy/backtest.js +657 -2
- package/dist/strategy/backtest.js.map +1 -1
- package/dist/strategy/client.d.ts.map +1 -1
- package/dist/strategy/client.js +4 -1
- package/dist/strategy/client.js.map +1 -1
- package/dist/strategy/evaluate.d.ts.map +1 -1
- package/dist/strategy/evaluate.js +35 -8
- package/dist/strategy/evaluate.js.map +1 -1
- package/dist/strategy/index.d.ts +5 -2
- package/dist/strategy/index.d.ts.map +1 -1
- package/dist/strategy/index.js +11 -1
- package/dist/strategy/index.js.map +1 -1
- package/dist/strategy/livefolio.d.ts +25 -0
- package/dist/strategy/livefolio.d.ts.map +1 -0
- package/dist/strategy/livefolio.js +67 -0
- package/dist/strategy/livefolio.js.map +1 -0
- package/dist/strategy/rules.d.ts +3 -0
- package/dist/strategy/rules.d.ts.map +1 -0
- package/dist/strategy/rules.js +100 -0
- package/dist/strategy/rules.js.map +1 -0
- package/dist/strategy/types.d.ts +86 -1
- package/dist/strategy/types.d.ts.map +1 -1
- package/package.json +6 -2
|
@@ -1,7 +1,662 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.backtestWithMarketData = backtestWithMarketData;
|
|
4
|
+
exports.backtestRulesWithMarketData = backtestRulesWithMarketData;
|
|
3
5
|
exports.backtest = backtest;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
const evaluate_1 = require("./evaluate");
|
|
7
|
+
const rules_1 = require("./rules");
|
|
8
|
+
const symbols_1 = require("./symbols");
|
|
9
|
+
const EPSILON = 1e-8;
|
|
10
|
+
const DEFAULT_DEBUG_LOG_EVERY_DAYS = 63;
|
|
11
|
+
function nowMs() {
|
|
12
|
+
return typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
13
|
+
? performance.now()
|
|
14
|
+
: Date.now();
|
|
15
|
+
}
|
|
16
|
+
function getDebugOptions(options) {
|
|
17
|
+
if (!options.debug)
|
|
18
|
+
return null;
|
|
19
|
+
if (options.debug === true)
|
|
20
|
+
return { logEveryDays: DEFAULT_DEBUG_LOG_EVERY_DAYS };
|
|
21
|
+
const logEveryDays = Math.max(1, Math.floor(options.debug.logEveryDays ?? DEFAULT_DEBUG_LOG_EVERY_DAYS));
|
|
22
|
+
return { logEveryDays };
|
|
23
|
+
}
|
|
24
|
+
function toDateYmd(value) {
|
|
25
|
+
return value.toISOString().slice(0, 10);
|
|
26
|
+
}
|
|
27
|
+
function addDaysToYmd(ymd, days) {
|
|
28
|
+
const date = new Date(`${ymd}T00:00:00.000Z`);
|
|
29
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
30
|
+
return date.toISOString().slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
function taxYearFromDate(ymd) {
|
|
33
|
+
return Number(ymd.slice(0, 4));
|
|
34
|
+
}
|
|
35
|
+
function normalizeSeries(batchSeries) {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [symbol, observations] of Object.entries(batchSeries)) {
|
|
38
|
+
out[symbol] = observations
|
|
39
|
+
.map((observation) => ({
|
|
40
|
+
timestamp: new Date(observation.timestamp).getTime(),
|
|
41
|
+
value: observation.value,
|
|
42
|
+
}))
|
|
43
|
+
.filter((observation) => Number.isFinite(observation.timestamp) && Number.isFinite(observation.value))
|
|
44
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function latestPriceAtOrBefore(series, timestamp) {
|
|
49
|
+
if (!series.length)
|
|
50
|
+
return null;
|
|
51
|
+
let low = 0;
|
|
52
|
+
let high = series.length - 1;
|
|
53
|
+
let found = -1;
|
|
54
|
+
while (low <= high) {
|
|
55
|
+
const middle = Math.floor((low + high) / 2);
|
|
56
|
+
if (series[middle].timestamp <= timestamp) {
|
|
57
|
+
found = middle;
|
|
58
|
+
low = middle + 1;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
high = middle - 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return found >= 0 ? series[found].value : null;
|
|
65
|
+
}
|
|
66
|
+
function positionKey(symbol, leverage) {
|
|
67
|
+
return `${symbol}::${leverage}`;
|
|
68
|
+
}
|
|
69
|
+
function parsePositionKey(key) {
|
|
70
|
+
const [symbol, leverage] = key.split('::');
|
|
71
|
+
return { symbol, leverage: Number(leverage) };
|
|
72
|
+
}
|
|
73
|
+
function getRequiredSymbols(strategy) {
|
|
74
|
+
const symbols = new Set();
|
|
75
|
+
const push = (symbol) => {
|
|
76
|
+
const normalized = symbol.trim();
|
|
77
|
+
if (!normalized)
|
|
78
|
+
return;
|
|
79
|
+
symbols.add(normalized);
|
|
80
|
+
};
|
|
81
|
+
const pushIndicatorSymbol = (indicator) => {
|
|
82
|
+
if (indicator.type === 'Threshold')
|
|
83
|
+
return;
|
|
84
|
+
push(indicator.ticker.symbol);
|
|
85
|
+
};
|
|
86
|
+
for (const ns of strategy.signals) {
|
|
87
|
+
pushIndicatorSymbol(ns.signal.left);
|
|
88
|
+
pushIndicatorSymbol(ns.signal.right);
|
|
89
|
+
}
|
|
90
|
+
for (const allocation of strategy.allocations) {
|
|
91
|
+
for (const holding of allocation.allocation.holdings) {
|
|
92
|
+
push(holding.ticker.symbol);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...symbols];
|
|
96
|
+
}
|
|
97
|
+
function validateDefaultAllocation(strategy) {
|
|
98
|
+
const defaultAllocations = strategy.allocations.filter((allocation) => allocation.name.toLowerCase() === 'default');
|
|
99
|
+
if (defaultAllocations.length !== 1) {
|
|
100
|
+
throw new Error('Strategy must include exactly one allocation named "Default".');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function normalizeTradingDays(tradingDays, startDate, endDate) {
|
|
104
|
+
return tradingDays
|
|
105
|
+
.filter((day) => day.date >= startDate && day.date <= endDate)
|
|
106
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
107
|
+
}
|
|
108
|
+
function findEffectiveStartDate(requiredSymbols, batchSeries, startDate, endDate) {
|
|
109
|
+
let effectiveStartDate = startDate;
|
|
110
|
+
let effectiveEndDate = endDate;
|
|
111
|
+
for (const symbol of requiredSymbols) {
|
|
112
|
+
const observations = batchSeries[symbol] ?? [];
|
|
113
|
+
let symbolEarliest = null;
|
|
114
|
+
let symbolLatest = null;
|
|
115
|
+
for (const observation of observations) {
|
|
116
|
+
const timestamp = new Date(observation.timestamp);
|
|
117
|
+
if (!Number.isFinite(timestamp.getTime()))
|
|
118
|
+
continue;
|
|
119
|
+
const date = toDateYmd(timestamp);
|
|
120
|
+
if (date < startDate || date > endDate)
|
|
121
|
+
continue;
|
|
122
|
+
if (!symbolEarliest || date < symbolEarliest) {
|
|
123
|
+
symbolEarliest = date;
|
|
124
|
+
}
|
|
125
|
+
if (!symbolLatest || date > symbolLatest) {
|
|
126
|
+
symbolLatest = date;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!symbolEarliest) {
|
|
130
|
+
throw new Error(`No market data for symbol ${symbol} in selected date range.`);
|
|
131
|
+
}
|
|
132
|
+
if (symbolEarliest > effectiveStartDate) {
|
|
133
|
+
effectiveStartDate = symbolEarliest;
|
|
134
|
+
}
|
|
135
|
+
if (symbolLatest && symbolLatest < effectiveEndDate) {
|
|
136
|
+
effectiveEndDate = symbolLatest;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (effectiveStartDate > effectiveEndDate) {
|
|
140
|
+
throw new Error('No overlapping market-data window across required symbols.');
|
|
141
|
+
}
|
|
142
|
+
return effectiveStartDate;
|
|
143
|
+
}
|
|
144
|
+
function buildLeveragedPriceSeries(tradingDays, batchSeries, requiredPositions) {
|
|
145
|
+
const raw = normalizeSeries(batchSeries);
|
|
146
|
+
const leveragedByPosition = {};
|
|
147
|
+
for (const { symbol, leverage } of requiredPositions) {
|
|
148
|
+
const key = positionKey(symbol, leverage);
|
|
149
|
+
const baseSeries = raw[symbol] ?? [];
|
|
150
|
+
if (!baseSeries.length) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (leverage === 1) {
|
|
154
|
+
const direct = {};
|
|
155
|
+
for (const day of tradingDays) {
|
|
156
|
+
const closeTs = new Date(day.close).getTime();
|
|
157
|
+
const price = latestPriceAtOrBefore(baseSeries, closeTs);
|
|
158
|
+
if (price != null && price > 0) {
|
|
159
|
+
direct[day.date] = price;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
leveragedByPosition[key] = direct;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const synthetic = {};
|
|
166
|
+
let previousBasePrice = null;
|
|
167
|
+
let previousLeveragedPrice = null;
|
|
168
|
+
for (const day of tradingDays) {
|
|
169
|
+
const closeTs = new Date(day.close).getTime();
|
|
170
|
+
const basePrice = latestPriceAtOrBefore(baseSeries, closeTs);
|
|
171
|
+
if (basePrice == null || basePrice <= 0) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (previousBasePrice == null || previousLeveragedPrice == null) {
|
|
175
|
+
previousBasePrice = basePrice;
|
|
176
|
+
previousLeveragedPrice = basePrice;
|
|
177
|
+
synthetic[day.date] = basePrice;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const baseReturn = (basePrice - previousBasePrice) / previousBasePrice;
|
|
181
|
+
const leveragedReturn = baseReturn * leverage;
|
|
182
|
+
const nextLeveragedPrice = previousLeveragedPrice * (1 + leveragedReturn);
|
|
183
|
+
previousBasePrice = basePrice;
|
|
184
|
+
previousLeveragedPrice = nextLeveragedPrice;
|
|
185
|
+
synthetic[day.date] = nextLeveragedPrice;
|
|
186
|
+
}
|
|
187
|
+
leveragedByPosition[key] = synthetic;
|
|
188
|
+
}
|
|
189
|
+
return leveragedByPosition;
|
|
190
|
+
}
|
|
191
|
+
function computePortfolioValue(positions, pricesByPosition, cash) {
|
|
192
|
+
let total = cash;
|
|
193
|
+
for (const [key, shares] of Object.entries(positions)) {
|
|
194
|
+
const price = pricesByPosition[key];
|
|
195
|
+
if (!Number.isFinite(price) || !Number.isFinite(shares))
|
|
196
|
+
continue;
|
|
197
|
+
total += shares * price;
|
|
198
|
+
}
|
|
199
|
+
return total;
|
|
200
|
+
}
|
|
201
|
+
function standardDeviation(values) {
|
|
202
|
+
if (values.length < 2)
|
|
203
|
+
return 0;
|
|
204
|
+
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
205
|
+
const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (values.length - 1);
|
|
206
|
+
return Math.sqrt(Math.max(variance, 0));
|
|
207
|
+
}
|
|
208
|
+
function finiteOrZero(value) {
|
|
209
|
+
return Number.isFinite(value) ? value : 0;
|
|
210
|
+
}
|
|
211
|
+
function isLongTermLot(acquiredDate, soldDate) {
|
|
212
|
+
const acquired = new Date(`${acquiredDate}T00:00:00.000Z`);
|
|
213
|
+
const threshold = new Date(acquired);
|
|
214
|
+
threshold.setUTCFullYear(threshold.getUTCFullYear() + 1);
|
|
215
|
+
const sold = new Date(`${soldDate}T00:00:00.000Z`);
|
|
216
|
+
return sold.getTime() > threshold.getTime();
|
|
217
|
+
}
|
|
218
|
+
function addAnnualTaxForTerm(annualTaxByYear, year, term, amount) {
|
|
219
|
+
const current = annualTaxByYear.get(year) ?? { shortTerm: 0, longTerm: 0 };
|
|
220
|
+
current[term] += amount;
|
|
221
|
+
annualTaxByYear.set(year, current);
|
|
222
|
+
}
|
|
223
|
+
function sellLotsHifo(lots, sharesToSell) {
|
|
224
|
+
const sorted = [...lots].sort((a, b) => b.costPerShare - a.costPerShare);
|
|
225
|
+
let remaining = sharesToSell;
|
|
226
|
+
const sold = [];
|
|
227
|
+
for (const lot of sorted) {
|
|
228
|
+
if (remaining <= EPSILON)
|
|
229
|
+
break;
|
|
230
|
+
if (lot.shares <= EPSILON)
|
|
231
|
+
continue;
|
|
232
|
+
const consumed = Math.min(lot.shares, remaining);
|
|
233
|
+
sold.push({
|
|
234
|
+
shares: consumed,
|
|
235
|
+
costPerShare: lot.costPerShare,
|
|
236
|
+
acquiredDate: lot.acquiredDate,
|
|
237
|
+
});
|
|
238
|
+
lot.shares -= consumed;
|
|
239
|
+
remaining -= consumed;
|
|
240
|
+
}
|
|
241
|
+
lots.length = 0;
|
|
242
|
+
for (const lot of sorted) {
|
|
243
|
+
if (lot.shares > EPSILON)
|
|
244
|
+
lots.push(lot);
|
|
245
|
+
}
|
|
246
|
+
return sold;
|
|
247
|
+
}
|
|
248
|
+
function applyWashToExistingReplacementLots(lots, soldDate, lossPerShare, sharesToMatch) {
|
|
249
|
+
if (sharesToMatch <= EPSILON)
|
|
250
|
+
return 0;
|
|
251
|
+
const windowStart = addDaysToYmd(soldDate, -30);
|
|
252
|
+
let remaining = sharesToMatch;
|
|
253
|
+
let matched = 0;
|
|
254
|
+
const nextLots = [];
|
|
255
|
+
for (const lot of lots) {
|
|
256
|
+
if (remaining <= EPSILON || lot.shares <= EPSILON) {
|
|
257
|
+
nextLots.push(lot);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const inWindow = lot.acquiredDate >= windowStart && lot.acquiredDate <= soldDate;
|
|
261
|
+
if (!inWindow) {
|
|
262
|
+
nextLots.push(lot);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const consumed = Math.min(lot.shares, remaining);
|
|
266
|
+
if (consumed >= lot.shares - EPSILON) {
|
|
267
|
+
nextLots.push({
|
|
268
|
+
shares: lot.shares,
|
|
269
|
+
costPerShare: lot.costPerShare + lossPerShare,
|
|
270
|
+
acquiredDate: lot.acquiredDate,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
nextLots.push({
|
|
275
|
+
shares: consumed,
|
|
276
|
+
costPerShare: lot.costPerShare + lossPerShare,
|
|
277
|
+
acquiredDate: lot.acquiredDate,
|
|
278
|
+
});
|
|
279
|
+
nextLots.push({
|
|
280
|
+
shares: lot.shares - consumed,
|
|
281
|
+
costPerShare: lot.costPerShare,
|
|
282
|
+
acquiredDate: lot.acquiredDate,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
remaining -= consumed;
|
|
286
|
+
matched += consumed;
|
|
287
|
+
}
|
|
288
|
+
lots.length = 0;
|
|
289
|
+
for (const lot of nextLots) {
|
|
290
|
+
if (lot.shares > EPSILON)
|
|
291
|
+
lots.push(lot);
|
|
292
|
+
}
|
|
293
|
+
return matched;
|
|
294
|
+
}
|
|
295
|
+
function applyPendingWashToNewBuy(pending, buyDate, buyPrice, buyShares, annualTaxByYear) {
|
|
296
|
+
const active = pending.filter((entry) => entry.remainingShares > EPSILON && buyDate <= entry.windowEndDate);
|
|
297
|
+
pending.length = 0;
|
|
298
|
+
pending.push(...active);
|
|
299
|
+
let remaining = buyShares;
|
|
300
|
+
const newLots = [];
|
|
301
|
+
for (const entry of pending) {
|
|
302
|
+
if (remaining <= EPSILON)
|
|
303
|
+
break;
|
|
304
|
+
if (entry.remainingShares <= EPSILON)
|
|
305
|
+
continue;
|
|
306
|
+
const matched = Math.min(remaining, entry.remainingShares);
|
|
307
|
+
if (matched <= EPSILON)
|
|
308
|
+
continue;
|
|
309
|
+
newLots.push({
|
|
310
|
+
shares: matched,
|
|
311
|
+
costPerShare: buyPrice + entry.lossPerShare,
|
|
312
|
+
acquiredDate: buyDate,
|
|
313
|
+
});
|
|
314
|
+
entry.remainingShares -= matched;
|
|
315
|
+
remaining -= matched;
|
|
316
|
+
addAnnualTaxForTerm(annualTaxByYear, taxYearFromDate(entry.saleDate), entry.term, matched * entry.lossPerShare);
|
|
317
|
+
}
|
|
318
|
+
if (remaining > EPSILON) {
|
|
319
|
+
newLots.push({
|
|
320
|
+
shares: remaining,
|
|
321
|
+
costPerShare: buyPrice,
|
|
322
|
+
acquiredDate: buyDate,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const unresolved = pending.filter((entry) => entry.remainingShares > EPSILON);
|
|
326
|
+
pending.length = 0;
|
|
327
|
+
pending.push(...unresolved);
|
|
328
|
+
return newLots;
|
|
329
|
+
}
|
|
330
|
+
function isCalendarRebalanceDue(frequency, date, lastRebalancedDate) {
|
|
331
|
+
if (!lastRebalancedDate)
|
|
332
|
+
return true;
|
|
333
|
+
if (frequency === 'Daily')
|
|
334
|
+
return date !== lastRebalancedDate;
|
|
335
|
+
if (frequency === 'Monthly')
|
|
336
|
+
return date.slice(0, 7) !== lastRebalancedDate.slice(0, 7);
|
|
337
|
+
return date.slice(0, 4) !== lastRebalancedDate.slice(0, 4);
|
|
338
|
+
}
|
|
339
|
+
function getRebalanceConfig(options, allocationName) {
|
|
340
|
+
return options.allocationRebalance?.[allocationName] ?? { mode: 'on_change' };
|
|
341
|
+
}
|
|
342
|
+
function computeAllocationDriftPct(positions, pricesByPosition, targetShares, totalValue) {
|
|
343
|
+
if (!Number.isFinite(totalValue) || totalValue <= 0)
|
|
344
|
+
return 0;
|
|
345
|
+
let maxDrift = 0;
|
|
346
|
+
const keys = new Set([...Object.keys(positions), ...Object.keys(targetShares)]);
|
|
347
|
+
for (const key of keys) {
|
|
348
|
+
const price = pricesByPosition[key];
|
|
349
|
+
if (!Number.isFinite(price) || price <= 0)
|
|
350
|
+
continue;
|
|
351
|
+
const currentValue = (positions[key] ?? 0) * price;
|
|
352
|
+
const targetValue = (targetShares[key] ?? 0) * price;
|
|
353
|
+
const currentWeight = (currentValue / totalValue) * 100;
|
|
354
|
+
const targetWeight = (targetValue / totalValue) * 100;
|
|
355
|
+
maxDrift = Math.max(maxDrift, Math.abs(currentWeight - targetWeight));
|
|
356
|
+
}
|
|
357
|
+
return maxDrift;
|
|
358
|
+
}
|
|
359
|
+
async function resolveBacktestInputs(market, strategy, options) {
|
|
360
|
+
const batchSeries = options.batchSeries ??
|
|
361
|
+
(await market.getBatchSeriesFromDb((0, symbols_1.extractSymbols)(strategy), options.startDate, options.endDate));
|
|
362
|
+
const tradingDays = options.tradingDays ?? (await market.getTradingDays(options.startDate, options.endDate));
|
|
363
|
+
return { ...options, batchSeries, tradingDays };
|
|
364
|
+
}
|
|
365
|
+
async function backtestWithMarketData(market, strategy, options) {
|
|
366
|
+
return backtest(strategy, await resolveBacktestInputs(market, strategy, options));
|
|
367
|
+
}
|
|
368
|
+
async function backtestRulesWithMarketData(market, strategyDraft, options) {
|
|
369
|
+
const strategy = (0, rules_1.compileRules)(strategyDraft);
|
|
370
|
+
return backtestWithMarketData(market, strategy, options);
|
|
371
|
+
}
|
|
372
|
+
async function backtest(strategy, options) {
|
|
373
|
+
const debug = getDebugOptions(options);
|
|
374
|
+
const logEveryDays = debug?.logEveryDays ?? DEFAULT_DEBUG_LOG_EVERY_DAYS;
|
|
375
|
+
const startedAt = nowMs();
|
|
376
|
+
const timings = {
|
|
377
|
+
validateMs: 0,
|
|
378
|
+
normalizeTradingDaysMs: 0,
|
|
379
|
+
buildPricePathsMs: 0,
|
|
380
|
+
evaluateMs: 0,
|
|
381
|
+
rebalanceMs: 0,
|
|
382
|
+
bookkeepingMs: 0,
|
|
383
|
+
};
|
|
384
|
+
const tValidateStart = nowMs();
|
|
385
|
+
validateDefaultAllocation(strategy);
|
|
386
|
+
if (!options.batchSeries) {
|
|
387
|
+
throw new Error('Backtest requires batchSeries in options.');
|
|
388
|
+
}
|
|
389
|
+
if (!options.tradingDays) {
|
|
390
|
+
throw new Error('Backtest requires tradingDays in options.');
|
|
391
|
+
}
|
|
392
|
+
timings.validateMs = nowMs() - tValidateStart;
|
|
393
|
+
const initialCapital = options.initialCapital ?? 100_000;
|
|
394
|
+
const requiredPositions = new Map();
|
|
395
|
+
for (const allocation of strategy.allocations) {
|
|
396
|
+
for (const holding of allocation.allocation.holdings) {
|
|
397
|
+
requiredPositions.set(positionKey(holding.ticker.symbol, holding.ticker.leverage), {
|
|
398
|
+
symbol: holding.ticker.symbol,
|
|
399
|
+
leverage: holding.ticker.leverage,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const symbols = getRequiredSymbols(strategy);
|
|
404
|
+
for (const symbol of symbols) {
|
|
405
|
+
if (!options.batchSeries[symbol]?.length) {
|
|
406
|
+
throw new Error(`Missing market series for symbol ${symbol}.`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const effectiveStartDate = findEffectiveStartDate(symbols, options.batchSeries, options.startDate, options.endDate);
|
|
410
|
+
const tNormalizeTradingDaysStart = nowMs();
|
|
411
|
+
const tradingDays = normalizeTradingDays(options.tradingDays, effectiveStartDate, options.endDate);
|
|
412
|
+
timings.normalizeTradingDaysMs = nowMs() - tNormalizeTradingDaysStart;
|
|
413
|
+
if (!tradingDays.length) {
|
|
414
|
+
throw new Error('No trading days in selected date range.');
|
|
415
|
+
}
|
|
416
|
+
const tBuildPricePathsStart = nowMs();
|
|
417
|
+
const pricePaths = buildLeveragedPriceSeries(tradingDays, options.batchSeries, [...requiredPositions.values()]);
|
|
418
|
+
timings.buildPricePathsMs = nowMs() - tBuildPricePathsStart;
|
|
419
|
+
const positions = {};
|
|
420
|
+
let cash = initialCapital;
|
|
421
|
+
let previousSignalStates = {};
|
|
422
|
+
let previousIndicatorMetadata = {};
|
|
423
|
+
let previousAllocationName = null;
|
|
424
|
+
const lastRebalancedDateByAllocation = new Map();
|
|
425
|
+
const lotsByPosition = new Map();
|
|
426
|
+
const pendingWashByPosition = new Map();
|
|
427
|
+
const annualTaxByYear = new Map();
|
|
428
|
+
let runningPeak = initialCapital;
|
|
429
|
+
const trades = [];
|
|
430
|
+
const dates = [];
|
|
431
|
+
const portfolioValues = [];
|
|
432
|
+
const cashSeries = [];
|
|
433
|
+
const drawdownPct = [];
|
|
434
|
+
const allocationSeries = [];
|
|
435
|
+
for (let dayIndex = 0; dayIndex < tradingDays.length; dayIndex++) {
|
|
436
|
+
const day = tradingDays[dayIndex];
|
|
437
|
+
const tEvaluateStart = nowMs();
|
|
438
|
+
const closeAt = new Date(day.close);
|
|
439
|
+
const currentDate = day.date;
|
|
440
|
+
const evaluation = (0, evaluate_1.evaluate)(strategy, {
|
|
441
|
+
at: closeAt,
|
|
442
|
+
batchSeries: options.batchSeries,
|
|
443
|
+
previousSignalStates,
|
|
444
|
+
previousIndicatorMetadata,
|
|
445
|
+
});
|
|
446
|
+
timings.evaluateMs += nowMs() - tEvaluateStart;
|
|
447
|
+
const pricesByPosition = {};
|
|
448
|
+
for (const [key] of requiredPositions) {
|
|
449
|
+
const price = pricePaths[key]?.[currentDate];
|
|
450
|
+
if (price != null && Number.isFinite(price) && price > 0) {
|
|
451
|
+
pricesByPosition[key] = price;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const asOfDate = toDateYmd(evaluation.asOf);
|
|
455
|
+
const evaluationDue = asOfDate === currentDate;
|
|
456
|
+
const allocationChanged = previousAllocationName !== evaluation.allocation.name;
|
|
457
|
+
const rebalanceConfig = getRebalanceConfig(options, evaluation.allocation.name);
|
|
458
|
+
const lastRebalancedDate = lastRebalancedDateByAllocation.get(evaluation.allocation.name);
|
|
459
|
+
let shouldRebalance = false;
|
|
460
|
+
if (evaluationDue) {
|
|
461
|
+
shouldRebalance = allocationChanged;
|
|
462
|
+
if (!shouldRebalance) {
|
|
463
|
+
if (rebalanceConfig.mode === 'calendar') {
|
|
464
|
+
shouldRebalance = isCalendarRebalanceDue(rebalanceConfig.frequency, currentDate, lastRebalancedDate);
|
|
465
|
+
}
|
|
466
|
+
else if (rebalanceConfig.mode === 'drift') {
|
|
467
|
+
const totalValue = computePortfolioValue(positions, pricesByPosition, cash);
|
|
468
|
+
const targetSharesForDrift = {};
|
|
469
|
+
for (const holding of evaluation.allocation.holdings) {
|
|
470
|
+
const key = positionKey(holding.ticker.symbol, holding.ticker.leverage);
|
|
471
|
+
const price = pricesByPosition[key];
|
|
472
|
+
if (!Number.isFinite(price) || price <= 0)
|
|
473
|
+
continue;
|
|
474
|
+
const targetValue = totalValue * (holding.weight / 100);
|
|
475
|
+
targetSharesForDrift[key] = targetValue / price;
|
|
476
|
+
}
|
|
477
|
+
const driftPct = computeAllocationDriftPct(positions, pricesByPosition, targetSharesForDrift, totalValue);
|
|
478
|
+
shouldRebalance = driftPct >= rebalanceConfig.driftPct;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const tRebalanceStart = nowMs();
|
|
483
|
+
if (shouldRebalance) {
|
|
484
|
+
lastRebalancedDateByAllocation.set(evaluation.allocation.name, currentDate);
|
|
485
|
+
const totalValue = computePortfolioValue(positions, pricesByPosition, cash);
|
|
486
|
+
const targetShares = {};
|
|
487
|
+
for (const holding of evaluation.allocation.holdings) {
|
|
488
|
+
const key = positionKey(holding.ticker.symbol, holding.ticker.leverage);
|
|
489
|
+
const price = pricesByPosition[key];
|
|
490
|
+
if (!Number.isFinite(price) || price <= 0)
|
|
491
|
+
continue;
|
|
492
|
+
const targetValue = totalValue * (holding.weight / 100);
|
|
493
|
+
targetShares[key] = targetValue / price;
|
|
494
|
+
}
|
|
495
|
+
const keysToTrade = new Set([...Object.keys(positions), ...Object.keys(targetShares)]);
|
|
496
|
+
for (const key of keysToTrade) {
|
|
497
|
+
const currentShares = positions[key] ?? 0;
|
|
498
|
+
const target = targetShares[key] ?? 0;
|
|
499
|
+
const delta = target - currentShares;
|
|
500
|
+
if (Math.abs(delta) <= EPSILON)
|
|
501
|
+
continue;
|
|
502
|
+
const price = pricesByPosition[key];
|
|
503
|
+
if (!Number.isFinite(price) || price <= 0)
|
|
504
|
+
continue;
|
|
505
|
+
const tradeValue = delta * price;
|
|
506
|
+
if (Math.abs(target) <= EPSILON) {
|
|
507
|
+
delete positions[key];
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
positions[key] = target;
|
|
511
|
+
}
|
|
512
|
+
cash -= tradeValue;
|
|
513
|
+
if (delta > 0) {
|
|
514
|
+
const lots = lotsByPosition.get(key) ?? [];
|
|
515
|
+
const pending = pendingWashByPosition.get(key) ?? [];
|
|
516
|
+
const buyLots = applyPendingWashToNewBuy(pending, currentDate, price, delta, annualTaxByYear);
|
|
517
|
+
lots.push(...buyLots);
|
|
518
|
+
lotsByPosition.set(key, lots);
|
|
519
|
+
pendingWashByPosition.set(key, pending);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
const lots = lotsByPosition.get(key) ?? [];
|
|
523
|
+
const soldLots = sellLotsHifo(lots, Math.abs(delta));
|
|
524
|
+
lotsByPosition.set(key, lots);
|
|
525
|
+
const pending = pendingWashByPosition.get(key) ?? [];
|
|
526
|
+
for (const soldLot of soldLots) {
|
|
527
|
+
const realized = (price - soldLot.costPerShare) * soldLot.shares;
|
|
528
|
+
const term = isLongTermLot(soldLot.acquiredDate, currentDate)
|
|
529
|
+
? 'longTerm'
|
|
530
|
+
: 'shortTerm';
|
|
531
|
+
if (realized >= 0) {
|
|
532
|
+
addAnnualTaxForTerm(annualTaxByYear, taxYearFromDate(currentDate), term, realized);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const lossPerShare = soldLot.costPerShare - price;
|
|
536
|
+
const preMatchedShares = applyWashToExistingReplacementLots(lots, currentDate, lossPerShare, soldLot.shares);
|
|
537
|
+
const disallowedPre = preMatchedShares * lossPerShare;
|
|
538
|
+
const taxableNow = realized + disallowedPre;
|
|
539
|
+
addAnnualTaxForTerm(annualTaxByYear, taxYearFromDate(currentDate), term, taxableNow);
|
|
540
|
+
const remainingForFuture = soldLot.shares - preMatchedShares;
|
|
541
|
+
if (remainingForFuture > EPSILON) {
|
|
542
|
+
pending.push({
|
|
543
|
+
saleDate: currentDate,
|
|
544
|
+
windowEndDate: addDaysToYmd(currentDate, 30),
|
|
545
|
+
remainingShares: remainingForFuture,
|
|
546
|
+
lossPerShare,
|
|
547
|
+
term,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
pendingWashByPosition.set(key, pending);
|
|
552
|
+
}
|
|
553
|
+
const parsed = parsePositionKey(key);
|
|
554
|
+
trades.push({
|
|
555
|
+
date: currentDate,
|
|
556
|
+
ticker: parsed.symbol,
|
|
557
|
+
leverage: parsed.leverage,
|
|
558
|
+
shares: delta,
|
|
559
|
+
price,
|
|
560
|
+
value: tradeValue,
|
|
561
|
+
action: delta > 0 ? 'buy' : 'sell',
|
|
562
|
+
allocation: evaluation.allocation.name,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
if (Math.abs(cash) <= EPSILON) {
|
|
566
|
+
cash = 0;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
timings.rebalanceMs += nowMs() - tRebalanceStart;
|
|
570
|
+
const tBookkeepingStart = nowMs();
|
|
571
|
+
const portfolioValue = computePortfolioValue(positions, pricesByPosition, cash);
|
|
572
|
+
runningPeak = Math.max(runningPeak, portfolioValue);
|
|
573
|
+
const dd = runningPeak > 0 ? ((portfolioValue - runningPeak) / runningPeak) * 100 : 0;
|
|
574
|
+
dates.push(currentDate);
|
|
575
|
+
portfolioValues.push(portfolioValue);
|
|
576
|
+
cashSeries.push(cash);
|
|
577
|
+
drawdownPct.push(dd);
|
|
578
|
+
allocationSeries.push(evaluation.allocation.name);
|
|
579
|
+
previousSignalStates = evaluation.signals;
|
|
580
|
+
previousAllocationName = evaluation.allocation.name;
|
|
581
|
+
previousIndicatorMetadata = Object.fromEntries(Object.entries(evaluation.indicators)
|
|
582
|
+
.filter(([, indicator]) => indicator.metadata !== undefined)
|
|
583
|
+
.map(([key, indicator]) => [key, indicator.metadata]));
|
|
584
|
+
timings.bookkeepingMs += nowMs() - tBookkeepingStart;
|
|
585
|
+
if (debug && ((dayIndex + 1) % logEveryDays === 0 || dayIndex === tradingDays.length - 1)) {
|
|
586
|
+
const elapsedMs = nowMs() - startedAt;
|
|
587
|
+
console.info('[sdk.backtest] progress', {
|
|
588
|
+
day: dayIndex + 1,
|
|
589
|
+
totalDays: tradingDays.length,
|
|
590
|
+
date: currentDate,
|
|
591
|
+
elapsedMs: Math.round(elapsedMs),
|
|
592
|
+
trades: trades.length,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const finalValue = portfolioValues.at(-1) ?? initialCapital;
|
|
597
|
+
const totalReturn = initialCapital > 0 ? (finalValue - initialCapital) / initialCapital : 0;
|
|
598
|
+
const dailyReturns = [];
|
|
599
|
+
for (let i = 1; i < portfolioValues.length; i++) {
|
|
600
|
+
const prev = portfolioValues[i - 1];
|
|
601
|
+
const curr = portfolioValues[i];
|
|
602
|
+
if (prev > 0)
|
|
603
|
+
dailyReturns.push((curr - prev) / prev);
|
|
604
|
+
}
|
|
605
|
+
const daysSpan = Math.max(tradingDays.length - 1, 0);
|
|
606
|
+
const yearsSpan = daysSpan / 252;
|
|
607
|
+
let cagr = totalReturn;
|
|
608
|
+
if (yearsSpan > 0 && initialCapital > 0 && finalValue > 0) {
|
|
609
|
+
cagr = (finalValue / initialCapital) ** (1 / yearsSpan) - 1;
|
|
610
|
+
}
|
|
611
|
+
const volatility = standardDeviation(dailyReturns) * Math.sqrt(252);
|
|
612
|
+
const meanDailyReturn = dailyReturns.length > 0
|
|
613
|
+
? dailyReturns.reduce((sum, value) => sum + value, 0) / dailyReturns.length
|
|
614
|
+
: 0;
|
|
615
|
+
const annualizedReturn = meanDailyReturn * 252;
|
|
616
|
+
const sharpe = volatility > 0 ? annualizedReturn / volatility : 0;
|
|
617
|
+
const maxDrawdown = drawdownPct.length ? Math.min(...drawdownPct) : 0;
|
|
618
|
+
const annualTax = [...annualTaxByYear.entries()]
|
|
619
|
+
.sort((a, b) => a[0] - b[0])
|
|
620
|
+
.map(([year, totals]) => ({
|
|
621
|
+
year,
|
|
622
|
+
shortTermRealizedGains: totals.shortTerm,
|
|
623
|
+
longTermRealizedGains: totals.longTerm,
|
|
624
|
+
}));
|
|
625
|
+
const result = {
|
|
626
|
+
timeseries: {
|
|
627
|
+
dates,
|
|
628
|
+
portfolio: portfolioValues,
|
|
629
|
+
cash: cashSeries,
|
|
630
|
+
drawdownPct,
|
|
631
|
+
allocation: allocationSeries,
|
|
632
|
+
},
|
|
633
|
+
summary: {
|
|
634
|
+
initialValue: initialCapital,
|
|
635
|
+
finalValue: finiteOrZero(finalValue),
|
|
636
|
+
totalReturnPct: finiteOrZero(totalReturn * 100),
|
|
637
|
+
cagrPct: finiteOrZero(cagr * 100),
|
|
638
|
+
maxDrawdownPct: finiteOrZero(maxDrawdown),
|
|
639
|
+
annualizedVolatilityPct: finiteOrZero(volatility * 100),
|
|
640
|
+
sharpeRatio: finiteOrZero(sharpe),
|
|
641
|
+
tradeCount: trades.length,
|
|
642
|
+
},
|
|
643
|
+
trades,
|
|
644
|
+
annualTax,
|
|
645
|
+
};
|
|
646
|
+
if (debug) {
|
|
647
|
+
const totalMs = nowMs() - startedAt;
|
|
648
|
+
console.info('[sdk.backtest] timing', {
|
|
649
|
+
days: tradingDays.length,
|
|
650
|
+
trades: trades.length,
|
|
651
|
+
totalMs: Math.round(totalMs),
|
|
652
|
+
validateMs: Math.round(timings.validateMs),
|
|
653
|
+
normalizeTradingDaysMs: Math.round(timings.normalizeTradingDaysMs),
|
|
654
|
+
buildPricePathsMs: Math.round(timings.buildPricePathsMs),
|
|
655
|
+
evaluateMs: Math.round(timings.evaluateMs),
|
|
656
|
+
rebalanceMs: Math.round(timings.rebalanceMs),
|
|
657
|
+
bookkeepingMs: Math.round(timings.bookkeepingMs),
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
return result;
|
|
6
661
|
}
|
|
7
662
|
//# sourceMappingURL=backtest.js.map
|