@livefolio/sdk 0.3.0 → 0.3.2
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/dist/index.d.ts +104 -0
- package/dist/index.js +455 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -45,14 +45,40 @@ var FRED_SERIES = {
|
|
|
45
45
|
T20Y: "DGS20",
|
|
46
46
|
T30Y: "DGS30"
|
|
47
47
|
};
|
|
48
|
+
var RATE_TICKER_SYMBOLS = /* @__PURE__ */ new Set([
|
|
49
|
+
"DTB3",
|
|
50
|
+
"DTB6",
|
|
51
|
+
"DFF",
|
|
52
|
+
"DGS3MO",
|
|
53
|
+
"DGS6MO",
|
|
54
|
+
"DGS1",
|
|
55
|
+
"DGS2",
|
|
56
|
+
"DGS3",
|
|
57
|
+
"DGS5",
|
|
58
|
+
"DGS7",
|
|
59
|
+
"DGS10",
|
|
60
|
+
"DGS20",
|
|
61
|
+
"DGS30"
|
|
62
|
+
]);
|
|
48
63
|
var COMPUTED_TYPES = /* @__PURE__ */ new Set(["SMA", "EMA", "RSI", "Return", "Volatility", "Drawdown"]);
|
|
49
64
|
var CALENDAR_TYPES = /* @__PURE__ */ new Set(["Month", "Day of Week", "Day of Month", "Day of Year"]);
|
|
65
|
+
function isRateTickerSymbol(symbol) {
|
|
66
|
+
return symbol != null && RATE_TICKER_SYMBOLS.has(symbol);
|
|
67
|
+
}
|
|
50
68
|
function getProviderInfo(type, tickerSymbol) {
|
|
51
|
-
if (type === "Price")
|
|
69
|
+
if (type === "Price") {
|
|
70
|
+
const info = { provider: "yahoo", symbol: tickerSymbol };
|
|
71
|
+
if (isRateTickerSymbol(tickerSymbol)) info.rateSeries = true;
|
|
72
|
+
return info;
|
|
73
|
+
}
|
|
52
74
|
if (type === "VIX") return { provider: "yahoo", symbol: "^VIX" };
|
|
53
75
|
if (type === "VIX3M") return { provider: "yahoo", symbol: "^VIX3M" };
|
|
54
|
-
if (type in FRED_SERIES) return { provider: "fred", seriesId: FRED_SERIES[type] };
|
|
55
|
-
if (COMPUTED_TYPES.has(type))
|
|
76
|
+
if (type in FRED_SERIES) return { provider: "fred", seriesId: FRED_SERIES[type], rateSeries: true };
|
|
77
|
+
if (COMPUTED_TYPES.has(type)) {
|
|
78
|
+
const info = { provider: "computed", dependsOn: "Price", symbol: tickerSymbol };
|
|
79
|
+
if (isRateTickerSymbol(tickerSymbol)) info.rateSeries = true;
|
|
80
|
+
return info;
|
|
81
|
+
}
|
|
56
82
|
if (CALENDAR_TYPES.has(type)) return { provider: "calendar" };
|
|
57
83
|
return { provider: "none" };
|
|
58
84
|
}
|
|
@@ -123,14 +149,14 @@ function computeRsi(bars, lookback) {
|
|
|
123
149
|
}
|
|
124
150
|
|
|
125
151
|
// src/computations/returns.ts
|
|
126
|
-
function computeReturns(bars, lookback) {
|
|
152
|
+
function computeReturns(bars, lookback, mode = "pct") {
|
|
127
153
|
if (bars.length <= lookback) return [];
|
|
128
154
|
const result = [];
|
|
129
155
|
for (let i = lookback; i < bars.length; i++) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
});
|
|
156
|
+
const curr = bars[i].value;
|
|
157
|
+
const prev = bars[i - lookback].value;
|
|
158
|
+
const value = mode === "abs" ? curr - prev : (curr - prev) / prev;
|
|
159
|
+
result.push({ date: bars[i].date, value });
|
|
134
160
|
}
|
|
135
161
|
return result;
|
|
136
162
|
}
|
|
@@ -212,7 +238,40 @@ function getComputation(type) {
|
|
|
212
238
|
return COMPUTATIONS[type] ?? null;
|
|
213
239
|
}
|
|
214
240
|
|
|
241
|
+
// src/providers/quote-overlay.ts
|
|
242
|
+
function createQuoteOverlay(base, overridesByDate, options = {}) {
|
|
243
|
+
return {
|
|
244
|
+
async fetchBars(symbol, from) {
|
|
245
|
+
const bars = await base.fetchBars(symbol, from);
|
|
246
|
+
const dates = Object.keys(overridesByDate).sort();
|
|
247
|
+
if (dates.length === 0) return bars;
|
|
248
|
+
const result = [...bars];
|
|
249
|
+
for (const date of dates) {
|
|
250
|
+
const overrideValue = overridesByDate[date][symbol];
|
|
251
|
+
let value = overrideValue;
|
|
252
|
+
if (value === void 0) {
|
|
253
|
+
if (!options.fallbackMissingQuotes) continue;
|
|
254
|
+
if (result.length === 0) continue;
|
|
255
|
+
value = result[result.length - 1].value;
|
|
256
|
+
}
|
|
257
|
+
const existingIdx = result.findIndex((b) => b.date === date);
|
|
258
|
+
if (existingIdx >= 0) {
|
|
259
|
+
result[existingIdx] = { date, value };
|
|
260
|
+
} else {
|
|
261
|
+
result.push({ date, value });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
215
269
|
// src/handles/indicator.ts
|
|
270
|
+
function _subtractCalendarDays(date, days) {
|
|
271
|
+
const d = new Date(date);
|
|
272
|
+
d.setUTCDate(d.getUTCDate() - days);
|
|
273
|
+
return d.toISOString().slice(0, 10);
|
|
274
|
+
}
|
|
216
275
|
var IndicatorHandle = class _IndicatorHandle {
|
|
217
276
|
type;
|
|
218
277
|
ticker;
|
|
@@ -315,9 +374,13 @@ var IndicatorHandle = class _IndicatorHandle {
|
|
|
315
374
|
});
|
|
316
375
|
await priceHandle._ensureFresh();
|
|
317
376
|
const priceBars = await priceHandle._querySeriesFromDb();
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
377
|
+
if (this.type === "Return") {
|
|
378
|
+
bars = computeReturns(priceBars, this.lookback, info.rateSeries ? "abs" : "pct");
|
|
379
|
+
} else {
|
|
380
|
+
const computeFn = getComputation(this.type);
|
|
381
|
+
if (!computeFn) throw new Error(`No computation found for type "${this.type}"`);
|
|
382
|
+
bars = computeFn(priceBars, this.lookback);
|
|
383
|
+
}
|
|
321
384
|
if (fromDate) {
|
|
322
385
|
bars = bars.filter((b) => b.date > fromDate);
|
|
323
386
|
}
|
|
@@ -335,22 +398,8 @@ var IndicatorHandle = class _IndicatorHandle {
|
|
|
335
398
|
case "none":
|
|
336
399
|
return;
|
|
337
400
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
let anchor;
|
|
341
|
-
if (fromDate) {
|
|
342
|
-
const lastStored = await this._storage.indicators.getValue(this._resolvedId, fromDate);
|
|
343
|
-
anchor = lastStored ?? bars[0].value;
|
|
344
|
-
} else {
|
|
345
|
-
anchor = bars[0].value;
|
|
346
|
-
}
|
|
347
|
-
const leveraged = [{ date: bars[0].date, value: anchor }];
|
|
348
|
-
for (let i = 1; i < bars.length; i++) {
|
|
349
|
-
const dailyReturn = (bars[i].value - bars[i - 1].value) / bars[i - 1].value;
|
|
350
|
-
const prev = leveraged[i - 1].value;
|
|
351
|
-
leveraged.push({ date: bars[i].date, value: prev * (1 + leverage * dailyReturn) });
|
|
352
|
-
}
|
|
353
|
-
bars = leveraged;
|
|
401
|
+
if (info.provider !== "computed") {
|
|
402
|
+
bars = await this._applyLeverage(bars, fromDate);
|
|
354
403
|
}
|
|
355
404
|
bars = bars.filter((b) => b.date <= latestClosed);
|
|
356
405
|
if (bars.length > 0) {
|
|
@@ -365,6 +414,103 @@ var IndicatorHandle = class _IndicatorHandle {
|
|
|
365
414
|
const { id } = await this.resolve();
|
|
366
415
|
return this._storage.indicators.getSeries(id, range);
|
|
367
416
|
}
|
|
417
|
+
withMarket(market) {
|
|
418
|
+
if (market === this._market) return this;
|
|
419
|
+
return _IndicatorHandle.fromResolved(this._storage, market, this.id, {
|
|
420
|
+
type: this.type,
|
|
421
|
+
ticker: this.ticker,
|
|
422
|
+
lookback: this.lookback,
|
|
423
|
+
delay: this.delay,
|
|
424
|
+
unit: this.unit,
|
|
425
|
+
threshold: this.threshold
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Apply leverage compounding to a raw bar series, anchored to a stored
|
|
430
|
+
* leveraged value. Used by both `_sync` and `computeAt` so they stay
|
|
431
|
+
* consistent.
|
|
432
|
+
*
|
|
433
|
+
* `anchorDate` is the date of the last *already-stored* leveraged bar
|
|
434
|
+
* (i.e., the bar just before `rawBars[0]`). The stored leveraged value
|
|
435
|
+
* at that date becomes `leveraged[0]`; raw returns are then compounded
|
|
436
|
+
* forward for each subsequent bar.
|
|
437
|
+
*
|
|
438
|
+
* If no stored anchor exists (first-ever sync), falls back to rawBars[0]
|
|
439
|
+
* as the starting raw value — identical to `_sync`'s behaviour.
|
|
440
|
+
*/
|
|
441
|
+
async _applyLeverage(rawBars, anchorDate) {
|
|
442
|
+
const leverage = this.ticker?.leverage ?? 1;
|
|
443
|
+
if (leverage === 1 || rawBars.length === 0) return rawBars;
|
|
444
|
+
if (isRateTickerSymbol(this.ticker?.symbol ?? null)) return rawBars;
|
|
445
|
+
let anchor;
|
|
446
|
+
if (anchorDate) {
|
|
447
|
+
const lastStored = await this._storage.indicators.getValue(this._resolvedId, anchorDate);
|
|
448
|
+
anchor = lastStored ?? rawBars[0].value;
|
|
449
|
+
} else {
|
|
450
|
+
anchor = rawBars[0].value;
|
|
451
|
+
}
|
|
452
|
+
const leveraged = [{ date: rawBars[0].date, value: anchor }];
|
|
453
|
+
for (let i = 1; i < rawBars.length; i++) {
|
|
454
|
+
const dailyReturn = (rawBars[i].value - rawBars[i - 1].value) / rawBars[i - 1].value;
|
|
455
|
+
const prev = leveraged[i - 1].value;
|
|
456
|
+
leveraged.push({ date: rawBars[i].date, value: prev * (1 + leverage * dailyReturn) });
|
|
457
|
+
}
|
|
458
|
+
return leveraged;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Compute the indicator's value at `date` using the given market (typically
|
|
462
|
+
* an overlay market for pre-close preview). Pure — no writes to storage.
|
|
463
|
+
*
|
|
464
|
+
* For fetched types (yahoo/fred): fetches a small window of bars from
|
|
465
|
+
* `market`, applies leverage compounding anchored to the stored leveraged
|
|
466
|
+
* value at the bar before `date`.
|
|
467
|
+
* For computed types (SMA, RSI, etc.): fetches enough raw price bars to
|
|
468
|
+
* cover the indicator's lookback from `market`, applies leverage anchored
|
|
469
|
+
* to the stored value just before the fetch window, runs the computation,
|
|
470
|
+
* and returns the value at `date`.
|
|
471
|
+
* For Threshold: returns the threshold constant.
|
|
472
|
+
* For calendar: computes calendar value from the trading days list.
|
|
473
|
+
* Returns null if the value cannot be computed.
|
|
474
|
+
*/
|
|
475
|
+
async computeAt(market, date) {
|
|
476
|
+
if (this.type === "Threshold") return this.threshold;
|
|
477
|
+
const tickerSymbol = this.ticker?.symbol ?? null;
|
|
478
|
+
const info = getProviderInfo(this.type, tickerSymbol);
|
|
479
|
+
if (info.provider === "none") return null;
|
|
480
|
+
if (info.provider === "calendar") {
|
|
481
|
+
const allDays = await this._storage.tradingDays.getRange();
|
|
482
|
+
const dayBars = allDays.map((d) => ({ date: d, value: 0 }));
|
|
483
|
+
const computed = computeCalendar(dayBars, this.type);
|
|
484
|
+
return computed.find((b) => b.date === date)?.value ?? null;
|
|
485
|
+
}
|
|
486
|
+
if (info.provider === "computed") {
|
|
487
|
+
const from2 = _subtractCalendarDays(date, this.lookback + 10);
|
|
488
|
+
const rawBars2 = await market.fetchBars(info.symbol, from2);
|
|
489
|
+
const anchorDate = rawBars2.length > 0 ? rawBars2[0].date : void 0;
|
|
490
|
+
const priceBars = await this._applyLeverage(rawBars2, anchorDate);
|
|
491
|
+
const computeFn = getComputation(this.type);
|
|
492
|
+
if (!computeFn) throw new Error(`No computation found for type "${this.type}"`);
|
|
493
|
+
const computed = computeFn(priceBars, this.lookback);
|
|
494
|
+
return computed.find((b) => b.date === date)?.value ?? null;
|
|
495
|
+
}
|
|
496
|
+
const symbol = info.provider === "yahoo" ? info.symbol : info.seriesId;
|
|
497
|
+
const from = _subtractCalendarDays(date, 5);
|
|
498
|
+
const rawBars = await market.fetchBars(symbol, from);
|
|
499
|
+
const leverage = this.ticker?.leverage ?? 1;
|
|
500
|
+
if (leverage === 1) {
|
|
501
|
+
return rawBars.find((b) => b.date === date)?.value ?? null;
|
|
502
|
+
}
|
|
503
|
+
const dateIdx = rawBars.findIndex((b) => b.date === date);
|
|
504
|
+
if (dateIdx < 0) return null;
|
|
505
|
+
const prevBar = rawBars[dateIdx - 1];
|
|
506
|
+
if (!prevBar) {
|
|
507
|
+
return rawBars[dateIdx].value;
|
|
508
|
+
}
|
|
509
|
+
const storedPrev = await this._storage.indicators.getValue(this._resolvedId, prevBar.date);
|
|
510
|
+
const leveragedPrev = storedPrev ?? prevBar.value;
|
|
511
|
+
const rawReturn = (rawBars[dateIdx].value - prevBar.value) / prevBar.value;
|
|
512
|
+
return leveragedPrev * (1 + leverage * rawReturn);
|
|
513
|
+
}
|
|
368
514
|
// ── Public data access ─────────────────────────────────────────────
|
|
369
515
|
async series(range) {
|
|
370
516
|
if (this.type === "Threshold") {
|
|
@@ -386,6 +532,46 @@ var IndicatorHandle = class _IndicatorHandle {
|
|
|
386
532
|
const { id } = await this.resolve();
|
|
387
533
|
return this._storage.indicators.getValue(id, date);
|
|
388
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* Read-only preview of the indicator series that includes an in-memory bar
|
|
537
|
+
* at `date` computed via `computeAt` against a quote-overlay market. Does
|
|
538
|
+
* NOT write to `indicators_series`. Safe to call before market close.
|
|
539
|
+
*
|
|
540
|
+
* @param date - Target trading day whose value is computed in-memory from
|
|
541
|
+
* the overridden quotes. Must be in `tradingDays.getRange()`.
|
|
542
|
+
* @param quoteOverrides - Raw (unleveraged) quotes keyed by ticker symbol.
|
|
543
|
+
* Symbols omitted here fall back to yesterday's close via the overlay.
|
|
544
|
+
* @param range - Optional filter applied to the returned bars.
|
|
545
|
+
* @returns Stored historical bars plus (or with) today's in-memory value.
|
|
546
|
+
*/
|
|
547
|
+
async previewSeries(date, quoteOverrides, range) {
|
|
548
|
+
const tradingDays = await this._storage.tradingDays.getRange();
|
|
549
|
+
if (!tradingDays.includes(date)) {
|
|
550
|
+
throw new Error(`previewSeries: ${date} is not a trading day`);
|
|
551
|
+
}
|
|
552
|
+
const overlay = createQuoteOverlay(this._market, { [date]: quoteOverrides }, { fallbackMissingQuotes: true });
|
|
553
|
+
let bars;
|
|
554
|
+
if (this.type === "Threshold") {
|
|
555
|
+
bars = await this._syntheticThresholdSeries();
|
|
556
|
+
} else {
|
|
557
|
+
bars = await this._querySeriesFromDb();
|
|
558
|
+
}
|
|
559
|
+
const todayValue = await this.computeAt(overlay, date);
|
|
560
|
+
if (todayValue !== null) {
|
|
561
|
+
const idx = bars.findIndex((b) => b.date === date);
|
|
562
|
+
if (idx >= 0) {
|
|
563
|
+
bars[idx] = { date, value: todayValue };
|
|
564
|
+
} else {
|
|
565
|
+
bars = [...bars, { date, value: todayValue }].sort((a, b) => a.date.localeCompare(b.date));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (range) {
|
|
569
|
+
bars = bars.filter(
|
|
570
|
+
(b) => (range.from === void 0 || b.date >= range.from) && (range.to === void 0 || b.date <= range.to)
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return bars;
|
|
574
|
+
}
|
|
389
575
|
};
|
|
390
576
|
|
|
391
577
|
// src/computations/signal.ts
|
|
@@ -558,6 +744,61 @@ var SignalHandle = class _SignalHandle {
|
|
|
558
744
|
const { id } = await this.resolve();
|
|
559
745
|
return this._storage.signals.getSeries(id, range);
|
|
560
746
|
}
|
|
747
|
+
withMarket(market) {
|
|
748
|
+
if (market === this._market) return this;
|
|
749
|
+
return _SignalHandle.fromResolved(this._storage, market, this.id, {
|
|
750
|
+
indicator1: this.indicator1.withMarket(market),
|
|
751
|
+
indicator2: this.indicator2.withMarket(market),
|
|
752
|
+
comparison: this.comparison,
|
|
753
|
+
tolerance: this.tolerance
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Compute the signal's boolean value at `date` using the given market
|
|
758
|
+
* (typically an overlay market for pre-close preview). Pure — no writes.
|
|
759
|
+
* Returns null if either indicator cannot produce a value at `date`.
|
|
760
|
+
*
|
|
761
|
+
* @param prevBool - The signal's boolean value at the bar immediately
|
|
762
|
+
* preceding `date`, used for hysteresis when `tolerance > 0`. If not
|
|
763
|
+
* provided, falls back to `storage.signals.getLastValue` (suitable for
|
|
764
|
+
* standalone callers). On the preview path `_evaluate` passes this from
|
|
765
|
+
* the in-memory `dateMap` so we never read stale storage.
|
|
766
|
+
*/
|
|
767
|
+
async computeAt(market, date, prevBool) {
|
|
768
|
+
const [v1, v2] = await Promise.all([
|
|
769
|
+
this.indicator1.computeAt(market, date),
|
|
770
|
+
this.indicator2.computeAt(market, date)
|
|
771
|
+
]);
|
|
772
|
+
if (v1 === null || v2 === null) return null;
|
|
773
|
+
const absolute = ABSOLUTE_TOLERANCE_TYPES.has(this.indicator1.type);
|
|
774
|
+
if (this.tolerance === 0) {
|
|
775
|
+
switch (this.comparison) {
|
|
776
|
+
case ">":
|
|
777
|
+
return v1 > v2;
|
|
778
|
+
case "<":
|
|
779
|
+
return v1 < v2;
|
|
780
|
+
case "=":
|
|
781
|
+
return v1 === v2;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const tolerance = this.tolerance;
|
|
785
|
+
const upper = absolute ? v2 + tolerance : v2 * (1 + tolerance / 100);
|
|
786
|
+
const lower = absolute ? v2 - tolerance : v2 * (1 - tolerance / 100);
|
|
787
|
+
if (this.comparison === "=") {
|
|
788
|
+
return v1 >= lower && v1 <= upper;
|
|
789
|
+
}
|
|
790
|
+
let resolvedPrevBool;
|
|
791
|
+
if (prevBool !== void 0 && prevBool !== null) {
|
|
792
|
+
resolvedPrevBool = prevBool;
|
|
793
|
+
} else {
|
|
794
|
+
const prev = await this._storage.signals.getLastValue(this.id);
|
|
795
|
+
resolvedPrevBool = prev === 1;
|
|
796
|
+
}
|
|
797
|
+
if (this.comparison === ">") {
|
|
798
|
+
return resolvedPrevBool ? v1 >= lower : v1 > upper;
|
|
799
|
+
}
|
|
800
|
+
return resolvedPrevBool ? v1 <= upper : v1 < lower;
|
|
801
|
+
}
|
|
561
802
|
// ── Public data access ─────────────────────────────────────────────
|
|
562
803
|
async series(range) {
|
|
563
804
|
await this._ensureFresh();
|
|
@@ -575,6 +816,44 @@ var SignalHandle = class _SignalHandle {
|
|
|
575
816
|
const { id } = await this.resolve();
|
|
576
817
|
return this._storage.signals.getLastValue(id);
|
|
577
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* Read-only preview of the signal series with an in-memory bar at `date`
|
|
821
|
+
* computed via `computeAt` against a quote-overlay market. Does NOT write
|
|
822
|
+
* to `signals_series`.
|
|
823
|
+
*
|
|
824
|
+
* @param date - Target trading day whose boolean is computed in-memory.
|
|
825
|
+
* @param quoteOverrides - Raw (unleveraged) quotes keyed by ticker symbol.
|
|
826
|
+
* @param range - Optional filter applied to the returned bars.
|
|
827
|
+
*/
|
|
828
|
+
async previewSeries(date, quoteOverrides, range) {
|
|
829
|
+
const tradingDays = await this._storage.tradingDays.getRange();
|
|
830
|
+
if (!tradingDays.includes(date)) {
|
|
831
|
+
throw new Error(`previewSeries: ${date} is not a trading day`);
|
|
832
|
+
}
|
|
833
|
+
const overlay = createQuoteOverlay(this._market, { [date]: quoteOverrides }, { fallbackMissingQuotes: true });
|
|
834
|
+
let bars = await this._querySeriesFromDb();
|
|
835
|
+
const dateMap = /* @__PURE__ */ new Map();
|
|
836
|
+
for (const bar of bars) dateMap.set(bar.date, bar.value === 1);
|
|
837
|
+
const limitIdx = tradingDays.indexOf(date);
|
|
838
|
+
const prevDate = limitIdx > 0 ? tradingDays[limitIdx - 1] : void 0;
|
|
839
|
+
const prevBool = prevDate !== void 0 ? dateMap.get(prevDate) ?? null : null;
|
|
840
|
+
const todayBool = await this.computeAt(overlay, date, prevBool);
|
|
841
|
+
if (todayBool !== null) {
|
|
842
|
+
const numeric = todayBool ? 1 : 0;
|
|
843
|
+
const idx = bars.findIndex((b) => b.date === date);
|
|
844
|
+
if (idx >= 0) {
|
|
845
|
+
bars[idx] = { date, value: numeric };
|
|
846
|
+
} else {
|
|
847
|
+
bars = [...bars, { date, value: numeric }].sort((a, b) => a.date.localeCompare(b.date));
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (range) {
|
|
851
|
+
bars = bars.filter(
|
|
852
|
+
(b) => (range.from === void 0 || b.date >= range.from) && (range.to === void 0 || b.date <= range.to)
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
return bars;
|
|
856
|
+
}
|
|
578
857
|
};
|
|
579
858
|
|
|
580
859
|
// src/handles/allocation.ts
|
|
@@ -698,15 +977,12 @@ var PortfolioHandle = class {
|
|
|
698
977
|
holdings;
|
|
699
978
|
constructor(holdings) {
|
|
700
979
|
const seen = /* @__PURE__ */ new Set();
|
|
701
|
-
for (const [ticker
|
|
980
|
+
for (const [ticker] of holdings) {
|
|
702
981
|
const key = `${ticker.symbol}:${ticker.leverage}`;
|
|
703
982
|
if (seen.has(key)) {
|
|
704
983
|
throw new Error(`Duplicate ticker: ${ticker.symbol}`);
|
|
705
984
|
}
|
|
706
985
|
seen.add(key);
|
|
707
|
-
if (quantity < 0) {
|
|
708
|
-
throw new Error(`Quantity for ${ticker.symbol} is negative: ${quantity}`);
|
|
709
|
-
}
|
|
710
986
|
}
|
|
711
987
|
this.holdings = holdings;
|
|
712
988
|
}
|
|
@@ -719,6 +995,7 @@ var PortfolioHandle = class {
|
|
|
719
995
|
}
|
|
720
996
|
_priceFor(ticker, priceMap) {
|
|
721
997
|
if (ticker.symbol === "CASHX") return 1;
|
|
998
|
+
if (isRateTickerSymbol(ticker.symbol)) return 1;
|
|
722
999
|
const key = `${ticker.symbol}:${ticker.leverage}`;
|
|
723
1000
|
const price = priceMap.get(key);
|
|
724
1001
|
if (price == null) {
|
|
@@ -799,6 +1076,26 @@ var EPSILON = 1e-8;
|
|
|
799
1076
|
function tkey(symbol, leverage) {
|
|
800
1077
|
return `${symbol}:${leverage}`;
|
|
801
1078
|
}
|
|
1079
|
+
function symbolFromKey(key) {
|
|
1080
|
+
const idx = key.lastIndexOf(":");
|
|
1081
|
+
return idx === -1 ? key : key.slice(0, idx);
|
|
1082
|
+
}
|
|
1083
|
+
function isRateKey(key) {
|
|
1084
|
+
return isRateTickerSymbol(symbolFromKey(key));
|
|
1085
|
+
}
|
|
1086
|
+
function navPriceForKey(key, date, prices, lastPrice) {
|
|
1087
|
+
if (isRateKey(key)) return 1;
|
|
1088
|
+
const live = prices[key]?.[date];
|
|
1089
|
+
if (live != null) {
|
|
1090
|
+
lastPrice[key] = live;
|
|
1091
|
+
return live;
|
|
1092
|
+
}
|
|
1093
|
+
return lastPrice[key];
|
|
1094
|
+
}
|
|
1095
|
+
function daysBetween(prevIsoDate, currIsoDate) {
|
|
1096
|
+
const ms = Date.UTC(Number(currIsoDate.slice(0, 4)), Number(currIsoDate.slice(5, 7)) - 1, Number(currIsoDate.slice(8, 10))) - Date.UTC(Number(prevIsoDate.slice(0, 4)), Number(prevIsoDate.slice(5, 7)) - 1, Number(prevIsoDate.slice(8, 10)));
|
|
1097
|
+
return Math.round(ms / (1e3 * 60 * 60 * 24));
|
|
1098
|
+
}
|
|
802
1099
|
function runSimulation(bars, prices, rebalanceDates, portfolio) {
|
|
803
1100
|
const positions = {};
|
|
804
1101
|
const lastPrice = {};
|
|
@@ -813,15 +1110,24 @@ function runSimulation(bars, prices, rebalanceDates, portfolio) {
|
|
|
813
1110
|
const series = [];
|
|
814
1111
|
const trades = [];
|
|
815
1112
|
function valuationPrice(key, date) {
|
|
816
|
-
|
|
817
|
-
if (live != null) {
|
|
818
|
-
lastPrice[key] = live;
|
|
819
|
-
return live;
|
|
820
|
-
}
|
|
821
|
-
return lastPrice[key];
|
|
1113
|
+
return navPriceForKey(key, date, prices, lastPrice);
|
|
822
1114
|
}
|
|
1115
|
+
let prevDate = null;
|
|
823
1116
|
for (const bar of bars) {
|
|
824
1117
|
const date = bar.date;
|
|
1118
|
+
if (prevDate != null) {
|
|
1119
|
+
const days = daysBetween(prevDate, date);
|
|
1120
|
+
if (days > 0) {
|
|
1121
|
+
for (const [key, shares] of Object.entries(positions)) {
|
|
1122
|
+
if (!isRateKey(key)) continue;
|
|
1123
|
+
const ratePct = prices[key]?.[prevDate];
|
|
1124
|
+
if (ratePct == null) continue;
|
|
1125
|
+
const leverage = Number(key.slice(key.lastIndexOf(":") + 1)) || 1;
|
|
1126
|
+
const factor = 1 + leverage * (ratePct / 100) * (days / 360);
|
|
1127
|
+
positions[key] = shares * factor;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
825
1131
|
if (rebalanceDates.has(date)) {
|
|
826
1132
|
let portfolioValue = cash;
|
|
827
1133
|
for (const [key, shares] of Object.entries(positions)) {
|
|
@@ -834,8 +1140,14 @@ function runSimulation(bars, prices, rebalanceDates, portfolio) {
|
|
|
834
1140
|
}
|
|
835
1141
|
const allKeys = /* @__PURE__ */ new Set([...Object.keys(positions), ...Object.keys(targetWeights)]);
|
|
836
1142
|
for (const key of allKeys) {
|
|
837
|
-
|
|
838
|
-
if (
|
|
1143
|
+
let price;
|
|
1144
|
+
if (isRateKey(key)) {
|
|
1145
|
+
price = 1;
|
|
1146
|
+
} else {
|
|
1147
|
+
const live = prices[key]?.[date];
|
|
1148
|
+
if (live == null || live <= 0) continue;
|
|
1149
|
+
price = live;
|
|
1150
|
+
}
|
|
839
1151
|
const currentShares = positions[key] ?? 0;
|
|
840
1152
|
const targetValue = portfolioValue * (targetWeights[key] ?? 0);
|
|
841
1153
|
const targetShares = targetValue / price;
|
|
@@ -863,6 +1175,7 @@ function runSimulation(bars, prices, rebalanceDates, portfolio) {
|
|
|
863
1175
|
if (price != null) value += shares * price;
|
|
864
1176
|
}
|
|
865
1177
|
series.push({ date, value });
|
|
1178
|
+
prevDate = date;
|
|
866
1179
|
}
|
|
867
1180
|
const finalHoldings = [];
|
|
868
1181
|
const tickerByKey = /* @__PURE__ */ new Map();
|
|
@@ -1153,20 +1466,56 @@ var StrategyHandle = class {
|
|
|
1153
1466
|
}
|
|
1154
1467
|
async _sync(latestClosed) {
|
|
1155
1468
|
const { id } = await this.resolve();
|
|
1156
|
-
const
|
|
1469
|
+
const { entries } = await this._evaluate(this._market, latestClosed);
|
|
1470
|
+
if (entries.length > 0) {
|
|
1471
|
+
await this._storage.strategies.writeSeries(id, entries);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Pure evaluate — runs the same pipeline as _sync but returns the computed
|
|
1476
|
+
* evaluation instead of persisting. Used by both _sync (post-close write
|
|
1477
|
+
* path) and previewAllocation (pre-close read-only path).
|
|
1478
|
+
*
|
|
1479
|
+
* The `market === this._market` identity check is what distinguishes the two
|
|
1480
|
+
* paths: when they are the same object the method takes the write path (syncing
|
|
1481
|
+
* signals through storage as normal); when they differ it takes the read-only
|
|
1482
|
+
* preview path (historical bars from storage, today's value computed in-memory
|
|
1483
|
+
* via `computeAt`). Callers that want the preview path must therefore supply a
|
|
1484
|
+
* *different* market object — for example one produced by `createQuoteOverlay`.
|
|
1485
|
+
*/
|
|
1486
|
+
async _evaluate(market, limitDate) {
|
|
1157
1487
|
const allSignals = /* @__PURE__ */ new Set();
|
|
1158
1488
|
for (const rule of this._rules) {
|
|
1159
1489
|
if (rule.when) rule.when.forEach((s) => allSignals.add(s));
|
|
1160
1490
|
}
|
|
1161
|
-
|
|
1162
|
-
Array.from(allSignals).map(async (signal) => {
|
|
1163
|
-
const bars = await signal.series();
|
|
1164
|
-
const dateMap = /* @__PURE__ */ new Map();
|
|
1165
|
-
for (const bar of bars) dateMap.set(bar.date, bar.value === 1);
|
|
1166
|
-
signalSeries.set(signal.id, dateMap);
|
|
1167
|
-
})
|
|
1168
|
-
);
|
|
1491
|
+
const signalSeries = /* @__PURE__ */ new Map();
|
|
1169
1492
|
const tradingDays = await this._storage.tradingDays.getRange();
|
|
1493
|
+
if (market === this._market) {
|
|
1494
|
+
await Promise.all(
|
|
1495
|
+
Array.from(allSignals).map(async (signal) => {
|
|
1496
|
+
const bars = await signal.withMarket(market).series();
|
|
1497
|
+
const dateMap = /* @__PURE__ */ new Map();
|
|
1498
|
+
for (const bar of bars) dateMap.set(bar.date, bar.value === 1);
|
|
1499
|
+
signalSeries.set(signal.id, dateMap);
|
|
1500
|
+
})
|
|
1501
|
+
);
|
|
1502
|
+
} else {
|
|
1503
|
+
const limitIdx = tradingDays.indexOf(limitDate);
|
|
1504
|
+
const prevDate = limitIdx > 0 ? tradingDays[limitIdx - 1] : void 0;
|
|
1505
|
+
await Promise.all(
|
|
1506
|
+
Array.from(allSignals).map(async (signal) => {
|
|
1507
|
+
const historicalBars = await this._storage.signals.getSeries(signal.id);
|
|
1508
|
+
const dateMap = /* @__PURE__ */ new Map();
|
|
1509
|
+
for (const bar of historicalBars) dateMap.set(bar.date, bar.value === 1);
|
|
1510
|
+
const prevBool = prevDate !== void 0 ? dateMap.get(prevDate) ?? null : null;
|
|
1511
|
+
const todayValue = await signal.computeAt(market, limitDate, prevBool);
|
|
1512
|
+
if (todayValue !== null) {
|
|
1513
|
+
dateMap.set(limitDate, todayValue);
|
|
1514
|
+
}
|
|
1515
|
+
signalSeries.set(signal.id, dateMap);
|
|
1516
|
+
})
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1170
1519
|
const rebalanceDates = computeRebalanceDates(tradingDays, this._freq, this._offset);
|
|
1171
1520
|
const allocations = [];
|
|
1172
1521
|
const allocIndexMap = /* @__PURE__ */ new Map();
|
|
@@ -1183,13 +1532,8 @@ var StrategyHandle = class {
|
|
|
1183
1532
|
};
|
|
1184
1533
|
});
|
|
1185
1534
|
const evalResult = evaluateStrategy(signalSeries, rulesInput, rebalanceDates, tradingDays);
|
|
1186
|
-
const entries = Array.from(evalResult.entries()).filter(([date]) => date <=
|
|
1187
|
-
|
|
1188
|
-
allocationId: allocations[allocIdx].id
|
|
1189
|
-
}));
|
|
1190
|
-
if (entries.length > 0) {
|
|
1191
|
-
await this._storage.strategies.writeSeries(id, entries);
|
|
1192
|
-
}
|
|
1535
|
+
const entries = Array.from(evalResult.entries()).filter(([date]) => date <= limitDate).map(([date, allocIdx]) => ({ date, allocationId: allocations[allocIdx].id }));
|
|
1536
|
+
return { allocations, entries };
|
|
1193
1537
|
}
|
|
1194
1538
|
async _querySeriesFromDb(range) {
|
|
1195
1539
|
const { id } = await this.resolve();
|
|
@@ -1242,6 +1586,62 @@ var StrategyHandle = class {
|
|
|
1242
1586
|
};
|
|
1243
1587
|
return new SimulationHandle(result.series, result.trades, options.portfolio, finalState);
|
|
1244
1588
|
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Preview the allocation this strategy would produce for `date` if today
|
|
1591
|
+
* closed at the provided raw quote prices. Does NOT write to strategies_series,
|
|
1592
|
+
* signals_series, or indicators_series. Safe to call before market close.
|
|
1593
|
+
*
|
|
1594
|
+
* @param date - The trading day to preview (must be in tradingDays.getRange()).
|
|
1595
|
+
* @param quoteOverrides - Raw (unleveraged) live prices keyed by ticker symbol.
|
|
1596
|
+
* Symbols absent from this map will fall back to yesterday's close via the
|
|
1597
|
+
* quote overlay.
|
|
1598
|
+
* @returns The AllocationHandle for `date`, or null if the strategy has no
|
|
1599
|
+
* evaluable entry for that date.
|
|
1600
|
+
*/
|
|
1601
|
+
async previewAllocation(date, quoteOverrides) {
|
|
1602
|
+
await this.resolve();
|
|
1603
|
+
const tradingDays = await this._storage.tradingDays.getRange();
|
|
1604
|
+
if (!tradingDays.includes(date)) {
|
|
1605
|
+
throw new Error(`previewAllocation: ${date} is not a trading day`);
|
|
1606
|
+
}
|
|
1607
|
+
const overlayMarket = createQuoteOverlay(this._market, { [date]: quoteOverrides }, { fallbackMissingQuotes: true });
|
|
1608
|
+
const { allocations, entries } = await this._evaluate(overlayMarket, date);
|
|
1609
|
+
const target = entries.find((e) => e.date === date);
|
|
1610
|
+
if (!target) return null;
|
|
1611
|
+
const alloc = allocations.find((a) => a.id === target.allocationId);
|
|
1612
|
+
return alloc ?? this._allocationMap.get(target.allocationId) ?? null;
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Read-only preview of the strategy's allocation series including `date`.
|
|
1616
|
+
* Returns stored historical allocations plus an in-memory bar at `date`
|
|
1617
|
+
* computed via the same overlay path as `previewAllocation`.
|
|
1618
|
+
*
|
|
1619
|
+
* @param date - Target trading day to splice in-memory via overlay market.
|
|
1620
|
+
* @param quoteOverrides - Raw (unleveraged) quotes keyed by ticker symbol.
|
|
1621
|
+
* @param range - Optional filter applied to the returned bars.
|
|
1622
|
+
*/
|
|
1623
|
+
async previewSeries(date, quoteOverrides, range) {
|
|
1624
|
+
await this.resolve();
|
|
1625
|
+
const tradingDays = await this._storage.tradingDays.getRange();
|
|
1626
|
+
if (!tradingDays.includes(date)) {
|
|
1627
|
+
throw new Error(`previewSeries: ${date} is not a trading day`);
|
|
1628
|
+
}
|
|
1629
|
+
const overlayMarket = createQuoteOverlay(this._market, { [date]: quoteOverrides }, { fallbackMissingQuotes: true });
|
|
1630
|
+
const { allocations, entries } = await this._evaluate(overlayMarket, date);
|
|
1631
|
+
const allocById = /* @__PURE__ */ new Map();
|
|
1632
|
+
for (const a of allocations) allocById.set(a.id, a);
|
|
1633
|
+
for (const [id, a] of this._allocationMap) if (!allocById.has(id)) allocById.set(id, a);
|
|
1634
|
+
let bars = entries.map((e) => ({
|
|
1635
|
+
date: e.date,
|
|
1636
|
+
allocation: allocById.get(e.allocationId)
|
|
1637
|
+
}));
|
|
1638
|
+
if (range) {
|
|
1639
|
+
bars = bars.filter(
|
|
1640
|
+
(b) => (range.from === void 0 || b.date >= range.from) && (range.to === void 0 || b.date <= range.to)
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
return bars;
|
|
1644
|
+
}
|
|
1245
1645
|
async _fetchPricesForTickers(bars, from, to) {
|
|
1246
1646
|
const tickerMap = /* @__PURE__ */ new Map();
|
|
1247
1647
|
for (const bar of bars) {
|