@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.d.ts +395 -50
- package/dist/index.js +253 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
111
|
+
const take2 = Math.min(l.quantity, need);
|
|
84
112
|
const basisPerShare = l.basis / l.quantity;
|
|
85
|
-
const consumedBasis = basisPerShare *
|
|
86
|
-
const proceeds =
|
|
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:
|
|
118
|
+
quantity: take2,
|
|
91
119
|
openDate: l.openDate,
|
|
92
120
|
closeDate,
|
|
93
121
|
proceeds,
|
|
94
122
|
basis: consumedBasis,
|
|
95
|
-
termType:
|
|
123
|
+
termType: isLongTerm(holdingPeriodDays(l, closeDate)) ? "long" : "short",
|
|
96
124
|
gain: proceeds - consumedBasis,
|
|
97
125
|
incomeKind: "capital-gain"
|
|
98
126
|
});
|
|
99
|
-
l.quantity -=
|
|
127
|
+
l.quantity -= take2;
|
|
100
128
|
l.basis -= consumedBasis;
|
|
101
|
-
need -=
|
|
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
|