@livefolio/sdk 0.3.1 → 0.3.3

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
@@ -239,6 +239,23 @@ function getComputation(type) {
239
239
  }
240
240
 
241
241
  // src/handles/indicator.ts
242
+ var FRED_SYMBOL_TO_TYPE = {
243
+ DGS3MO: "T3M",
244
+ DGS6MO: "T6M",
245
+ DGS1: "T1Y",
246
+ DGS2: "T2Y",
247
+ DGS3: "T3Y",
248
+ DGS5: "T5Y",
249
+ DGS7: "T7Y",
250
+ DGS10: "T10Y",
251
+ DGS20: "T20Y",
252
+ DGS30: "T30Y"
253
+ };
254
+ function _subtractCalendarDays(date, days) {
255
+ const d = new Date(date);
256
+ d.setUTCDate(d.getUTCDate() - days);
257
+ return d.toISOString().slice(0, 10);
258
+ }
242
259
  var IndicatorHandle = class _IndicatorHandle {
243
260
  type;
244
261
  ticker;
@@ -304,14 +321,24 @@ var IndicatorHandle = class _IndicatorHandle {
304
321
  const { id } = await this.resolve();
305
322
  const latestClosed = await this._getLatestClosedTradingDay();
306
323
  if (this._cachedAsOf === latestClosed) return;
324
+ let horizon = latestClosed;
325
+ if (this.delay > 0) {
326
+ const tradingDays = await this._storage.tradingDays.getRange();
327
+ const idx = tradingDays.indexOf(latestClosed);
328
+ if (idx >= this.delay) {
329
+ horizon = tradingDays[idx - this.delay];
330
+ }
331
+ }
307
332
  const latestSeries = await this._getLatestSeriesDate(id);
308
- if (latestSeries === latestClosed) {
333
+ if (latestSeries === horizon) {
309
334
  this._cachedSeries = null;
310
335
  this._cachedAsOf = latestClosed;
311
336
  return;
312
337
  }
313
338
  if (!this._syncing) {
314
- this._syncing = this._sync(latestSeries ?? void 0, latestClosed).finally(() => {
339
+ this._syncing = this._sync(latestSeries ?? void 0, latestClosed).catch((err) => {
340
+ console.warn("[sdk] indicator sync failed, using stored data:", err);
341
+ }).finally(() => {
315
342
  this._syncing = null;
316
343
  });
317
344
  }
@@ -365,25 +392,19 @@ var IndicatorHandle = class _IndicatorHandle {
365
392
  case "none":
366
393
  return;
367
394
  }
368
- const leverage = this.ticker?.leverage ?? 1;
369
- const isRate = isRateTickerSymbol(this.ticker?.symbol ?? null);
370
- if (leverage !== 1 && info.provider !== "computed" && !isRate && bars.length > 0) {
371
- let anchor;
372
- if (fromDate) {
373
- const lastStored = await this._storage.indicators.getValue(this._resolvedId, fromDate);
374
- anchor = lastStored ?? bars[0].value;
375
- } else {
376
- anchor = bars[0].value;
377
- }
378
- const leveraged = [{ date: bars[0].date, value: anchor }];
379
- for (let i = 1; i < bars.length; i++) {
380
- const dailyReturn = (bars[i].value - bars[i - 1].value) / bars[i - 1].value;
381
- const prev = leveraged[i - 1].value;
382
- leveraged.push({ date: bars[i].date, value: prev * (1 + leverage * dailyReturn) });
395
+ if (info.provider !== "computed") {
396
+ bars = await this._applyLeverage(bars, fromDate);
397
+ }
398
+ let horizon = latestClosed;
399
+ if (this.delay > 0) {
400
+ const tradingDays = await this._storage.tradingDays.getRange();
401
+ const idx = tradingDays.indexOf(latestClosed);
402
+ if (idx < this.delay) {
403
+ return;
383
404
  }
384
- bars = leveraged;
405
+ horizon = tradingDays[idx - this.delay];
385
406
  }
386
- bars = bars.filter((b) => b.date <= latestClosed);
407
+ bars = bars.filter((b) => b.date <= horizon);
387
408
  if (bars.length > 0) {
388
409
  await this._upsertSeries(bars);
389
410
  }
@@ -396,6 +417,175 @@ var IndicatorHandle = class _IndicatorHandle {
396
417
  const { id } = await this.resolve();
397
418
  return this._storage.indicators.getSeries(id, range);
398
419
  }
420
+ /**
421
+ * Apply leverage compounding to a raw bar series, anchored to a stored
422
+ * leveraged value. Used by both `_sync` and `computeAt` so they stay
423
+ * consistent.
424
+ *
425
+ * `anchorDate` is the date of the last *already-stored* leveraged bar
426
+ * (i.e., the bar just before `rawBars[0]`). The stored leveraged value
427
+ * at that date becomes `leveraged[0]`; raw returns are then compounded
428
+ * forward for each subsequent bar.
429
+ *
430
+ * If no stored anchor exists (first-ever sync), falls back to rawBars[0]
431
+ * as the starting raw value — identical to `_sync`'s behaviour.
432
+ */
433
+ async _applyLeverage(rawBars, anchorDate) {
434
+ const leverage = this.ticker?.leverage ?? 1;
435
+ if (leverage === 1 || rawBars.length === 0) return rawBars;
436
+ if (isRateTickerSymbol(this.ticker?.symbol ?? null)) return rawBars;
437
+ let anchor;
438
+ if (anchorDate) {
439
+ const lastStored = await this._storage.indicators.getValue(this._resolvedId, anchorDate);
440
+ anchor = lastStored ?? rawBars[0].value;
441
+ } else {
442
+ anchor = rawBars[0].value;
443
+ }
444
+ const leveraged = [{ date: rawBars[0].date, value: anchor }];
445
+ for (let i = 1; i < rawBars.length; i++) {
446
+ const dailyReturn = (rawBars[i].value - rawBars[i - 1].value) / rawBars[i - 1].value;
447
+ const prev = leveraged[i - 1].value;
448
+ leveraged.push({ date: rawBars[i].date, value: prev * (1 + leverage * dailyReturn) });
449
+ }
450
+ return leveraged;
451
+ }
452
+ /**
453
+ * Compute the indicator's value at `date` without persisting anything, with
454
+ * optional live-quote `overrides` keyed by raw market symbol (the same symbol
455
+ * space `MarketProvider.fetchBars` uses — ticker symbols for Price/SMA/etc.,
456
+ * `^VIX` / `^VIX3M` for macro, FRED series IDs like `DGS3MO` for Treasury).
457
+ *
458
+ * Bars for the underlying symbol are resolved storage-first when the market
459
+ * hasn't yet produced bars for `date` (trading day still open), and storage
460
+ * is the fallback whenever the remote fetch fails — see `_resolveRawBars`.
461
+ *
462
+ * For Threshold: returns the threshold constant. For calendar types: computed
463
+ * from `tradingDays.getRange()`. For all others: `_resolveRawBars` → leverage
464
+ * compounding (if any) → lookback-specific computation. Returns null if the
465
+ * value cannot be computed.
466
+ */
467
+ async computeAt(date, overrides) {
468
+ if (this.type === "Threshold") return this.threshold;
469
+ const tickerSymbol = this.ticker?.symbol ?? null;
470
+ const info = getProviderInfo(this.type, tickerSymbol);
471
+ if (info.provider === "none") return null;
472
+ if (info.provider === "calendar") {
473
+ const allDays = await this._storage.tradingDays.getRange();
474
+ const dayBars = allDays.map((d) => ({ date: d, value: 0 }));
475
+ const computed = computeCalendar(dayBars, this.type);
476
+ return computed.find((b) => b.date === date)?.value ?? null;
477
+ }
478
+ if (info.provider === "computed") {
479
+ let calendarDays;
480
+ if (this.type === "RSI") {
481
+ calendarDays = Math.max(this.lookback * 10, 90);
482
+ } else if (this.type === "EMA") {
483
+ calendarDays = Math.max(this.lookback * 5, 60);
484
+ } else {
485
+ calendarDays = Math.ceil(this.lookback * 1.5) + 15;
486
+ }
487
+ const from2 = _subtractCalendarDays(date, this.lookback + calendarDays);
488
+ const rawBars2 = await this._resolveRawBars(info.symbol, from2, date, overrides);
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, 15);
498
+ const rawBars = await this._resolveRawBars(symbol, from, date, overrides);
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
+ }
514
+ /**
515
+ * Raw (unleveraged) bars for `symbol` up through `date`, with the live quote
516
+ * from `overrides[symbol]` (if any) spliced in at `date`.
517
+ *
518
+ * Decision policy:
519
+ * - `date` > `tradingDays.getLatestClosed()`: market has nothing for that
520
+ * day yet — skip the remote fetch entirely and read from storage.
521
+ * - otherwise: try `this._market.fetchBars(symbol, from)`. On failure, fall
522
+ * back to storage — upstream HTTP providers (Yahoo / FRED) are flaky.
523
+ *
524
+ * After the base is resolved, `overrides[symbol]` is spliced at `date`
525
+ * (replaces the existing bar, or is appended in-order). When no override is
526
+ * present but `date` isn't in the base bars, the last known value is carried
527
+ * forward to `date` — this preserves the fallbackMissingQuotes behaviour the
528
+ * old overlay exposed so leverage compounding / computations always have a
529
+ * point at `date` to land on.
530
+ */
531
+ async _resolveRawBars(symbol, from, date, overrides) {
532
+ const latestClosed = await this._storage.tradingDays.getLatestClosed();
533
+ const closedForDate = latestClosed !== null && date <= latestClosed;
534
+ let bars;
535
+ if (closedForDate) {
536
+ try {
537
+ bars = await this._market.fetchBars(symbol, from);
538
+ } catch {
539
+ bars = await this._readStoredBars(symbol, from);
540
+ }
541
+ } else {
542
+ bars = await this._readStoredBars(symbol, from);
543
+ }
544
+ const override = overrides?.[symbol];
545
+ const existingIdx = bars.findIndex((b) => b.date === date);
546
+ if (override !== void 0) {
547
+ if (existingIdx >= 0) {
548
+ bars[existingIdx] = { date, value: override };
549
+ } else {
550
+ bars = [...bars, { date, value: override }].sort((a, b) => a.date.localeCompare(b.date));
551
+ }
552
+ } else if (existingIdx < 0 && bars.length > 0) {
553
+ bars = [...bars, { date, value: bars[bars.length - 1].value }];
554
+ }
555
+ return bars;
556
+ }
557
+ /**
558
+ * Resolve raw (unleveraged) bars for a market symbol from storage. Maps:
559
+ * - `^VIX` → the VIX indicator's stored series
560
+ * - `^VIX3M` → the VIX3M indicator's stored series
561
+ * - `DGS*` → the matching Treasury-tenor indicator's stored series
562
+ * - anything else → the `Price` indicator for that ticker symbol with
563
+ * `leverage = 1` (the raw contract that `MarketProvider.fetchBars` has).
564
+ *
565
+ * Returns `[]` when the resolved indicator has no stored bars yet.
566
+ */
567
+ async _readStoredBars(symbol, from) {
568
+ let identity;
569
+ if (symbol === "^VIX") {
570
+ identity = { type: "VIX", tickerId: null, lookback: 0, delay: 0, unit: null, threshold: null };
571
+ } else if (symbol === "^VIX3M") {
572
+ identity = { type: "VIX3M", tickerId: null, lookback: 0, delay: 0, unit: null, threshold: null };
573
+ } else if (FRED_SYMBOL_TO_TYPE[symbol]) {
574
+ identity = {
575
+ type: FRED_SYMBOL_TO_TYPE[symbol],
576
+ tickerId: null,
577
+ lookback: 0,
578
+ delay: 0,
579
+ unit: null,
580
+ threshold: null
581
+ };
582
+ } else {
583
+ const { id: tickerId } = await this._storage.tickers.findOrCreate(symbol, 1);
584
+ identity = { type: "Price", tickerId, lookback: 0, delay: 0, unit: null, threshold: null };
585
+ }
586
+ const { id } = await this._storage.indicators.findOrCreate(identity);
587
+ return this._storage.indicators.getSeries(id, { from });
588
+ }
399
589
  // ── Public data access ─────────────────────────────────────────────
400
590
  async series(range) {
401
591
  if (this.type === "Threshold") {
@@ -417,6 +607,47 @@ var IndicatorHandle = class _IndicatorHandle {
417
607
  const { id } = await this.resolve();
418
608
  return this._storage.indicators.getValue(id, date);
419
609
  }
610
+ /**
611
+ * Read-only preview of the indicator series with an in-memory bar at `date`
612
+ * computed via `computeAt` with the supplied live-quote `overrides`. Does
613
+ * NOT write to `indicators_series`. Safe to call before market close.
614
+ *
615
+ * @param date - Target trading day whose value is computed in-memory.
616
+ * Must be in `tradingDays.getRange()`.
617
+ * @param overrides - Raw (unleveraged) quotes keyed by market symbol.
618
+ * Symbols omitted fall back to the last known value (see `_resolveRawBars`).
619
+ * @param range - Optional filter applied to the returned bars.
620
+ * @returns Stored historical bars plus (or with) today's in-memory value.
621
+ */
622
+ async previewSeries(date, overrides, range) {
623
+ const tradingDays = await this._storage.tradingDays.getRange();
624
+ if (!tradingDays.includes(date)) {
625
+ throw new Error(`previewSeries: ${date} is not a trading day`);
626
+ }
627
+ let bars;
628
+ if (this.type === "Threshold") {
629
+ bars = await this._syntheticThresholdSeries();
630
+ } else {
631
+ bars = await this._querySeriesFromDb();
632
+ }
633
+ if (this.delay === 0) {
634
+ const todayValue = await this.computeAt(date, overrides);
635
+ if (todayValue !== null) {
636
+ const idx = bars.findIndex((b) => b.date === date);
637
+ if (idx >= 0) {
638
+ bars[idx] = { date, value: todayValue };
639
+ } else {
640
+ bars = [...bars, { date, value: todayValue }].sort((a, b) => a.date.localeCompare(b.date));
641
+ }
642
+ }
643
+ }
644
+ if (range) {
645
+ bars = bars.filter(
646
+ (b) => (range.from === void 0 || b.date >= range.from) && (range.to === void 0 || b.date <= range.to)
647
+ );
648
+ }
649
+ return bars;
650
+ }
420
651
  };
421
652
 
422
653
  // src/computations/signal.ts
@@ -497,15 +728,17 @@ var SignalHandle = class _SignalHandle {
497
728
  comparison;
498
729
  tolerance;
499
730
  _storage;
500
- _market;
501
731
  _resolvedId = null;
502
732
  _resolving = null;
503
733
  _cachedSeries = null;
504
734
  _cachedAsOf = null;
505
735
  _syncing = null;
506
- constructor(storage, market, identity) {
736
+ // The `market` parameter is kept in the signature for API compatibility with
737
+ // `new SignalHandle(storage, market, identity)` — signals no longer carry
738
+ // their own market reference since `computeAt` delegates to the indicator
739
+ // handles, which already hold one.
740
+ constructor(storage, _market, identity) {
507
741
  this._storage = storage;
508
- this._market = market;
509
742
  this.indicator1 = identity.indicator1;
510
743
  this.indicator2 = identity.indicator2;
511
744
  this.comparison = identity.comparison;
@@ -561,7 +794,9 @@ var SignalHandle = class _SignalHandle {
561
794
  return;
562
795
  }
563
796
  if (!this._syncing) {
564
- this._syncing = this._sync(latestSeries ?? void 0, latestClosed).finally(() => {
797
+ this._syncing = this._sync(latestSeries ?? void 0, latestClosed).catch((err) => {
798
+ console.warn("[sdk] signal sync failed, using stored data:", err);
799
+ }).finally(() => {
565
800
  this._syncing = null;
566
801
  });
567
802
  }
@@ -589,6 +824,53 @@ var SignalHandle = class _SignalHandle {
589
824
  const { id } = await this.resolve();
590
825
  return this._storage.signals.getSeries(id, range);
591
826
  }
827
+ /**
828
+ * Compute the signal's boolean value at `date` without persisting anything,
829
+ * with optional live-quote `overrides` that are routed through each
830
+ * indicator's `computeAt`. Returns null if either indicator cannot produce
831
+ * a value at `date`.
832
+ *
833
+ * @param prevBool - The signal's boolean value at the bar immediately
834
+ * preceding `date`, used for hysteresis when `tolerance > 0`. If not
835
+ * provided, falls back to `storage.signals.getLastValue` (suitable for
836
+ * standalone callers). On the preview path `_evaluate` passes this from
837
+ * the in-memory `dateMap` so we never read stale storage.
838
+ */
839
+ async computeAt(date, overrides, prevBool) {
840
+ const [v1, v2] = await Promise.all([
841
+ this.indicator1.computeAt(date, overrides),
842
+ this.indicator2.computeAt(date, overrides)
843
+ ]);
844
+ if (v1 === null || v2 === null) return null;
845
+ const absolute = ABSOLUTE_TOLERANCE_TYPES.has(this.indicator1.type);
846
+ if (this.tolerance === 0) {
847
+ switch (this.comparison) {
848
+ case ">":
849
+ return v1 > v2;
850
+ case "<":
851
+ return v1 < v2;
852
+ case "=":
853
+ return v1 === v2;
854
+ }
855
+ }
856
+ const tolerance = this.tolerance;
857
+ const upper = absolute ? v2 + tolerance : v2 * (1 + tolerance / 100);
858
+ const lower = absolute ? v2 - tolerance : v2 * (1 - tolerance / 100);
859
+ if (this.comparison === "=") {
860
+ return v1 >= lower && v1 <= upper;
861
+ }
862
+ let resolvedPrevBool;
863
+ if (prevBool !== void 0 && prevBool !== null) {
864
+ resolvedPrevBool = prevBool;
865
+ } else {
866
+ const prev = await this._storage.signals.getLastValue(this.id);
867
+ resolvedPrevBool = prev === 1;
868
+ }
869
+ if (this.comparison === ">") {
870
+ return resolvedPrevBool ? v1 >= lower : v1 > upper;
871
+ }
872
+ return resolvedPrevBool ? v1 <= upper : v1 < lower;
873
+ }
592
874
  // ── Public data access ─────────────────────────────────────────────
593
875
  async series(range) {
594
876
  await this._ensureFresh();
@@ -606,6 +888,43 @@ var SignalHandle = class _SignalHandle {
606
888
  const { id } = await this.resolve();
607
889
  return this._storage.signals.getLastValue(id);
608
890
  }
891
+ /**
892
+ * Read-only preview of the signal series with an in-memory bar at `date`
893
+ * computed via `computeAt` with the supplied live-quote `overrides`. Does
894
+ * NOT write to `signals_series`.
895
+ *
896
+ * @param date - Target trading day whose boolean is computed in-memory.
897
+ * @param overrides - Raw (unleveraged) quotes keyed by market symbol.
898
+ * @param range - Optional filter applied to the returned bars.
899
+ */
900
+ async previewSeries(date, overrides, range) {
901
+ const tradingDays = await this._storage.tradingDays.getRange();
902
+ if (!tradingDays.includes(date)) {
903
+ throw new Error(`previewSeries: ${date} is not a trading day`);
904
+ }
905
+ let bars = await this._querySeriesFromDb();
906
+ const dateMap = /* @__PURE__ */ new Map();
907
+ for (const bar of bars) dateMap.set(bar.date, bar.value === 1);
908
+ const limitIdx = tradingDays.indexOf(date);
909
+ const prevDate = limitIdx > 0 ? tradingDays[limitIdx - 1] : void 0;
910
+ const prevBool = prevDate !== void 0 ? dateMap.get(prevDate) ?? null : null;
911
+ const todayBool = await this.computeAt(date, overrides, prevBool);
912
+ if (todayBool !== null) {
913
+ const numeric = todayBool ? 1 : 0;
914
+ const idx = bars.findIndex((b) => b.date === date);
915
+ if (idx >= 0) {
916
+ bars[idx] = { date, value: numeric };
917
+ } else {
918
+ bars = [...bars, { date, value: numeric }].sort((a, b) => a.date.localeCompare(b.date));
919
+ }
920
+ }
921
+ if (range) {
922
+ bars = bars.filter(
923
+ (b) => (range.from === void 0 || b.date >= range.from) && (range.to === void 0 || b.date <= range.to)
924
+ );
925
+ }
926
+ return bars;
927
+ }
609
928
  };
610
929
 
611
930
  // src/handles/allocation.ts
@@ -971,7 +1290,9 @@ var SimulationHandle = class {
971
1290
  _lastLeveragedPrices;
972
1291
  _currentLeveragedPrices;
973
1292
  _lastDate;
974
- constructor(series, trades, startingPortfolio, finalState) {
1293
+ _pushedQuotes;
1294
+ _liveEvaluator;
1295
+ constructor(series, trades, startingPortfolio, finalState, liveEvaluator) {
975
1296
  this.series = series;
976
1297
  this.trades = trades;
977
1298
  this.startingPortfolio = startingPortfolio;
@@ -990,6 +1311,8 @@ var SimulationHandle = class {
990
1311
  this._currentLeveragedPrices = /* @__PURE__ */ new Map();
991
1312
  this._lastDate = "";
992
1313
  }
1314
+ this._pushedQuotes = {};
1315
+ this._liveEvaluator = liveEvaluator ?? null;
993
1316
  }
994
1317
  push(...prices) {
995
1318
  if (!this._portfolio || !this._currentAllocation) {
@@ -1024,6 +1347,48 @@ var SimulationHandle = class {
1024
1347
  pendingTrades: this._portfolio.trades(this._currentAllocation, priceArray, this._lastDate)
1025
1348
  };
1026
1349
  }
1350
+ /**
1351
+ * One-call live update. Feeds portfolio-relevant ticker prices into `push`
1352
+ * (derived from `quotes` via the running portfolio's holdings), accumulates
1353
+ * every symbol in `quotes` into an internal override map so macro symbols
1354
+ * (e.g. `^VIX`) persist across ticks, then delegates to the simulation's
1355
+ * strategy for rule / signal / indicator evaluation at `date`.
1356
+ *
1357
+ * Without a live evaluator attached, returns just the portfolio snapshot
1358
+ * with allocation/rules/signals empty.
1359
+ *
1360
+ * @param quotes Symbol → raw live price. Portfolio tickers flow through
1361
+ * `push` for leveraged-equity math; non-portfolio symbols are still
1362
+ * layered into the overlay so indicators can see them.
1363
+ * @param options.date Target trading day to evaluate against. Defaults to
1364
+ * the current UTC ISO date; callers with non-UTC semantics or after-hours
1365
+ * rollover should supply their own.
1366
+ */
1367
+ async pushAndPreview(quotes, options = {}) {
1368
+ const priceArgs = [];
1369
+ if (this._portfolio) {
1370
+ const seen = /* @__PURE__ */ new Set();
1371
+ for (const [ticker] of this._portfolio.holdings) {
1372
+ if (ticker.symbol === "CASHX") continue;
1373
+ if (seen.has(ticker.symbol)) continue;
1374
+ const price = quotes[ticker.symbol];
1375
+ if (price !== void 0) {
1376
+ priceArgs.push([ticker, price]);
1377
+ seen.add(ticker.symbol);
1378
+ }
1379
+ }
1380
+ }
1381
+ const snapshot = this.push(...priceArgs);
1382
+ for (const [symbol, price] of Object.entries(quotes)) {
1383
+ this._pushedQuotes[symbol] = price;
1384
+ }
1385
+ if (!this._liveEvaluator) {
1386
+ return { snapshot, allocation: null, activeRuleIndex: -1, rules: [] };
1387
+ }
1388
+ const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1389
+ const strategyState = await this._liveEvaluator.previewLiveState(date, { ...this._pushedQuotes });
1390
+ return { snapshot, ...strategyState };
1391
+ }
1027
1392
  };
1028
1393
 
1029
1394
  // src/handles/strategy.ts
@@ -1208,7 +1573,9 @@ var StrategyHandle = class {
1208
1573
  return;
1209
1574
  }
1210
1575
  if (!this._syncing) {
1211
- this._syncing = this._sync(latestClosed).finally(() => {
1576
+ this._syncing = this._sync(latestClosed).catch((err) => {
1577
+ console.warn("[sdk] strategy sync failed, using stored data:", err);
1578
+ }).finally(() => {
1212
1579
  this._syncing = null;
1213
1580
  });
1214
1581
  }
@@ -1218,20 +1585,55 @@ var StrategyHandle = class {
1218
1585
  }
1219
1586
  async _sync(latestClosed) {
1220
1587
  const { id } = await this.resolve();
1221
- const signalSeries = /* @__PURE__ */ new Map();
1588
+ const { entries } = await this._evaluate(latestClosed);
1589
+ if (entries.length > 0) {
1590
+ await this._storage.strategies.writeSeries(id, entries);
1591
+ }
1592
+ }
1593
+ /**
1594
+ * Pure evaluate — runs the same pipeline as _sync but returns the computed
1595
+ * evaluation instead of persisting. Used by both _sync (post-close write
1596
+ * path) and the public preview methods (pre-close read-only path).
1597
+ *
1598
+ * When `overrides` is `undefined` we take the write path — syncing signals
1599
+ * through storage as normal. When `overrides` is provided (even an empty
1600
+ * map) we take the read-only preview path: historical signal bars come
1601
+ * straight from storage, today's bar is computed in-memory via
1602
+ * `signal.computeAt(date, overrides, prevBool)`, and nothing is written.
1603
+ */
1604
+ async _evaluate(limitDate, overrides) {
1222
1605
  const allSignals = /* @__PURE__ */ new Set();
1223
1606
  for (const rule of this._rules) {
1224
1607
  if (rule.when) rule.when.forEach((s) => allSignals.add(s));
1225
1608
  }
1226
- await Promise.all(
1227
- Array.from(allSignals).map(async (signal) => {
1228
- const bars = await signal.series();
1229
- const dateMap = /* @__PURE__ */ new Map();
1230
- for (const bar of bars) dateMap.set(bar.date, bar.value === 1);
1231
- signalSeries.set(signal.id, dateMap);
1232
- })
1233
- );
1609
+ const signalSeries = /* @__PURE__ */ new Map();
1234
1610
  const tradingDays = await this._storage.tradingDays.getRange();
1611
+ if (overrides === void 0) {
1612
+ await Promise.all(
1613
+ Array.from(allSignals).map(async (signal) => {
1614
+ const bars = await signal.series();
1615
+ const dateMap = /* @__PURE__ */ new Map();
1616
+ for (const bar of bars) dateMap.set(bar.date, bar.value === 1);
1617
+ signalSeries.set(signal.id, dateMap);
1618
+ })
1619
+ );
1620
+ } else {
1621
+ const limitIdx = tradingDays.indexOf(limitDate);
1622
+ const prevDate = limitIdx > 0 ? tradingDays[limitIdx - 1] : void 0;
1623
+ await Promise.all(
1624
+ Array.from(allSignals).map(async (signal) => {
1625
+ const historicalBars = await this._storage.signals.getSeries(signal.id);
1626
+ const dateMap = /* @__PURE__ */ new Map();
1627
+ for (const bar of historicalBars) dateMap.set(bar.date, bar.value === 1);
1628
+ const prevBool = prevDate !== void 0 ? dateMap.get(prevDate) ?? null : null;
1629
+ const todayValue = await signal.computeAt(limitDate, overrides, prevBool);
1630
+ if (todayValue !== null) {
1631
+ dateMap.set(limitDate, todayValue);
1632
+ }
1633
+ signalSeries.set(signal.id, dateMap);
1634
+ })
1635
+ );
1636
+ }
1235
1637
  const rebalanceDates = computeRebalanceDates(tradingDays, this._freq, this._offset);
1236
1638
  const allocations = [];
1237
1639
  const allocIndexMap = /* @__PURE__ */ new Map();
@@ -1248,13 +1650,8 @@ var StrategyHandle = class {
1248
1650
  };
1249
1651
  });
1250
1652
  const evalResult = evaluateStrategy(signalSeries, rulesInput, rebalanceDates, tradingDays);
1251
- const entries = Array.from(evalResult.entries()).filter(([date]) => date <= latestClosed).map(([date, allocIdx]) => ({
1252
- date,
1253
- allocationId: allocations[allocIdx].id
1254
- }));
1255
- if (entries.length > 0) {
1256
- await this._storage.strategies.writeSeries(id, entries);
1257
- }
1653
+ const entries = Array.from(evalResult.entries()).filter(([date]) => date <= limitDate).map(([date, allocIdx]) => ({ date, allocationId: allocations[allocIdx].id }));
1654
+ return { allocations, entries };
1258
1655
  }
1259
1656
  async _querySeriesFromDb(range) {
1260
1657
  const { id } = await this.resolve();
@@ -1305,7 +1702,128 @@ var StrategyHandle = class {
1305
1702
  closePrices,
1306
1703
  leveragedPrices
1307
1704
  };
1308
- return new SimulationHandle(result.series, result.trades, options.portfolio, finalState);
1705
+ const liveEvaluator = {
1706
+ previewLiveState: (date, overrides) => this.previewLiveState(date, overrides)
1707
+ };
1708
+ return new SimulationHandle(result.series, result.trades, options.portfolio, finalState, liveEvaluator);
1709
+ }
1710
+ /**
1711
+ * Preview the allocation this strategy would produce for `date` if today
1712
+ * closed at the provided raw quote prices. Does NOT write to strategies_series,
1713
+ * signals_series, or indicators_series. Safe to call before market close.
1714
+ *
1715
+ * @param date - The trading day to preview (must be in tradingDays.getRange()).
1716
+ * @param overrides - Raw (unleveraged) live prices keyed by market symbol.
1717
+ * Symbols absent from this map fall back to the last stored value
1718
+ * (see `IndicatorHandle._resolveRawBars`).
1719
+ * @returns The AllocationHandle for `date`, or null if the strategy has no
1720
+ * evaluable entry for that date.
1721
+ */
1722
+ async previewAllocation(date, overrides) {
1723
+ await this.resolve();
1724
+ const tradingDays = await this._storage.tradingDays.getRange();
1725
+ if (!tradingDays.includes(date)) {
1726
+ throw new Error(`previewAllocation: ${date} is not a trading day`);
1727
+ }
1728
+ const { allocations, entries } = await this._evaluate(date, overrides);
1729
+ const target = entries.find((e) => e.date === date);
1730
+ if (!target) return null;
1731
+ const alloc = allocations.find((a) => a.id === target.allocationId);
1732
+ return alloc ?? this._allocationMap.get(target.allocationId) ?? null;
1733
+ }
1734
+ /**
1735
+ * Read-only preview of the strategy's allocation series including `date`.
1736
+ * Returns stored historical allocations plus an in-memory bar at `date`
1737
+ * computed via the same overrides-based preview path as `previewAllocation`.
1738
+ *
1739
+ * @param date - Target trading day to splice in-memory.
1740
+ * @param overrides - Raw (unleveraged) quotes keyed by market symbol.
1741
+ * @param range - Optional filter applied to the returned bars.
1742
+ */
1743
+ async previewSeries(date, overrides, range) {
1744
+ await this.resolve();
1745
+ const tradingDays = await this._storage.tradingDays.getRange();
1746
+ if (!tradingDays.includes(date)) {
1747
+ throw new Error(`previewSeries: ${date} is not a trading day`);
1748
+ }
1749
+ const { allocations, entries } = await this._evaluate(date, overrides);
1750
+ const allocById = /* @__PURE__ */ new Map();
1751
+ for (const a of allocations) allocById.set(a.id, a);
1752
+ for (const [id, a] of this._allocationMap) if (!allocById.has(id)) allocById.set(id, a);
1753
+ let bars = entries.map((e) => ({
1754
+ date: e.date,
1755
+ allocation: allocById.get(e.allocationId)
1756
+ }));
1757
+ if (range) {
1758
+ bars = bars.filter(
1759
+ (b) => (range.from === void 0 || b.date >= range.from) && (range.to === void 0 || b.date <= range.to)
1760
+ );
1761
+ }
1762
+ return bars;
1763
+ }
1764
+ /**
1765
+ * Full live strategy view at `date` under live-quote `overrides`: the active
1766
+ * allocation, the index of the rule that fired (or fallback), and per-rule
1767
+ * per-signal indicator values + truth. Computed entirely through the
1768
+ * overrides preview path — no writes to any `*_series` tables.
1769
+ *
1770
+ * Threshold indicators have their date suppressed (`null`) since their
1771
+ * synthetic series runs over every trading day in storage including future
1772
+ * dates and would report a far-future date for the last bar.
1773
+ */
1774
+ async previewLiveState(date, overrides) {
1775
+ await this.resolve();
1776
+ const tradingDays = await this._storage.tradingDays.getRange();
1777
+ if (!tradingDays.includes(date)) {
1778
+ throw new Error(`previewLiveState: ${date} is not a trading day`);
1779
+ }
1780
+ const [{ allocations, entries }, rules] = await Promise.all([
1781
+ this._evaluate(date, overrides),
1782
+ Promise.all(
1783
+ this._rules.map(async (rule) => {
1784
+ const signalHandles = rule.when ?? [];
1785
+ const signals = await Promise.all(
1786
+ signalHandles.map(async (sig) => {
1787
+ const [i1Series, i2Series, sigSeries] = await Promise.all([
1788
+ sig.indicator1.previewSeries(date, overrides),
1789
+ sig.indicator2.previewSeries(date, overrides),
1790
+ sig.previewSeries(date, overrides)
1791
+ ]);
1792
+ const last1 = i1Series.at(-1);
1793
+ const last2 = i2Series.at(-1);
1794
+ const lastSig = sigSeries.at(-1);
1795
+ const i1IsThreshold = sig.indicator1.type === "Threshold";
1796
+ const i2IsThreshold = sig.indicator2.type === "Threshold";
1797
+ return {
1798
+ indicator1: {
1799
+ value: last1?.value ?? null,
1800
+ date: i1IsThreshold ? null : last1?.date ?? null
1801
+ },
1802
+ indicator2: {
1803
+ value: last2?.value ?? null,
1804
+ date: i2IsThreshold ? null : last2?.date ?? null
1805
+ },
1806
+ isTrue: lastSig?.value === 1
1807
+ };
1808
+ })
1809
+ );
1810
+ return { signals };
1811
+ })
1812
+ )
1813
+ ]);
1814
+ const target = entries.find((e) => e.date === date);
1815
+ const allocation = target ? allocations.find((a) => a.id === target.allocationId) ?? this._allocationMap.get(target.allocationId) ?? null : null;
1816
+ const fallbackIndex = this._rules.length - 1;
1817
+ let activeRuleIndex = fallbackIndex;
1818
+ if (target) {
1819
+ for (let r = 0; r < this._rules.length; r++) {
1820
+ if (this._rules[r].hold.id === target.allocationId) {
1821
+ activeRuleIndex = r;
1822
+ break;
1823
+ }
1824
+ }
1825
+ }
1826
+ return { allocation, activeRuleIndex, rules };
1309
1827
  }
1310
1828
  async _fetchPricesForTickers(bars, from, to) {
1311
1829
  const tickerMap = /* @__PURE__ */ new Map();