Use market option labels for probability buckets
This commit is contained in:
@@ -32,7 +32,7 @@ const SUMMARY_TEXT = {
|
|||||||
region: { en: "Region", zh: "区域" },
|
region: { en: "Region", zh: "区域" },
|
||||||
localTime: { en: "Local Time", zh: "当地时间" },
|
localTime: { en: "Local Time", zh: "当地时间" },
|
||||||
gaussianMu: { en: "Gaussian μ", zh: "高斯 μ" },
|
gaussianMu: { en: "Gaussian μ", zh: "高斯 μ" },
|
||||||
detailedProbability: { en: "Detailed Probability", zh: "详细概率分布" },
|
detailedProbability: { en: "Market Option Probability", zh: "市场选项概率" },
|
||||||
noProbabilityDistribution: { en: "No probability distribution", zh: "暂无详细概率" },
|
noProbabilityDistribution: { en: "No probability distribution", zh: "暂无详细概率" },
|
||||||
marketMatch: { en: "Market Match", zh: "市场匹配" },
|
marketMatch: { en: "Market Match", zh: "市场匹配" },
|
||||||
median: { en: "Median", zh: "模型中位数" },
|
median: { en: "Median", zh: "模型中位数" },
|
||||||
|
|||||||
+62
-21
@@ -54,19 +54,26 @@ export function runTests() {
|
|||||||
"AROME HD": 32.1,
|
"AROME HD": 32.1,
|
||||||
},
|
},
|
||||||
distribution_full: [
|
distribution_full: [
|
||||||
{ value: 31, model_probability: 0.16, range: "[30.5~31.5)" },
|
{ value: 32, model_probability: 0.41, range: "[31.5~32.5)" },
|
||||||
{ value: 32, model_probability: 0.42, range: "[31.5~32.5)" },
|
{ value: 33, model_probability: 0.56, range: "[32.5~33.5)" },
|
||||||
{ value: 33, model_probability: 0.31, range: "[32.5~33.5)" },
|
{ value: 34, model_probability: 0.03, range: "[33.5~34.5)" },
|
||||||
],
|
],
|
||||||
probability_engine: "legacy",
|
probability_engine: "legacy",
|
||||||
all_buckets: [
|
all_buckets: [
|
||||||
{
|
{
|
||||||
label: "31.5-32.5°C",
|
label: "32°C",
|
||||||
model_probability: 0.42,
|
lower: 31.5,
|
||||||
|
upper: 32.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "33.5-34.5°C",
|
label: "33°C",
|
||||||
model_probability: 0.08,
|
lower: 32.5,
|
||||||
|
upper: 33.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "34°C",
|
||||||
|
lower: 33.5,
|
||||||
|
upper: 34.5,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -89,6 +96,27 @@ export function runTests() {
|
|||||||
{ value: 36, probability: 0.52 },
|
{ value: 36, probability: 0.52 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
city: "houston",
|
||||||
|
city_display_name: "Houston",
|
||||||
|
trading_region_label: "North America",
|
||||||
|
trading_region_label_zh: "北美",
|
||||||
|
trading_region_sort: 7,
|
||||||
|
temp_symbol: "°F",
|
||||||
|
current_max_so_far: 90,
|
||||||
|
deb_prediction: 94.2,
|
||||||
|
local_time: "09:30",
|
||||||
|
model_cluster_sources: {
|
||||||
|
ECMWF: 94,
|
||||||
|
GFS: 96,
|
||||||
|
},
|
||||||
|
distribution_full: [
|
||||||
|
{ value: 94, probability: 0.4 },
|
||||||
|
{ value: 95, probability: 0.25 },
|
||||||
|
{ value: 96, probability: 0.08 },
|
||||||
|
{ value: 97, probability: 0.02 },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
city: "amsterdam",
|
city: "amsterdam",
|
||||||
city_display_name: "Amsterdam",
|
city_display_name: "Amsterdam",
|
||||||
@@ -111,6 +139,7 @@ export function runTests() {
|
|||||||
const beijingRow = summaryRows.find((row) => row.cityName === "Beijing");
|
const beijingRow = summaryRows.find((row) => row.cityName === "Beijing");
|
||||||
const madridRow = summaryRows.find((row) => row.cityName === "Madrid");
|
const madridRow = summaryRows.find((row) => row.cityName === "Madrid");
|
||||||
const parisRow = summaryRows.find((row) => row.cityName === "Paris");
|
const parisRow = summaryRows.find((row) => row.cityName === "Paris");
|
||||||
|
const houstonRow = summaryRows.find((row) => row.cityName === "Houston");
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("AROME HD") &&
|
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("AROME HD") &&
|
||||||
@@ -118,7 +147,7 @@ export function runTests() {
|
|||||||
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("NAM"),
|
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("NAM"),
|
||||||
"model summary must expose the fixed model columns including optional short-range models",
|
"model summary must expose the fixed model columns including optional short-range models",
|
||||||
);
|
);
|
||||||
assert(summaryRows.length === 4, "model summary should keep one row per city");
|
assert(summaryRows.length === 5, "model summary should keep one row per city");
|
||||||
assert(summaryRows[0].cityName === "Beijing", "model summary should sort by resolved region then city name");
|
assert(summaryRows[0].cityName === "Beijing", "model summary should sort by resolved region then city name");
|
||||||
assert(amsterdamRow?.regionLabel === "欧洲 / 非洲", "model summary should override stale backend timezone regions for known European cities");
|
assert(amsterdamRow?.regionLabel === "欧洲 / 非洲", "model summary should override stale backend timezone regions for known European cities");
|
||||||
if (!madridRow || !parisRow) throw new Error("model summary should keep European rows");
|
if (!madridRow || !parisRow) throw new Error("model summary should keep European rows");
|
||||||
@@ -133,24 +162,32 @@ export function runTests() {
|
|||||||
assert(parisRow.models.HRRR === null, "missing models should be normalized to null");
|
assert(parisRow.models.HRRR === null, "missing models should be normalized to null");
|
||||||
assert(parisRow.modelMedian === 32.1, "model median should use available model values only");
|
assert(parisRow.modelMedian === 32.1, "model median should use available model values only");
|
||||||
assert(parisRow.modelSpread === 2.5, "model spread should use available model min/max only");
|
assert(parisRow.modelSpread === 2.5, "model spread should use available model min/max only");
|
||||||
assert(parisRow.gaussianMu === 32.2, "model summary should compute Gaussian mu from probability buckets");
|
assert(parisRow.gaussianMu === 32.6, "model summary should compute Gaussian mu from probability buckets");
|
||||||
assert(parisRow.probabilityEngine === "legacy", "model summary should preserve probability engine metadata");
|
assert(parisRow.probabilityEngine === "legacy", "model summary should preserve probability engine metadata");
|
||||||
assert(parisRow.probabilityBuckets.length === 3, "model summary should keep every probability bucket");
|
assert(parisRow.probabilityBuckets.length === 3, "model summary should keep every market-option probability bucket");
|
||||||
assert(
|
assert(
|
||||||
parisRow.probabilityBucketMap["31.5-32.5°C"]?.probability === 0.42 &&
|
parisRow.probabilityBucketMap["32°C"]?.probability === 0.41 &&
|
||||||
parisRow.topProbabilityBucketKey === "31.5-32.5°C",
|
parisRow.probabilityBucketMap["33°C"]?.probability === 0.56 &&
|
||||||
"model summary should map probability buckets and identify the top bucket",
|
parisRow.topProbabilityBucketKey === "33°C" &&
|
||||||
|
!parisRow.probabilityBucketMap["31.5-32.5°C"],
|
||||||
|
"model summary should map Celsius probabilities to market option labels instead of half-degree ranges",
|
||||||
);
|
);
|
||||||
assert(parisRow.marketMatches.length === 2, "model summary should keep every Polymarket tradable bucket");
|
|
||||||
assert(
|
assert(
|
||||||
parisRow.marketMatches[0].label === "31.5-32.5°C" &&
|
houstonRow?.probabilityBucketMap["94-95°F"]?.probability === 0.65 &&
|
||||||
parisRow.marketMatches[0].modelProbability === 0.42 &&
|
houstonRow.probabilityBucketMap["96-97°F"]?.probability === 0.1 &&
|
||||||
|
houstonRow.topProbabilityBucketKey === "94-95°F",
|
||||||
|
"model summary should aggregate Fahrenheit probabilities into two-degree market option labels",
|
||||||
|
);
|
||||||
|
assert(parisRow.marketMatches.length === 3, "model summary should keep every Polymarket tradable bucket");
|
||||||
|
assert(
|
||||||
|
parisRow.marketMatches[0].label === "32°C" &&
|
||||||
|
parisRow.marketMatches[0].modelProbability === null &&
|
||||||
parisRow.marketMatches[0].marketUrl === null,
|
parisRow.marketMatches[0].marketUrl === null,
|
||||||
"model summary should expose model probability for market-matched buckets without requiring market price",
|
"model summary should expose model probability for market-matched buckets without requiring market price",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
parisRow.marketMatches[1].label === "33.5-34.5°C" &&
|
parisRow.marketMatches[1].label === "33°C" &&
|
||||||
parisRow.marketMatches[1].modelProbability === 0.08,
|
parisRow.marketMatches[1].modelProbability === null,
|
||||||
"model summary should keep low-probability tradable buckets for manual NO review",
|
"model summary should keep low-probability tradable buckets for manual NO review",
|
||||||
);
|
);
|
||||||
assert(formatModelSummaryProbability(null) === "—", "missing probability should render as an em dash");
|
assert(formatModelSummaryProbability(null) === "—", "missing probability should render as an em dash");
|
||||||
@@ -188,7 +225,9 @@ export function runTests() {
|
|||||||
wideSpreadOnly: true,
|
wideSpreadOnly: true,
|
||||||
});
|
});
|
||||||
assert(
|
assert(
|
||||||
wideSpread.length === 1 && wideSpread[0].cityName === "Paris",
|
wideSpread.length === 2 &&
|
||||||
|
wideSpread.some((row) => row.cityName === "Paris") &&
|
||||||
|
wideSpread.some((row) => row.cityName === "Houston"),
|
||||||
"wide-spread filter should only keep rows with model spread >= 2°C",
|
"wide-spread filter should only keep rows with model spread >= 2°C",
|
||||||
);
|
);
|
||||||
assert(rows[0].model_cluster_sources === originalFirstModelSources, "model summary filters must not mutate source rows");
|
assert(rows[0].model_cluster_sources === originalFirstModelSources, "model summary filters must not mutate source rows");
|
||||||
@@ -221,13 +260,15 @@ export function runTests() {
|
|||||||
modelSummarySource.includes("hasModelSummaryForecastData") &&
|
modelSummarySource.includes("hasModelSummaryForecastData") &&
|
||||||
modelSummarySource.includes("Gaussian μ") &&
|
modelSummarySource.includes("Gaussian μ") &&
|
||||||
modelSummarySource.includes("高斯 μ") &&
|
modelSummarySource.includes("高斯 μ") &&
|
||||||
modelSummarySource.includes("Detailed Probability") &&
|
modelSummarySource.includes("Market Option Probability") &&
|
||||||
modelSummarySource.includes("详细概率分布") &&
|
modelSummarySource.includes("市场选项概率") &&
|
||||||
modelSummarySource.includes("expandedCityKeys") &&
|
modelSummarySource.includes("expandedCityKeys") &&
|
||||||
modelSummarySource.includes("toggleExpandedCity") &&
|
modelSummarySource.includes("toggleExpandedCity") &&
|
||||||
modelSummarySource.includes("aria-expanded") &&
|
modelSummarySource.includes("aria-expanded") &&
|
||||||
modelSummarySource.includes("ChevronRight") &&
|
modelSummarySource.includes("ChevronRight") &&
|
||||||
modelSummarySource.includes("probabilityBuckets.map") &&
|
modelSummarySource.includes("probabilityBuckets.map") &&
|
||||||
|
!modelSummarySource.includes("Detailed Probability") &&
|
||||||
|
!modelSummarySource.includes("详细概率分布") &&
|
||||||
modelSummarySource.includes("Market Match") &&
|
modelSummarySource.includes("Market Match") &&
|
||||||
modelSummarySource.includes("市场匹配") &&
|
modelSummarySource.includes("市场匹配") &&
|
||||||
modelSummarySource.includes("marketMatches.map") &&
|
modelSummarySource.includes("marketMatches.map") &&
|
||||||
|
|||||||
+172
-29
@@ -96,22 +96,6 @@ function probabilityFromBucket(bucket: Record<string, unknown>) {
|
|||||||
return raw > 1 ? raw / 100 : raw;
|
return raw > 1 ? raw / 100 : raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rangeFromBucket(bucket: Record<string, unknown>, value: number) {
|
|
||||||
const rawRange = String(bucket.range || bucket.bucket || bucket.label || "").trim();
|
|
||||||
const rangeMatch = rawRange.match(/(-?\d+(?:\.\d+)?)\s*(?:~|-|to)\s*(-?\d+(?:\.\d+)?)/i);
|
|
||||||
if (rangeMatch) {
|
|
||||||
const lower = Number(rangeMatch[1]);
|
|
||||||
const upper = Number(rangeMatch[2]);
|
|
||||||
if (Number.isFinite(lower) && Number.isFinite(upper) && upper > lower) {
|
|
||||||
return { lower, upper };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
lower: Number((value - 0.5).toFixed(2)),
|
|
||||||
upper: Number((value + 0.5).toFixed(2)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBucketBound(value: number) {
|
function formatBucketBound(value: number) {
|
||||||
return Number(value.toFixed(1)).toString();
|
return Number(value.toFixed(1)).toString();
|
||||||
}
|
}
|
||||||
@@ -124,6 +108,101 @@ function probabilityBucketLabel(lower: number, upper: number, unit: string) {
|
|||||||
return probabilityBucketKey(lower, upper, unit);
|
return probabilityBucketKey(lower, upper, unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFahrenheitUnit(unit: string) {
|
||||||
|
return unit.toUpperCase().includes("F");
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketOptionBucketForValue(value: number, unit: string) {
|
||||||
|
const settledValue = Math.round(value);
|
||||||
|
if (isFahrenheitUnit(unit)) {
|
||||||
|
const lowerValue = settledValue % 2 === 0 ? settledValue : settledValue - 1;
|
||||||
|
const upperValue = lowerValue + 1;
|
||||||
|
return {
|
||||||
|
key: `${lowerValue}-${upperValue}${unit || "°F"}`,
|
||||||
|
label: `${lowerValue}-${upperValue}${unit || "°F"}`,
|
||||||
|
lower: lowerValue - 0.5,
|
||||||
|
upper: upperValue + 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: `${settledValue}${unit || "°C"}`,
|
||||||
|
label: `${settledValue}${unit || "°C"}`,
|
||||||
|
lower: settledValue - 0.5,
|
||||||
|
upper: settledValue + 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundProbability(value: number) {
|
||||||
|
return Math.round(value * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceMarketBuckets(row: ScanOpportunityRow) {
|
||||||
|
const marketRow = row as ScanOpportunityRow & {
|
||||||
|
all_buckets?: Array<Record<string, unknown>> | null;
|
||||||
|
top_buckets?: Array<Record<string, unknown>> | null;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
Array.isArray(marketRow.all_buckets) && marketRow.all_buckets.length
|
||||||
|
? marketRow.all_buckets
|
||||||
|
: Array.isArray(marketRow.top_buckets)
|
||||||
|
? marketRow.top_buckets
|
||||||
|
: []
|
||||||
|
) as Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarketOptionBucket(bucket: Record<string, unknown>, unit: string) {
|
||||||
|
const rawLabel = String(bucket.label || bucket.bucket || bucket.range || "").trim();
|
||||||
|
const labelNumbers = rawLabel.match(/-?\d+(?:\.\d+)?/g)?.map(Number) || [];
|
||||||
|
const lower = finiteNumber(bucket.lower);
|
||||||
|
const upper = finiteNumber(bucket.upper);
|
||||||
|
let lowerValue: number | null = null;
|
||||||
|
let upperValue: number | null = null;
|
||||||
|
|
||||||
|
if (/below/i.test(rawLabel) && labelNumbers.length) {
|
||||||
|
upperValue = Math.round(labelNumbers[0]);
|
||||||
|
} else if (/higher/i.test(rawLabel) && labelNumbers.length) {
|
||||||
|
lowerValue = Math.round(labelNumbers[0]);
|
||||||
|
} else if (labelNumbers.length >= 2) {
|
||||||
|
lowerValue = Math.round(labelNumbers[0]);
|
||||||
|
upperValue = Math.round(labelNumbers[1]);
|
||||||
|
} else if (labelNumbers.length === 1) {
|
||||||
|
lowerValue = Math.round(labelNumbers[0]);
|
||||||
|
upperValue = Math.round(labelNumbers[0]);
|
||||||
|
} else if (lower != null && upper != null && upper > lower) {
|
||||||
|
lowerValue = Math.ceil(lower);
|
||||||
|
upperValue = Math.ceil(upper) - 1;
|
||||||
|
} else {
|
||||||
|
const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature);
|
||||||
|
if (value == null) return null;
|
||||||
|
const option = marketOptionBucketForValue(value, unit);
|
||||||
|
lowerValue = Math.ceil(option.lower);
|
||||||
|
upperValue = Math.ceil(option.upper) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finiteLower = lowerValue ?? Number.NEGATIVE_INFINITY;
|
||||||
|
const finiteUpper = upperValue ?? Number.POSITIVE_INFINITY;
|
||||||
|
if (finiteUpper < finiteLower) return null;
|
||||||
|
|
||||||
|
let label = rawLabel;
|
||||||
|
if (!label || /\.5\b/.test(label)) {
|
||||||
|
const representative =
|
||||||
|
Number.isFinite(finiteLower) && Number.isFinite(finiteUpper)
|
||||||
|
? (finiteLower + finiteUpper) / 2
|
||||||
|
: Number.isFinite(finiteLower)
|
||||||
|
? finiteLower
|
||||||
|
: finiteUpper;
|
||||||
|
label = marketOptionBucketForValue(representative, unit).label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: label,
|
||||||
|
label,
|
||||||
|
lowerValue: finiteLower,
|
||||||
|
upperValue: finiteUpper,
|
||||||
|
sortValue: Number.isFinite(finiteLower) ? finiteLower : finiteUpper,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildProbabilityBuckets(row: ScanOpportunityRow): ModelSummaryProbabilityBucket[] {
|
function buildProbabilityBuckets(row: ScanOpportunityRow): ModelSummaryProbabilityBucket[] {
|
||||||
const rawBuckets = (
|
const rawBuckets = (
|
||||||
Array.isArray(row.distribution_full) && row.distribution_full.length
|
Array.isArray(row.distribution_full) && row.distribution_full.length
|
||||||
@@ -133,24 +212,88 @@ function buildProbabilityBuckets(row: ScanOpportunityRow): ModelSummaryProbabili
|
|||||||
: []
|
: []
|
||||||
) as Array<Record<string, unknown>>;
|
) as Array<Record<string, unknown>>;
|
||||||
const unit = row.temp_symbol || "°C";
|
const unit = row.temp_symbol || "°C";
|
||||||
|
const rawProbabilityPoints = rawBuckets
|
||||||
return rawBuckets
|
|
||||||
.map((bucket) => {
|
.map((bucket) => {
|
||||||
const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature);
|
const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature);
|
||||||
const probability = probabilityFromBucket(bucket);
|
const probability = probabilityFromBucket(bucket);
|
||||||
if (value == null || probability == null || probability <= 0) return null;
|
if (value == null || probability == null || probability <= 0) return null;
|
||||||
const { lower, upper } = rangeFromBucket(bucket, value);
|
return { value, settledValue: Math.round(value), probability };
|
||||||
const key = probabilityBucketKey(lower, upper, unit);
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
label: probabilityBucketLabel(lower, upper, unit),
|
|
||||||
value,
|
|
||||||
lower,
|
|
||||||
upper,
|
|
||||||
probability,
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.filter((bucket): bucket is ModelSummaryProbabilityBucket => bucket !== null)
|
.filter((point): point is { value: number; settledValue: number; probability: number } => point !== null);
|
||||||
|
|
||||||
|
const marketBuckets = sourceMarketBuckets(row)
|
||||||
|
.map((bucket) => parseMarketOptionBucket(bucket, unit))
|
||||||
|
.filter((bucket): bucket is NonNullable<typeof bucket> => bucket !== null);
|
||||||
|
|
||||||
|
if (marketBuckets.length && rawProbabilityPoints.length) {
|
||||||
|
const fromMarketBuckets = marketBuckets
|
||||||
|
.map((bucket) => {
|
||||||
|
const matchingPoints = rawProbabilityPoints.filter(
|
||||||
|
(point) =>
|
||||||
|
point.settledValue >= bucket.lowerValue &&
|
||||||
|
point.settledValue <= bucket.upperValue,
|
||||||
|
);
|
||||||
|
const probability = matchingPoints.reduce((sum, point) => sum + point.probability, 0);
|
||||||
|
if (probability <= 0) return null;
|
||||||
|
const weightedValue = matchingPoints.reduce(
|
||||||
|
(sum, point) => sum + point.value * point.probability,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
key: bucket.key,
|
||||||
|
label: bucket.label,
|
||||||
|
value: roundToOneDecimal(weightedValue / probability),
|
||||||
|
lower: bucket.sortValue,
|
||||||
|
upper: bucket.sortValue,
|
||||||
|
probability: roundProbability(probability),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((bucket): bucket is ModelSummaryProbabilityBucket => bucket !== null)
|
||||||
|
.sort((a, b) => a.lower - b.lower || a.upper - b.upper);
|
||||||
|
|
||||||
|
if (fromMarketBuckets.length) return fromMarketBuckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
lower: number;
|
||||||
|
upper: number;
|
||||||
|
probability: number;
|
||||||
|
weightedValue: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
rawProbabilityPoints.forEach(({ value, probability }) => {
|
||||||
|
const option = marketOptionBucketForValue(value, unit);
|
||||||
|
const existing = grouped.get(option.key);
|
||||||
|
if (existing) {
|
||||||
|
existing.probability += probability;
|
||||||
|
existing.weightedValue += value * probability;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grouped.set(option.key, {
|
||||||
|
label: option.label,
|
||||||
|
lower: option.lower,
|
||||||
|
upper: option.upper,
|
||||||
|
probability,
|
||||||
|
weightedValue: value * probability,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...grouped.entries()]
|
||||||
|
.map(([key, bucket]) => ({
|
||||||
|
key,
|
||||||
|
label: bucket.label,
|
||||||
|
value:
|
||||||
|
bucket.probability > 0
|
||||||
|
? roundToOneDecimal(bucket.weightedValue / bucket.probability)
|
||||||
|
: roundToOneDecimal((bucket.lower + bucket.upper) / 2),
|
||||||
|
lower: bucket.lower,
|
||||||
|
upper: bucket.upper,
|
||||||
|
probability: roundProbability(bucket.probability),
|
||||||
|
}))
|
||||||
.sort((a, b) => a.lower - b.lower || a.upper - b.upper);
|
.sort((a, b) => a.lower - b.lower || a.upper - b.upper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user