@livefolio/sdk 0.5.0-rc.2 → 0.5.0-rc.4

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
@@ -127,6 +127,22 @@ type Series = ReadonlyArray<{
127
127
  t: Date;
128
128
  v: number;
129
129
  }>;
130
+ /**
131
+ * A cash distribution (dividend) or interest payment for an asset, carrying
132
+ * what the SDK needs to credit cash and classify the income per-lot.
133
+ *
134
+ * `incomeKind: 'qualified-eligible'` means the distribution CAN be qualified if
135
+ * a holding lot satisfies the 60-of-121-day rule; the runtime resolves the
136
+ * actual qualified-vs-ordinary split per lot. `'ordinary'`/`'interest'` are
137
+ * never qualified.
138
+ */
139
+ type DividendEvent = {
140
+ asset: Asset;
141
+ exDate: Date;
142
+ payDate: Date;
143
+ amountPerShare: number;
144
+ incomeKind: 'qualified-eligible' | 'ordinary' | 'interest';
145
+ };
130
146
 
131
147
  /**
132
148
  * Stable opaque string identifier for a {@link Position}. Generated by the
@@ -187,7 +203,7 @@ type IncomeKind = 'capital-gain' | 'qualified-dividend' | 'ordinary-dividend' |
187
203
  * sales reduce `quantity` and pro-rate `basis`.
188
204
  */
189
205
  type Lot = {
190
- /** Opaque id assigned by {@link nextLotId} on creation. */
206
+ /** Opaque id assigned by `nextLotId` (internal) on creation. */
191
207
  id: string;
192
208
  asset: Asset;
193
209
  /** Shares remaining in this lot after any partial sales. */
@@ -767,9 +783,13 @@ interface DataFeed {
767
783
  * @param asset - The instrument to fetch bars for.
768
784
  * @param range - Half-open date range; `range.from` inclusive, `range.to` exclusive.
769
785
  * @param freq - Bar width. `'1d'` returns one bar per trading day.
786
+ * @param kind - `'adjusted'` (default) applies split/dividend adjustments;
787
+ * `'unadjusted'` returns raw prices. Indicators consume adjusted bars;
788
+ * execution fills and dividend cash-flow use unadjusted bars. Vendors that
789
+ * do not distinguish may ignore this and always return their single series.
770
790
  * @returns An async iterable of {@link Bar} objects.
771
791
  */
772
- bars(asset: Asset, range: DateRange, freq: Frequency): AsyncIterable<Bar>;
792
+ bars(asset: Asset, range: DateRange, freq: Frequency, kind?: 'adjusted' | 'unadjusted'): AsyncIterable<Bar>;
773
793
  /**
774
794
  * Returns a snapshot of fundamental data for `asset` as of `t`.
775
795
  * Optional — not all data providers carry fundamentals.
@@ -790,6 +810,17 @@ interface DataFeed {
790
810
  * @returns An async iterable of {@link DataEvent} objects.
791
811
  */
792
812
  events?(range: DateRange, kinds: ReadonlyArray<EventKind>): AsyncIterable<DataEvent>;
813
+ /**
814
+ * Returns cash distributions (dividends / interest) for `asset` over `range`.
815
+ * Optional — providers without dividend data omit it. Consumed by
816
+ * `runBacktest`'s per-session dividend hook. Amounts are per-share, on the
817
+ * unadjusted price basis.
818
+ *
819
+ * @param asset - The instrument to query.
820
+ * @param range - Half-open date range.
821
+ * @returns A promise resolving to an array of {@link DividendEvent} objects.
822
+ */
823
+ dividends?(asset: Asset, range: DateRange): Promise<DividendEvent[]>;
793
824
  }
794
825
 
795
826
  /**
@@ -1599,6 +1630,27 @@ declare class FeatureRuntime {
1599
1630
  compute(spec: FeatureSpec, asset: Asset): Promise<Series>;
1600
1631
  }
1601
1632
 
1633
+ /**
1634
+ * A scheduled cash injection or withdrawal. Applied at the start of the
1635
+ * matching session — BEFORE `universe`/`features`/`build` — by `runBacktest`
1636
+ * (see Task 10 wiring). Events with `t <= sessionT` that have not yet been
1637
+ * consumed are applied (and summed) on that session.
1638
+ */
1639
+ type CashEvent = {
1640
+ t: Date;
1641
+ /** Positive = deposit, negative = withdrawal. */
1642
+ delta: number;
1643
+ /**
1644
+ * Optional attribution tag for downstream metrics. `'deposit'`/`'withdrawal'`
1645
+ * are the natural tags for user-scheduled flows (this surface's main use case).
1646
+ * `'interest'`/`'dividend'` are accepted for callers who want to tag a
1647
+ * manually-scheduled flow as income, but the SDK's automatic per-session
1648
+ * interest/dividend hooks do NOT emit `CashEvent`s — they credit cash directly
1649
+ * and report via `BacktestSnapshot.interestIncome`/`dividendIncome`. User code
1650
+ * typically only sets `'deposit'`/`'withdrawal'`.
1651
+ */
1652
+ reason?: 'deposit' | 'withdrawal' | 'interest' | 'dividend';
1653
+ };
1602
1654
  /**
1603
1655
  * All inputs required to run a historical backtest.
1604
1656
  *
@@ -1653,6 +1705,16 @@ type RunBacktestOptions<F extends Features = Features, S = unknown> = {
1653
1705
  * runtime seed its buffer from the historical bars without refetching).
1654
1706
  */
1655
1707
  featureRuntime?: FeatureRuntime;
1708
+ /**
1709
+ * Optional scheduled deposits/withdrawals applied per-session before the
1710
+ * strategy runs. Matched by `t <= sessionT`; multiple due events are summed.
1711
+ * Defaults to none (today's behavior). See `BacktestSnapshot.cashFlow`.
1712
+ *
1713
+ * @remarks A withdrawal that exceeds available cash is allowed to drive cash
1714
+ * negative (logged via `console.warn`); automatic force-selling of holdings to
1715
+ * fund withdrawals is deferred to a later release, so this behavior may change.
1716
+ */
1717
+ cashEvents?: ReadonlyArray<CashEvent>;
1656
1718
  };
1657
1719
  /**
1658
1720
  * A point-in-time snapshot of the simulation at the end of a single trading session.
@@ -1669,6 +1731,10 @@ type BacktestSnapshot = {
1669
1731
  orders: ReadonlyArray<Order>;
1670
1732
  /** Fills returned by the executor for the orders above. */
1671
1733
  fills: ReadonlyArray<Fill>;
1734
+ /**
1735
+ * Net cash delta applied this session via `cashEvents`. Omitted when zero.
1736
+ */
1737
+ cashFlow?: number;
1672
1738
  };
1673
1739
  /**
1674
1740
  * The return value of `runBacktest`, containing the full simulation history
@@ -1820,6 +1886,18 @@ interface StreamingDataFeed {
1820
1886
  subscribe(assets: ReadonlyArray<Asset>): AsyncIterable<StreamingBar>;
1821
1887
  }
1822
1888
 
1889
+ /**
1890
+ * Mutable queue for scheduling {@link CashEvent}s into a running `runLive`
1891
+ * stream. Construct one, pass it via {@link RunLiveOptions.cashEventQueue}, and
1892
+ * `push` events from outside the generator; they are drained at each session
1893
+ * close. A non-breaking alternative to a returned scheduling handle.
1894
+ */
1895
+ declare class CashEventQueue {
1896
+ private pending;
1897
+ push(e: CashEvent): void;
1898
+ /** Remove and return events with `t <= now`, leaving later events queued. */
1899
+ drainDue(now: Date): CashEvent[];
1900
+ }
1823
1901
  /**
1824
1902
  * Unified event stream from {@link runLive}. Discriminated union of two variants:
1825
1903
  *
@@ -1861,6 +1939,25 @@ type LiveEvent<F extends Features = Features, _S = unknown> = {
1861
1939
  * `portfolio` + `prices`.
1862
1940
  */
1863
1941
  previewPortfolio: Portfolio;
1942
+ } | {
1943
+ /**
1944
+ * A net cash injection/withdrawal applied to the committed portfolio at a
1945
+ * session close. Emitted BEFORE the `snapshot` for that session, so the
1946
+ * subsequent snapshot's `portfolio.cash` already reflects this delta.
1947
+ * Only emitted when the summed delta of due events is non-zero.
1948
+ */
1949
+ type: 'cash';
1950
+ /** Session-close timestamp at which the delta was applied. */
1951
+ t: Date;
1952
+ /** Net cash delta applied (sum of all due events). Positive = deposit. */
1953
+ delta: number;
1954
+ /**
1955
+ * Attribution tag of the FIRST contributing event. When multiple cash
1956
+ * events are due at the same session close they are summed into one delta
1957
+ * and the other reasons are dropped — use `cashEventQueue` and drain
1958
+ * manually if you need per-event attribution.
1959
+ */
1960
+ reason?: CashEvent['reason'];
1864
1961
  } | (BacktestSnapshot & {
1865
1962
  type: 'snapshot';
1866
1963
  });
@@ -1891,6 +1988,20 @@ type RunLiveOptions<F extends Features = Features, S = unknown> = {
1891
1988
  * seeded from `history.bars`.
1892
1989
  */
1893
1990
  streamingRuntime?: FeatureRuntime;
1991
+ /**
1992
+ * Cash injections/withdrawals known up front. Each is applied to the
1993
+ * committed portfolio at the first session close whose date is `>= e.t`,
1994
+ * summed with any other due events for that session. For dynamic scheduling
1995
+ * after the stream has started, use {@link RunLiveOptions.cashEventQueue}.
1996
+ */
1997
+ cashEvents?: ReadonlyArray<CashEvent>;
1998
+ /**
1999
+ * Optional mutable queue for scheduling cash events into the running stream
2000
+ * from outside the generator. Drained (alongside `cashEvents`) at each
2001
+ * session close. Construct a {@link CashEventQueue}, pass it here, and `push`
2002
+ * events as they arrive.
2003
+ */
2004
+ cashEventQueue?: CashEventQueue;
1894
2005
  };
1895
2006
  /**
1896
2007
  * Drives a {@link Strategy} against a streaming market-data source and yields
@@ -2122,6 +2233,28 @@ type BacktestExecutorOptions = {
2122
2233
  * the fill quantity and recorded in `Fill.fees`. Defaults to `0`.
2123
2234
  */
2124
2235
  perShareFee?: number;
2236
+ /**
2237
+ * Tax-lot selection method applied to long sells (a `rebalance` reduce or a
2238
+ * `close` of a long position). When set to a non-default method
2239
+ * (`'LIFO'` / `'HIFO'` / `'min-tax'`), such a sell is split into one
2240
+ * {@link Fill} per selected lot — each carrying `Fill.lotId` — so
2241
+ * {@link applyFills} consumes those exact lots.
2242
+ *
2243
+ * Defaults to `'FIFO'` (equivalently, leaving this unset): the executor emits
2244
+ * a single fill per order with no `lotId`, and `applyFills` performs its own
2245
+ * internal FIFO. Buys, short-side closes, and `adjust` orders are never split.
2246
+ */
2247
+ lotMethod?: 'FIFO' | 'LIFO' | 'HIFO' | 'min-tax';
2248
+ /**
2249
+ * Short- and long-term capital-gains tax rates (as decimals, e.g. `0.37`)
2250
+ * forwarded to the `'min-tax'` selector. **Required** when
2251
+ * `lotMethod === 'min-tax'`; the constructor throws otherwise. Ignored for
2252
+ * all other lot methods.
2253
+ */
2254
+ taxRates?: {
2255
+ shortTerm: number;
2256
+ longTerm: number;
2257
+ };
2125
2258
  };
2126
2259
  /**
2127
2260
  * Reference {@link Executor} implementation for backtesting. Fills each order
@@ -2142,6 +2275,12 @@ type BacktestExecutorOptions = {
2142
2275
  * A flat per-share fee is added to `Fill.fees`. Orders with zero quantity are
2143
2276
  * silently skipped.
2144
2277
  *
2278
+ * **Lot selection**: by default each order yields exactly one fill. When a
2279
+ * non-default `lotMethod` (`'LIFO'` / `'HIFO'` / `'min-tax'`) is configured,
2280
+ * long sells (a `rebalance` reduce or a `close` of a long position) are split
2281
+ * into one fill per selected lot — each tagged with `Fill.lotId` — so
2282
+ * {@link applyFills} consumes those exact lots. See {@link BacktestExecutorOptions.lotMethod}.
2283
+ *
2145
2284
  * @example
2146
2285
  * ```ts
2147
2286
  * import { BacktestExecutor } from '@livefolio/sdk';
@@ -2170,7 +2309,8 @@ declare class BacktestExecutor implements Executor {
2170
2309
  * when the routed feed does not support the requested optional method.
2171
2310
  *
2172
2311
  * Distinguish the two cases via the message text: "no feed registered" vs
2173
- * "does not implement fundamentals".
2312
+ * "does not implement `<method>`" (e.g. "does not implement fundamentals()" or
2313
+ * "does not implement dividends()").
2174
2314
  */
2175
2315
  declare class RoutingDataFeedError extends Error {
2176
2316
  constructor(message: string);
@@ -2191,9 +2331,11 @@ type RoutingDataFeedRouteMap = Readonly<Partial<Record<Asset['kind'], DataFeed>>
2191
2331
  * - **Function form:** `new RoutingDataFeed((a) => a.kind === 'macro' ? fred : yahoo)`.
2192
2332
  * Use when routing depends on more than `kind` (e.g. allowlists).
2193
2333
  *
2194
- * The router does **not** implement `events()` — the optional method is
2195
- * genuinely absent (`'events' in router === false`). Cross-feed event
2196
- * fan-out is deferred until a real consumer materializes.
2334
+ * `dividends()` and `fundamentals()` ARE implemented each targets a single
2335
+ * asset, so it resolves to that asset's routed feed (or throws if the feed
2336
+ * lacks the method). The router does **not** implement `events()` — the
2337
+ * optional method is genuinely absent (`'events' in router === false`) because
2338
+ * cross-feed event fan-out is a separate, deferred problem.
2197
2339
  *
2198
2340
  * @example
2199
2341
  * ```ts
@@ -2212,8 +2354,17 @@ type RoutingDataFeedRouteMap = Readonly<Partial<Record<Asset['kind'], DataFeed>>
2212
2354
  declare class RoutingDataFeed implements DataFeed {
2213
2355
  private readonly route;
2214
2356
  constructor(routes: RoutingDataFeedRouteMap | RoutingDataFeedRouteFn);
2215
- bars(asset: Asset, range: DateRange, freq: Frequency): AsyncGenerator<Bar>;
2357
+ bars(asset: Asset, range: DateRange, freq: Frequency, kind?: 'adjusted' | 'unadjusted'): AsyncGenerator<Bar>;
2216
2358
  fundamentals(asset: Asset, t: Date): Promise<Fundamentals>;
2359
+ /**
2360
+ * Resolves `asset` to its routed feed and delegates `dividends`. This method
2361
+ * is ALWAYS present on the router (unlike a leaf feed's optional `dividends?`),
2362
+ * so `typeof routingFeed.dividends === 'function'` is always `true` — capability
2363
+ * detection must account for the routed feed possibly lacking it at call time.
2364
+ *
2365
+ * @throws {RoutingDataFeedError} when the routed feed does not implement `dividends`.
2366
+ */
2367
+ dividends(asset: Asset, range: DateRange): Promise<DividendEvent[]>;
2217
2368
  private resolve;
2218
2369
  }
2219
2370
 
@@ -3114,7 +3265,7 @@ declare function evaluateFeatureSpecs(specs: ReadonlyArray<TacticalFeatureSpec>,
3114
3265
  * independently scaled); `volume` is passed through from the underlying bar.
3115
3266
  *
3116
3267
  * Non-synthetic assets are proxied transparently to the original `dataFeed`.
3117
- * `fundamentals` and `events` methods, if present, are forwarded unchanged.
3268
+ * `fundamentals`, `events`, and `dividends` methods, if present, are forwarded unchanged.
3118
3269
  *
3119
3270
  * Throws at construction time if `synthetics` contains duplicate `id` values.
3120
3271
  *
@@ -3977,4 +4128,4 @@ declare function applyOrders(portfolio: Portfolio, orders: ReadonlyArray<Order>)
3977
4128
  */
3978
4129
  declare function positionsByAsset(portfolio: Portfolio): Position[];
3979
4130
 
3980
- 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 LotSlice, type MacroAsset, MemoryFeatureCache, NYSEExchangeCalendar, type NextOpenFn, ORDINARY_OFFSET_CAP, type OpenOrder, type Order, type PollingSchedule, type PollingStreamOptions, type Portfolio, type Position, type PositionId, type PriceMap, type Quote, type QuoteFeed, type RealizeResult, 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 TaxRates, type TaxableIncome, type TimeOfDay, type Tolerance, type WithStreamingSyntheticsOptions, aggregateByYear, applyFills, applyOrders, barsToSeries, bucketByTerm, collectBars, computeTaxBill, crossOffset, defineFeature, drawdown, ema, evaluateFeatureSpecs, evaluateRuleTree, index$1 as features, fromSpec, getCalendar, getFeatureCompute, holdingPeriodDays, isLongTerm, isRebalanceDay, netWithinBucket, paramsHash, periodKey, pollingStreamFromHistorical, positionsByAsset, realize, reconcile, returnSeries, rsi, runBacktest, runLive, selectFIFO, selectHIFO, selectLIFO, selectMinTax, seriesAt, sma, index$2 as tactical, index as tax, volatility, withStreamingSynthetics, withSynthetics };
4131
+ 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 CashEvent, CashEventQueue, type CloseOrder, type Comparison, type ComparisonOp, type ComputeFn, Crypto24x7Calendar, type DataEvent, type DataFeed, type DateRange, type DividendEvent, 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 LotSlice, type MacroAsset, MemoryFeatureCache, NYSEExchangeCalendar, type NextOpenFn, ORDINARY_OFFSET_CAP, type OpenOrder, type Order, type PollingSchedule, type PollingStreamOptions, type Portfolio, type Position, type PositionId, type PriceMap, type Quote, type QuoteFeed, type RealizeResult, 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 TaxRates, type TaxableIncome, type TimeOfDay, type Tolerance, type WithStreamingSyntheticsOptions, aggregateByYear, applyFills, applyOrders, barsToSeries, bucketByTerm, collectBars, computeTaxBill, crossOffset, defineFeature, drawdown, ema, evaluateFeatureSpecs, evaluateRuleTree, index$1 as features, fromSpec, getCalendar, getFeatureCompute, holdingPeriodDays, isLongTerm, isRebalanceDay, netWithinBucket, paramsHash, periodKey, pollingStreamFromHistorical, positionsByAsset, realize, reconcile, returnSeries, rsi, runBacktest, runLive, selectFIFO, selectHIFO, selectLIFO, selectMinTax, seriesAt, sma, index$2 as tactical, index as tax, volatility, withStreamingSynthetics, withSynthetics };
package/dist/index.js CHANGED
@@ -331,7 +331,24 @@ async function runBacktest(opts) {
331
331
  let portfolio = opts.initialPortfolio;
332
332
  let state = initialStateValue;
333
333
  const snapshots = [];
334
+ const cashEvents = [...opts.cashEvents ?? []].sort((a, b) => a.t.getTime() - b.t.getTime());
335
+ let eventCursor = 0;
336
+ let warnedNegativeCash = false;
334
337
  for (const t of sessions) {
338
+ let cashFlow = 0;
339
+ while (eventCursor < cashEvents.length && cashEvents[eventCursor].t.getTime() <= t.getTime()) {
340
+ cashFlow += cashEvents[eventCursor].delta;
341
+ eventCursor++;
342
+ }
343
+ if (cashFlow !== 0) {
344
+ portfolio = { ...portfolio, cash: portfolio.cash + cashFlow };
345
+ if (portfolio.cash < 0 && !warnedNegativeCash) {
346
+ warnedNegativeCash = true;
347
+ console.warn(
348
+ `[runBacktest] cash went negative at ${t.toISOString()}: ${portfolio.cash}. Withdrawals exceed available cash (force-sell is deferred); further occurrences this run are suppressed.`
349
+ );
350
+ }
351
+ }
335
352
  const universe = opts.strategy.universe(t, portfolio);
336
353
  const features = await opts.strategy.features(universe, portfolio, t);
337
354
  const buildResult = opts.strategy.build(features, portfolio, state, t);
@@ -344,7 +361,7 @@ async function runBacktest(opts) {
344
361
  }
345
362
  const fills = await opts.executor.submit(orders, t, portfolio);
346
363
  portfolio = applyFills(portfolio, fills, orders);
347
- snapshots.push({ t, portfolio, orders, fills });
364
+ snapshots.push({ t, portfolio, orders, fills, ...cashFlow !== 0 ? { cashFlow } : {} });
348
365
  }
349
366
  const bars = opts.featureRuntime?.getAllBars() ?? /* @__PURE__ */ new Map();
350
367
  return { snapshots, finalPortfolio: portfolio, finalState: state, bars };
@@ -757,6 +774,18 @@ var MemoryFeatureCache = class {
757
774
  };
758
775
 
759
776
  // src/strategy/run-live.ts
777
+ var CashEventQueue = class {
778
+ pending = [];
779
+ push(e) {
780
+ this.pending.push(e);
781
+ }
782
+ /** Remove and return events with `t <= now`, leaving later events queued. */
783
+ drainDue(now) {
784
+ const due = this.pending.filter((e) => e.t.getTime() <= now.getTime());
785
+ if (due.length > 0) this.pending = this.pending.filter((e) => e.t.getTime() > now.getTime());
786
+ return due;
787
+ }
788
+ };
760
789
  function snapshotState(state) {
761
790
  if (state === void 0) return void 0;
762
791
  return structuredClone(state);
@@ -775,6 +804,8 @@ async function* runLive(opts) {
775
804
  });
776
805
  let portfolio = history.finalPortfolio;
777
806
  let state = history.finalState;
807
+ const seedQueue = new CashEventQueue();
808
+ for (const e of opts.cashEvents ?? []) seedQueue.push(e);
778
809
  const anchorTime = history.snapshots.length > 0 ? history.snapshots[history.snapshots.length - 1].t : /* @__PURE__ */ new Date(0);
779
810
  const universe = strategy.universe(anchorTime, portfolio);
780
811
  let currentSession = history.snapshots.length > 0 ? calendar.next(history.snapshots[history.snapshots.length - 1].t) : null;
@@ -826,6 +857,17 @@ async function* runLive(opts) {
826
857
  }
827
858
  const fills = await executor.submit(orders, currentSession, portfolio);
828
859
  portfolio = applyFills(portfolio, fills, orders);
860
+ const dueCash = [...seedQueue.drainDue(currentSession), ...opts.cashEventQueue?.drainDue(currentSession) ?? []];
861
+ const cashDelta = dueCash.reduce((s, e) => s + e.delta, 0);
862
+ if (cashDelta !== 0) {
863
+ portfolio = { ...portfolio, cash: portfolio.cash + cashDelta };
864
+ yield {
865
+ type: "cash",
866
+ t: currentSession,
867
+ delta: cashDelta,
868
+ reason: dueCash[0]?.reason
869
+ };
870
+ }
829
871
  yield {
830
872
  type: "snapshot",
831
873
  t: currentSession,
@@ -867,40 +909,124 @@ async function* runLive(opts) {
867
909
  }
868
910
  }
869
911
 
912
+ // src/tax/lot-selection.ts
913
+ function take(sorted, qty) {
914
+ let need = qty;
915
+ const out = [];
916
+ for (const lot of sorted) {
917
+ if (lot.quantity <= 0) continue;
918
+ if (need <= 0) break;
919
+ const q = Math.min(lot.quantity, need);
920
+ out.push({ lotId: lot.id, quantity: q });
921
+ need -= q;
922
+ }
923
+ if (need > 1e-9) {
924
+ const held = sorted.reduce((s, l) => s + Math.max(0, l.quantity), 0);
925
+ throw new RangeError(`lot-selection: need ${qty} but only ${held} held`);
926
+ }
927
+ return out;
928
+ }
929
+ function selectFIFO(lots, qty) {
930
+ return take(
931
+ [...lots].sort((a, b) => a.openDate.getTime() - b.openDate.getTime()),
932
+ qty
933
+ );
934
+ }
935
+ function selectLIFO(lots, qty) {
936
+ return take(
937
+ [...lots].sort((a, b) => b.openDate.getTime() - a.openDate.getTime()),
938
+ qty
939
+ );
940
+ }
941
+ function selectHIFO(lots, qty) {
942
+ return take(
943
+ [...lots].filter((l) => l.quantity > 0).sort((a, b) => b.basis / b.quantity - a.basis / a.quantity),
944
+ qty
945
+ );
946
+ }
947
+ function selectMinTax(lots, qty, ctx) {
948
+ const tier = (l) => {
949
+ const gainPerShare = ctx.price - l.basis / l.quantity;
950
+ const lt = isLongTerm(holdingPeriodDays(l, ctx.asOf));
951
+ if (gainPerShare < 0) return lt ? 0 : 1;
952
+ return lt ? 2 : 3;
953
+ };
954
+ const sorted = [...lots].filter((l) => l.quantity > 0).sort((a, b) => {
955
+ const ta = tier(a);
956
+ const tb = tier(b);
957
+ if (ta !== tb) return ta - tb;
958
+ const gainA = ctx.price - a.basis / a.quantity;
959
+ const gainB = ctx.price - b.basis / b.quantity;
960
+ return gainA - gainB;
961
+ });
962
+ return take(sorted, qty);
963
+ }
964
+
870
965
  // src/reference/backtest-executor.ts
871
966
  function resolveAsset(order, portfolio) {
872
967
  switch (order.kind) {
873
968
  case "open":
874
- return { asset: order.asset, sign: order.side === "long" ? 1 : -1, qty: order.quantity };
969
+ return { asset: order.asset, sign: order.side === "long" ? 1 : -1, qty: order.quantity, lotConsumingSell: false };
875
970
  case "rebalance":
876
- return { asset: order.asset, sign: order.delta >= 0 ? 1 : -1, qty: Math.abs(order.delta) };
971
+ return {
972
+ asset: order.asset,
973
+ sign: order.delta >= 0 ? 1 : -1,
974
+ qty: Math.abs(order.delta),
975
+ lotConsumingSell: order.delta < 0
976
+ };
877
977
  case "close": {
878
978
  const p = portfolio.positions.find((x) => x.id === order.positionId);
879
979
  if (!p) throw new Error(`BacktestExecutor: close target position ${order.positionId} not found`);
880
- return { asset: p.asset, sign: p.side === "long" ? -1 : 1, qty: order.quantity ?? p.quantity };
980
+ return {
981
+ asset: p.asset,
982
+ sign: p.side === "long" ? -1 : 1,
983
+ qty: order.quantity ?? p.quantity,
984
+ lotConsumingSell: p.side === "long"
985
+ };
881
986
  }
882
987
  case "adjust": {
883
988
  const p = portfolio.positions.find((x) => x.id === order.positionId);
884
989
  if (!p) throw new Error(`BacktestExecutor: adjust target position ${order.positionId} not found`);
885
990
  const target = order.changes.quantity ?? p.quantity;
886
991
  const delta = target - p.quantity;
887
- return { asset: p.asset, sign: delta >= 0 ? 1 : -1, qty: Math.abs(delta) };
992
+ return { asset: p.asset, sign: delta >= 0 ? 1 : -1, qty: Math.abs(delta), lotConsumingSell: false };
888
993
  }
889
994
  }
890
995
  }
891
996
  var BacktestExecutor = class {
892
997
  constructor(opts) {
893
998
  this.opts = opts;
999
+ if (opts.lotMethod === "min-tax" && !opts.taxRates) {
1000
+ throw new Error("BacktestExecutor: lotMethod 'min-tax' requires taxRates");
1001
+ }
894
1002
  }
895
1003
  async submit(orders, t, portfolio) {
896
1004
  const fills = [];
897
1005
  const slip = (this.opts.slippageBps ?? 0) / 1e4;
898
1006
  const feePer = this.opts.perShareFee ?? 0;
1007
+ const method = this.opts.lotMethod;
899
1008
  for (const order of orders) {
900
- const { asset, sign, qty } = resolveAsset(order, portfolio);
1009
+ const { asset, sign, qty, lotConsumingSell } = resolveAsset(order, portfolio);
901
1010
  if (qty === 0) continue;
902
1011
  const open = await this.opts.nextOpen(asset, t);
903
1012
  const adjustedPrice = open.price * (1 + sign * slip);
1013
+ if (method && method !== "FIFO" && lotConsumingSell) {
1014
+ const lots = (portfolio.lots ?? []).filter((l) => l.asset.id === asset.id && l.quantity > 0);
1015
+ if (lots.length > 0) {
1016
+ const slices = method === "LIFO" ? selectLIFO(lots, qty) : method === "HIFO" ? selectHIFO(lots, qty) : selectMinTax(lots, qty, { price: adjustedPrice, asOf: open.t, rates: this.opts.taxRates });
1017
+ for (const slice of slices) {
1018
+ fills.push({
1019
+ orderRef: order.id,
1020
+ t: open.t,
1021
+ quantity: slice.quantity,
1022
+ price: adjustedPrice,
1023
+ fees: feePer * slice.quantity,
1024
+ lotId: slice.lotId
1025
+ });
1026
+ }
1027
+ continue;
1028
+ }
1029
+ }
904
1030
  fills.push({
905
1031
  orderRef: order.id,
906
1032
  t: open.t,
@@ -932,9 +1058,9 @@ var RoutingDataFeed = class {
932
1058
  // Async generator (rather than plain delegation) so resolve() runs lazily on
933
1059
  // the first next() call, surfacing errors via the iterable's normal rejection
934
1060
  // path instead of throwing synchronously at call time.
935
- async *bars(asset, range, freq) {
1061
+ async *bars(asset, range, freq, kind) {
936
1062
  const feed = this.resolve(asset);
937
- yield* feed.bars(asset, range, freq);
1063
+ yield* feed.bars(asset, range, freq, kind);
938
1064
  }
939
1065
  async fundamentals(asset, t) {
940
1066
  const feed = this.resolve(asset);
@@ -945,6 +1071,23 @@ var RoutingDataFeed = class {
945
1071
  }
946
1072
  return feed.fundamentals(asset, t);
947
1073
  }
1074
+ /**
1075
+ * Resolves `asset` to its routed feed and delegates `dividends`. This method
1076
+ * is ALWAYS present on the router (unlike a leaf feed's optional `dividends?`),
1077
+ * so `typeof routingFeed.dividends === 'function'` is always `true` — capability
1078
+ * detection must account for the routed feed possibly lacking it at call time.
1079
+ *
1080
+ * @throws {RoutingDataFeedError} when the routed feed does not implement `dividends`.
1081
+ */
1082
+ async dividends(asset, range) {
1083
+ const feed = this.resolve(asset);
1084
+ if (typeof feed.dividends !== "function") {
1085
+ throw new RoutingDataFeedError(
1086
+ `RoutingDataFeed: routed feed for asset.kind="${asset.kind}" (id="${asset.id}") does not implement dividends()`
1087
+ );
1088
+ }
1089
+ return feed.dividends(asset, range);
1090
+ }
948
1091
  resolve(asset) {
949
1092
  const feed = this.route(asset);
950
1093
  if (feed === void 0) {
@@ -2838,11 +2981,13 @@ function withSynthetics(dataFeed, synthetics) {
2838
2981
  byId.set(s.id, s);
2839
2982
  }
2840
2983
  const wrapped = {
2841
- bars(asset, range, freq) {
2984
+ // `kind` ('adjusted' | 'unadjusted') is forwarded to the underlying feed
2985
+ // before synthesis so synthetic bars derive from the requested price series.
2986
+ bars(asset, range, freq, kind) {
2842
2987
  const synth = byId.get(asset.id);
2843
- if (!synth) return dataFeed.bars(asset, range, freq);
2988
+ if (!synth) return dataFeed.bars(asset, range, freq, kind);
2844
2989
  const underlying = resolveAssetRef(synth.underlying);
2845
- return synthesize(dataFeed.bars(underlying, range, freq), synth.leverage, synth.expense);
2990
+ return synthesize(dataFeed.bars(underlying, range, freq, kind), synth.leverage, synth.expense);
2846
2991
  }
2847
2992
  };
2848
2993
  if (dataFeed.fundamentals) {
@@ -2851,6 +2996,9 @@ function withSynthetics(dataFeed, synthetics) {
2851
2996
  if (dataFeed.events) {
2852
2997
  wrapped.events = dataFeed.events.bind(dataFeed);
2853
2998
  }
2999
+ if (dataFeed.dividends) {
3000
+ wrapped.dividends = dataFeed.dividends.bind(dataFeed);
3001
+ }
2854
3002
  return wrapped;
2855
3003
  }
2856
3004
  function withStreamingSynthetics(inner, synthetics, opts) {
@@ -3085,59 +3233,6 @@ __export(tax_exports, {
3085
3233
  selectMinTax: () => selectMinTax
3086
3234
  });
3087
3235
 
3088
- // src/tax/lot-selection.ts
3089
- function take(sorted, qty) {
3090
- let need = qty;
3091
- const out = [];
3092
- for (const lot of sorted) {
3093
- if (lot.quantity <= 0) continue;
3094
- if (need <= 0) break;
3095
- const q = Math.min(lot.quantity, need);
3096
- out.push({ lotId: lot.id, quantity: q });
3097
- need -= q;
3098
- }
3099
- if (need > 1e-9) {
3100
- const held = sorted.reduce((s, l) => s + Math.max(0, l.quantity), 0);
3101
- throw new RangeError(`lot-selection: need ${qty} but only ${held} held`);
3102
- }
3103
- return out;
3104
- }
3105
- function selectFIFO(lots, qty) {
3106
- return take(
3107
- [...lots].sort((a, b) => a.openDate.getTime() - b.openDate.getTime()),
3108
- qty
3109
- );
3110
- }
3111
- function selectLIFO(lots, qty) {
3112
- return take(
3113
- [...lots].sort((a, b) => b.openDate.getTime() - a.openDate.getTime()),
3114
- qty
3115
- );
3116
- }
3117
- function selectHIFO(lots, qty) {
3118
- return take(
3119
- [...lots].filter((l) => l.quantity > 0).sort((a, b) => b.basis / b.quantity - a.basis / a.quantity),
3120
- qty
3121
- );
3122
- }
3123
- function selectMinTax(lots, qty, ctx) {
3124
- const tier = (l) => {
3125
- const gainPerShare = ctx.price - l.basis / l.quantity;
3126
- const lt = isLongTerm(holdingPeriodDays(l, ctx.asOf));
3127
- if (gainPerShare < 0) return lt ? 0 : 1;
3128
- return lt ? 2 : 3;
3129
- };
3130
- const sorted = [...lots].filter((l) => l.quantity > 0).sort((a, b) => {
3131
- const ta = tier(a);
3132
- const tb = tier(b);
3133
- if (ta !== tb) return ta - tb;
3134
- const gainA = ctx.price - a.basis / a.quantity;
3135
- const gainB = ctx.price - b.basis / b.quantity;
3136
- return gainA - gainB;
3137
- });
3138
- return take(sorted, qty);
3139
- }
3140
-
3141
3236
  // src/tax/aggregation.ts
3142
3237
  var ORDINARY_OFFSET_CAP = 3e3;
3143
3238
  function bucketByTerm(events) {
@@ -3258,6 +3353,7 @@ function positionsByAsset(portfolio) {
3258
3353
  }
3259
3354
  export {
3260
3355
  BacktestExecutor,
3356
+ CashEventQueue,
3261
3357
  Crypto24x7Calendar,
3262
3358
  ExchangeCalendar,
3263
3359
  FeatureRuntime,