@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.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") return { provider: "yahoo", symbol: tickerSymbol };
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)) return { provider: "computed", dependsOn: "Price", symbol: tickerSymbol };
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
- result.push({
131
- date: bars[i].date,
132
- value: (bars[i].value - bars[i - lookback].value) / bars[i - lookback].value
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
- const computeFn = getComputation(this.type);
319
- if (!computeFn) throw new Error(`No computation found for type "${this.type}"`);
320
- bars = computeFn(priceBars, this.lookback);
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
- const leverage = this.ticker?.leverage ?? 1;
339
- if (leverage !== 1 && info.provider !== "computed" && bars.length > 0) {
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, quantity] of holdings) {
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
- const live = prices[key]?.[date];
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
- const price = prices[key]?.[date];
838
- if (price == null || price <= 0) continue;
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 signalSeries = /* @__PURE__ */ new Map();
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
- await Promise.all(
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 <= latestClosed).map(([date, allocIdx]) => ({
1187
- date,
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) {