@adaptic/utils 0.0.1031 → 0.0.1033
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.cjs +631 -208
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +627 -209
- package/dist/index.mjs.map +1 -1
- package/dist/types/__tests__/indicator-parity/generate.d.ts +64 -0
- package/dist/types/__tests__/indicator-parity/generate.d.ts.map +1 -0
- package/dist/types/__tests__/indicator-parity/record.d.ts +144 -0
- package/dist/types/__tests__/indicator-parity/record.d.ts.map +1 -0
- package/dist/types/__tests__/indicator-parity/reference.d.ts +92 -0
- package/dist/types/__tests__/indicator-parity/reference.d.ts.map +1 -0
- package/dist/types/__tests__/indicator-parity/series.d.ts +64 -0
- package/dist/types/__tests__/indicator-parity/series.d.ts.map +1 -0
- package/dist/types/__tests__/indicator-parity/subjects.d.ts +55 -0
- package/dist/types/__tests__/indicator-parity/subjects.d.ts.map +1 -0
- package/dist/types/__tests__/support/statistic.d.ts +18 -0
- package/dist/types/__tests__/support/statistic.d.ts.map +1 -0
- package/dist/types/alpaca/trading/order-utils.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/llm/circuit-breaker.d.ts +18 -1
- package/dist/types/llm/circuit-breaker.d.ts.map +1 -1
- package/dist/types/llm/fallback-chain.d.ts.map +1 -1
- package/dist/types/llm/index.d.ts +3 -1
- package/dist/types/llm/index.d.ts.map +1 -1
- package/dist/types/llm/rate-guard.d.ts +82 -9
- package/dist/types/llm/rate-guard.d.ts.map +1 -1
- package/dist/types/llm/structured-content.d.ts +73 -0
- package/dist/types/llm/structured-content.d.ts.map +1 -0
- package/dist/types/llm/transports/gateway.d.ts.map +1 -1
- package/dist/types/metrics-calcs.d.ts +25 -0
- package/dist/types/metrics-calcs.d.ts.map +1 -1
- package/dist/types/performance-metrics.d.ts +16 -4
- package/dist/types/performance-metrics.d.ts.map +1 -1
- package/dist/types/sample-statistic.d.ts +123 -0
- package/dist/types/sample-statistic.d.ts.map +1 -0
- package/dist/types/schemas/alpaca-schemas.d.ts.map +1 -1
- package/dist/types/strategy-metrics.d.ts +38 -16
- package/dist/types/strategy-metrics.d.ts.map +1 -1
- package/dist/types/trading-policy/schemas/effective-policy.schema.d.ts +18 -18
- package/dist/types/trading-policy/schemas/model-prefs.schema.d.ts +24 -24
- package/dist/types/trading-policy/schemas/policy-mutation.schema.d.ts +36 -36
- package/dist/types/types/alpaca-types.d.ts +36 -5
- package/dist/types/types/alpaca-types.d.ts.map +1 -1
- package/package.json +3 -1
package/dist/index.cjs
CHANGED
|
@@ -20243,6 +20243,93 @@ function getEquityValues(equityData, portfolioHistory, marketTimeUtil, period) {
|
|
|
20243
20243
|
};
|
|
20244
20244
|
}
|
|
20245
20245
|
|
|
20246
|
+
/**
|
|
20247
|
+
* A measured statistic and the cohort it was measured on, carried as one
|
|
20248
|
+
* inseparable value.
|
|
20249
|
+
*
|
|
20250
|
+
* A ratio is meaningless without the population it was taken over: the same
|
|
20251
|
+
* `0.42` is a strong result on 2,000 trades and noise on five, and a `0.0`
|
|
20252
|
+
* returned because nothing could be computed is indistinguishable from a `0.0`
|
|
20253
|
+
* that was genuinely measured. Both confusions are the same error — a number
|
|
20254
|
+
* read apart from its unit and its cohort — and both have produced wrong
|
|
20255
|
+
* conclusions from correct arithmetic.
|
|
20256
|
+
*
|
|
20257
|
+
* This type removes the option. Every statistic shaped by a population carries
|
|
20258
|
+
* `sampleCount` (how many observations actually entered the computation) and
|
|
20259
|
+
* `coverage` (what fraction of the observations the caller offered were usable),
|
|
20260
|
+
* on BOTH branches: an unavailable statistic still reports how much data it
|
|
20261
|
+
* saw, because "we had nothing" and "we had 900 rows and still could not
|
|
20262
|
+
* compute it" are different facts with different responses.
|
|
20263
|
+
*
|
|
20264
|
+
* Absence is a branch of the union rather than a sentinel value. There is no
|
|
20265
|
+
* number a caller can read without first proving the statistic exists, which is
|
|
20266
|
+
* what keeps an unknown from silently becoming a zero on its way to a decision.
|
|
20267
|
+
*
|
|
20268
|
+
* @module sample-statistic
|
|
20269
|
+
*/
|
|
20270
|
+
/**
|
|
20271
|
+
* Build the cohort descriptor for a computation.
|
|
20272
|
+
*
|
|
20273
|
+
* `coverage` is derived here rather than supplied, so it cannot drift from the
|
|
20274
|
+
* counts it claims to summarise. A zero request yields zero coverage: no
|
|
20275
|
+
* observations were asked for, so none were covered, and the alternative (`1`)
|
|
20276
|
+
* would report a vacuous computation as fully covered.
|
|
20277
|
+
*
|
|
20278
|
+
* @param requestedCount - Observations offered, or the window width requested.
|
|
20279
|
+
* @param sampleCount - Observations that entered the computation.
|
|
20280
|
+
* @returns The cohort descriptor with `coverage` derived from the two counts.
|
|
20281
|
+
* @throws When either count is negative or non-finite, which is a programming
|
|
20282
|
+
* error rather than a data condition.
|
|
20283
|
+
*/
|
|
20284
|
+
function sampleCohort(requestedCount, sampleCount) {
|
|
20285
|
+
if (!Number.isFinite(requestedCount) || requestedCount < 0) {
|
|
20286
|
+
throw new Error(`sampleCohort: requestedCount must be a non-negative finite number (got ${requestedCount})`);
|
|
20287
|
+
}
|
|
20288
|
+
if (!Number.isFinite(sampleCount) || sampleCount < 0) {
|
|
20289
|
+
throw new Error(`sampleCohort: sampleCount must be a non-negative finite number (got ${sampleCount})`);
|
|
20290
|
+
}
|
|
20291
|
+
const NOTHING_REQUESTED_COVERAGE = 0;
|
|
20292
|
+
const FULL_COVERAGE = 1;
|
|
20293
|
+
const coverage = requestedCount === 0
|
|
20294
|
+
? NOTHING_REQUESTED_COVERAGE
|
|
20295
|
+
: Math.min(FULL_COVERAGE, sampleCount / requestedCount);
|
|
20296
|
+
return { sampleCount, requestedCount, coverage };
|
|
20297
|
+
}
|
|
20298
|
+
/**
|
|
20299
|
+
* Wrap a computed value with its cohort.
|
|
20300
|
+
*
|
|
20301
|
+
* @param value - The measured statistic.
|
|
20302
|
+
* @param cohort - The cohort it was measured on.
|
|
20303
|
+
* @returns The available branch of {@link SampleStatistic}.
|
|
20304
|
+
*/
|
|
20305
|
+
function availableStatistic(value, cohort) {
|
|
20306
|
+
return { available: true, value, ...cohort };
|
|
20307
|
+
}
|
|
20308
|
+
/**
|
|
20309
|
+
* Record that a statistic could not be computed, and what was seen instead.
|
|
20310
|
+
*
|
|
20311
|
+
* @param reason - Which class of failure prevented the computation.
|
|
20312
|
+
* @param detail - Specifics for logs; never machine-parsed.
|
|
20313
|
+
* @param cohort - What data was available when the attempt was abandoned.
|
|
20314
|
+
* @returns The unavailable branch of {@link SampleStatistic}.
|
|
20315
|
+
*/
|
|
20316
|
+
function unavailableStatistic(reason, detail, cohort) {
|
|
20317
|
+
return { available: false, reason, detail, ...cohort };
|
|
20318
|
+
}
|
|
20319
|
+
/**
|
|
20320
|
+
* Narrow a statistic to its available branch.
|
|
20321
|
+
*
|
|
20322
|
+
* Exists so consumers in other packages can discriminate without restating the
|
|
20323
|
+
* predicate, and so the discriminant stays a single named concept if the shape
|
|
20324
|
+
* ever grows a third branch.
|
|
20325
|
+
*
|
|
20326
|
+
* @param statistic - The statistic to test.
|
|
20327
|
+
* @returns Whether the statistic carries a value.
|
|
20328
|
+
*/
|
|
20329
|
+
function isAvailable(statistic) {
|
|
20330
|
+
return statistic.available;
|
|
20331
|
+
}
|
|
20332
|
+
|
|
20246
20333
|
// risk-free-rate.ts
|
|
20247
20334
|
/**
|
|
20248
20335
|
* Conservative fallback annual risk-free rate used when no live rate has been
|
|
@@ -20601,57 +20688,60 @@ function alignReturns(tradeBars, benchmarkBars) {
|
|
|
20601
20688
|
});
|
|
20602
20689
|
return { alignedTradeReturns, alignedBenchmarkReturns, alignedDates };
|
|
20603
20690
|
}
|
|
20604
|
-
|
|
20605
|
-
*
|
|
20606
|
-
*
|
|
20607
|
-
*
|
|
20608
|
-
*
|
|
20691
|
+
/**
|
|
20692
|
+
* Beta of a portfolio against a benchmark, from paired period returns.
|
|
20693
|
+
*
|
|
20694
|
+
* Non-finite rows are dropped pairwise — a return that is `NaN` on either leg
|
|
20695
|
+
* cannot contribute to a covariance — and the count that survives is reported
|
|
20696
|
+
* as the cohort rather than discarded. That reporting is the point: silently
|
|
20697
|
+
* computing a beta on the 12 rows that happened to be clean, and returning it
|
|
20698
|
+
* with the same shape as a beta over all 900, is how a statistic measured on
|
|
20699
|
+
* one population gets applied to another.
|
|
20700
|
+
*
|
|
20701
|
+
* When beta cannot be computed the result is the unavailable branch, never a
|
|
20702
|
+
* numeric stand-in. A beta of `0` asserts that the portfolio does not move with
|
|
20703
|
+
* the market, which is a strong and consequential claim; emitting it to mean
|
|
20704
|
+
* "we could not tell" makes every alpha derived from it wrong by the whole
|
|
20705
|
+
* benchmark term.
|
|
20706
|
+
*
|
|
20707
|
+
* @param portfolioReturns - Portfolio period returns.
|
|
20708
|
+
* @param benchmarkReturns - Benchmark period returns, index-aligned to the portfolio.
|
|
20709
|
+
* @returns The beta components with their cohort, or a typed unavailable result.
|
|
20609
20710
|
* @example
|
|
20610
|
-
* const
|
|
20611
|
-
*
|
|
20612
|
-
*
|
|
20613
|
-
*
|
|
20614
|
-
* @throws Will log warnings if input data is invalid or insufficient
|
|
20615
|
-
* @throws Will log warnings if benchmark variance is effectively zero
|
|
20616
|
-
* @throws Will log warnings if beta calculation results in a non-finite value
|
|
20617
|
-
* @throws Will log warnings if there are not enough valid data points for calculation
|
|
20618
|
-
* @throws Will log warnings if benchmark variance is zero or non-finite
|
|
20711
|
+
* const result = calculateBetaFromReturns([0.05, -0.02, 0.03], [0.03, -0.01, 0.02]);
|
|
20712
|
+
* if (result.available) {
|
|
20713
|
+
* // result.value.beta, alongside result.sampleCount and result.coverage
|
|
20714
|
+
* }
|
|
20619
20715
|
*/
|
|
20620
20716
|
function calculateBetaFromReturns$1(portfolioReturns, benchmarkReturns) {
|
|
20621
|
-
//
|
|
20622
|
-
|
|
20623
|
-
|
|
20624
|
-
|
|
20625
|
-
|
|
20626
|
-
|
|
20627
|
-
|
|
20628
|
-
|
|
20629
|
-
|
|
20630
|
-
|
|
20631
|
-
|
|
20632
|
-
|
|
20633
|
-
|
|
20634
|
-
|
|
20635
|
-
|
|
20636
|
-
const
|
|
20637
|
-
|
|
20638
|
-
|
|
20639
|
-
|
|
20640
|
-
|
|
20641
|
-
|
|
20642
|
-
|
|
20643
|
-
averagePortfolioReturn: 0,
|
|
20644
|
-
averageBenchmarkReturn: 0,
|
|
20645
|
-
};
|
|
20717
|
+
// A covariance is defined over PAIRS, so the offered cohort is the number of
|
|
20718
|
+
// index positions both series can supply. Ragged input is a caller defect
|
|
20719
|
+
// rather than a data condition, and it is reported as such instead of being
|
|
20720
|
+
// silently truncated to the shorter series.
|
|
20721
|
+
if (!Array.isArray(portfolioReturns) || !Array.isArray(benchmarkReturns)) {
|
|
20722
|
+
return unavailableStatistic("invalid_input", "portfolioReturns and benchmarkReturns must both be arrays", sampleCohort(0, 0));
|
|
20723
|
+
}
|
|
20724
|
+
const requestedCount = portfolioReturns.length;
|
|
20725
|
+
if (portfolioReturns.length !== benchmarkReturns.length) {
|
|
20726
|
+
return unavailableStatistic("invalid_input", `series lengths differ: portfolio ${portfolioReturns.length}, benchmark ${benchmarkReturns.length}`, sampleCohort(requestedCount, 0));
|
|
20727
|
+
}
|
|
20728
|
+
// Pairwise finiteness filter. Both legs must be usable for the pair to
|
|
20729
|
+
// contribute; keeping a pair on the strength of one leg would mix a real
|
|
20730
|
+
// observation with a fabricated one.
|
|
20731
|
+
const validIndices = [...Array(requestedCount).keys()].filter((i) => isFinite(portfolioReturns[i]) && isFinite(benchmarkReturns[i]));
|
|
20732
|
+
const cohort = sampleCohort(requestedCount, validIndices.length);
|
|
20733
|
+
// Bessel-corrected estimators need at least one degree of freedom, so two
|
|
20734
|
+
// usable pairs is the floor below which no sample variance exists.
|
|
20735
|
+
const MIN_PAIRS_FOR_SAMPLE_VARIANCE = 2;
|
|
20736
|
+
if (validIndices.length < MIN_PAIRS_FOR_SAMPLE_VARIANCE) {
|
|
20737
|
+
getLogger().warn(`Beta unavailable: ${validIndices.length} usable pairs of ${requestedCount} offered.`);
|
|
20738
|
+
return unavailableStatistic(validIndices.length === 0 ? "no_usable_samples" : "insufficient_samples", `beta needs at least ${MIN_PAIRS_FOR_SAMPLE_VARIANCE} finite pairs; ${validIndices.length} of ${requestedCount} were usable`, cohort);
|
|
20646
20739
|
}
|
|
20647
|
-
// Use validated indices only
|
|
20648
20740
|
const validPortfolioReturns = validIndices.map((i) => portfolioReturns[i]);
|
|
20649
20741
|
const validBenchmarkReturns = validIndices.map((i) => benchmarkReturns[i]);
|
|
20650
|
-
// Calculate means
|
|
20651
20742
|
const n = validIndices.length;
|
|
20652
20743
|
const averagePortfolioReturn = validPortfolioReturns.reduce((sum, ret) => sum + ret, 0) / n;
|
|
20653
20744
|
const averageBenchmarkReturn = validBenchmarkReturns.reduce((sum, ret) => sum + ret, 0) / n;
|
|
20654
|
-
// Calculate covariance and variance with Welford's online algorithm for numerical stability
|
|
20655
20745
|
let covariance = 0;
|
|
20656
20746
|
let variance = 0;
|
|
20657
20747
|
for (let i = 0; i < n; i++) {
|
|
@@ -20660,30 +20750,26 @@ function calculateBetaFromReturns$1(portfolioReturns, benchmarkReturns) {
|
|
|
20660
20750
|
covariance += portfolioDiff * benchmarkDiff;
|
|
20661
20751
|
variance += benchmarkDiff * benchmarkDiff;
|
|
20662
20752
|
}
|
|
20663
|
-
//
|
|
20664
|
-
//
|
|
20665
|
-
// already ensures n >= 2, so (n - 1) is always safe.
|
|
20753
|
+
// Sample (Bessel-corrected) estimators — divide by (n - 1), not n. The guard
|
|
20754
|
+
// above ensures n >= 2, so (n - 1) is always safe.
|
|
20666
20755
|
covariance /= n - 1;
|
|
20667
20756
|
variance /= n - 1;
|
|
20668
|
-
//
|
|
20669
|
-
|
|
20670
|
-
|
|
20671
|
-
|
|
20672
|
-
|
|
20673
|
-
|
|
20674
|
-
|
|
20675
|
-
|
|
20676
|
-
|
|
20677
|
-
|
|
20678
|
-
|
|
20679
|
-
const beta = covariance / variance;
|
|
20680
|
-
return {
|
|
20681
|
-
beta,
|
|
20757
|
+
// A benchmark that never moved has no variance to regress against, so beta is
|
|
20758
|
+
// undefined rather than zero. VARIANCE_NOISE_FLOOR absorbs the case where a
|
|
20759
|
+
// constant series still produces a tiny positive variance because the computed
|
|
20760
|
+
// mean differs from the constant by a rounding unit.
|
|
20761
|
+
const VARIANCE_NOISE_FLOOR = 1e-10;
|
|
20762
|
+
if (Math.abs(variance) < VARIANCE_NOISE_FLOOR) {
|
|
20763
|
+
getLogger().warn("Beta unavailable: benchmark variance is effectively zero.");
|
|
20764
|
+
return unavailableStatistic("degenerate_population", `benchmark variance ${variance} is below the noise floor ${VARIANCE_NOISE_FLOOR}; beta is undefined`, cohort);
|
|
20765
|
+
}
|
|
20766
|
+
return availableStatistic({
|
|
20767
|
+
beta: covariance / variance,
|
|
20682
20768
|
covariance,
|
|
20683
20769
|
variance,
|
|
20684
20770
|
averagePortfolioReturn,
|
|
20685
20771
|
averageBenchmarkReturn,
|
|
20686
|
-
};
|
|
20772
|
+
}, cohort);
|
|
20687
20773
|
}
|
|
20688
20774
|
/**
|
|
20689
20775
|
* Calculates the total return for a position, respecting position direction
|
|
@@ -20767,7 +20853,18 @@ async function calculateAlphaAndBeta$1(tradeBars, benchmarkBars, isShort) {
|
|
|
20767
20853
|
: rawTradeReturns;
|
|
20768
20854
|
// Calculate beta with position-adjusted returns
|
|
20769
20855
|
const beta = calculateBetaFromReturns$1(alignedTradeReturns, alignedBenchmarkReturns);
|
|
20770
|
-
|
|
20856
|
+
// Alpha is the return left over after the benchmark term, so an unknown beta
|
|
20857
|
+
// makes alpha unknown too. Substituting any number here — zero most of all —
|
|
20858
|
+
// would credit the whole benchmark move to the strategy.
|
|
20859
|
+
if (!beta.available) {
|
|
20860
|
+
getLogger().warn(`Alpha unavailable: beta could not be computed (${beta.reason}: ${beta.detail}).`);
|
|
20861
|
+
return {
|
|
20862
|
+
alpha: "N/A",
|
|
20863
|
+
alphaAnnualized: "N/A",
|
|
20864
|
+
beta: "N/A",
|
|
20865
|
+
};
|
|
20866
|
+
}
|
|
20867
|
+
if (!isFinite(beta.value.beta)) {
|
|
20771
20868
|
getLogger().warn("Beta calculation resulted in a non-finite value.");
|
|
20772
20869
|
return {
|
|
20773
20870
|
alpha: "N/A",
|
|
@@ -20778,7 +20875,7 @@ async function calculateAlphaAndBeta$1(tradeBars, benchmarkBars, isShort) {
|
|
|
20778
20875
|
// For short positions, the interpretation of beta changes
|
|
20779
20876
|
// A positive beta on a short means the position moves with the market,
|
|
20780
20877
|
// which is bad for a short. We invert it for consistency.
|
|
20781
|
-
const positionAwareBeta = isShort ? -beta.beta : beta.beta;
|
|
20878
|
+
const positionAwareBeta = isShort ? -beta.value.beta : beta.value.beta;
|
|
20782
20879
|
const avgTradeReturn = alignedTradeReturns.reduce((sum, ret) => sum + ret, 0) /
|
|
20783
20880
|
alignedTradeReturns.length;
|
|
20784
20881
|
const avgBenchmarkReturn = alignedBenchmarkReturns.reduce((sum, ret) => sum + ret, 0) /
|
|
@@ -21397,7 +21494,18 @@ async function calculateAlphaAndBeta(portfolioHistory, benchmarkBars) {
|
|
|
21397
21494
|
const benchmarkAvgReturn = alignedBenchmarkReturns.reduce((sum, ret) => sum + ret, 0) / n;
|
|
21398
21495
|
// **Calculate beta**
|
|
21399
21496
|
const beta = calculateBetaFromReturns(alignedPortfolioReturns, alignedBenchmarkReturns);
|
|
21400
|
-
|
|
21497
|
+
// Alpha is what remains after subtracting the benchmark term, so an unknown
|
|
21498
|
+
// beta leaves alpha unknown. Any numeric stand-in — zero above all — would
|
|
21499
|
+
// attribute the entire benchmark move to the strategy.
|
|
21500
|
+
if (!beta.available) {
|
|
21501
|
+
getLogger().warn(`Alpha unavailable: beta could not be computed (${beta.reason}: ${beta.detail}).`);
|
|
21502
|
+
return {
|
|
21503
|
+
alpha: "N/A",
|
|
21504
|
+
alphaAnnualized: "N/A",
|
|
21505
|
+
beta: "N/A",
|
|
21506
|
+
};
|
|
21507
|
+
}
|
|
21508
|
+
if (!isFinite(beta.value.beta)) {
|
|
21401
21509
|
getLogger().warn("Beta calculation resulted in a non-finite value.");
|
|
21402
21510
|
return {
|
|
21403
21511
|
alpha: "N/A",
|
|
@@ -21412,7 +21520,7 @@ async function calculateAlphaAndBeta(portfolioHistory, benchmarkBars) {
|
|
|
21412
21520
|
const tradingDaysPerYear = 252;
|
|
21413
21521
|
const riskFreeRateDaily = riskFreeRateAnnual / tradingDaysPerYear;
|
|
21414
21522
|
const alpha = portfolioAvgReturn -
|
|
21415
|
-
(riskFreeRateDaily + beta.beta * (benchmarkAvgReturn - riskFreeRateDaily));
|
|
21523
|
+
(riskFreeRateDaily + beta.value.beta * (benchmarkAvgReturn - riskFreeRateDaily));
|
|
21416
21524
|
const alphaAnnualized = alpha * tradingDaysPerYear;
|
|
21417
21525
|
if (!isFinite(alphaAnnualized)) {
|
|
21418
21526
|
getLogger().warn("Alpha calculation resulted in a non-finite value.");
|
|
@@ -21425,7 +21533,7 @@ async function calculateAlphaAndBeta(portfolioHistory, benchmarkBars) {
|
|
|
21425
21533
|
return {
|
|
21426
21534
|
alpha: `${(alpha * 100).toFixed(2)}`,
|
|
21427
21535
|
alphaAnnualized: `${(alphaAnnualized * 100).toFixed(2)}`,
|
|
21428
|
-
beta: `${(beta.beta * 100).toFixed(2)}`,
|
|
21536
|
+
beta: `${(beta.value.beta * 100).toFixed(2)}`,
|
|
21429
21537
|
};
|
|
21430
21538
|
}
|
|
21431
21539
|
// **Helper function to calculate daily returns with Unix millisecond timestamps**
|
|
@@ -21648,27 +21756,40 @@ function alignReturnsByDate(portfolioHistory, benchmarkBars) {
|
|
|
21648
21756
|
return { alignedPortfolioReturns, alignedBenchmarkReturns };
|
|
21649
21757
|
}
|
|
21650
21758
|
/**
|
|
21651
|
-
*
|
|
21652
|
-
*
|
|
21653
|
-
*
|
|
21654
|
-
*
|
|
21759
|
+
* Beta of a portfolio against a benchmark, from paired period returns.
|
|
21760
|
+
*
|
|
21761
|
+
* The two series are index-aligned pairs by contract: every mean, covariance
|
|
21762
|
+
* and variance below is taken over the SAME row set. A length mismatch is
|
|
21763
|
+
* therefore reported as invalid input rather than absorbed, because dividing
|
|
21764
|
+
* one series' sum by the other series' length produces a mean of a population
|
|
21765
|
+
* that does not exist — a number with no cohort, which is the failure this
|
|
21766
|
+
* return type exists to make impossible.
|
|
21767
|
+
*
|
|
21768
|
+
* An uncomputable beta is returned as the unavailable branch, never as `0`.
|
|
21769
|
+
* Zero beta is a claim of no market exposure, and downstream alpha attributes
|
|
21770
|
+
* the entire benchmark move to the strategy when it believes that claim.
|
|
21771
|
+
*
|
|
21772
|
+
* @param portfolioReturns - Portfolio period returns.
|
|
21773
|
+
* @param benchmarkReturns - Benchmark period returns, index-aligned to the portfolio.
|
|
21774
|
+
* @returns The beta components with their cohort, or a typed unavailable result.
|
|
21655
21775
|
*/
|
|
21656
21776
|
function calculateBetaFromReturns(portfolioReturns, benchmarkReturns) {
|
|
21657
|
-
const
|
|
21658
|
-
if (
|
|
21659
|
-
getLogger().warn(
|
|
21660
|
-
return {
|
|
21661
|
-
|
|
21662
|
-
|
|
21663
|
-
|
|
21664
|
-
|
|
21665
|
-
|
|
21666
|
-
|
|
21667
|
-
|
|
21668
|
-
|
|
21777
|
+
const requestedCount = portfolioReturns.length;
|
|
21778
|
+
if (portfolioReturns.length !== benchmarkReturns.length) {
|
|
21779
|
+
getLogger().warn(`Beta unavailable: series lengths differ (portfolio ${portfolioReturns.length}, benchmark ${benchmarkReturns.length}).`);
|
|
21780
|
+
return unavailableStatistic("invalid_input", `series lengths differ: portfolio ${portfolioReturns.length}, benchmark ${benchmarkReturns.length}`, sampleCohort(requestedCount, 0));
|
|
21781
|
+
}
|
|
21782
|
+
// Bessel-corrected estimators need one degree of freedom, so two paired
|
|
21783
|
+
// observations is the floor below which no sample variance exists.
|
|
21784
|
+
const MIN_PAIRS_FOR_SAMPLE_VARIANCE = 2;
|
|
21785
|
+
const n = requestedCount;
|
|
21786
|
+
if (n < MIN_PAIRS_FOR_SAMPLE_VARIANCE) {
|
|
21787
|
+
getLogger().warn(`Beta unavailable: ${n} paired returns offered.`);
|
|
21788
|
+
return unavailableStatistic(n === 0 ? "no_usable_samples" : "insufficient_samples", `beta needs at least ${MIN_PAIRS_FOR_SAMPLE_VARIANCE} paired returns; ${n} were offered`, sampleCohort(requestedCount, n));
|
|
21789
|
+
}
|
|
21790
|
+
const cohort = sampleCohort(requestedCount, n);
|
|
21669
21791
|
const averagePortfolioReturn = portfolioReturns.reduce((sum, ret) => sum + ret, 0) / n;
|
|
21670
21792
|
const averageBenchmarkReturn = benchmarkReturns.reduce((sum, ret) => sum + ret, 0) / n;
|
|
21671
|
-
// Calculate covariance and variance
|
|
21672
21793
|
let covariance = 0;
|
|
21673
21794
|
let variance = 0;
|
|
21674
21795
|
for (let i = 0; i < n; i++) {
|
|
@@ -21678,37 +21799,26 @@ function calculateBetaFromReturns(portfolioReturns, benchmarkReturns) {
|
|
|
21678
21799
|
variance += benchmarkDiff ** 2;
|
|
21679
21800
|
}
|
|
21680
21801
|
// Use sample (Bessel-corrected) estimators — divide by (n - 1), not n.
|
|
21681
|
-
|
|
21682
|
-
|
|
21683
|
-
|
|
21684
|
-
|
|
21685
|
-
|
|
21686
|
-
//
|
|
21687
|
-
//
|
|
21688
|
-
// mean
|
|
21689
|
-
// rounding noise yields a meaningless beta. Treat any variance at or
|
|
21690
|
-
// below the summation noise floor — (n * eps * |mean|)^2, the square of
|
|
21691
|
-
// the worst-case naive-summation error — as zero. When the mean is
|
|
21692
|
-
// exactly 0 this reduces to the exact zero check.
|
|
21802
|
+
covariance /= n - 1;
|
|
21803
|
+
variance /= n - 1;
|
|
21804
|
+
// A constant benchmark series can still produce a tiny nonzero variance
|
|
21805
|
+
// because the computed mean differs from the constant by an ulp; dividing
|
|
21806
|
+
// covariance by that rounding noise yields a meaningless beta. Treat any
|
|
21807
|
+
// variance at or below the summation noise floor — (n * eps * |mean|)^2, the
|
|
21808
|
+
// square of the worst-case naive-summation error — as no variance at all.
|
|
21809
|
+
// When the mean is exactly 0 this reduces to the exact zero check.
|
|
21693
21810
|
const varianceNoiseFloor = (n * Number.EPSILON * Math.abs(averageBenchmarkReturn)) ** 2;
|
|
21694
21811
|
if (variance <= varianceNoiseFloor) {
|
|
21695
|
-
getLogger().warn("
|
|
21696
|
-
return {
|
|
21697
|
-
beta: 0,
|
|
21698
|
-
covariance,
|
|
21699
|
-
variance,
|
|
21700
|
-
averagePortfolioReturn,
|
|
21701
|
-
averageBenchmarkReturn,
|
|
21702
|
-
};
|
|
21812
|
+
getLogger().warn("Beta unavailable: benchmark variance is zero or below the floating-point noise floor.");
|
|
21813
|
+
return unavailableStatistic("degenerate_population", `benchmark variance ${variance} is at or below the summation noise floor ${varianceNoiseFloor}; beta is undefined`, cohort);
|
|
21703
21814
|
}
|
|
21704
|
-
|
|
21705
|
-
|
|
21706
|
-
beta,
|
|
21815
|
+
return availableStatistic({
|
|
21816
|
+
beta: covariance / variance,
|
|
21707
21817
|
covariance,
|
|
21708
21818
|
variance,
|
|
21709
21819
|
averagePortfolioReturn,
|
|
21710
21820
|
averageBenchmarkReturn,
|
|
21711
|
-
};
|
|
21821
|
+
}, cohort);
|
|
21712
21822
|
}
|
|
21713
21823
|
/**
|
|
21714
21824
|
* Calculates the information ratio of the portfolio compared to a benchmark.
|
|
@@ -22187,7 +22297,14 @@ var riskNs = /*#__PURE__*/Object.freeze({
|
|
|
22187
22297
|
* Conventions:
|
|
22188
22298
|
* - tradePnls / tradeReturns is an array of per-trade realised P&L or return
|
|
22189
22299
|
* (positive = win, negative = loss, zero = breakeven).
|
|
22190
|
-
* -
|
|
22300
|
+
* - Every statistic here is a ratio or a mean over a WINDOW, so every one is
|
|
22301
|
+
* returned as a {@link SampleStatistic}: the value cannot be read without the
|
|
22302
|
+
* `sampleCount` it was taken over and the `coverage` of the window that was
|
|
22303
|
+
* asked for. A hit-rate is a different claim on 5 trades than on 500, and a
|
|
22304
|
+
* window that could only be half-filled is a different cohort from a full
|
|
22305
|
+
* one — a caller holding a bare number can tell neither apart.
|
|
22306
|
+
* - A window that cannot support the statistic returns the unavailable branch
|
|
22307
|
+
* with a reason, never a numeric stand-in. Zero is a measurement.
|
|
22191
22308
|
* - All public functions reject non-finite inputs (NaN, Infinity) by throwing.
|
|
22192
22309
|
* Callers must pre-validate or filter their inputs.
|
|
22193
22310
|
*/
|
|
@@ -22203,21 +22320,38 @@ function assertFiniteArray(name, arr) {
|
|
|
22203
22320
|
}
|
|
22204
22321
|
}
|
|
22205
22322
|
}
|
|
22323
|
+
/**
|
|
22324
|
+
* Report a window that holds fewer trades than it asked for.
|
|
22325
|
+
*
|
|
22326
|
+
* Shared so every rolling function describes a short window the same way — the
|
|
22327
|
+
* cohort is `(requested = windowSize, sampled = what exists)`, which is the
|
|
22328
|
+
* pair a caller needs to distinguish a warm-up from a data gap.
|
|
22329
|
+
*
|
|
22330
|
+
* @param name - The calling function, for the detail string.
|
|
22331
|
+
* @param available - Trades actually present.
|
|
22332
|
+
* @param windowSize - Trades the window asked for.
|
|
22333
|
+
* @returns The unavailable branch describing the short window.
|
|
22334
|
+
*/
|
|
22335
|
+
function insufficientWindow(name, available, windowSize) {
|
|
22336
|
+
return unavailableStatistic("insufficient_samples", `${name}: window of ${windowSize} requested, only ${available} trades available`, sampleCohort(windowSize, available));
|
|
22337
|
+
}
|
|
22206
22338
|
/**
|
|
22207
22339
|
* Rolling expectancy: mean P&L over the most-recent `windowSize` trades.
|
|
22208
22340
|
*
|
|
22209
22341
|
* @param tradePnls - Array of per-trade realised P&L values.
|
|
22210
22342
|
* @param windowSize - Number of most-recent trades to include. Must be a positive integer.
|
|
22211
|
-
* @returns Mean P&L of the last `windowSize` trades
|
|
22343
|
+
* @returns Mean P&L of the last `windowSize` trades with its cohort, or a typed
|
|
22344
|
+
* unavailable result when fewer than `windowSize` trades exist.
|
|
22212
22345
|
* @throws When `windowSize` is not a positive integer or any input is non-finite.
|
|
22213
22346
|
*/
|
|
22214
22347
|
function calculateRollingExpectancy(tradePnls, windowSize) {
|
|
22215
22348
|
assertWindowSize("calculateRollingExpectancy", windowSize);
|
|
22216
|
-
if (tradePnls.length < windowSize)
|
|
22217
|
-
return
|
|
22349
|
+
if (tradePnls.length < windowSize) {
|
|
22350
|
+
return insufficientWindow("calculateRollingExpectancy", tradePnls.length, windowSize);
|
|
22351
|
+
}
|
|
22218
22352
|
assertFiniteArray("calculateRollingExpectancy", tradePnls);
|
|
22219
22353
|
const slice = tradePnls.slice(-windowSize);
|
|
22220
|
-
return slice.reduce((a, b) => a + b, 0) / windowSize;
|
|
22354
|
+
return availableStatistic(slice.reduce((a, b) => a + b, 0) / windowSize, sampleCohort(windowSize, windowSize));
|
|
22221
22355
|
}
|
|
22222
22356
|
/**
|
|
22223
22357
|
* Rolling hit-rate: fraction of strictly-positive P&L trades in the most-recent
|
|
@@ -22225,42 +22359,53 @@ function calculateRollingExpectancy(tradePnls, windowSize) {
|
|
|
22225
22359
|
*
|
|
22226
22360
|
* @param tradePnls - Array of per-trade realised P&L values.
|
|
22227
22361
|
* @param windowSize - Number of most-recent trades to include. Must be a positive integer.
|
|
22228
|
-
* @returns Fraction of winning trades in the window
|
|
22362
|
+
* @returns Fraction of winning trades in the window with its cohort, or a typed
|
|
22363
|
+
* unavailable result when fewer than `windowSize` trades exist.
|
|
22229
22364
|
* @throws When `windowSize` is not a positive integer or any input is non-finite.
|
|
22230
22365
|
*/
|
|
22231
22366
|
function calculateRollingHitRate(tradePnls, windowSize) {
|
|
22232
22367
|
assertWindowSize("calculateRollingHitRate", windowSize);
|
|
22233
|
-
if (tradePnls.length < windowSize)
|
|
22234
|
-
return
|
|
22368
|
+
if (tradePnls.length < windowSize) {
|
|
22369
|
+
return insufficientWindow("calculateRollingHitRate", tradePnls.length, windowSize);
|
|
22370
|
+
}
|
|
22235
22371
|
assertFiniteArray("calculateRollingHitRate", tradePnls);
|
|
22236
22372
|
const slice = tradePnls.slice(-windowSize);
|
|
22237
22373
|
const wins = slice.filter((p) => p > 0).length;
|
|
22238
|
-
return wins / windowSize;
|
|
22374
|
+
return availableStatistic(wins / windowSize, sampleCohort(windowSize, windowSize));
|
|
22239
22375
|
}
|
|
22240
22376
|
/**
|
|
22241
22377
|
* Rolling profit factor: sum(wins) / |sum(losses)| over the most-recent `windowSize` trades.
|
|
22242
22378
|
*
|
|
22243
22379
|
* Edge cases:
|
|
22244
|
-
* - no losses and at least one win → +Infinity
|
|
22245
|
-
* - no wins and no losses (all zeros) → 0
|
|
22246
|
-
*
|
|
22380
|
+
* - no losses and at least one win → +Infinity (an unbounded but real ratio)
|
|
22381
|
+
* - no wins and no losses (all zeros) → unavailable: `0 / 0` is undefined, and a
|
|
22382
|
+
* window of breakeven trades has no profit factor rather than a profit factor
|
|
22383
|
+
* of zero
|
|
22384
|
+
* - fewer than windowSize trades → unavailable
|
|
22247
22385
|
*
|
|
22248
22386
|
* @param tradePnls - Array of per-trade realised P&L values.
|
|
22249
22387
|
* @param windowSize - Number of most-recent trades to include. Must be a positive integer.
|
|
22250
|
-
* @returns Profit factor for the rolling window
|
|
22388
|
+
* @returns Profit factor for the rolling window with its cohort, or a typed
|
|
22389
|
+
* unavailable result.
|
|
22251
22390
|
* @throws When `windowSize` is not a positive integer or any input is non-finite.
|
|
22252
22391
|
*/
|
|
22253
22392
|
function calculateRollingProfitFactor(tradePnls, windowSize) {
|
|
22254
22393
|
assertWindowSize("calculateRollingProfitFactor", windowSize);
|
|
22255
|
-
if (tradePnls.length < windowSize)
|
|
22256
|
-
return
|
|
22394
|
+
if (tradePnls.length < windowSize) {
|
|
22395
|
+
return insufficientWindow("calculateRollingProfitFactor", tradePnls.length, windowSize);
|
|
22396
|
+
}
|
|
22257
22397
|
assertFiniteArray("calculateRollingProfitFactor", tradePnls);
|
|
22398
|
+
const cohort = sampleCohort(windowSize, windowSize);
|
|
22258
22399
|
const slice = tradePnls.slice(-windowSize);
|
|
22259
22400
|
const wins = slice.filter((p) => p > 0).reduce((a, b) => a + b, 0);
|
|
22260
22401
|
const losses = slice.filter((p) => p < 0).reduce((a, b) => a + Math.abs(b), 0);
|
|
22261
|
-
if (losses === 0)
|
|
22262
|
-
|
|
22263
|
-
|
|
22402
|
+
if (losses === 0) {
|
|
22403
|
+
if (wins > 0) {
|
|
22404
|
+
return availableStatistic(Number.POSITIVE_INFINITY, cohort);
|
|
22405
|
+
}
|
|
22406
|
+
return unavailableStatistic("degenerate_population", `calculateRollingProfitFactor: window of ${windowSize} contains neither wins nor losses; the ratio is undefined`, cohort);
|
|
22407
|
+
}
|
|
22408
|
+
return availableStatistic(wins / losses, cohort);
|
|
22264
22409
|
}
|
|
22265
22410
|
/**
|
|
22266
22411
|
* Rolling Sortino: delegate to `calculateSortino` over the most-recent `windowSize` returns.
|
|
@@ -22268,34 +22413,58 @@ function calculateRollingProfitFactor(tradePnls, windowSize) {
|
|
|
22268
22413
|
* @param tradeReturns - Array of per-trade return values.
|
|
22269
22414
|
* @param windowSize - Number of most-recent trades to include. Must be a positive integer.
|
|
22270
22415
|
* @param riskFreeRate - Risk-free rate to subtract from returns (default 0).
|
|
22271
|
-
* @returns Sortino ratio for the rolling window
|
|
22416
|
+
* @returns Sortino ratio for the rolling window with its cohort, or a typed
|
|
22417
|
+
* unavailable result.
|
|
22272
22418
|
* @throws When `windowSize` is not a positive integer or any input is non-finite.
|
|
22273
22419
|
*/
|
|
22274
22420
|
function calculateRollingSortino(tradeReturns, windowSize, riskFreeRate = 0) {
|
|
22275
22421
|
assertWindowSize("calculateRollingSortino", windowSize);
|
|
22276
|
-
if (tradeReturns.length < windowSize)
|
|
22277
|
-
return
|
|
22422
|
+
if (tradeReturns.length < windowSize) {
|
|
22423
|
+
return insufficientWindow("calculateRollingSortino", tradeReturns.length, windowSize);
|
|
22424
|
+
}
|
|
22278
22425
|
assertFiniteArray("calculateRollingSortino", tradeReturns);
|
|
22279
|
-
|
|
22426
|
+
const cohort = sampleCohort(windowSize, windowSize);
|
|
22427
|
+
const sortino = calculateSortino(tradeReturns.slice(-windowSize), riskFreeRate);
|
|
22428
|
+
if (sortino === null) {
|
|
22429
|
+
// `calculateSortino` returns null only for a window it cannot form a
|
|
22430
|
+
// dispersion over — fewer than two samples. That is a property of the
|
|
22431
|
+
// window, so it is reported as one rather than as a ratio of zero.
|
|
22432
|
+
return unavailableStatistic("insufficient_samples", `calculateRollingSortino: window of ${windowSize} cannot support a dispersion estimate`, cohort);
|
|
22433
|
+
}
|
|
22434
|
+
return availableStatistic(sortino, cohort);
|
|
22280
22435
|
}
|
|
22281
22436
|
/**
|
|
22282
22437
|
* Z-score of live-expectancy vs backtest-expectancy, scaled by the backtest stddev.
|
|
22283
22438
|
* Positive Z = live outperforming; negative Z = live underperforming.
|
|
22284
22439
|
*
|
|
22285
|
-
*
|
|
22440
|
+
* The live expectancy is taken as a {@link SampleStatistic} rather than a bare
|
|
22441
|
+
* number so the z-score inherits the cohort it was actually derived from. A
|
|
22442
|
+
* z-score is a statement about how surprising a sample mean is, and how
|
|
22443
|
+
* surprising it is depends entirely on how many trades produced it — quoting
|
|
22444
|
+
* the z alone is the exact substitution this type exists to block. An
|
|
22445
|
+
* unavailable live expectancy yields an unavailable z, because there is no
|
|
22446
|
+
* mean to compare.
|
|
22447
|
+
*
|
|
22448
|
+
* @param liveExpectancy - Mean P&L per trade in the live window, with its cohort.
|
|
22286
22449
|
* @param backtestExpectancy - Mean P&L per trade from the calibration backtest.
|
|
22287
22450
|
* @param backtestStddev - Stddev of per-trade P&L in the backtest. Must be > 0.
|
|
22288
|
-
* @returns Z-score measuring divergence
|
|
22289
|
-
* @throws When
|
|
22451
|
+
* @returns Z-score measuring live-vs-backtest divergence, carrying the live cohort.
|
|
22452
|
+
* @throws When the backtest inputs are non-finite or `backtestStddev` is not positive.
|
|
22290
22453
|
*/
|
|
22291
22454
|
function calculateBacktestDivergenceZ(liveExpectancy, backtestExpectancy, backtestStddev) {
|
|
22292
|
-
if (!Number.isFinite(
|
|
22455
|
+
if (!Number.isFinite(backtestExpectancy) || !Number.isFinite(backtestStddev)) {
|
|
22293
22456
|
throw new Error("calculateBacktestDivergenceZ: inputs must be finite numbers");
|
|
22294
22457
|
}
|
|
22295
22458
|
if (backtestStddev <= 0) {
|
|
22296
22459
|
throw new Error("calculateBacktestDivergenceZ: stddev must be > 0");
|
|
22297
22460
|
}
|
|
22298
|
-
|
|
22461
|
+
if (!liveExpectancy.available) {
|
|
22462
|
+
return unavailableStatistic(liveExpectancy.reason, `calculateBacktestDivergenceZ: live expectancy unavailable (${liveExpectancy.detail})`, sampleCohort(liveExpectancy.requestedCount, liveExpectancy.sampleCount));
|
|
22463
|
+
}
|
|
22464
|
+
if (!Number.isFinite(liveExpectancy.value)) {
|
|
22465
|
+
throw new Error("calculateBacktestDivergenceZ: inputs must be finite numbers");
|
|
22466
|
+
}
|
|
22467
|
+
return availableStatistic((liveExpectancy.value - backtestExpectancy) / backtestStddev, sampleCohort(liveExpectancy.requestedCount, liveExpectancy.sampleCount));
|
|
22299
22468
|
}
|
|
22300
22469
|
|
|
22301
22470
|
var strategyNs = /*#__PURE__*/Object.freeze({
|
|
@@ -62339,13 +62508,18 @@ const DEFAULT_PAGINATION_DELAY_MS = 300;
|
|
|
62339
62508
|
*/
|
|
62340
62509
|
const MAX_ORDERS_PER_REQUEST = 500;
|
|
62341
62510
|
/**
|
|
62342
|
-
* Order statuses that are considered "open"
|
|
62511
|
+
* Order statuses that are considered "open".
|
|
62512
|
+
*
|
|
62513
|
+
* `held` is included because a held conditional leg (a bracket's stop-loss,
|
|
62514
|
+
* say) is a working order resting at the broker, returned by Alpaca's own
|
|
62515
|
+
* `status=open` listing and cancelable like any other open order.
|
|
62343
62516
|
*/
|
|
62344
62517
|
const OPEN_ORDER_STATUSES = [
|
|
62345
62518
|
"new",
|
|
62346
62519
|
"accepted",
|
|
62347
62520
|
"pending_new",
|
|
62348
62521
|
"accepted_for_bidding",
|
|
62522
|
+
"held",
|
|
62349
62523
|
"partially_filled",
|
|
62350
62524
|
];
|
|
62351
62525
|
/**
|
|
@@ -62353,13 +62527,15 @@ const OPEN_ORDER_STATUSES = [
|
|
|
62353
62527
|
*/
|
|
62354
62528
|
const FILLED_ORDER_STATUSES = ["filled"];
|
|
62355
62529
|
/**
|
|
62356
|
-
* Order statuses that can still potentially be filled
|
|
62530
|
+
* Order statuses that can still potentially be filled. A `held` leg fills once
|
|
62531
|
+
* its parent fills or its trigger is met.
|
|
62357
62532
|
*/
|
|
62358
62533
|
const FILLABLE_ORDER_STATUSES = [
|
|
62359
62534
|
"new",
|
|
62360
62535
|
"accepted",
|
|
62361
62536
|
"pending_new",
|
|
62362
62537
|
"accepted_for_bidding",
|
|
62538
|
+
"held",
|
|
62363
62539
|
"partially_filled",
|
|
62364
62540
|
];
|
|
62365
62541
|
/**
|
|
@@ -72167,11 +72343,35 @@ class CircuitBreakerRegistry {
|
|
|
72167
72343
|
* Register that an attempt is starting, so half-open probes stay bounded.
|
|
72168
72344
|
*
|
|
72169
72345
|
* @param routeKey The route's stable key.
|
|
72170
|
-
* @returns
|
|
72346
|
+
* @returns Whether the attempt took a half-open probe slot. A caller holding
|
|
72347
|
+
* one must end the attempt with {@link onSuccess}, {@link onFailure} or
|
|
72348
|
+
* {@link onAttemptAbandoned}, or the slot is never returned.
|
|
72171
72349
|
*/
|
|
72172
72350
|
onAttemptStart(routeKey) {
|
|
72173
72351
|
if (this.stateOf(routeKey) === "half-open") {
|
|
72174
72352
|
this.recordFor(routeKey).probesInFlight += 1;
|
|
72353
|
+
return true;
|
|
72354
|
+
}
|
|
72355
|
+
return false;
|
|
72356
|
+
}
|
|
72357
|
+
/**
|
|
72358
|
+
* Return a half-open probe slot whose attempt ended without a verdict.
|
|
72359
|
+
*
|
|
72360
|
+
* A probe that never tested the provider — refused by the client's own
|
|
72361
|
+
* pacing guard, cancelled by its caller, or found to be the wrong leg for the
|
|
72362
|
+
* request — says nothing about whether the route has recovered, so neither a
|
|
72363
|
+
* success nor a failure is recorded. The slot must still come back. Without
|
|
72364
|
+
* it the half-open route admits no further probe, no probe can ever close or
|
|
72365
|
+
* re-open the breaker, and the route stays excluded for the life of the
|
|
72366
|
+
* process while its traffic is quietly served by the next leg.
|
|
72367
|
+
*
|
|
72368
|
+
* @param routeKey The route's stable key.
|
|
72369
|
+
* @returns void
|
|
72370
|
+
*/
|
|
72371
|
+
onAttemptAbandoned(routeKey) {
|
|
72372
|
+
const record = this.records.get(routeKey);
|
|
72373
|
+
if (record !== undefined && record.probesInFlight > 0) {
|
|
72374
|
+
record.probesInFlight -= 1;
|
|
72175
72375
|
}
|
|
72176
72376
|
}
|
|
72177
72377
|
/**
|
|
@@ -72442,11 +72642,13 @@ var defaults$1 = {
|
|
|
72442
72642
|
var providers$1 = {
|
|
72443
72643
|
anthropic: {
|
|
72444
72644
|
basis: "conservative-default",
|
|
72645
|
+
scope: "model",
|
|
72646
|
+
scope_source: "https://platform.claude.com/docs/en/api/rate-limits",
|
|
72445
72647
|
requests_per_minute: 120,
|
|
72446
72648
|
max_concurrent: 12,
|
|
72447
72649
|
acquire_timeout_ms: 15000,
|
|
72448
72650
|
source: null,
|
|
72449
|
-
note: "
|
|
72651
|
+
note: "The unit is per model: the scope_source states 'Rate limits are applied separately for each model; therefore you can use different models up to their respective limits simultaneously', measured as requests, input tokens and output tokens per minute for each model class, with no concurrency ceiling. Each model therefore gets its own guard, so traffic on one model class (an Opus incumbent serving background aliases) cannot refuse calls to another (the Haiku incumbent of the hot-path alias). The NUMBERS stay conservative-default: the organisation's usage tier sets the real ceilings and is read only from the Claude Console rate-limits page, which has not been transcribed. The lowest standard tier listed at the source allows 1,000 requests per minute per model; organisations with limited history can start on an evaluation tier below that, which is why these values are held well under it. Transcribe the tier's limits (W3-07) and set basis to published."
|
|
72450
72652
|
},
|
|
72451
72653
|
openai: {
|
|
72452
72654
|
basis: "conservative-default",
|
|
@@ -72465,12 +72667,14 @@ var providers$1 = {
|
|
|
72465
72667
|
note: "Raised 2026-09-15 after the 4-concurrent ceiling was measured starving the live equity decision path: the alias chain reported 'exhausted its fallback chain' with every leg skipped by this guard (deepinfra primary+secondary and the anthropic incumbent), 173 of 473 signal-coordination calls failed (36.6%), and decisions were lost outright. Corroborating evidence at the time: ZERO 429s observed on any provider, the engine's own global fan-out gate permits 100 concurrent with 17 active, and 877 signal-analysis calls had been admitted to that gate. Concurrency and RPM are raised TOGETHER because they bind in series - lifting max_concurrent alone would only move the bottleneck to the token bucket. STILL conservative-default, NOT published: no provider console was read for these numbers, so they remain a deliberate under-estimate of an unknown ceiling. Transcribe the real tier limits (W3-07) and set basis to published."
|
|
72466
72668
|
},
|
|
72467
72669
|
deepinfra: {
|
|
72468
|
-
basis: "
|
|
72670
|
+
basis: "published",
|
|
72671
|
+
scope: "model",
|
|
72469
72672
|
requests_per_minute: 240,
|
|
72470
|
-
|
|
72673
|
+
requests_per_minute_basis: "conservative-default",
|
|
72674
|
+
max_concurrent: 200,
|
|
72471
72675
|
acquire_timeout_ms: 15000,
|
|
72472
|
-
source:
|
|
72473
|
-
note: "
|
|
72676
|
+
source: "https://docs.deepinfra.com/account/rate-limits",
|
|
72677
|
+
note: "Transcribed 2026-09-23 from the source, which states 'Every account has a default limit of 200 concurrent requests per model', that two models queried simultaneously allow 400 in total (200 per model), and that 'The rate limit is on concurrent requests, not per-minute volume.' Concurrency is therefore the bound DeepInfra enforces, and it is enforced per MODEL, so each model gets its own guard at 200. One guard shared by the whole account enforced a ceiling DeepInfra does not impose, and because an alias's primary and secondary are both served from this account, it refused the secondary exactly when the primary's queue was full. DeepInfra publishes no per-minute ceiling, so requests_per_minute is the client's own pacing backstop (requests_per_minute_basis: conservative-default), keyed per model like the bound DeepInfra does enforce. Exceeding the ceiling returns HTTP 429, and a very busy model can return 429 below it. The ceiling belongs to the account and this guard to one process, so every process calling the same model through the same account shares the 200."
|
|
72474
72678
|
},
|
|
72475
72679
|
fireworks: {
|
|
72476
72680
|
basis: "conservative-default",
|
|
@@ -72520,7 +72724,10 @@ var limitsConfig = {
|
|
|
72520
72724
|
* provider's circuit breaker, fail over to a more expensive leg, and keep doing
|
|
72521
72725
|
* so — converting a self-inflicted pacing problem into a permanent routing
|
|
72522
72726
|
* change nobody chose. Pacing at the client is what keeps the breaker measuring
|
|
72523
|
-
* the provider rather than measuring us.
|
|
72727
|
+
* the provider rather than measuring us. The same reasoning bounds the guard
|
|
72728
|
+
* from the other side: a client held far BELOW the provider's ceiling refuses
|
|
72729
|
+
* calls the provider would have served, and the chain answers those refusals by
|
|
72730
|
+
* failing over — the same unchosen routing change, arrived at by under-driving.
|
|
72524
72731
|
*
|
|
72525
72732
|
* Two distinct bounds are applied because they fail differently. The rate bound
|
|
72526
72733
|
* (requests per minute) protects the provider's published ceiling. The
|
|
@@ -72529,8 +72736,18 @@ var limitsConfig = {
|
|
|
72529
72736
|
* every one of them blows its latency budget and the fan-out produces a hundred
|
|
72530
72737
|
* timeouts instead of a queue.
|
|
72531
72738
|
*
|
|
72532
|
-
*
|
|
72533
|
-
*
|
|
72739
|
+
* Each guard is keyed by the unit its provider enforces limits in. A provider
|
|
72740
|
+
* that publishes its ceilings per model gets one independent guard per model.
|
|
72741
|
+
* Sharing one guard across its models would enforce a ceiling the provider does
|
|
72742
|
+
* not impose, and — when a chain's primary and secondary are served by the same
|
|
72743
|
+
* provider — would refuse the secondary at exactly the moment the primary's
|
|
72744
|
+
* queue is full, so the fallback that exists for that moment is never reached.
|
|
72745
|
+
*
|
|
72746
|
+
* Limits live in `provider-limits.json` rather than in code, each beside the
|
|
72747
|
+
* source it was transcribed from, so a published ceiling and a conservative
|
|
72748
|
+
* guess can never be mistaken for one another in review. The file is bundled
|
|
72749
|
+
* at build time: changing a limit is a release of this package, not a runtime
|
|
72750
|
+
* switch.
|
|
72534
72751
|
*
|
|
72535
72752
|
* @module llm/rate-guard
|
|
72536
72753
|
*/
|
|
@@ -72570,64 +72787,141 @@ class RateGuardTimeoutError extends Error {
|
|
|
72570
72787
|
provider;
|
|
72571
72788
|
/** Which of the two bounds the caller waited on. */
|
|
72572
72789
|
bound;
|
|
72790
|
+
/** The model whose guard refused the call, when the provider's limits apply per model. */
|
|
72791
|
+
modelId;
|
|
72792
|
+
/** Whether the caller stopped waiting before the guard's own wait budget ran out. */
|
|
72793
|
+
abandoned;
|
|
72573
72794
|
/**
|
|
72574
72795
|
* @param provider The provider.
|
|
72575
72796
|
* @param bound Which bound was binding.
|
|
72576
|
-
* @param waitedMs How long the caller
|
|
72797
|
+
* @param waitedMs How long the caller was prepared to wait.
|
|
72798
|
+
* @param detail The model, and whether the caller left before the budget ran out.
|
|
72577
72799
|
*/
|
|
72578
|
-
constructor(provider, bound, waitedMs) {
|
|
72579
|
-
|
|
72800
|
+
constructor(provider, bound, waitedMs, detail = {}) {
|
|
72801
|
+
const guard = detail.modelId === undefined
|
|
72802
|
+
? `client-side ${bound} guard for provider "${provider}"`
|
|
72803
|
+
: `client-side ${bound} guard for provider "${provider}", model "${detail.modelId}",`;
|
|
72804
|
+
const outcome = detail.abandoned === true
|
|
72805
|
+
? `was left by its caller before it could admit the call (wait budget ${waitedMs} ms)`
|
|
72806
|
+
: `did not admit the call within ${waitedMs} ms`;
|
|
72807
|
+
super(`${guard} ${outcome}. ` +
|
|
72580
72808
|
"The provider was never contacted, so this says nothing about its health.");
|
|
72581
72809
|
this.name = "RateGuardTimeoutError";
|
|
72582
72810
|
this.provider = provider;
|
|
72583
72811
|
this.bound = bound;
|
|
72812
|
+
this.modelId = detail.modelId;
|
|
72813
|
+
this.abandoned = detail.abandoned === true;
|
|
72584
72814
|
}
|
|
72585
72815
|
}
|
|
72816
|
+
/**
|
|
72817
|
+
* The guard a call is held by.
|
|
72818
|
+
*
|
|
72819
|
+
* A call to a per-model provider that names no model shares one provider-wide
|
|
72820
|
+
* guard held at the per-model ceiling. That is never looser than the limit of
|
|
72821
|
+
* any single model it might reach, so the fallback errs toward pacing.
|
|
72822
|
+
*
|
|
72823
|
+
* @param provider The provider key.
|
|
72824
|
+
* @param modelId The model the call is addressed to, if known.
|
|
72825
|
+
* @returns The guard's identity.
|
|
72826
|
+
*/
|
|
72827
|
+
function guardIdentity(provider, modelId) {
|
|
72828
|
+
const perModel = limitsFor(provider).scope === "model" && modelId !== undefined && modelId.length > 0;
|
|
72829
|
+
return perModel
|
|
72830
|
+
? { key: `${provider}/${modelId}`, provider, modelId }
|
|
72831
|
+
: { key: provider, provider, modelId: undefined };
|
|
72832
|
+
}
|
|
72586
72833
|
/**
|
|
72587
72834
|
* A counting semaphore bounding simultaneous in-flight calls.
|
|
72588
72835
|
*
|
|
72589
|
-
* Written here rather than pulled from a dependency because
|
|
72590
|
-
*
|
|
72591
|
-
*
|
|
72592
|
-
*
|
|
72836
|
+
* Written here rather than pulled from a dependency because the waiting
|
|
72837
|
+
* behaviour is the point. A waiter that times out must be removed from the
|
|
72838
|
+
* queue, or a burst of abandoned callers permanently consumes the permits that
|
|
72839
|
+
* later callers need. And a waiter whose caller has stopped waiting must leave
|
|
72840
|
+
* at once: left queued, it holds its caller until the wait budget expires and
|
|
72841
|
+
* is then handed a permit it can only waste.
|
|
72593
72842
|
*/
|
|
72594
72843
|
class ConcurrencyGate {
|
|
72595
72844
|
inFlight = 0;
|
|
72596
72845
|
waiters = [];
|
|
72597
72846
|
limit;
|
|
72598
|
-
|
|
72847
|
+
identity;
|
|
72599
72848
|
/**
|
|
72600
|
-
* @param
|
|
72849
|
+
* @param identity The guard this gate implements.
|
|
72601
72850
|
* @param limit Maximum simultaneous in-flight calls.
|
|
72602
72851
|
*/
|
|
72603
|
-
constructor(
|
|
72604
|
-
this.
|
|
72852
|
+
constructor(identity, limit) {
|
|
72853
|
+
this.identity = identity;
|
|
72605
72854
|
this.limit = limit;
|
|
72606
72855
|
}
|
|
72607
72856
|
/**
|
|
72608
72857
|
* Wait for a permit.
|
|
72609
72858
|
*
|
|
72610
72859
|
* @param timeoutMs How long the caller is willing to queue.
|
|
72860
|
+
* @param signal The caller's cancellation; firing it takes the caller out of the queue.
|
|
72611
72861
|
* @returns A release function the caller must invoke exactly once.
|
|
72862
|
+
* @throws {RateGuardTimeoutError} When no permit was granted in time, or the caller stopped waiting.
|
|
72612
72863
|
*/
|
|
72613
|
-
async acquire(timeoutMs) {
|
|
72864
|
+
async acquire(timeoutMs, signal) {
|
|
72865
|
+
if (signal?.aborted === true) {
|
|
72866
|
+
// Nobody is waiting for this answer. Taking a permit for it would spend
|
|
72867
|
+
// capacity a live caller needs on a call that can only be torn down.
|
|
72868
|
+
throw this.refusal(timeoutMs, true);
|
|
72869
|
+
}
|
|
72614
72870
|
if (this.inFlight < this.limit) {
|
|
72615
72871
|
this.inFlight += 1;
|
|
72616
72872
|
return () => this.release();
|
|
72617
72873
|
}
|
|
72618
72874
|
await new Promise((resolve, reject) => {
|
|
72619
|
-
|
|
72620
|
-
|
|
72621
|
-
|
|
72622
|
-
|
|
72875
|
+
/**
|
|
72876
|
+
* Take this waiter out of the queue and refuse it. A waiter that `release`
|
|
72877
|
+
* has already admitted is no longer queued; it now holds a permit, which
|
|
72878
|
+
* its call returns, so there is nothing to undo here.
|
|
72879
|
+
*
|
|
72880
|
+
* @param abandoned Whether the caller left before the wait budget ran out.
|
|
72881
|
+
* @returns void
|
|
72882
|
+
*/
|
|
72883
|
+
const leave = (abandoned) => {
|
|
72884
|
+
const index = this.waiters.indexOf(waiter);
|
|
72885
|
+
if (index === -1) {
|
|
72886
|
+
return;
|
|
72623
72887
|
}
|
|
72624
|
-
|
|
72888
|
+
this.waiters.splice(index, 1);
|
|
72889
|
+
clearTimeout(timer);
|
|
72890
|
+
signal?.removeEventListener("abort", onAbort);
|
|
72891
|
+
reject(this.refusal(timeoutMs, abandoned));
|
|
72892
|
+
};
|
|
72893
|
+
const onAbort = () => {
|
|
72894
|
+
leave(true);
|
|
72895
|
+
};
|
|
72896
|
+
const timer = setTimeout(() => {
|
|
72897
|
+
leave(false);
|
|
72625
72898
|
}, timeoutMs);
|
|
72626
|
-
|
|
72899
|
+
const waiter = {
|
|
72900
|
+
admit: () => {
|
|
72901
|
+
clearTimeout(timer);
|
|
72902
|
+
signal?.removeEventListener("abort", onAbort);
|
|
72903
|
+
resolve();
|
|
72904
|
+
},
|
|
72905
|
+
};
|
|
72906
|
+
this.waiters.push(waiter);
|
|
72907
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
72627
72908
|
});
|
|
72628
72909
|
this.inFlight += 1;
|
|
72629
72910
|
return () => this.release();
|
|
72630
72911
|
}
|
|
72912
|
+
/**
|
|
72913
|
+
* Build the refusal for a caller this gate did not admit.
|
|
72914
|
+
*
|
|
72915
|
+
* @param timeoutMs The wait budget the caller had.
|
|
72916
|
+
* @param abandoned Whether the caller left before the budget ran out.
|
|
72917
|
+
* @returns The error to raise.
|
|
72918
|
+
*/
|
|
72919
|
+
refusal(timeoutMs, abandoned) {
|
|
72920
|
+
return new RateGuardTimeoutError(this.identity.provider, "concurrency", timeoutMs, {
|
|
72921
|
+
modelId: this.identity.modelId,
|
|
72922
|
+
abandoned,
|
|
72923
|
+
});
|
|
72924
|
+
}
|
|
72631
72925
|
/**
|
|
72632
72926
|
* Return a permit and admit the next waiter.
|
|
72633
72927
|
*
|
|
@@ -72637,8 +72931,7 @@ class ConcurrencyGate {
|
|
|
72637
72931
|
this.inFlight -= 1;
|
|
72638
72932
|
const next = this.waiters.shift();
|
|
72639
72933
|
if (next !== undefined) {
|
|
72640
|
-
|
|
72641
|
-
next.resolve();
|
|
72934
|
+
next.admit();
|
|
72642
72935
|
}
|
|
72643
72936
|
}
|
|
72644
72937
|
/**
|
|
@@ -72654,44 +72947,47 @@ class ConcurrencyGate {
|
|
|
72654
72947
|
return this.waiters.length;
|
|
72655
72948
|
}
|
|
72656
72949
|
}
|
|
72657
|
-
/**
|
|
72950
|
+
/** Guards, created on first use and shared process-wide, keyed by {@link GuardIdentity.key}. */
|
|
72658
72951
|
const rateLimiters = new Map();
|
|
72659
72952
|
const concurrencyGates = new Map();
|
|
72953
|
+
const guardIdentities = new Map();
|
|
72660
72954
|
/**
|
|
72661
|
-
* The rate limiter for a
|
|
72955
|
+
* The rate limiter for a guard.
|
|
72662
72956
|
*
|
|
72663
72957
|
* Shared process-wide rather than per-call-site, because the provider's ceiling
|
|
72664
72958
|
* applies to the process as a whole. Per-call-site limiters would each stay
|
|
72665
72959
|
* under the ceiling while their sum sailed past it.
|
|
72666
72960
|
*
|
|
72667
|
-
* @param
|
|
72961
|
+
* @param identity The guard.
|
|
72668
72962
|
* @returns Its limiter.
|
|
72669
72963
|
*/
|
|
72670
|
-
function rateLimiterFor(
|
|
72671
|
-
let limiter = rateLimiters.get(
|
|
72964
|
+
function rateLimiterFor(identity) {
|
|
72965
|
+
let limiter = rateLimiters.get(identity.key);
|
|
72672
72966
|
if (limiter === undefined) {
|
|
72673
|
-
const limits = limitsFor(provider);
|
|
72967
|
+
const limits = limitsFor(identity.provider);
|
|
72674
72968
|
limiter = new TokenBucketRateLimiter({
|
|
72675
72969
|
maxTokens: limits.requests_per_minute,
|
|
72676
72970
|
refillRate: limits.requests_per_minute / SECONDS_PER_MINUTE,
|
|
72677
|
-
label: `llm:${
|
|
72971
|
+
label: `llm:${identity.key}`,
|
|
72678
72972
|
timeoutMs: limits.acquire_timeout_ms,
|
|
72679
72973
|
});
|
|
72680
|
-
rateLimiters.set(
|
|
72974
|
+
rateLimiters.set(identity.key, limiter);
|
|
72975
|
+
guardIdentities.set(identity.key, identity);
|
|
72681
72976
|
}
|
|
72682
72977
|
return limiter;
|
|
72683
72978
|
}
|
|
72684
72979
|
/**
|
|
72685
|
-
* The concurrency gate for a
|
|
72980
|
+
* The concurrency gate for a guard.
|
|
72686
72981
|
*
|
|
72687
|
-
* @param
|
|
72982
|
+
* @param identity The guard.
|
|
72688
72983
|
* @returns Its gate.
|
|
72689
72984
|
*/
|
|
72690
|
-
function concurrencyGateFor(
|
|
72691
|
-
let gate = concurrencyGates.get(
|
|
72985
|
+
function concurrencyGateFor(identity) {
|
|
72986
|
+
let gate = concurrencyGates.get(identity.key);
|
|
72692
72987
|
if (gate === undefined) {
|
|
72693
|
-
gate = new ConcurrencyGate(
|
|
72694
|
-
concurrencyGates.set(
|
|
72988
|
+
gate = new ConcurrencyGate(identity, limitsFor(identity.provider).max_concurrent);
|
|
72989
|
+
concurrencyGates.set(identity.key, gate);
|
|
72990
|
+
guardIdentities.set(identity.key, identity);
|
|
72695
72991
|
}
|
|
72696
72992
|
return gate;
|
|
72697
72993
|
}
|
|
@@ -72713,21 +73009,26 @@ function concurrencyGateFor(provider) {
|
|
|
72713
73009
|
* @param provider The provider key.
|
|
72714
73010
|
* @param call The work to run once admitted.
|
|
72715
73011
|
* @param maxWaitMs Ceiling on queue time; the configured guard timeout applies when lower.
|
|
73012
|
+
* @param scope The model the call addresses, and the caller's cancellation.
|
|
72716
73013
|
* @returns The call's result.
|
|
72717
|
-
* @throws {RateGuardTimeoutError} When neither bound admitted the call in time
|
|
73014
|
+
* @throws {RateGuardTimeoutError} When neither bound admitted the call in time,
|
|
73015
|
+
* or the caller stopped waiting first.
|
|
72718
73016
|
*/
|
|
72719
|
-
async function withProviderGuards(provider, call, maxWaitMs) {
|
|
73017
|
+
async function withProviderGuards(provider, call, maxWaitMs, scope = {}) {
|
|
72720
73018
|
const limits = limitsFor(provider);
|
|
73019
|
+
const identity = guardIdentity(provider, scope.modelId);
|
|
72721
73020
|
const waitBudgetMs = maxWaitMs === undefined
|
|
72722
73021
|
? limits.acquire_timeout_ms
|
|
72723
73022
|
: Math.min(maxWaitMs, limits.acquire_timeout_ms);
|
|
72724
73023
|
try {
|
|
72725
|
-
await rateLimiterFor(
|
|
73024
|
+
await rateLimiterFor(identity).acquire();
|
|
72726
73025
|
}
|
|
72727
73026
|
catch {
|
|
72728
|
-
throw new RateGuardTimeoutError(provider, "rate", waitBudgetMs
|
|
73027
|
+
throw new RateGuardTimeoutError(provider, "rate", waitBudgetMs, {
|
|
73028
|
+
modelId: identity.modelId,
|
|
73029
|
+
});
|
|
72729
73030
|
}
|
|
72730
|
-
const release = await concurrencyGateFor(
|
|
73031
|
+
const release = await concurrencyGateFor(identity).acquire(waitBudgetMs, scope.signal);
|
|
72731
73032
|
try {
|
|
72732
73033
|
return await call();
|
|
72733
73034
|
}
|
|
@@ -72741,16 +73042,20 @@ async function withProviderGuards(provider, call, maxWaitMs) {
|
|
|
72741
73042
|
/**
|
|
72742
73043
|
* Inspect the guards currently in use.
|
|
72743
73044
|
*
|
|
72744
|
-
* @returns A snapshot per
|
|
73045
|
+
* @returns A snapshot per guard that has been used, sorted by guard key.
|
|
72745
73046
|
*/
|
|
72746
73047
|
function guardSnapshots() {
|
|
72747
|
-
|
|
72748
|
-
|
|
72749
|
-
|
|
72750
|
-
const
|
|
72751
|
-
const
|
|
73048
|
+
return [...guardIdentities.values()]
|
|
73049
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
73050
|
+
.map((identity) => {
|
|
73051
|
+
const limits = limitsFor(identity.provider);
|
|
73052
|
+
const limiter = rateLimiters.get(identity.key);
|
|
73053
|
+
const gate = concurrencyGates.get(identity.key);
|
|
72752
73054
|
return {
|
|
72753
|
-
|
|
73055
|
+
key: identity.key,
|
|
73056
|
+
provider: identity.provider,
|
|
73057
|
+
modelId: identity.modelId,
|
|
73058
|
+
scope: limits.scope ?? "provider",
|
|
72754
73059
|
basis: limits.basis,
|
|
72755
73060
|
requestsPerMinute: limits.requests_per_minute,
|
|
72756
73061
|
maxConcurrent: limits.max_concurrent,
|
|
@@ -72775,6 +73080,105 @@ function resetProviderGuards() {
|
|
|
72775
73080
|
}
|
|
72776
73081
|
rateLimiters.clear();
|
|
72777
73082
|
concurrencyGates.clear();
|
|
73083
|
+
guardIdentities.clear();
|
|
73084
|
+
}
|
|
73085
|
+
|
|
73086
|
+
/**
|
|
73087
|
+
* Interpretation of a model's answer to a structured (JSON) request.
|
|
73088
|
+
*
|
|
73089
|
+
* A JSON request is a promise about the answer's SHAPE, and providers keep it
|
|
73090
|
+
* in different ways. An OpenAI-compatible host given `json_object` constrains
|
|
73091
|
+
* its decoder, so its answer is bare JSON. A provider with no schema-less JSON
|
|
73092
|
+
* mode — Anthropic, reached through the gateway, supports structured output
|
|
73093
|
+
* only against a caller-supplied schema — receives nothing but the prompt's
|
|
73094
|
+
* instructions for a `json` request, and a model following them commonly
|
|
73095
|
+
* returns the object inside one markdown code fence. The fence is presentation,
|
|
73096
|
+
* not content: the object inside it is the answer the model gave.
|
|
73097
|
+
*
|
|
73098
|
+
* So exactly ONE enclosing fence is removed before parsing, and nothing else is
|
|
73099
|
+
* forgiven. Prose before or after the fence, two fenced blocks, a fence that
|
|
73100
|
+
* never closes (a truncated answer), and a fence declaring another language all
|
|
73101
|
+
* still fail. Each of those is an answer whose meaning a parser would have to
|
|
73102
|
+
* guess, and a guessed object is a decision made on data no model produced.
|
|
73103
|
+
*
|
|
73104
|
+
* Content that is not fenced is parsed exactly as it always was: JSON cannot
|
|
73105
|
+
* begin with a backtick, so every answer that parsed before this unwrapping
|
|
73106
|
+
* existed takes the same path and yields the same value.
|
|
73107
|
+
*
|
|
73108
|
+
* @module llm/structured-content
|
|
73109
|
+
*/
|
|
73110
|
+
/**
|
|
73111
|
+
* One markdown fence enclosing the whole answer: an opening line of three
|
|
73112
|
+
* backticks, optionally labelled `json`, then the body, then three closing
|
|
73113
|
+
* backticks, with nothing but whitespace outside them. The body is anchored at
|
|
73114
|
+
* both ends, so an answer holding two fenced blocks captures the text between
|
|
73115
|
+
* them and fails to parse instead of yielding either block.
|
|
73116
|
+
*/
|
|
73117
|
+
const SINGLE_ENCLOSING_JSON_FENCE = /^\s*```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n?[ \t]*```\s*$/i;
|
|
73118
|
+
/**
|
|
73119
|
+
* Thrown when a provider answered a structured request with content that does
|
|
73120
|
+
* not parse.
|
|
73121
|
+
*
|
|
73122
|
+
* Carries the usage the provider billed for that answer. The tokens were spent
|
|
73123
|
+
* whether or not the content parsed, and a chain that dropped them would report
|
|
73124
|
+
* a failed attempt as free — understating spend by exactly the calls that went
|
|
73125
|
+
* wrong.
|
|
73126
|
+
*/
|
|
73127
|
+
class LlmResponseFormatError extends Error {
|
|
73128
|
+
/** The format the caller asked for. */
|
|
73129
|
+
responseFormat;
|
|
73130
|
+
/** What the provider billed for the answer that did not parse. */
|
|
73131
|
+
usage;
|
|
73132
|
+
/** Whether the answer sat inside one enclosing fence that was removed before parsing. */
|
|
73133
|
+
fenced;
|
|
73134
|
+
/**
|
|
73135
|
+
* @param responseFormat The format the caller asked for.
|
|
73136
|
+
* @param usage What the provider billed for the answer.
|
|
73137
|
+
* @param fenced Whether one enclosing fence was removed before parsing.
|
|
73138
|
+
* @param cause The parser's own complaint.
|
|
73139
|
+
*/
|
|
73140
|
+
constructor(responseFormat, usage, fenced, cause) {
|
|
73141
|
+
super(`LLM returned content that is not valid JSON for a ${responseFormat} request` +
|
|
73142
|
+
(fenced ? " (inside one enclosing markdown fence)" : "") +
|
|
73143
|
+
`: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
73144
|
+
this.name = "LlmResponseFormatError";
|
|
73145
|
+
this.responseFormat = responseFormat;
|
|
73146
|
+
this.usage = usage;
|
|
73147
|
+
this.fenced = fenced;
|
|
73148
|
+
}
|
|
73149
|
+
}
|
|
73150
|
+
/**
|
|
73151
|
+
* The body of the one markdown fence that encloses an answer, if exactly one does.
|
|
73152
|
+
*
|
|
73153
|
+
* @param text The model's answer.
|
|
73154
|
+
* @returns The fenced body, or null when the answer is not wholly one fenced block.
|
|
73155
|
+
*/
|
|
73156
|
+
function unwrapSingleJsonFence(text) {
|
|
73157
|
+
const match = SINGLE_ENCLOSING_JSON_FENCE.exec(text);
|
|
73158
|
+
return match === null ? null : match[1];
|
|
73159
|
+
}
|
|
73160
|
+
/**
|
|
73161
|
+
* Parse a model's answer to a structured request.
|
|
73162
|
+
*
|
|
73163
|
+
* A JSON format that does not parse is an error, not an empty object. Returning
|
|
73164
|
+
* a default here would hand the caller a well-typed value that means nothing,
|
|
73165
|
+
* and the failure would surface much later as a decision made on absent data.
|
|
73166
|
+
*
|
|
73167
|
+
* @param content The raw content of the model's message.
|
|
73168
|
+
* @param responseFormat The structured format the caller asked for.
|
|
73169
|
+
* @param usage What the provider billed for this answer, carried on failure.
|
|
73170
|
+
* @returns The parsed value.
|
|
73171
|
+
* @throws {LlmResponseFormatError} When the content is not JSON, fenced or not.
|
|
73172
|
+
*/
|
|
73173
|
+
function parseStructuredContent(content, responseFormat, usage) {
|
|
73174
|
+
const text = typeof content === "string" ? content : "";
|
|
73175
|
+
const fencedBody = unwrapSingleJsonFence(text);
|
|
73176
|
+
try {
|
|
73177
|
+
return JSON.parse(fencedBody ?? text);
|
|
73178
|
+
}
|
|
73179
|
+
catch (error) {
|
|
73180
|
+
throw new LlmResponseFormatError(typeof responseFormat === "string" ? responseFormat : "json_schema", usage, fencedBody !== null, error);
|
|
73181
|
+
}
|
|
72778
73182
|
}
|
|
72779
73183
|
|
|
72780
73184
|
/**
|
|
@@ -72911,7 +73315,9 @@ async function runLeg(leg, params, execution) {
|
|
|
72911
73315
|
// The guards wrap the transport rather than the whole leg, so the per-leg
|
|
72912
73316
|
// timeout above still bounds the total wait: a caller queued behind the
|
|
72913
73317
|
// rate limiter is spending its budget just as surely as one waiting on the
|
|
72914
|
-
// provider, and only one clock should govern both.
|
|
73318
|
+
// provider, and only one clock should govern both. The leg's own signal is
|
|
73319
|
+
// handed to the guard as well, so a leg whose budget or caller is gone
|
|
73320
|
+
// leaves the queue at once instead of holding its place in it.
|
|
72915
73321
|
return await withProviderGuards(leg.route.providerName, () => leg.transport.execute({
|
|
72916
73322
|
route: leg.route,
|
|
72917
73323
|
content: execution.content,
|
|
@@ -72921,7 +73327,7 @@ async function runLeg(leg, params, execution) {
|
|
|
72921
73327
|
context: execution.context,
|
|
72922
73328
|
signal: controller.signal,
|
|
72923
73329
|
correlationId: execution.correlationId,
|
|
72924
|
-
}), budgetMs);
|
|
73330
|
+
}), budgetMs, { modelId: leg.route.modelId, signal: controller.signal });
|
|
72925
73331
|
}
|
|
72926
73332
|
finally {
|
|
72927
73333
|
clearTimeout(timer);
|
|
@@ -73033,7 +73439,7 @@ async function executeChain(alias, execution) {
|
|
|
73033
73439
|
continue;
|
|
73034
73440
|
}
|
|
73035
73441
|
const startedAt = now();
|
|
73036
|
-
execution.breakers.onAttemptStart(route.routeKey);
|
|
73442
|
+
const holdsProbe = execution.breakers.onAttemptStart(route.routeKey);
|
|
73037
73443
|
try {
|
|
73038
73444
|
const response = await runLeg(leg, leg.params, execution);
|
|
73039
73445
|
execution.breakers.onSuccess(route.routeKey);
|
|
@@ -73056,6 +73462,15 @@ async function executeChain(alias, execution) {
|
|
|
73056
73462
|
if (countsAgainstHealth) {
|
|
73057
73463
|
execution.breakers.onFailure(route.routeKey);
|
|
73058
73464
|
}
|
|
73465
|
+
else if (holdsProbe) {
|
|
73466
|
+
// No verdict on the route's health, but the probe slot this attempt
|
|
73467
|
+
// took must come back, or a half-open route admits no probe ever again.
|
|
73468
|
+
execution.breakers.onAttemptAbandoned(route.routeKey);
|
|
73469
|
+
}
|
|
73470
|
+
// A provider that answered with unparseable content still billed for the
|
|
73471
|
+
// answer; the spend belongs in the total whether or not a later leg serves.
|
|
73472
|
+
const billed = error instanceof LlmResponseFormatError ? error.usage : undefined;
|
|
73473
|
+
totalUsage = sumUsage(totalUsage, billed);
|
|
73059
73474
|
const record = {
|
|
73060
73475
|
routeKey: route.routeKey,
|
|
73061
73476
|
role: route.role,
|
|
@@ -73064,6 +73479,7 @@ async function executeChain(alias, execution) {
|
|
|
73064
73479
|
outcome,
|
|
73065
73480
|
durationMs: now() - startedAt,
|
|
73066
73481
|
reason,
|
|
73482
|
+
...(billed === undefined ? {} : { usage: billed }),
|
|
73067
73483
|
};
|
|
73068
73484
|
attempts.push(record);
|
|
73069
73485
|
execution.onAttempt?.(record);
|
|
@@ -74357,9 +74773,13 @@ function createGatewayTransport(config) {
|
|
|
74357
74773
|
const payload = (await response.json());
|
|
74358
74774
|
const choices = payload.choices;
|
|
74359
74775
|
const message = choices?.[0]?.message;
|
|
74776
|
+
// Usage is read before the content is interpreted. The provider billed for
|
|
74777
|
+
// this answer whether or not it parses, and a parse failure that dropped
|
|
74778
|
+
// the count would report the attempt as free.
|
|
74779
|
+
const usage = readUsage(payload, request);
|
|
74360
74780
|
return {
|
|
74361
|
-
response:
|
|
74362
|
-
usage
|
|
74781
|
+
response: interpretContent(message?.content, request.responseFormat, usage),
|
|
74782
|
+
usage,
|
|
74363
74783
|
tool_calls: Array.isArray(message?.tool_calls)
|
|
74364
74784
|
? message.tool_calls
|
|
74365
74785
|
: undefined,
|
|
@@ -74394,25 +74814,22 @@ function buildMessages(request) {
|
|
|
74394
74814
|
/**
|
|
74395
74815
|
* Interpret the model's content according to the requested format.
|
|
74396
74816
|
*
|
|
74397
|
-
*
|
|
74398
|
-
*
|
|
74399
|
-
*
|
|
74817
|
+
* Text is returned as sent. A structured format is parsed under the strict
|
|
74818
|
+
* single-fence rule of {@link parseStructuredContent}; a structured answer that
|
|
74819
|
+
* does not parse is an error carrying what the provider billed for it, never an
|
|
74820
|
+
* empty object.
|
|
74400
74821
|
*
|
|
74401
74822
|
* @param content The raw content.
|
|
74402
74823
|
* @param responseFormat The format the caller asked for.
|
|
74403
|
-
* @
|
|
74824
|
+
* @param usage What the provider billed for this answer.
|
|
74825
|
+
* @returns The interpreted value.
|
|
74826
|
+
* @throws {LlmResponseFormatError} When a structured answer does not parse.
|
|
74404
74827
|
*/
|
|
74405
|
-
function
|
|
74406
|
-
const text = typeof content === "string" ? content : "";
|
|
74828
|
+
function interpretContent(content, responseFormat, usage) {
|
|
74407
74829
|
if (responseFormat === "text") {
|
|
74408
|
-
return
|
|
74409
|
-
}
|
|
74410
|
-
try {
|
|
74411
|
-
return JSON.parse(text);
|
|
74412
|
-
}
|
|
74413
|
-
catch (error) {
|
|
74414
|
-
throw new Error(`LLM returned content that is not valid JSON for a ${typeof responseFormat === "string" ? responseFormat : "json_schema"} request: ${error instanceof Error ? error.message : String(error)}`);
|
|
74830
|
+
return (typeof content === "string" ? content : "");
|
|
74415
74831
|
}
|
|
74832
|
+
return parseStructuredContent(content, responseFormat, usage);
|
|
74416
74833
|
}
|
|
74417
74834
|
|
|
74418
74835
|
/**
|
|
@@ -78507,6 +78924,7 @@ const OrderStatusSchema = enumType([
|
|
|
78507
78924
|
"accepted",
|
|
78508
78925
|
"pending_new",
|
|
78509
78926
|
"accepted_for_bidding",
|
|
78927
|
+
"held",
|
|
78510
78928
|
"stopped",
|
|
78511
78929
|
"rejected",
|
|
78512
78930
|
"suspended",
|
|
@@ -80163,6 +80581,7 @@ exports.GatewayUnreachableError = GatewayUnreachableError;
|
|
|
80163
80581
|
exports.HttpClientError = HttpClientError;
|
|
80164
80582
|
exports.HttpServerError = HttpServerError;
|
|
80165
80583
|
exports.KEEP_ALIVE_DEFAULTS = KEEP_ALIVE_DEFAULTS;
|
|
80584
|
+
exports.LlmResponseFormatError = LlmResponseFormatError;
|
|
80166
80585
|
exports.MARKET_DATA_API = MARKET_DATA_API;
|
|
80167
80586
|
exports.MassiveAggregatesResponseSchema = MassiveAggregatesResponseSchema;
|
|
80168
80587
|
exports.MassiveApiError = MassiveApiError;
|
|
@@ -80210,6 +80629,7 @@ exports.alpaca = alpaca;
|
|
|
80210
80629
|
exports.analyzeBars = analyzeBars;
|
|
80211
80630
|
exports.approximateImpliedVolatility = approximateImpliedVolatility;
|
|
80212
80631
|
exports.atr = atrNs;
|
|
80632
|
+
exports.availableStatistic = availableStatistic;
|
|
80213
80633
|
exports.bracketOrders = bracketOrders;
|
|
80214
80634
|
exports.buildOCCSymbol = buildOCCSymbol;
|
|
80215
80635
|
exports.buildOptionSymbol = buildOptionSymbol;
|
|
@@ -80383,6 +80803,7 @@ exports.hasSufficientVolume = hasSufficientVolume;
|
|
|
80383
80803
|
exports.httpAgent = httpAgent;
|
|
80384
80804
|
exports.httpsAgent = httpsAgent;
|
|
80385
80805
|
exports.isAlpacaBrokerCredentials = isAlpacaBrokerCredentials;
|
|
80806
|
+
exports.isAvailable = isAvailable;
|
|
80386
80807
|
exports.isContractTradable = isContractTradable;
|
|
80387
80808
|
exports.isCryptoPair = isCryptoPair;
|
|
80388
80809
|
exports.isExpiringWithin = isExpiringWithin;
|
|
@@ -80429,6 +80850,7 @@ exports.routeKeyFor = routeKeyFor;
|
|
|
80429
80850
|
exports.routeSupports = routeSupports;
|
|
80430
80851
|
exports.routeTable = routeTable;
|
|
80431
80852
|
exports.safeValidateResponse = safeValidateResponse;
|
|
80853
|
+
exports.sampleCohort = sampleCohort;
|
|
80432
80854
|
exports.searchNews = searchNews;
|
|
80433
80855
|
exports.sellAllCrypto = sellAllCrypto;
|
|
80434
80856
|
exports.sellCryptoNotional = sellCryptoNotional;
|
|
@@ -80442,6 +80864,7 @@ exports.strategy = strategyNs;
|
|
|
80442
80864
|
exports.sumUsage = sumUsage;
|
|
80443
80865
|
exports.tradingPolicy = index;
|
|
80444
80866
|
exports.trailingStops = trailingStops;
|
|
80867
|
+
exports.unavailableStatistic = unavailableStatistic;
|
|
80445
80868
|
exports.updateAccountConfiguration = updateAccountConfiguration;
|
|
80446
80869
|
exports.updateTrailingStop = updateTrailingStop;
|
|
80447
80870
|
exports.validateAlpacaCredentials = validateAlpacaCredentials;
|