@livefolio/sdk 0.4.3 → 0.5.0-rc.1

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 CHANGED
@@ -176,6 +176,54 @@ type Position = {
176
176
  /** Optional key-value labels for strategy attribution or downstream reporting. */
177
177
  tags?: Record<string, unknown>;
178
178
  };
179
+ /**
180
+ * Tax classification of a {@link RealizedEvent}. Drives which rate bucket the
181
+ * income falls into during year-level tax aggregation.
182
+ */
183
+ type IncomeKind = 'capital-gain' | 'qualified-dividend' | 'ordinary-dividend' | 'interest';
184
+ /**
185
+ * A single tax lot — one acquisition of `quantity` shares of `asset` at a
186
+ * point in time. The cost-basis source of truth for long holdings. Partial
187
+ * sales reduce `quantity` and pro-rate `basis`.
188
+ */
189
+ type Lot = {
190
+ /** Opaque id assigned by {@link nextLotId} on creation. */
191
+ id: string;
192
+ asset: Asset;
193
+ /** Shares remaining in this lot after any partial sales. */
194
+ quantity: number;
195
+ /** Date the lot was opened (a DRIP lot's clock starts at its pay date). */
196
+ openDate: Date;
197
+ /** Per-share open price, excluding fees. */
198
+ openPrice: number;
199
+ /** Total cost basis for `quantity` shares incl. entry fees; pro-rated on partial sale and bumped by wash-sale §1091. */
200
+ basis: number;
201
+ /** Running total of disallowed wash-sale losses absorbed into `basis`. */
202
+ washSaleAdjustment?: number;
203
+ /** Set when this lot was created via dividend reinvestment; references the lot whose dividend funded it. */
204
+ dripParent?: string;
205
+ };
206
+ /**
207
+ * An append-only record of realized taxable activity: a capital gain/loss from
208
+ * closing (part of) a lot, or dividend/interest income. `quantity` is `0` for
209
+ * income events (dividends, interest), which carry `basis: 0` and `gain = proceeds`.
210
+ */
211
+ type RealizedEvent = {
212
+ asset: Asset;
213
+ /** The lot this event closed against. For income events, a reference token (e.g. the paying lot, or `'cash'` for interest). */
214
+ lotId: string;
215
+ /** Shares closed; `0` for dividend and interest income events. */
216
+ quantity: number;
217
+ openDate: Date;
218
+ closeDate: Date;
219
+ proceeds: number;
220
+ basis: number;
221
+ termType: 'short' | 'long';
222
+ gain: number;
223
+ incomeKind: IncomeKind;
224
+ /** When `> 0`, this much of a (negative) capital gain was disallowed by the wash-sale rule and rolled into a replacement lot's basis. */
225
+ washSaleDisallowed?: number;
226
+ };
179
227
  /**
180
228
  * A point-in-time snapshot of the full portfolio state.
181
229
  *
@@ -199,6 +247,17 @@ type Portfolio = {
199
247
  cash: number;
200
248
  /** All currently open positions. Immutable array — replaced (not mutated) by apply functions. */
201
249
  positions: ReadonlyArray<Position>;
250
+ /**
251
+ * Long-side tax ledger — the cost-basis source of truth for lot accounting.
252
+ * Maintained in parallel with `positions` by `applyFills`; defaults to `[]`
253
+ * when absent. Short positions and `adjust` orders do not participate.
254
+ */
255
+ lots?: ReadonlyArray<Lot>;
256
+ /**
257
+ * Append-only log of realized capital gains and dividend/interest income
258
+ * accumulated during a run. Defaults to `[]` when absent.
259
+ */
260
+ realized?: ReadonlyArray<RealizedEvent>;
202
261
  /** Logical timestamp of the most recent fill (or the portfolio's start date). */
203
262
  t: Date;
204
263
  };
@@ -380,6 +439,13 @@ type Fill = {
380
439
  price: number;
381
440
  /** Total transaction fees in the portfolio's base currency. */
382
441
  fees: number;
442
+ /**
443
+ * Optional id of the specific {@link Lot} this fill draws from on a sell.
444
+ * Set by {@link BacktestExecutor} when a `lotMethod` is configured so
445
+ * {@link applyFills} consumes the chosen lot rather than defaulting to FIFO.
446
+ * Absent on buys and on default-FIFO sells.
447
+ */
448
+ lotId?: string;
383
449
  };
384
450
 
385
451
  /**
@@ -2760,12 +2826,27 @@ type FeatureRef = {
2760
2826
  * - `'lt'` — strictly less than (`l < r`)
2761
2827
  * - `'gte'` — greater than or equal to (`l >= r`)
2762
2828
  * - `'lte'` — less than or equal to (`l <= r`)
2829
+ * - `'eq'` — equality. Without {@link Tolerance}, this is strict `l === r`
2830
+ * (no epsilon) — intended for comparing integer-valued features (e.g.
2831
+ * calendar features like `dayOfWeek`) against integer literals. With
2832
+ * {@link Tolerance}, this is "within the symmetric band around `r`" —
2833
+ * `true` while `l ∈ [r − tol, r + tol]`, `false` outside. State is still
2834
+ * persisted via {@link RuleTreeState} but, because entry and exit share
2835
+ * the same band edges, the per-step result is effectively stateless.
2763
2836
  */
2764
- type ComparisonOp = 'gt' | 'lt' | 'gte' | 'lte';
2837
+ type ComparisonOp = 'gt' | 'lt' | 'gte' | 'lte' | 'eq';
2765
2838
  /**
2766
- * Hysteresis band applied to a {@link Comparison} with `op: 'gt'` or `op: 'lt'`.
2767
- * Once the comparison has flipped, it will not flip back until the left operand
2768
- * exits the tolerance band around the right operand.
2839
+ * Tolerance band applied to a {@link Comparison} with `op: 'gt'`, `op: 'lt'`,
2840
+ * or `op: 'eq'`.
2841
+ *
2842
+ * For `gt` / `lt`, the band implements **hysteresis**: once the comparison
2843
+ * has flipped, it will not flip back until the left operand exits the band
2844
+ * around the right operand. Entry and exit thresholds differ.
2845
+ *
2846
+ * For `eq`, the band defines a **symmetric range** around `right`: the
2847
+ * comparison is `true` while `l ∈ [r − value, r + value]`. Entry and exit
2848
+ * share the same edges, so behavior is stateless in practice even though the
2849
+ * outcome is still recorded in {@link RuleTreeState}.
2769
2850
  *
2770
2851
  * `mode: 'absolute'` defines a ±`value` band around `right`.
2771
2852
  * `mode: 'relative'` defines a ±`value`% band (i.e. `value` is a percentage).
@@ -2774,7 +2855,7 @@ type ComparisonOp = 'gt' | 'lt' | 'gte' | 'lte';
2774
2855
  * so the runtime can persist the last-known state across rebalance periods.
2775
2856
  */
2776
2857
  type Tolerance = {
2777
- /** Half-width of the hysteresis band. */
2858
+ /** Half-width of the band. */
2778
2859
  value: number;
2779
2860
  /** `'absolute'` uses raw units; `'relative'` uses a percentage of `right`. */
2780
2861
  mode: 'absolute' | 'relative';
@@ -2809,8 +2890,9 @@ type Comparison = {
2809
2890
  /** Right-hand operand — a feature reference or a literal number. */
2810
2891
  right: FeatureRef | number;
2811
2892
  /**
2812
- * Optional hysteresis band. Requires `op` to be `'gt'` or `'lt'` and
2813
- * requires `id` to be set.
2893
+ * Optional tolerance band. Requires `op` to be `'gt'`, `'lt'`, or `'eq'`,
2894
+ * and requires `id` to be set. For `gt`/`lt` the band implements
2895
+ * hysteresis; for `eq` it defines a symmetric range around `right`.
2814
2896
  */
2815
2897
  tolerance?: Tolerance;
2816
2898
  /**
@@ -3535,6 +3617,14 @@ declare namespace index {
3535
3617
  *
3536
3618
  * The returned `portfolio.t` is updated to the maximum fill timestamp.
3537
3619
  *
3620
+ * In parallel with `positions`/`cash`, a long-side tax ledger is maintained:
3621
+ * buys (`open` long, `rebalance` delta>0) append a {@link Lot}; sells
3622
+ * (`close` of a long, `rebalance` delta<0) consume lots — by `fill.lotId` if
3623
+ * present, else FIFO oldest-first — pro-rating basis and appending one
3624
+ * {@link RealizedEvent} per consumed slice. Short positions and `adjust`
3625
+ * orders do not participate. The `positions`/`cash` outputs are unaffected by
3626
+ * this ledger.
3627
+ *
3538
3628
  * @param portfolio - The current portfolio state before this batch.
3539
3629
  * @param fills - Execution confirmations returned by {@link Executor.submit}.
3540
3630
  * Each fill's `orderRef` MUST match an `id` in `orders`.
@@ -3572,6 +3662,11 @@ declare function applyFills(portfolio: Portfolio, fills: ReadonlyArray<Fill>, or
3572
3662
  * - `cash` is left unchanged (no price is available at projection time).
3573
3663
  * - Newly opened positions have `basis: 0` and `entry.price: 0` as
3574
3664
  * provisional values. A price-aware projection is planned for a later phase.
3665
+ * - The long-side tax ledger (`lots` / `realized`) is **not** advanced — it is
3666
+ * carried through unchanged, so it will be stale relative to the projected
3667
+ * `positions`. Use {@link applyFills} to settle the ledger after confirmed
3668
+ * execution; do not read `lots` from an `applyOrders` result expecting it to
3669
+ * reflect the projected positions.
3575
3670
  *
3576
3671
  * Use {@link applyFills} (not this function) to settle the portfolio after
3577
3672
  * confirmed execution.
@@ -3601,4 +3696,20 @@ declare function applyFills(portfolio: Portfolio, fills: ReadonlyArray<Fill>, or
3601
3696
  */
3602
3697
  declare function applyOrders(portfolio: Portfolio, orders: ReadonlyArray<Order>): Portfolio;
3603
3698
 
3604
- export { type AdhocTimeOverrides, type AdjustOrder, type AllocateNode, type Asset, type AssetId, type AssetRef, BacktestExecutor, type BacktestExecutorOptions, type BacktestResult, type BacktestSnapshot, type Bar, type BarField, type Calendar, type CloseOrder, type Comparison, type ComparisonOp, type ComputeFn, Crypto24x7Calendar, type DataEvent, type DataFeed, type DateRange, type EquityAsset, type EventKind, ExchangeCalendar, type ExchangeName, type Executor, type FeatureCache, type FeatureKey, type FeatureKind, type FeatureRef, FeatureRuntime, type FeatureRuntimeOptions, type FeatureScope, type FeatureSpec, type Features, type Fill, type Frequency, type FromSpecOptions, type Fundamentals, type HolidayRule, type IfNode, LSEExchangeCalendar, type LiveEvent, type MacroAsset, MemoryFeatureCache, NYSEExchangeCalendar, type NextOpenFn, type OpenOrder, type Order, type PollingSchedule, type PollingStreamOptions, type Portfolio, type Position, type PositionId, type PriceMap, type Quote, type QuoteFeed, type RebalanceConfig, type RebalanceFrequency, type RebalanceOrder, type ReturnMode, RoutingDataFeed, RoutingDataFeedError, type RoutingDataFeedRouteFn, type RoutingDataFeedRouteMap, RoutingQuoteFeed, RoutingQuoteFeedError, type RoutingQuoteFeedRouteFn, type RoutingQuoteFeedRouteMap, RoutingStreamingDataFeed, RoutingStreamingDataFeedError, type RoutingStreamingDataFeedRouteFn, type RoutingStreamingDataFeedRouteMap, type RuleNode, type RuleTreeState, type RunBacktestOptions, type RunLiveOptions, type Series, type Session, type SpecialClose, type SpecialOpen, type Strategy, type StreamingBar, type StreamingDataFeed, type SyntheticAsset, type TacticalFeatureKind, type TacticalFeatureSpec, type TacticalFeatures, type TacticalSpec, type TargetWeights, type TimeOfDay, type Tolerance, type WithStreamingSyntheticsOptions, applyFills, applyOrders, barsToSeries, collectBars, defineFeature, drawdown, ema, evaluateFeatureSpecs, evaluateRuleTree, index as features, fromSpec, getCalendar, getFeatureCompute, isRebalanceDay, paramsHash, periodKey, pollingStreamFromHistorical, reconcile, returnSeries, rsi, runBacktest, runLive, seriesAt, sma, index$1 as tactical, volatility, withStreamingSynthetics, withSynthetics };
3699
+ /**
3700
+ * Aggregates a portfolio's tax {@link Lot}s into a per-asset {@link Position}
3701
+ * view (`side: 'long'`). The lot-level analogue of `portfolio.positions`,
3702
+ * offered for consumers that want a single position per asset derived from the
3703
+ * cost-basis ledger. `reconcile` continues to read `portfolio.positions`.
3704
+ *
3705
+ * The returned ids are synthetic view keys (`lot_view_<assetId>`) — they are
3706
+ * NOT stable `PositionId`s and must not be passed to `CloseOrder.positionId`
3707
+ * or compared against `portfolio.positions[*].id`.
3708
+ *
3709
+ * @param portfolio - Source portfolio; reads `portfolio.lots` (treated as `[]` when absent).
3710
+ * @returns One {@link Position} per distinct asset id, summing quantity and basis,
3711
+ * with `entry` taken from the earliest lot. Empty when there are no lots.
3712
+ */
3713
+ declare function positionsByAsset(portfolio: Portfolio): Position[];
3714
+
3715
+ export { type AdhocTimeOverrides, type AdjustOrder, type AllocateNode, type Asset, type AssetId, type AssetRef, BacktestExecutor, type BacktestExecutorOptions, type BacktestResult, type BacktestSnapshot, type Bar, type BarField, type Calendar, type CloseOrder, type Comparison, type ComparisonOp, type ComputeFn, Crypto24x7Calendar, type DataEvent, type DataFeed, type DateRange, type EquityAsset, type EventKind, ExchangeCalendar, type ExchangeName, type Executor, type FeatureCache, type FeatureKey, type FeatureKind, type FeatureRef, FeatureRuntime, type FeatureRuntimeOptions, type FeatureScope, type FeatureSpec, type Features, type Fill, type Frequency, type FromSpecOptions, type Fundamentals, type HolidayRule, type IfNode, type IncomeKind, LSEExchangeCalendar, type LiveEvent, type Lot, type MacroAsset, MemoryFeatureCache, NYSEExchangeCalendar, type NextOpenFn, type OpenOrder, type Order, type PollingSchedule, type PollingStreamOptions, type Portfolio, type Position, type PositionId, type PriceMap, type Quote, type QuoteFeed, type RealizedEvent, type RebalanceConfig, type RebalanceFrequency, type RebalanceOrder, type ReturnMode, RoutingDataFeed, RoutingDataFeedError, type RoutingDataFeedRouteFn, type RoutingDataFeedRouteMap, RoutingQuoteFeed, RoutingQuoteFeedError, type RoutingQuoteFeedRouteFn, type RoutingQuoteFeedRouteMap, RoutingStreamingDataFeed, RoutingStreamingDataFeedError, type RoutingStreamingDataFeedRouteFn, type RoutingStreamingDataFeedRouteMap, type RuleNode, type RuleTreeState, type RunBacktestOptions, type RunLiveOptions, type Series, type Session, type SpecialClose, type SpecialOpen, type Strategy, type StreamingBar, type StreamingDataFeed, type SyntheticAsset, type TacticalFeatureKind, type TacticalFeatureSpec, type TacticalFeatures, type TacticalSpec, type TargetWeights, type TimeOfDay, type Tolerance, type WithStreamingSyntheticsOptions, applyFills, applyOrders, barsToSeries, collectBars, defineFeature, drawdown, ema, evaluateFeatureSpecs, evaluateRuleTree, index as features, fromSpec, getCalendar, getFeatureCompute, isRebalanceDay, paramsHash, periodKey, pollingStreamFromHistorical, positionsByAsset, reconcile, returnSeries, rsi, runBacktest, runLive, seriesAt, sma, index$1 as tactical, volatility, withStreamingSynthetics, withSynthetics };
package/dist/index.js CHANGED
@@ -48,6 +48,12 @@ function reconcile(targets, portfolio, prices, assets) {
48
48
  return orders;
49
49
  }
50
50
 
51
+ // src/portfolio/ids.ts
52
+ var _lotCounter = 0;
53
+ function nextLotId() {
54
+ return `lot_${++_lotCounter}`;
55
+ }
56
+
51
57
  // src/portfolio/apply.ts
52
58
  var newPositionId = /* @__PURE__ */ (() => {
53
59
  let n = 0;
@@ -56,10 +62,51 @@ var newPositionId = /* @__PURE__ */ (() => {
56
62
  function findOrder(orders, id) {
57
63
  return orders.find((o) => o.id === id);
58
64
  }
65
+ var MS_PER_DAY = 864e5;
66
+ var isLong = (openDate, closeDate) => (closeDate.getTime() - openDate.getTime()) / MS_PER_DAY > 365;
67
+ function consumeLots(lots, realized, assetId, qty, price, fees, closeDate, preferLotId) {
68
+ const sortedLots = lots.filter((l) => l.asset.id === assetId && l.quantity > 0).sort((a, b) => {
69
+ if (preferLotId) {
70
+ if (a.id === preferLotId) return -1;
71
+ if (b.id === preferLotId) return 1;
72
+ }
73
+ return a.openDate.getTime() - b.openDate.getTime();
74
+ });
75
+ const held = sortedLots.reduce((s, x) => s + x.quantity, 0);
76
+ if (held < qty) {
77
+ throw new RangeError(`applyFills: cannot sell ${qty} of ${assetId} \u2014 lot ledger holds ${held}`);
78
+ }
79
+ let need = qty;
80
+ const totalQty = qty;
81
+ for (const l of sortedLots) {
82
+ if (need <= 0) break;
83
+ const take = Math.min(l.quantity, need);
84
+ const basisPerShare = l.basis / l.quantity;
85
+ const consumedBasis = basisPerShare * take;
86
+ const proceeds = take * price - take / totalQty * fees;
87
+ realized.push({
88
+ asset: l.asset,
89
+ lotId: l.id,
90
+ quantity: take,
91
+ openDate: l.openDate,
92
+ closeDate,
93
+ proceeds,
94
+ basis: consumedBasis,
95
+ termType: isLong(l.openDate, closeDate) ? "long" : "short",
96
+ gain: proceeds - consumedBasis,
97
+ incomeKind: "capital-gain"
98
+ });
99
+ l.quantity -= take;
100
+ l.basis -= consumedBasis;
101
+ need -= take;
102
+ }
103
+ }
59
104
  function applyFills(portfolio, fills, orders) {
60
105
  let positions = [...portfolio.positions];
61
106
  let cash = portfolio.cash;
62
107
  let t = portfolio.t;
108
+ const lots = (portfolio.lots ?? []).map((l) => ({ ...l }));
109
+ const realized = [...portfolio.realized ?? []];
63
110
  for (const fill of fills) {
64
111
  const order = findOrder(orders, fill.orderRef);
65
112
  if (!order) {
@@ -78,6 +125,16 @@ function applyFills(portfolio, fills, orders) {
78
125
  };
79
126
  positions.push(pos);
80
127
  cash -= fill.quantity * fill.price + fill.fees;
128
+ if (order.side === "long") {
129
+ lots.push({
130
+ id: nextLotId(),
131
+ asset: order.asset,
132
+ quantity: fill.quantity,
133
+ openDate: fill.t,
134
+ openPrice: fill.price,
135
+ basis: fill.quantity * fill.price + fill.fees
136
+ });
137
+ }
81
138
  break;
82
139
  }
83
140
  case "close": {
@@ -92,6 +149,9 @@ function applyFills(portfolio, fills, orders) {
92
149
  } else {
93
150
  positions[idx] = { ...pos, quantity: remaining };
94
151
  }
152
+ if (pos.side === "long") {
153
+ consumeLots(lots, realized, pos.asset.id, fill.quantity, fill.price, fill.fees, fill.t, fill.lotId);
154
+ }
95
155
  break;
96
156
  }
97
157
  case "adjust": {
@@ -125,6 +185,14 @@ function applyFills(portfolio, fills, orders) {
125
185
  basis: prev.basis + cost
126
186
  };
127
187
  }
188
+ lots.push({
189
+ id: nextLotId(),
190
+ asset: order.asset,
191
+ quantity: fill.quantity,
192
+ openDate: fill.t,
193
+ openPrice: fill.price,
194
+ basis: cost
195
+ });
128
196
  } else if (idx >= 0) {
129
197
  const prev = positions[idx];
130
198
  cash += fill.quantity * fill.price - fill.fees;
@@ -139,12 +207,16 @@ function applyFills(portfolio, fills, orders) {
139
207
  basis: basisPerShare * remaining
140
208
  };
141
209
  }
210
+ consumeLots(lots, realized, order.asset.id, fill.quantity, fill.price, fill.fees, fill.t, fill.lotId);
142
211
  }
143
212
  break;
144
213
  }
145
214
  }
215
+ for (let i = lots.length - 1; i >= 0; i--) {
216
+ if (lots[i].quantity <= 1e-9) lots.splice(i, 1);
217
+ }
146
218
  }
147
- return { cash, positions, t };
219
+ return { cash, positions, lots, realized, t };
148
220
  }
149
221
  function applyOrders(portfolio, orders) {
150
222
  let positions = [...portfolio.positions];
@@ -1037,7 +1109,7 @@ function pollingStreamFromHistorical(opts) {
1037
1109
  import { DateTime } from "luxon";
1038
1110
 
1039
1111
  // src/calendars/holiday-rules.ts
1040
- var MS_PER_DAY = 864e5;
1112
+ var MS_PER_DAY2 = 864e5;
1041
1113
  function nthWeekdayOfMonth(year, month, weekday, n) {
1042
1114
  const first = new Date(Date.UTC(year, month - 1, 1));
1043
1115
  const offset = (weekday - first.getUTCDay() + 7) % 7;
@@ -1046,7 +1118,7 @@ function nthWeekdayOfMonth(year, month, weekday, n) {
1046
1118
  function lastWeekdayOfMonth(year, month, weekday) {
1047
1119
  const last = new Date(Date.UTC(year, month, 0));
1048
1120
  const offset = (last.getUTCDay() - weekday + 7) % 7;
1049
- return new Date(last.getTime() - offset * MS_PER_DAY);
1121
+ return new Date(last.getTime() - offset * MS_PER_DAY2);
1050
1122
  }
1051
1123
  function easter(year) {
1052
1124
  const a = year % 19;
@@ -1067,8 +1139,8 @@ function easter(year) {
1067
1139
  }
1068
1140
  function observed(d) {
1069
1141
  const dow = d.getUTCDay();
1070
- if (dow === 6) return new Date(d.getTime() - MS_PER_DAY);
1071
- if (dow === 0) return new Date(d.getTime() + MS_PER_DAY);
1142
+ if (dow === 6) return new Date(d.getTime() - MS_PER_DAY2);
1143
+ if (dow === 0) return new Date(d.getTime() + MS_PER_DAY2);
1072
1144
  return d;
1073
1145
  }
1074
1146
  function resolveHolidays(rules, year) {
@@ -1106,21 +1178,21 @@ function resolveSpecialOpens(rules, year) {
1106
1178
  return out;
1107
1179
  }
1108
1180
  function sundayToMonday(d) {
1109
- return d.getUTCDay() === 0 ? new Date(d.getTime() + MS_PER_DAY) : d;
1181
+ return d.getUTCDay() === 0 ? new Date(d.getTime() + MS_PER_DAY2) : d;
1110
1182
  }
1111
1183
  function nearestWorkday(d) {
1112
1184
  const dow = d.getUTCDay();
1113
- if (dow === 6) return new Date(d.getTime() - MS_PER_DAY);
1114
- if (dow === 0) return new Date(d.getTime() + MS_PER_DAY);
1185
+ if (dow === 6) return new Date(d.getTime() - MS_PER_DAY2);
1186
+ if (dow === 0) return new Date(d.getTime() + MS_PER_DAY2);
1115
1187
  return d;
1116
1188
  }
1117
1189
  function firstMondayOnOrAfter(year, month, day, nth = 1) {
1118
1190
  const start = new Date(Date.UTC(year, month - 1, day));
1119
1191
  const offset = (1 - start.getUTCDay() + 7) % 7;
1120
- return new Date(start.getTime() + (offset + 7 * (nth - 1)) * MS_PER_DAY);
1192
+ return new Date(start.getTime() + (offset + 7 * (nth - 1)) * MS_PER_DAY2);
1121
1193
  }
1122
1194
  function easterPlus(year, dayDelta) {
1123
- return new Date(easter(year).getTime() + dayDelta * MS_PER_DAY);
1195
+ return new Date(easter(year).getTime() + dayDelta * MS_PER_DAY2);
1124
1196
  }
1125
1197
  function dropIfNotInDays(d, allowed) {
1126
1198
  if (d === null) return null;
@@ -1128,7 +1200,7 @@ function dropIfNotInDays(d, allowed) {
1128
1200
  }
1129
1201
 
1130
1202
  // src/calendars/exchange-calendar.ts
1131
- var MS_PER_DAY2 = 864e5;
1203
+ var MS_PER_DAY3 = 864e5;
1132
1204
  var DEFAULT_WEEKMASK = /* @__PURE__ */ new Set([1, 2, 3, 4, 5]);
1133
1205
  var EMPTY_ADHOC = /* @__PURE__ */ new Map();
1134
1206
  function ymdKey(d) {
@@ -1289,14 +1361,14 @@ var ExchangeCalendar = class {
1289
1361
  }
1290
1362
  /** Returns the first trading day strictly after `t`. */
1291
1363
  next(t) {
1292
- let d = new Date(this.normalize(t).getTime() + MS_PER_DAY2);
1293
- while (!this.isOpen(d)) d = new Date(d.getTime() + MS_PER_DAY2);
1364
+ let d = new Date(this.normalize(t).getTime() + MS_PER_DAY3);
1365
+ while (!this.isOpen(d)) d = new Date(d.getTime() + MS_PER_DAY3);
1294
1366
  return d;
1295
1367
  }
1296
1368
  /** Returns the first trading day strictly before `t`. */
1297
1369
  previous(t) {
1298
- let d = new Date(this.normalize(t).getTime() - MS_PER_DAY2);
1299
- while (!this.isOpen(d)) d = new Date(d.getTime() - MS_PER_DAY2);
1370
+ let d = new Date(this.normalize(t).getTime() - MS_PER_DAY3);
1371
+ while (!this.isOpen(d)) d = new Date(d.getTime() - MS_PER_DAY3);
1300
1372
  return d;
1301
1373
  }
1302
1374
  /**
@@ -1309,7 +1381,7 @@ var ExchangeCalendar = class {
1309
1381
  const end = this.normalize(range.to).getTime();
1310
1382
  while (d.getTime() < end) {
1311
1383
  if (this.isOpen(d)) out.push(d);
1312
- d = new Date(d.getTime() + MS_PER_DAY2);
1384
+ d = new Date(d.getTime() + MS_PER_DAY3);
1313
1385
  }
1314
1386
  return out;
1315
1387
  }
@@ -1360,7 +1432,7 @@ var ExchangeCalendar = class {
1360
1432
  };
1361
1433
 
1362
1434
  // src/calendars/nyse.ts
1363
- var MS_PER_DAY3 = 864e5;
1435
+ var MS_PER_DAY4 = 864e5;
1364
1436
  var SUN = 0;
1365
1437
  var MON = 1;
1366
1438
  var TUE = 2;
@@ -1524,7 +1596,7 @@ var REGULAR_HOLIDAYS = [
1524
1596
  resolve: (y) => {
1525
1597
  const start = utcDate(y, 11, 2);
1526
1598
  const offset = (TUE - start.getUTCDay() + 7) % 7;
1527
- return new Date(start.getTime() + offset * MS_PER_DAY3);
1599
+ return new Date(start.getTime() + offset * MS_PER_DAY4);
1528
1600
  }
1529
1601
  },
1530
1602
  // ── Veterans/Armistice Day (Nov 11, 1934-1953) ─────────────────────────────
@@ -1555,7 +1627,7 @@ var REGULAR_HOLIDAYS = [
1555
1627
  validUntil: 1941,
1556
1628
  resolve: (y) => {
1557
1629
  const last = lastWeekdayOfMonth(y, 11, THU);
1558
- return new Date(last.getTime() - 7 * MS_PER_DAY3);
1630
+ return new Date(last.getTime() - 7 * MS_PER_DAY4);
1559
1631
  }
1560
1632
  },
1561
1633
  // ── Christmas ──────────────────────────────────────────────────────────────
@@ -1912,7 +1984,7 @@ function* generateSummerSaturdays() {
1912
1984
  const end = /* @__PURE__ */ new Date(`${to}T00:00:00.000Z`);
1913
1985
  while (d.getTime() <= end.getTime()) {
1914
1986
  if (d.getUTCDay() === SAT) yield ymd(d);
1915
- d = new Date(d.getTime() + MS_PER_DAY3);
1987
+ d = new Date(d.getTime() + MS_PER_DAY4);
1916
1988
  }
1917
1989
  }
1918
1990
  }
@@ -1922,7 +1994,7 @@ function* generateWWIShutdown() {
1922
1994
  while (d.getTime() <= end.getTime()) {
1923
1995
  const dow = d.getUTCDay();
1924
1996
  if (dow !== SUN) yield ymd(d);
1925
- d = new Date(d.getTime() + MS_PER_DAY3);
1997
+ d = new Date(d.getTime() + MS_PER_DAY4);
1926
1998
  }
1927
1999
  }
1928
2000
  var ADHOC_HOLIDAYS = /* @__PURE__ */ new Set([
@@ -1940,7 +2012,7 @@ var SPECIAL_CLOSES = [
1940
2012
  closeAt: { h: 13, m: 0 },
1941
2013
  resolve: (y) => {
1942
2014
  const t = nthWeekdayOfMonth(y, 11, THU, 4);
1943
- return new Date(t.getTime() + MS_PER_DAY3);
2015
+ return new Date(t.getTime() + MS_PER_DAY4);
1944
2016
  }
1945
2017
  },
1946
2018
  {
@@ -1950,7 +2022,7 @@ var SPECIAL_CLOSES = [
1950
2022
  closeAt: { h: 14, m: 0 },
1951
2023
  resolve: (y) => {
1952
2024
  const t = nthWeekdayOfMonth(y, 11, THU, 4);
1953
- return new Date(t.getTime() + MS_PER_DAY3);
2025
+ return new Date(t.getTime() + MS_PER_DAY4);
1954
2026
  }
1955
2027
  },
1956
2028
  {
@@ -2111,7 +2183,7 @@ var SPECIAL_CLOSES_ADHOC = /* @__PURE__ */ new Map([
2111
2183
  if (dow >= MON && dow <= FRI) {
2112
2184
  SPECIAL_CLOSES_ADHOC.set(ymd(d), { h: 14, m: 0 });
2113
2185
  }
2114
- d = new Date(d.getTime() + MS_PER_DAY3);
2186
+ d = new Date(d.getTime() + MS_PER_DAY4);
2115
2187
  }
2116
2188
  })();
2117
2189
  (() => {
@@ -2122,7 +2194,7 @@ var SPECIAL_CLOSES_ADHOC = /* @__PURE__ */ new Map([
2122
2194
  if (dow >= MON && dow <= FRI) {
2123
2195
  SPECIAL_CLOSES_ADHOC.set(ymd(d), { h: 14, m: 0 });
2124
2196
  }
2125
- d = new Date(d.getTime() + MS_PER_DAY3);
2197
+ d = new Date(d.getTime() + MS_PER_DAY4);
2126
2198
  }
2127
2199
  })();
2128
2200
  (() => {
@@ -2133,7 +2205,7 @@ var SPECIAL_CLOSES_ADHOC = /* @__PURE__ */ new Map([
2133
2205
  if (dow >= MON && dow <= FRI) {
2134
2206
  SPECIAL_CLOSES_ADHOC.set(ymd(d), { h: 14, m: 0 });
2135
2207
  }
2136
- d = new Date(d.getTime() + MS_PER_DAY3);
2208
+ d = new Date(d.getTime() + MS_PER_DAY4);
2137
2209
  }
2138
2210
  })();
2139
2211
  (() => {
@@ -2144,7 +2216,7 @@ var SPECIAL_CLOSES_ADHOC = /* @__PURE__ */ new Map([
2144
2216
  if (dow >= MON && dow <= FRI) {
2145
2217
  SPECIAL_CLOSES_ADHOC.set(ymd(d), { h: 14, m: 0 });
2146
2218
  }
2147
- d = new Date(d.getTime() + MS_PER_DAY3);
2219
+ d = new Date(d.getTime() + MS_PER_DAY4);
2148
2220
  }
2149
2221
  })();
2150
2222
  (() => {
@@ -2155,7 +2227,7 @@ var SPECIAL_CLOSES_ADHOC = /* @__PURE__ */ new Map([
2155
2227
  if (dow >= MON && dow <= FRI) {
2156
2228
  SPECIAL_CLOSES_ADHOC.set(ymd(d), { h: 14, m: 30 });
2157
2229
  }
2158
- d = new Date(d.getTime() + MS_PER_DAY3);
2230
+ d = new Date(d.getTime() + MS_PER_DAY4);
2159
2231
  }
2160
2232
  })();
2161
2233
  (() => {
@@ -2166,7 +2238,7 @@ var SPECIAL_CLOSES_ADHOC = /* @__PURE__ */ new Map([
2166
2238
  if (dow >= MON && dow <= FRI) {
2167
2239
  SPECIAL_CLOSES_ADHOC.set(ymd(d), { h: 15, m: 0 });
2168
2240
  }
2169
- d = new Date(d.getTime() + MS_PER_DAY3);
2241
+ d = new Date(d.getTime() + MS_PER_DAY4);
2170
2242
  }
2171
2243
  })();
2172
2244
  var SPECIAL_OPENS = [];
@@ -2283,7 +2355,7 @@ var NYSEExchangeCalendar = class extends ExchangeCalendar {
2283
2355
  };
2284
2356
 
2285
2357
  // src/calendars/lse.ts
2286
- var MS_PER_DAY4 = 864e5;
2358
+ var MS_PER_DAY5 = 864e5;
2287
2359
  var SUN2 = 0;
2288
2360
  var MON2 = 1;
2289
2361
  var TUE2 = 2;
@@ -2295,14 +2367,14 @@ var WEEKDAYS_MON_FRI2 = /* @__PURE__ */ new Set([1, 2, 3, 4, 5]);
2295
2367
  var MON_TUE = /* @__PURE__ */ new Set([MON2, TUE2]);
2296
2368
  function weekendToMonday(d) {
2297
2369
  const dow = d.getUTCDay();
2298
- if (dow === SAT2) return new Date(d.getTime() + 2 * MS_PER_DAY4);
2299
- if (dow === SUN2) return new Date(d.getTime() + MS_PER_DAY4);
2370
+ if (dow === SAT2) return new Date(d.getTime() + 2 * MS_PER_DAY5);
2371
+ if (dow === SUN2) return new Date(d.getTime() + MS_PER_DAY5);
2300
2372
  return d;
2301
2373
  }
2302
2374
  function previousFriday(d) {
2303
2375
  const dow = d.getUTCDay();
2304
- if (dow === SAT2) return new Date(d.getTime() - MS_PER_DAY4);
2305
- if (dow === SUN2) return new Date(d.getTime() - 2 * MS_PER_DAY4);
2376
+ if (dow === SAT2) return new Date(d.getTime() - MS_PER_DAY5);
2377
+ if (dow === SUN2) return new Date(d.getTime() - 2 * MS_PER_DAY5);
2306
2378
  return d;
2307
2379
  }
2308
2380
  var REGULAR_HOLIDAYS2 = [
@@ -2494,7 +2566,7 @@ function getCalendar(name) {
2494
2566
  }
2495
2567
 
2496
2568
  // src/calendars/crypto-24x7.ts
2497
- var MS_PER_DAY5 = 864e5;
2569
+ var MS_PER_DAY6 = 864e5;
2498
2570
  function midnightUtc(d) {
2499
2571
  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
2500
2572
  }
@@ -2503,10 +2575,10 @@ var Crypto24x7Calendar = class {
2503
2575
  return true;
2504
2576
  }
2505
2577
  next(t) {
2506
- return new Date(midnightUtc(t).getTime() + MS_PER_DAY5);
2578
+ return new Date(midnightUtc(t).getTime() + MS_PER_DAY6);
2507
2579
  }
2508
2580
  previous(t) {
2509
- return new Date(midnightUtc(t).getTime() - MS_PER_DAY5);
2581
+ return new Date(midnightUtc(t).getTime() - MS_PER_DAY6);
2510
2582
  }
2511
2583
  sessions(range) {
2512
2584
  const out = [];
@@ -2514,7 +2586,7 @@ var Crypto24x7Calendar = class {
2514
2586
  const end = range.to.getTime();
2515
2587
  while (cursor.getTime() < end) {
2516
2588
  out.push(cursor);
2517
- cursor = new Date(cursor.getTime() + MS_PER_DAY5);
2589
+ cursor = new Date(cursor.getTime() + MS_PER_DAY6);
2518
2590
  }
2519
2591
  return out;
2520
2592
  }
@@ -2522,7 +2594,7 @@ var Crypto24x7Calendar = class {
2522
2594
  return this.sessions(range).map((date) => ({
2523
2595
  date,
2524
2596
  open: date,
2525
- close: new Date(date.getTime() + MS_PER_DAY5)
2597
+ close: new Date(date.getTime() + MS_PER_DAY6)
2526
2598
  }));
2527
2599
  }
2528
2600
  isEarlyClose(_t) {
@@ -2565,6 +2637,8 @@ function rawCompare(op, l, r) {
2565
2637
  return l >= r;
2566
2638
  case "lte":
2567
2639
  return l <= r;
2640
+ case "eq":
2641
+ return l === r;
2568
2642
  }
2569
2643
  }
2570
2644
  function band(right, tol) {
@@ -2583,13 +2657,15 @@ function evalComparison(cond, values, state, outState) {
2583
2657
  if (cond.id === void 0) {
2584
2658
  throw new Error("evaluateRuleTree: comparison with tolerance requires id");
2585
2659
  }
2586
- if (cond.op !== "gt" && cond.op !== "lt") {
2587
- throw new Error(`evaluateRuleTree: tolerance is only supported for op gt/lt, got ${cond.op}`);
2660
+ if (cond.op !== "gt" && cond.op !== "lt" && cond.op !== "eq") {
2661
+ throw new Error(`evaluateRuleTree: tolerance is only supported for op gt/lt/eq, got ${cond.op}`);
2588
2662
  }
2589
2663
  const prev = state.get(cond.id);
2590
2664
  const { lower, upper } = band(r, cond.tolerance);
2591
2665
  let result;
2592
- if (prev === void 0) {
2666
+ if (cond.op === "eq") {
2667
+ result = l >= lower && l <= upper ? 1 : 0;
2668
+ } else if (prev === void 0) {
2593
2669
  result = rawCompare(cond.op, l, r) ? 1 : 0;
2594
2670
  } else if (cond.op === "gt") {
2595
2671
  if (prev === 1) result = l < lower ? 0 : 1;
@@ -2962,6 +3038,38 @@ __export(features_exports, {
2962
3038
  sma: () => sma,
2963
3039
  volatility: () => volatility
2964
3040
  });
3041
+
3042
+ // src/portfolio/derived.ts
3043
+ function positionsByAsset(portfolio) {
3044
+ const byId = /* @__PURE__ */ new Map();
3045
+ for (const lot of portfolio.lots ?? []) {
3046
+ const cur = byId.get(lot.asset.id);
3047
+ if (cur) {
3048
+ cur.quantity += lot.quantity;
3049
+ cur.basis += lot.basis;
3050
+ if (lot.openDate < cur.openDate) {
3051
+ cur.openDate = lot.openDate;
3052
+ cur.openPrice = lot.openPrice;
3053
+ }
3054
+ } else {
3055
+ byId.set(lot.asset.id, {
3056
+ asset: lot.asset,
3057
+ quantity: lot.quantity,
3058
+ basis: lot.basis,
3059
+ openDate: lot.openDate,
3060
+ openPrice: lot.openPrice
3061
+ });
3062
+ }
3063
+ }
3064
+ return Array.from(byId.values()).map((agg) => ({
3065
+ id: `lot_view_${agg.asset.id}`,
3066
+ asset: agg.asset,
3067
+ side: "long",
3068
+ quantity: agg.quantity,
3069
+ entry: { date: agg.openDate, price: agg.openPrice },
3070
+ basis: agg.basis
3071
+ }));
3072
+ }
2965
3073
  export {
2966
3074
  BacktestExecutor,
2967
3075
  Crypto24x7Calendar,
@@ -2993,6 +3101,7 @@ export {
2993
3101
  paramsHash,
2994
3102
  periodKey,
2995
3103
  pollingStreamFromHistorical,
3104
+ positionsByAsset,
2996
3105
  reconcile,
2997
3106
  returnSeries,
2998
3107
  rsi,