feat: implement Polymarket WebSocket cache and add dashboard panel UI components
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user