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);
|
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) {
|
function getMarketYesPrice(scan?: MarketScan | null) {
|
||||||
if (scan?.market_price != null) {
|
if (scan?.market_price != null) {
|
||||||
const preferred = Number(scan.market_price);
|
const preferred = Number(scan.market_price);
|
||||||
@@ -100,6 +75,46 @@ function getMarketYesPrice(scan?: MarketScan | null) {
|
|||||||
return 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<
|
type ModelMetadata = NonNullable<
|
||||||
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
|
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
|
||||||
>["model_metadata"];
|
>["model_metadata"];
|
||||||
@@ -633,7 +648,6 @@ export function ProbabilityDistribution({
|
|||||||
}) {
|
}) {
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const view = getProbabilityView(detail, targetDate);
|
const view = getProbabilityView(detail, targetDate);
|
||||||
const marketBucketTemp = getMarketBucketTemp(marketScan);
|
|
||||||
const marketYesPrice = getMarketYesPrice(marketScan);
|
const marketYesPrice = getMarketYesPrice(marketScan);
|
||||||
const marketYesText = toPercent(marketYesPrice);
|
const marketYesText = toPercent(marketYesPrice);
|
||||||
const isToday = !targetDate || targetDate === detail.local_date;
|
const isToday = !targetDate || targetDate === detail.local_date;
|
||||||
@@ -684,15 +698,12 @@ export function ProbabilityDistribution({
|
|||||||
const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null;
|
const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null;
|
||||||
const linkedMarketBucket = useMemo(() => {
|
const linkedMarketBucket = useMemo(() => {
|
||||||
if (topProbabilityTemp == null) return null;
|
if (topProbabilityTemp == null) return null;
|
||||||
return (
|
return findMarketBucketForDisplayTemp(
|
||||||
marketAllBuckets.find((bucket) => {
|
marketAllBuckets,
|
||||||
const bucketTemp = bucket.temp ?? bucket.value ?? null;
|
topProbabilityTemp,
|
||||||
if (bucketTemp == null) return false;
|
detail,
|
||||||
const numeric = Number(bucketTemp);
|
|
||||||
return Number.isFinite(numeric) && Math.abs(numeric - topProbabilityTemp) < 0.26;
|
|
||||||
}) || null
|
|
||||||
);
|
);
|
||||||
}, [marketAllBuckets, topProbabilityTemp]);
|
}, [detail, marketAllBuckets, topProbabilityTemp]);
|
||||||
const priceAnalysis = marketScan?.price_analysis;
|
const priceAnalysis = marketScan?.price_analysis;
|
||||||
const yesPriceView = priceAnalysis?.yes;
|
const yesPriceView = priceAnalysis?.yes;
|
||||||
const noPriceView = priceAnalysis?.no;
|
const noPriceView = priceAnalysis?.no;
|
||||||
@@ -936,13 +947,15 @@ export function ProbabilityDistribution({
|
|||||||
Number(bucket.probability || 0) * 100,
|
Number(bucket.probability || 0) * 100,
|
||||||
);
|
);
|
||||||
const bucketTemp = getBucketTemp(bucket);
|
const bucketTemp = getBucketTemp(bucket);
|
||||||
const isMarketBucket =
|
const rowMarketBucket = findMarketBucketForDisplayTemp(
|
||||||
marketYesText != null &&
|
marketAllBuckets,
|
||||||
marketBucketTemp != null &&
|
bucketTemp,
|
||||||
bucketTemp != null &&
|
detail,
|
||||||
Math.abs(bucketTemp - marketBucketTemp) < 0.26;
|
);
|
||||||
const yesPriceText = toPriceCents(marketYesPrice);
|
const rowMarketPrice =
|
||||||
const marketTagFinal = isMarketBucket
|
rowMarketBucket?.market_price ?? rowMarketBucket?.yes_buy ?? null;
|
||||||
|
const yesPriceText = toPriceCents(rowMarketPrice);
|
||||||
|
const marketTagFinal = rowMarketBucket
|
||||||
? locale === "en-US"
|
? locale === "en-US"
|
||||||
? `Market ref: ${yesPriceText || "--"}`
|
? `Market ref: ${yesPriceText || "--"}`
|
||||||
: `市场参考: ${yesPriceText || "--"}`
|
: `市场参考: ${yesPriceText || "--"}`
|
||||||
@@ -951,9 +964,11 @@ export function ProbabilityDistribution({
|
|||||||
bucket.label || `${bucket.value}${detail.temp_symbol}`;
|
bucket.label || `${bucket.value}${detail.temp_symbol}`;
|
||||||
if (bucketLabel) {
|
if (bucketLabel) {
|
||||||
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
|
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
|
||||||
str = str.replace(/°?C($|\+|-)/g, "℃$1");
|
const symbol = detail.temp_symbol || "°C";
|
||||||
if (!str.includes("℃") && /[0-9]/.test(str)) {
|
str = str.replace(/°?C($|\+|-)/g, "°C$1");
|
||||||
str += "℃";
|
str = str.replace(/°?F($|\+|-)/g, "°F$1");
|
||||||
|
if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
|
||||||
|
str += symbol;
|
||||||
}
|
}
|
||||||
bucketLabel = str;
|
bucketLabel = str;
|
||||||
}
|
}
|
||||||
@@ -976,7 +991,7 @@ export function ProbabilityDistribution({
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"prob-market-inline",
|
"prob-market-inline",
|
||||||
isMarketBucket ? "yes" : "no",
|
rowMarketBucket ? "yes" : "no",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{marketTagFinal}
|
{marketTagFinal}
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ def _safe_float(value: Any) -> Optional[float]:
|
|||||||
return None
|
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:
|
def _env_bool(name: str, default: bool = False) -> bool:
|
||||||
raw = os.getenv(name)
|
raw = os.getenv(name)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
@@ -327,15 +335,15 @@ class PolymarketWsQuoteCache:
|
|||||||
if not asset_id:
|
if not asset_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
best_bid = (
|
best_bid = _first_float(
|
||||||
_safe_float(item.get("best_bid"))
|
item.get("best_bid"),
|
||||||
or _safe_float(item.get("bid"))
|
item.get("bid"),
|
||||||
or _safe_float(item.get("bestBid"))
|
item.get("bestBid"),
|
||||||
)
|
)
|
||||||
best_ask = (
|
best_ask = _first_float(
|
||||||
_safe_float(item.get("best_ask"))
|
item.get("best_ask"),
|
||||||
or _safe_float(item.get("ask"))
|
item.get("ask"),
|
||||||
or _safe_float(item.get("bestAsk"))
|
item.get("bestAsk"),
|
||||||
)
|
)
|
||||||
if event_type == "book":
|
if event_type == "book":
|
||||||
parsed_bid, parsed_ask = self._extract_book_top(item)
|
parsed_bid, parsed_ask = self._extract_book_top(item)
|
||||||
@@ -345,9 +353,9 @@ class PolymarketWsQuoteCache:
|
|||||||
side = str(item.get("side") or "").strip().upper()
|
side = str(item.get("side") or "").strip().upper()
|
||||||
if event_type == "price_change" and price is not None:
|
if event_type == "price_change" and price is not None:
|
||||||
if side == "BUY":
|
if side == "BUY":
|
||||||
best_ask = price
|
|
||||||
elif side == "SELL":
|
|
||||||
best_bid = price
|
best_bid = price
|
||||||
|
elif side == "SELL":
|
||||||
|
best_ask = price
|
||||||
|
|
||||||
last_trade = (
|
last_trade = (
|
||||||
_safe_float(item.get("last_trade_price"))
|
_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")
|
data = cache.get_market_data("asset-1")
|
||||||
|
|
||||||
assert data is not None
|
assert data is not None
|
||||||
assert data["buy"] == 0.48
|
assert data["buy"] == 0.45
|
||||||
assert data["sell"] == 0.45
|
assert data["sell"] == 0.48
|
||||||
|
|
||||||
|
|
||||||
def test_ws_cache_parses_price_changes_key_and_book_event():
|
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")
|
data = cache.get_market_data("asset-1")
|
||||||
|
|
||||||
assert data is not None
|
assert data is not None
|
||||||
assert data["sell"] == 0.44
|
assert data["sell"] == 0.48
|
||||||
assert data["buy"] == 0.48
|
assert data["buy"] == 0.49
|
||||||
|
|
||||||
|
|
||||||
def test_ws_cache_subscription_payloads_match_market_channel_shape():
|
def test_ws_cache_subscription_payloads_match_market_channel_shape():
|
||||||
|
|||||||
Reference in New Issue
Block a user