feat: implement Polymarket WebSocket cache and add dashboard panel UI components

This commit is contained in:
2569718930@qq.com
2026-04-22 00:09:49 +08:00
parent c40b7c54e3
commit 5a1610d428
3 changed files with 82 additions and 59 deletions
+60 -45
View File
@@ -63,31 +63,6 @@ function getBucketTemp(bucket: ProbabilityBucket) {
return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
}
function getMarketBucketTemp(scan?: MarketScan | null) {
if (!scan) return null;
if (scan.temperature_bucket?.value != null) {
const byBucketValue = Number(scan.temperature_bucket.value);
if (Number.isFinite(byBucketValue)) return byBucketValue;
}
const byBucketLabel = parseTempFromText(
scan.temperature_bucket?.label ||
scan.temperature_bucket?.bucket ||
scan.temperature_bucket?.range,
);
if (byBucketLabel != null) return byBucketLabel;
const slug = String(scan.selected_slug || scan.primary_market?.slug || "");
const slugMatch = slug.match(/-(-?\d+(?:\.\d+)?)c(?:$|[^a-z0-9])/i);
if (slugMatch) {
const numeric = Number(slugMatch[1]);
if (Number.isFinite(numeric)) return numeric;
}
return parseTempFromText(scan.primary_market?.question);
}
function getMarketYesPrice(scan?: MarketScan | null) {
if (scan?.market_price != null) {
const preferred = Number(scan.market_price);
@@ -100,6 +75,46 @@ function getMarketYesPrice(scan?: MarketScan | null) {
return null;
}
function isFahrenheitSymbol(symbol?: string | null) {
return String(symbol || "").toUpperCase().includes("F");
}
function displayTempToMarketCelsius(
value: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (value == null || !Number.isFinite(value)) return null;
if (isFahrenheitSymbol(detail.temp_symbol)) {
return ((value - 32) * 5) / 9;
}
return value;
}
function findMarketBucketForDisplayTemp(
buckets: MarketTopBucket[],
displayTemp: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
const marketTemp = displayTempToMarketCelsius(displayTemp, detail);
if (marketTemp == null) return null;
const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26;
let best: MarketTopBucket | null = null;
let bestDelta = Number.POSITIVE_INFINITY;
for (const bucket of buckets) {
const rawTemp = bucket.temp ?? bucket.value ?? null;
if (rawTemp == null) continue;
const candidateTemp = Number(rawTemp);
if (!Number.isFinite(candidateTemp)) continue;
const delta = Math.abs(candidateTemp - marketTemp);
if (delta < bestDelta) {
best = bucket;
bestDelta = delta;
}
}
return best && bestDelta <= tolerance ? best : null;
}
type ModelMetadata = NonNullable<
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
>["model_metadata"];
@@ -633,7 +648,6 @@ export function ProbabilityDistribution({
}) {
const { locale, t } = useI18n();
const view = getProbabilityView(detail, targetDate);
const marketBucketTemp = getMarketBucketTemp(marketScan);
const marketYesPrice = getMarketYesPrice(marketScan);
const marketYesText = toPercent(marketYesPrice);
const isToday = !targetDate || targetDate === detail.local_date;
@@ -684,15 +698,12 @@ export function ProbabilityDistribution({
const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null;
const linkedMarketBucket = useMemo(() => {
if (topProbabilityTemp == null) return null;
return (
marketAllBuckets.find((bucket) => {
const bucketTemp = bucket.temp ?? bucket.value ?? null;
if (bucketTemp == null) return false;
const numeric = Number(bucketTemp);
return Number.isFinite(numeric) && Math.abs(numeric - topProbabilityTemp) < 0.26;
}) || null
return findMarketBucketForDisplayTemp(
marketAllBuckets,
topProbabilityTemp,
detail,
);
}, [marketAllBuckets, topProbabilityTemp]);
}, [detail, marketAllBuckets, topProbabilityTemp]);
const priceAnalysis = marketScan?.price_analysis;
const yesPriceView = priceAnalysis?.yes;
const noPriceView = priceAnalysis?.no;
@@ -936,13 +947,15 @@ export function ProbabilityDistribution({
Number(bucket.probability || 0) * 100,
);
const bucketTemp = getBucketTemp(bucket);
const isMarketBucket =
marketYesText != null &&
marketBucketTemp != null &&
bucketTemp != null &&
Math.abs(bucketTemp - marketBucketTemp) < 0.26;
const yesPriceText = toPriceCents(marketYesPrice);
const marketTagFinal = isMarketBucket
const rowMarketBucket = findMarketBucketForDisplayTemp(
marketAllBuckets,
bucketTemp,
detail,
);
const rowMarketPrice =
rowMarketBucket?.market_price ?? rowMarketBucket?.yes_buy ?? null;
const yesPriceText = toPriceCents(rowMarketPrice);
const marketTagFinal = rowMarketBucket
? locale === "en-US"
? `Market ref: ${yesPriceText || "--"}`
: `市场参考: ${yesPriceText || "--"}`
@@ -951,9 +964,11 @@ export function ProbabilityDistribution({
bucket.label || `${bucket.value}${detail.temp_symbol}`;
if (bucketLabel) {
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
str = str.replace(/°?C($|\+|-)/g, "℃$1");
if (!str.includes("℃") && /[0-9]/.test(str)) {
str += "℃";
const symbol = detail.temp_symbol || "°C";
str = str.replace(/°?C($|\+|-)/g, "°C$1");
str = str.replace(/°?F($|\+|-)/g, "°F$1");
if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
str += symbol;
}
bucketLabel = str;
}
@@ -976,7 +991,7 @@ export function ProbabilityDistribution({
<div
className={clsx(
"prob-market-inline",
isMarketBucket ? "yes" : "no",
rowMarketBucket ? "yes" : "no",
)}
>
{marketTagFinal}
+18 -10
View File
@@ -35,6 +35,14 @@ def _safe_float(value: Any) -> Optional[float]:
return None
def _first_float(*values: Any) -> Optional[float]:
for value in values:
parsed = _safe_float(value)
if parsed is not None:
return parsed
return None
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
@@ -327,15 +335,15 @@ class PolymarketWsQuoteCache:
if not asset_id:
return
best_bid = (
_safe_float(item.get("best_bid"))
or _safe_float(item.get("bid"))
or _safe_float(item.get("bestBid"))
best_bid = _first_float(
item.get("best_bid"),
item.get("bid"),
item.get("bestBid"),
)
best_ask = (
_safe_float(item.get("best_ask"))
or _safe_float(item.get("ask"))
or _safe_float(item.get("bestAsk"))
best_ask = _first_float(
item.get("best_ask"),
item.get("ask"),
item.get("bestAsk"),
)
if event_type == "book":
parsed_bid, parsed_ask = self._extract_book_top(item)
@@ -345,9 +353,9 @@ class PolymarketWsQuoteCache:
side = str(item.get("side") or "").strip().upper()
if event_type == "price_change" and price is not None:
if side == "BUY":
best_ask = price
elif side == "SELL":
best_bid = price
elif side == "SELL":
best_ask = price
last_trade = (
_safe_float(item.get("last_trade_price"))
+4 -4
View File
@@ -63,8 +63,8 @@ def test_ws_cache_parses_price_change_side_updates():
data = cache.get_market_data("asset-1")
assert data is not None
assert data["buy"] == 0.48
assert data["sell"] == 0.45
assert data["buy"] == 0.45
assert data["sell"] == 0.48
def test_ws_cache_parses_price_changes_key_and_book_event():
@@ -94,8 +94,8 @@ def test_ws_cache_parses_price_changes_key_and_book_event():
data = cache.get_market_data("asset-1")
assert data is not None
assert data["sell"] == 0.44
assert data["buy"] == 0.48
assert data["sell"] == 0.48
assert data["buy"] == 0.49
def test_ws_cache_subscription_payloads_match_market_channel_shape():