@livefolio/sdk 0.5.0-rc.1 → 0.5.0-rc.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
@@ -54,6 +54,36 @@ function nextLotId() {
54
54
  return `lot_${++_lotCounter}`;
55
55
  }
56
56
 
57
+ // src/tax/holding-period.ts
58
+ var MS_PER_DAY = 864e5;
59
+ function holdingPeriodDays(lot, asOf) {
60
+ return (asOf.getTime() - lot.openDate.getTime()) / MS_PER_DAY;
61
+ }
62
+ function isLongTerm(days) {
63
+ return days > 365;
64
+ }
65
+ function realize(lot, qty, salePrice, asOf) {
66
+ if (qty <= 0) throw new RangeError(`realize: qty must be positive, got ${qty}`);
67
+ if (qty > lot.quantity) throw new RangeError(`realize: lot ${lot.id} has ${lot.quantity}, cannot sell ${qty}`);
68
+ const basisPerShare = lot.basis / lot.quantity;
69
+ const basis = basisPerShare * qty;
70
+ const proceeds = qty * salePrice;
71
+ const event = {
72
+ asset: lot.asset,
73
+ lotId: lot.id,
74
+ quantity: qty,
75
+ openDate: lot.openDate,
76
+ closeDate: asOf,
77
+ proceeds,
78
+ basis,
79
+ termType: isLongTerm(holdingPeriodDays(lot, asOf)) ? "long" : "short",
80
+ gain: proceeds - basis,
81
+ incomeKind: "capital-gain"
82
+ };
83
+ const remainingLot = qty === lot.quantity ? null : { ...lot, quantity: lot.quantity - qty, basis: lot.basis - basis };
84
+ return { event, remainingLot };
85
+ }
86
+
57
87
  // src/portfolio/apply.ts
58
88
  var newPositionId = /* @__PURE__ */ (() => {
59
89
  let n = 0;
@@ -62,8 +92,6 @@ var newPositionId = /* @__PURE__ */ (() => {
62
92
  function findOrder(orders, id) {
63
93
  return orders.find((o) => o.id === id);
64
94
  }
65
- var MS_PER_DAY = 864e5;
66
- var isLong = (openDate, closeDate) => (closeDate.getTime() - openDate.getTime()) / MS_PER_DAY > 365;
67
95
  function consumeLots(lots, realized, assetId, qty, price, fees, closeDate, preferLotId) {
68
96
  const sortedLots = lots.filter((l) => l.asset.id === assetId && l.quantity > 0).sort((a, b) => {
69
97
  if (preferLotId) {
@@ -80,25 +108,25 @@ function consumeLots(lots, realized, assetId, qty, price, fees, closeDate, prefe
80
108
  const totalQty = qty;
81
109
  for (const l of sortedLots) {
82
110
  if (need <= 0) break;
83
- const take = Math.min(l.quantity, need);
111
+ const take2 = Math.min(l.quantity, need);
84
112
  const basisPerShare = l.basis / l.quantity;
85
- const consumedBasis = basisPerShare * take;
86
- const proceeds = take * price - take / totalQty * fees;
113
+ const consumedBasis = basisPerShare * take2;
114
+ const proceeds = take2 * price - take2 / totalQty * fees;
87
115
  realized.push({
88
116
  asset: l.asset,
89
117
  lotId: l.id,
90
- quantity: take,
118
+ quantity: take2,
91
119
  openDate: l.openDate,
92
120
  closeDate,
93
121
  proceeds,
94
122
  basis: consumedBasis,
95
- termType: isLong(l.openDate, closeDate) ? "long" : "short",
123
+ termType: isLongTerm(holdingPeriodDays(l, closeDate)) ? "long" : "short",
96
124
  gain: proceeds - consumedBasis,
97
125
  incomeKind: "capital-gain"
98
126
  });
99
- l.quantity -= take;
127
+ l.quantity -= take2;
100
128
  l.basis -= consumedBasis;
101
- need -= take;
129
+ need -= take2;
102
130
  }
103
131
  }
104
132
  function applyFills(portfolio, fills, orders) {
@@ -303,7 +331,24 @@ async function runBacktest(opts) {
303
331
  let portfolio = opts.initialPortfolio;
304
332
  let state = initialStateValue;
305
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;
306
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
+ }
307
352
  const universe = opts.strategy.universe(t, portfolio);
308
353
  const features = await opts.strategy.features(universe, portfolio, t);
309
354
  const buildResult = opts.strategy.build(features, portfolio, state, t);
@@ -316,7 +361,7 @@ async function runBacktest(opts) {
316
361
  }
317
362
  const fills = await opts.executor.submit(orders, t, portfolio);
318
363
  portfolio = applyFills(portfolio, fills, orders);
319
- snapshots.push({ t, portfolio, orders, fills });
364
+ snapshots.push({ t, portfolio, orders, fills, ...cashFlow !== 0 ? { cashFlow } : {} });
320
365
  }
321
366
  const bars = opts.featureRuntime?.getAllBars() ?? /* @__PURE__ */ new Map();
322
367
  return { snapshots, finalPortfolio: portfolio, finalState: state, bars };
@@ -729,6 +774,18 @@ var MemoryFeatureCache = class {
729
774
  };
730
775
 
731
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
+ };
732
789
  function snapshotState(state) {
733
790
  if (state === void 0) return void 0;
734
791
  return structuredClone(state);
@@ -747,6 +804,8 @@ async function* runLive(opts) {
747
804
  });
748
805
  let portfolio = history.finalPortfolio;
749
806
  let state = history.finalState;
807
+ const seedQueue = new CashEventQueue();
808
+ for (const e of opts.cashEvents ?? []) seedQueue.push(e);
750
809
  const anchorTime = history.snapshots.length > 0 ? history.snapshots[history.snapshots.length - 1].t : /* @__PURE__ */ new Date(0);
751
810
  const universe = strategy.universe(anchorTime, portfolio);
752
811
  let currentSession = history.snapshots.length > 0 ? calendar.next(history.snapshots[history.snapshots.length - 1].t) : null;
@@ -798,6 +857,17 @@ async function* runLive(opts) {
798
857
  }
799
858
  const fills = await executor.submit(orders, currentSession, portfolio);
800
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
+ }
801
871
  yield {
802
872
  type: "snapshot",
803
873
  t: currentSession,
@@ -3039,6 +3109,164 @@ __export(features_exports, {
3039
3109
  volatility: () => volatility
3040
3110
  });
3041
3111
 
3112
+ // src/tax/index.ts
3113
+ var tax_exports = {};
3114
+ __export(tax_exports, {
3115
+ ORDINARY_OFFSET_CAP: () => ORDINARY_OFFSET_CAP,
3116
+ aggregateByYear: () => aggregateByYear,
3117
+ bucketByTerm: () => bucketByTerm,
3118
+ computeTaxBill: () => computeTaxBill,
3119
+ crossOffset: () => crossOffset,
3120
+ holdingPeriodDays: () => holdingPeriodDays,
3121
+ isLongTerm: () => isLongTerm,
3122
+ netWithinBucket: () => netWithinBucket,
3123
+ realize: () => realize,
3124
+ selectFIFO: () => selectFIFO,
3125
+ selectHIFO: () => selectHIFO,
3126
+ selectLIFO: () => selectLIFO,
3127
+ selectMinTax: () => selectMinTax
3128
+ });
3129
+
3130
+ // src/tax/lot-selection.ts
3131
+ function take(sorted, qty) {
3132
+ let need = qty;
3133
+ const out = [];
3134
+ for (const lot of sorted) {
3135
+ if (lot.quantity <= 0) continue;
3136
+ if (need <= 0) break;
3137
+ const q = Math.min(lot.quantity, need);
3138
+ out.push({ lotId: lot.id, quantity: q });
3139
+ need -= q;
3140
+ }
3141
+ if (need > 1e-9) {
3142
+ const held = sorted.reduce((s, l) => s + Math.max(0, l.quantity), 0);
3143
+ throw new RangeError(`lot-selection: need ${qty} but only ${held} held`);
3144
+ }
3145
+ return out;
3146
+ }
3147
+ function selectFIFO(lots, qty) {
3148
+ return take(
3149
+ [...lots].sort((a, b) => a.openDate.getTime() - b.openDate.getTime()),
3150
+ qty
3151
+ );
3152
+ }
3153
+ function selectLIFO(lots, qty) {
3154
+ return take(
3155
+ [...lots].sort((a, b) => b.openDate.getTime() - a.openDate.getTime()),
3156
+ qty
3157
+ );
3158
+ }
3159
+ function selectHIFO(lots, qty) {
3160
+ return take(
3161
+ [...lots].filter((l) => l.quantity > 0).sort((a, b) => b.basis / b.quantity - a.basis / a.quantity),
3162
+ qty
3163
+ );
3164
+ }
3165
+ function selectMinTax(lots, qty, ctx) {
3166
+ const tier = (l) => {
3167
+ const gainPerShare = ctx.price - l.basis / l.quantity;
3168
+ const lt = isLongTerm(holdingPeriodDays(l, ctx.asOf));
3169
+ if (gainPerShare < 0) return lt ? 0 : 1;
3170
+ return lt ? 2 : 3;
3171
+ };
3172
+ const sorted = [...lots].filter((l) => l.quantity > 0).sort((a, b) => {
3173
+ const ta = tier(a);
3174
+ const tb = tier(b);
3175
+ if (ta !== tb) return ta - tb;
3176
+ const gainA = ctx.price - a.basis / a.quantity;
3177
+ const gainB = ctx.price - b.basis / b.quantity;
3178
+ return gainA - gainB;
3179
+ });
3180
+ return take(sorted, qty);
3181
+ }
3182
+
3183
+ // src/tax/aggregation.ts
3184
+ var ORDINARY_OFFSET_CAP = 3e3;
3185
+ function bucketByTerm(events) {
3186
+ const short = [];
3187
+ const long = [];
3188
+ for (const e of events) {
3189
+ if (e.incomeKind !== "capital-gain") continue;
3190
+ (e.termType === "long" ? long : short).push(e);
3191
+ }
3192
+ return { short, long };
3193
+ }
3194
+ function netWithinBucket(events) {
3195
+ let gains = 0;
3196
+ let losses = 0;
3197
+ for (const e of events) {
3198
+ if (e.gain >= 0) gains += e.gain;
3199
+ else losses += -e.gain;
3200
+ }
3201
+ return { gains, losses, net: gains - losses };
3202
+ }
3203
+ function crossOffset(netShort, netLong) {
3204
+ if (netShort >= 0 && netLong >= 0) {
3205
+ return { taxableShort: netShort, taxableLong: netLong, ordinaryOffset: 0, carryForward: 0 };
3206
+ }
3207
+ if (netShort < 0 && netLong < 0) {
3208
+ const totalLoss = -(netShort + netLong);
3209
+ const ordinaryOffset2 = Math.min(ORDINARY_OFFSET_CAP, totalLoss);
3210
+ return { taxableShort: 0, taxableLong: 0, ordinaryOffset: ordinaryOffset2, carryForward: totalLoss - ordinaryOffset2 };
3211
+ }
3212
+ const combined = netShort + netLong;
3213
+ if (combined >= 0) {
3214
+ const taxableShort = netShort > 0 ? combined : 0;
3215
+ const taxableLong = netLong > 0 ? combined : 0;
3216
+ return { taxableShort, taxableLong, ordinaryOffset: 0, carryForward: 0 };
3217
+ }
3218
+ const loss = -combined;
3219
+ const ordinaryOffset = Math.min(ORDINARY_OFFSET_CAP, loss);
3220
+ return { taxableShort: 0, taxableLong: 0, ordinaryOffset, carryForward: loss - ordinaryOffset };
3221
+ }
3222
+ function aggregateByYear(events) {
3223
+ const out = /* @__PURE__ */ new Map();
3224
+ const blank = () => ({
3225
+ shortTermGains: 0,
3226
+ shortTermLosses: 0,
3227
+ longTermGains: 0,
3228
+ longTermLosses: 0,
3229
+ qualifiedDividends: 0,
3230
+ ordinaryDividends: 0,
3231
+ interestIncome: 0
3232
+ });
3233
+ for (const e of events) {
3234
+ const year = e.closeDate.getUTCFullYear();
3235
+ const acc = out.get(year) ?? blank();
3236
+ switch (e.incomeKind) {
3237
+ case "capital-gain":
3238
+ if (e.termType === "long") {
3239
+ if (e.gain >= 0) acc.longTermGains += e.gain;
3240
+ else acc.longTermLosses += -e.gain;
3241
+ } else {
3242
+ if (e.gain >= 0) acc.shortTermGains += e.gain;
3243
+ else acc.shortTermLosses += -e.gain;
3244
+ }
3245
+ break;
3246
+ case "qualified-dividend":
3247
+ acc.qualifiedDividends += e.proceeds;
3248
+ break;
3249
+ case "ordinary-dividend":
3250
+ acc.ordinaryDividends += e.proceeds;
3251
+ break;
3252
+ case "interest":
3253
+ acc.interestIncome += e.proceeds;
3254
+ break;
3255
+ }
3256
+ out.set(year, acc);
3257
+ }
3258
+ return out;
3259
+ }
3260
+ function computeTaxBill(income, rates) {
3261
+ const netShort = income.shortTermGains - income.shortTermLosses;
3262
+ const netLong = income.longTermGains - income.longTermLosses;
3263
+ const off = crossOffset(netShort, netLong);
3264
+ const ordinaryPortion = (off.taxableShort + income.ordinaryDividends + income.interestIncome) * rates.shortTerm;
3265
+ const ltPortion = (off.taxableLong + income.qualifiedDividends) * rates.longTerm;
3266
+ const total = ordinaryPortion + ltPortion;
3267
+ return { total, breakdown: { ordinaryPortion, ltPortion, carryForward: off.carryForward } };
3268
+ }
3269
+
3042
3270
  // src/portfolio/derived.ts
3043
3271
  function positionsByAsset(portfolio) {
3044
3272
  const byId = /* @__PURE__ */ new Map();
@@ -3072,22 +3300,28 @@ function positionsByAsset(portfolio) {
3072
3300
  }
3073
3301
  export {
3074
3302
  BacktestExecutor,
3303
+ CashEventQueue,
3075
3304
  Crypto24x7Calendar,
3076
3305
  ExchangeCalendar,
3077
3306
  FeatureRuntime,
3078
3307
  LSEExchangeCalendar,
3079
3308
  MemoryFeatureCache,
3080
3309
  NYSEExchangeCalendar,
3310
+ ORDINARY_OFFSET_CAP,
3081
3311
  RoutingDataFeed,
3082
3312
  RoutingDataFeedError,
3083
3313
  RoutingQuoteFeed,
3084
3314
  RoutingQuoteFeedError,
3085
3315
  RoutingStreamingDataFeed,
3086
3316
  RoutingStreamingDataFeedError,
3317
+ aggregateByYear,
3087
3318
  applyFills,
3088
3319
  applyOrders,
3089
3320
  barsToSeries,
3321
+ bucketByTerm,
3090
3322
  collectBars,
3323
+ computeTaxBill,
3324
+ crossOffset,
3091
3325
  defineFeature,
3092
3326
  drawdown,
3093
3327
  ema,
@@ -3097,19 +3331,28 @@ export {
3097
3331
  fromSpec,
3098
3332
  getCalendar,
3099
3333
  getFeatureCompute,
3334
+ holdingPeriodDays,
3335
+ isLongTerm,
3100
3336
  isRebalanceDay,
3337
+ netWithinBucket,
3101
3338
  paramsHash,
3102
3339
  periodKey,
3103
3340
  pollingStreamFromHistorical,
3104
3341
  positionsByAsset,
3342
+ realize,
3105
3343
  reconcile,
3106
3344
  returnSeries,
3107
3345
  rsi,
3108
3346
  runBacktest,
3109
3347
  runLive,
3348
+ selectFIFO,
3349
+ selectHIFO,
3350
+ selectLIFO,
3351
+ selectMinTax,
3110
3352
  seriesAt,
3111
3353
  sma,
3112
3354
  tactical_exports as tactical,
3355
+ tax_exports as tax,
3113
3356
  volatility,
3114
3357
  withStreamingSynthetics,
3115
3358
  withSynthetics