Show market buckets with model probability
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: "高斯 μ" },
|
||||||
probabilityDistribution: { en: "Probability Distribution", zh: "概率分布" },
|
marketMatch: { en: "Market Match", zh: "市场匹配" },
|
||||||
median: { en: "Median", zh: "模型中位数" },
|
median: { en: "Median", zh: "模型中位数" },
|
||||||
spread: { en: "Spread", zh: "分歧范围" },
|
spread: { en: "Spread", zh: "分歧范围" },
|
||||||
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
|
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
|
||||||
@@ -100,9 +100,11 @@ function FilterToggle({
|
|||||||
function ModelSummaryRowView({
|
function ModelSummaryRowView({
|
||||||
row,
|
row,
|
||||||
nowMs,
|
nowMs,
|
||||||
|
isEn,
|
||||||
}: {
|
}: {
|
||||||
row: ModelSummaryRow;
|
row: ModelSummaryRow;
|
||||||
nowMs: number | null;
|
nowMs: number | null;
|
||||||
|
isEn: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<tr className="group border-b border-slate-100 hover:bg-blue-50/40">
|
<tr className="group border-b border-slate-100 hover:bg-blue-50/40">
|
||||||
@@ -136,24 +138,26 @@ function ModelSummaryRowView({
|
|||||||
<TemperatureCell value={row.gaussianMu} symbol={row.tempSymbol} emphasis="median" />
|
<TemperatureCell value={row.gaussianMu} symbol={row.tempSymbol} emphasis="median" />
|
||||||
</td>
|
</td>
|
||||||
<td className="min-w-[420px] max-w-[520px] px-3 py-1.5 text-left">
|
<td className="min-w-[420px] max-w-[520px] px-3 py-1.5 text-left">
|
||||||
{row.probabilityBuckets.length ? (
|
{row.marketMatches.length ? (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{row.probabilityBuckets.map((bucket) => {
|
{row.marketMatches.map((match) => {
|
||||||
const isTopBucket = bucket.key === row.topProbabilityBucketKey;
|
|
||||||
return (
|
return (
|
||||||
<span
|
<a
|
||||||
key={bucket.key}
|
key={match.key}
|
||||||
|
href={match.marketUrl || undefined}
|
||||||
|
target={match.marketUrl ? "_blank" : undefined}
|
||||||
|
rel={match.marketUrl ? "noreferrer" : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[10px] tabular-nums",
|
"inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[10px] font-bold tabular-nums",
|
||||||
isTopBucket
|
(match.modelProbability ?? 0) >= 0.2
|
||||||
? "border-violet-200 bg-violet-50 font-black text-violet-800"
|
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||||
: "border-slate-200 bg-slate-50 font-bold text-violet-700",
|
: "border-slate-200 bg-slate-50 text-slate-600",
|
||||||
)}
|
)}
|
||||||
title={bucket.label}
|
title={`${match.label} model ${formatModelSummaryProbability(match.modelProbability)}`}
|
||||||
>
|
>
|
||||||
<span>{bucket.label}</span>
|
<span className="font-black">{match.label}</span>
|
||||||
<strong>{formatModelSummaryProbability(bucket.probability)}</strong>
|
<span>{isEn ? "M" : "模"} {formatModelSummaryProbability(match.modelProbability)}</span>
|
||||||
</span>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -315,8 +319,8 @@ export function ModelSummaryDashboard({
|
|||||||
<th className="min-w-[92px] px-3 py-2 text-right text-violet-700">
|
<th className="min-w-[92px] px-3 py-2 text-right text-violet-700">
|
||||||
{copy("gaussianMu", isEn)}
|
{copy("gaussianMu", isEn)}
|
||||||
</th>
|
</th>
|
||||||
<th className="min-w-[420px] max-w-[520px] px-3 py-2 text-left text-violet-700">
|
<th className="min-w-[420px] max-w-[520px] px-3 py-2 text-left text-emerald-700">
|
||||||
{copy("probabilityDistribution", isEn)}
|
{copy("marketMatch", isEn)}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -326,6 +330,7 @@ export function ModelSummaryDashboard({
|
|||||||
key={row.cityKey}
|
key={row.cityKey}
|
||||||
row={row}
|
row={row}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
|
isEn={isEn}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
+30
-4
@@ -59,6 +59,16 @@ export function runTests() {
|
|||||||
{ value: 33, model_probability: 0.31, range: "[32.5~33.5)" },
|
{ value: 33, model_probability: 0.31, range: "[32.5~33.5)" },
|
||||||
],
|
],
|
||||||
probability_engine: "legacy",
|
probability_engine: "legacy",
|
||||||
|
all_buckets: [
|
||||||
|
{
|
||||||
|
label: "31.5-32.5°C",
|
||||||
|
model_probability: 0.42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "33.5-34.5°C",
|
||||||
|
model_probability: 0.08,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
city: "madrid",
|
city: "madrid",
|
||||||
@@ -131,6 +141,18 @@ export function runTests() {
|
|||||||
parisRow.topProbabilityBucketKey === "31.5-32.5°C",
|
parisRow.topProbabilityBucketKey === "31.5-32.5°C",
|
||||||
"model summary should map probability buckets and identify the top bucket",
|
"model summary should map probability buckets and identify the top bucket",
|
||||||
);
|
);
|
||||||
|
assert(parisRow.marketMatches.length === 2, "model summary should keep every Polymarket tradable bucket");
|
||||||
|
assert(
|
||||||
|
parisRow.marketMatches[0].label === "31.5-32.5°C" &&
|
||||||
|
parisRow.marketMatches[0].modelProbability === 0.42 &&
|
||||||
|
parisRow.marketMatches[0].marketUrl === null,
|
||||||
|
"model summary should expose model probability for market-matched buckets without requiring market price",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
parisRow.marketMatches[1].label === "33.5-34.5°C" &&
|
||||||
|
parisRow.marketMatches[1].modelProbability === 0.08,
|
||||||
|
"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");
|
||||||
assert(formatModelSummaryProbability(0.424) === "42%", "probability buckets should render as rounded percentages");
|
assert(formatModelSummaryProbability(0.424) === "42%", "probability buckets should render as rounded percentages");
|
||||||
assert(formatModelSummaryTemp(null, "°C") === "—", "missing model temperatures should render as an em dash");
|
assert(formatModelSummaryTemp(null, "°C") === "—", "missing model temperatures should render as an em dash");
|
||||||
@@ -199,10 +221,14 @@ export function runTests() {
|
|||||||
modelSummarySource.includes("hasModelSummaryForecastData") &&
|
modelSummarySource.includes("hasModelSummaryForecastData") &&
|
||||||
modelSummarySource.includes("Gaussian μ") &&
|
modelSummarySource.includes("Gaussian μ") &&
|
||||||
modelSummarySource.includes("高斯 μ") &&
|
modelSummarySource.includes("高斯 μ") &&
|
||||||
modelSummarySource.includes("Probability Distribution") &&
|
modelSummarySource.includes("Market Match") &&
|
||||||
modelSummarySource.includes("概率分布") &&
|
modelSummarySource.includes("市场匹配") &&
|
||||||
modelSummarySource.includes("topProbabilityBucketKey") &&
|
modelSummarySource.includes("marketMatches.map") &&
|
||||||
modelSummarySource.includes("probabilityBuckets.map") &&
|
!modelSummarySource.includes("formatModelSummaryEdge") &&
|
||||||
|
!modelSummarySource.includes("marketProbability") &&
|
||||||
|
!modelSummarySource.includes("edgePercent") &&
|
||||||
|
!modelSummarySource.includes("Probability Distribution") &&
|
||||||
|
!modelSummarySource.includes("概率分布") &&
|
||||||
!modelSummarySource.includes("probabilityColumns") &&
|
!modelSummarySource.includes("probabilityColumns") &&
|
||||||
modelSummarySource.includes("min-w-[96px]") &&
|
modelSummarySource.includes("min-w-[96px]") &&
|
||||||
modelSummarySource.includes("Local Time") &&
|
modelSummarySource.includes("Local Time") &&
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export type ModelSummaryProbabilityBucket = {
|
|||||||
probability: number;
|
probability: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ModelSummaryMarketMatch = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
modelProbability: number | null;
|
||||||
|
marketUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ModelSummaryRow = {
|
export type ModelSummaryRow = {
|
||||||
cityKey: string;
|
cityKey: string;
|
||||||
cityName: string;
|
cityName: string;
|
||||||
@@ -47,6 +54,7 @@ export type ModelSummaryRow = {
|
|||||||
gaussianMu: number | null;
|
gaussianMu: number | null;
|
||||||
probabilityEngine: string | null;
|
probabilityEngine: string | null;
|
||||||
topProbabilityBucketKey: string | null;
|
topProbabilityBucketKey: string | null;
|
||||||
|
marketMatches: ModelSummaryMarketMatch[];
|
||||||
searchText: string;
|
searchText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,6 +164,54 @@ function weightedProbabilityMu(buckets: ModelSummaryProbabilityBucket[]) {
|
|||||||
return roundToOneDecimal(weightedValue / totalProbability);
|
return roundToOneDecimal(weightedValue / totalProbability);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeProbability(value: unknown) {
|
||||||
|
const numericValue = finiteNumber(value);
|
||||||
|
if (numericValue == null) return null;
|
||||||
|
return numericValue > 1 ? numericValue / 100 : numericValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketBucketLabel(bucket: Record<string, unknown>, tempSymbol: string) {
|
||||||
|
const textLabel = String(bucket.label || bucket.bucket || bucket.range || "").trim();
|
||||||
|
if (textLabel) return textLabel;
|
||||||
|
const lower = finiteNumber(bucket.lower);
|
||||||
|
const upper = finiteNumber(bucket.upper);
|
||||||
|
if (lower != null && upper != null && upper > lower) {
|
||||||
|
return probabilityBucketLabel(lower, upper, String(bucket.unit || tempSymbol || "°C"));
|
||||||
|
}
|
||||||
|
const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature);
|
||||||
|
return value == null ? "—" : `${formatBucketBound(value)}${bucket.unit || tempSymbol || "°C"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMarketMatches(row: ScanOpportunityRow): ModelSummaryMarketMatch[] {
|
||||||
|
const marketRow = row as ScanOpportunityRow & {
|
||||||
|
all_buckets?: Array<Record<string, unknown>> | null;
|
||||||
|
top_buckets?: Array<Record<string, unknown>> | null;
|
||||||
|
};
|
||||||
|
const sourceBuckets = (
|
||||||
|
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>>;
|
||||||
|
const tempSymbol = row.temp_symbol || "°C";
|
||||||
|
|
||||||
|
return sourceBuckets
|
||||||
|
.map((bucket, index) => {
|
||||||
|
const label = marketBucketLabel(bucket, tempSymbol);
|
||||||
|
const modelProbability = normalizeProbability(bucket.model_probability ?? bucket.probability);
|
||||||
|
return {
|
||||||
|
key: `${label}-${index}`,
|
||||||
|
label,
|
||||||
|
modelProbability,
|
||||||
|
marketUrl: typeof bucket.market_url === "string" ? bucket.market_url : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
return (b.modelProbability ?? -1) - (a.modelProbability ?? -1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCityKey(row: ScanOpportunityRow, index: number) {
|
function normalizeCityKey(row: ScanOpportunityRow, index: number) {
|
||||||
const rawKey = row.city || row.city_display_name || row.display_name || `row-${index}`;
|
const rawKey = row.city || row.city_display_name || row.display_name || `row-${index}`;
|
||||||
return String(rawKey).trim().toLowerCase();
|
return String(rawKey).trim().toLowerCase();
|
||||||
@@ -259,6 +315,13 @@ export function buildModelSummaryRows(
|
|||||||
const probabilitySearchText = probabilityBuckets
|
const probabilitySearchText = probabilityBuckets
|
||||||
.map((bucket) => `${bucket.label} ${formatModelSummaryProbability(bucket.probability)}`)
|
.map((bucket) => `${bucket.label} ${formatModelSummaryProbability(bucket.probability)}`)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
const marketMatches = buildMarketMatches(row);
|
||||||
|
const marketSearchText = marketMatches
|
||||||
|
.map(
|
||||||
|
(match) =>
|
||||||
|
`${match.label} ${formatModelSummaryProbability(match.modelProbability)}`,
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
byCity.set(cityKey, {
|
byCity.set(cityKey, {
|
||||||
cityKey,
|
cityKey,
|
||||||
@@ -278,8 +341,9 @@ export function buildModelSummaryRows(
|
|||||||
gaussianMu: weightedProbabilityMu(probabilityBuckets),
|
gaussianMu: weightedProbabilityMu(probabilityBuckets),
|
||||||
probabilityEngine: row.probability_engine || (probabilityBuckets.length ? "legacy" : null),
|
probabilityEngine: row.probability_engine || (probabilityBuckets.length ? "legacy" : null),
|
||||||
topProbabilityBucketKey: topProbabilityBucket?.key || null,
|
topProbabilityBucketKey: topProbabilityBucket?.key || null,
|
||||||
|
marketMatches,
|
||||||
searchText:
|
searchText:
|
||||||
`${cityName} ${row.city || ""} ${region.labelEn} ${region.labelZh} ${modelSearchText} ${probabilitySearchText}`.toLowerCase(),
|
`${cityName} ${row.city || ""} ${region.labelEn} ${region.labelZh} ${modelSearchText} ${probabilitySearchText} ${marketSearchText}`.toLowerCase(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user