移除 REST 价格获取,纯 WebSocket 报价;默认开启 WS 价格;修复图表 TS 错误
This commit is contained in:
@@ -41,39 +41,80 @@ function validNumber(value: unknown): number | null {
|
|||||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTimestamp(value?: string | null): number | null {
|
function getCityLocalUtcTimestamp(
|
||||||
const raw = String(value || "").trim();
|
value: string | number | null | undefined,
|
||||||
|
tzOffsetSeconds: number,
|
||||||
|
referenceLocalDate?: string | null
|
||||||
|
): number | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
const d = new Date(value + tzOffsetSeconds * 1000);
|
||||||
|
return Date.UTC(
|
||||||
|
d.getUTCFullYear(),
|
||||||
|
d.getUTCMonth(),
|
||||||
|
d.getUTCDate(),
|
||||||
|
d.getUTCHours(),
|
||||||
|
d.getUTCMinutes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = String(value).trim();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const d = new Date(raw);
|
|
||||||
if (!Number.isNaN(d.getTime())) return d.getTime();
|
if (raw.includes("T") || raw.includes("Z") || raw.includes("-")) {
|
||||||
// HH:MM or HH:MM:SS — treat as today, but handle cross-midnight:
|
const d = new Date(raw);
|
||||||
// if parsed time is >2h ahead of now, assume yesterday
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const localMs = d.getTime() + tzOffsetSeconds * 1000;
|
||||||
|
const localDate = new Date(localMs);
|
||||||
|
return Date.UTC(
|
||||||
|
localDate.getUTCFullYear(),
|
||||||
|
localDate.getUTCMonth(),
|
||||||
|
localDate.getUTCDate(),
|
||||||
|
localDate.getUTCHours(),
|
||||||
|
localDate.getUTCMinutes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const m = raw.match(/(\d{1,2}):(\d{2})/);
|
const m = raw.match(/(\d{1,2}):(\d{2})/);
|
||||||
if (m) {
|
if (m) {
|
||||||
const now = new Date();
|
const h = +m[1];
|
||||||
const h = +m[1], min = +m[2];
|
const min = +m[2];
|
||||||
const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, min);
|
|
||||||
if (candidate.getTime() - now.getTime() > 2 * 60 * 60 * 1000) {
|
let year = new Date().getUTCFullYear();
|
||||||
candidate.setDate(candidate.getDate() - 1);
|
let month = new Date().getUTCMonth();
|
||||||
|
let date = new Date().getUTCDate();
|
||||||
|
|
||||||
|
if (referenceLocalDate) {
|
||||||
|
const dateParts = referenceLocalDate.split("-");
|
||||||
|
if (dateParts.length === 3) {
|
||||||
|
year = parseInt(dateParts[0]);
|
||||||
|
month = parseInt(dateParts[1]) - 1;
|
||||||
|
date = parseInt(dateParts[2]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return candidate.getTime();
|
|
||||||
|
return Date.UTC(year, month, date, h, min);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
function formatTimestamp(ts: number): string {
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normObs(points?: ObsPoint[] | null, limit = MAX_OBS_POINTS) {
|
function normObs(points: ObsPoint[] | null | undefined, tzOffsetSeconds: number, limit = MAX_OBS_POINTS) {
|
||||||
return (points || [])
|
return (points || [])
|
||||||
.filter((p) => validNumber(p.temp) !== null && toTimestamp(p.time) !== null)
|
.filter((p) => validNumber(p.temp) !== null && p.time)
|
||||||
.slice(-limit)
|
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
ts: toTimestamp(p.time)!,
|
ts: getCityLocalUtcTimestamp(p.time, tzOffsetSeconds)!,
|
||||||
value: Number(p.temp),
|
value: Number(p.temp),
|
||||||
}));
|
}))
|
||||||
|
.filter((p) => p.ts !== null)
|
||||||
|
.slice(-limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function seriesStats(values: Array<number | null>) {
|
function seriesStats(values: Array<number | null>) {
|
||||||
@@ -99,8 +140,8 @@ function buildSlidingChartData(
|
|||||||
row: ScanOpportunityRow | null,
|
row: ScanOpportunityRow | null,
|
||||||
hourly: HourlyForecast,
|
hourly: HourlyForecast,
|
||||||
) {
|
) {
|
||||||
const settlementObs = normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs);
|
const settlementObs = normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, 0);
|
||||||
const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs);
|
const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, 0);
|
||||||
|
|
||||||
// Collect all timestamps from observations + forecasts
|
// Collect all timestamps from observations + forecasts
|
||||||
const allTimes = new Set<number>();
|
const allTimes = new Set<number>();
|
||||||
@@ -320,9 +361,11 @@ function buildChartDomain(
|
|||||||
export function LiveTemperatureThresholdChart({
|
export function LiveTemperatureThresholdChart({
|
||||||
isEn,
|
isEn,
|
||||||
row,
|
row,
|
||||||
|
allRows = [],
|
||||||
}: {
|
}: {
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
row: ScanOpportunityRow | null;
|
row: ScanOpportunityRow | null;
|
||||||
|
allRows?: ScanOpportunityRow[];
|
||||||
}) {
|
}) {
|
||||||
const [hourly, setHourly] = useState<HourlyForecast>(null);
|
const [hourly, setHourly] = useState<HourlyForecast>(null);
|
||||||
const city = String(row?.city || "").toLowerCase().trim();
|
const city = String(row?.city || "").toLowerCase().trim();
|
||||||
@@ -362,6 +405,36 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
const { data, series } = useMemo(() => buildSlidingChartData(row, hourly), [row, hourly]);
|
const { data, series } = useMemo(() => buildSlidingChartData(row, hourly), [row, hourly]);
|
||||||
const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value);
|
const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value);
|
||||||
|
|
||||||
|
const cityThresholds = useMemo(() => {
|
||||||
|
if (!row || !allRows || !allRows.length) return [];
|
||||||
|
const cityKey = String(row.city || "").toLowerCase().trim();
|
||||||
|
const sameCityRows = allRows.filter(
|
||||||
|
(r) => String(r.city || "").toLowerCase().trim() === cityKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const list: { threshold: number; label: string; isBreached: boolean; kind: "gte" | "lte" }[] = [];
|
||||||
|
sameCityRows.forEach((r) => {
|
||||||
|
const t = Number(r.target_threshold ?? r.target_value ?? r.target_lower ?? r.target_upper);
|
||||||
|
if (!Number.isFinite(t) || seen.has(t)) return;
|
||||||
|
seen.add(t);
|
||||||
|
|
||||||
|
const maxTemp = Number(r.current_max_so_far ?? r.current_temp ?? 0);
|
||||||
|
const q = String(r.market_question || r.target_label || "").toLowerCase();
|
||||||
|
const kind: "gte" | "lte" = q.includes("below") || q.includes("under") || q.includes("lte") ? "lte" : "gte";
|
||||||
|
const isBreached = kind === "lte" ? maxTemp > t : maxTemp >= t;
|
||||||
|
|
||||||
|
list.push({
|
||||||
|
threshold: t,
|
||||||
|
label: r.target_label || `${t}°C`,
|
||||||
|
isBreached,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return list.sort((a, b) => a.threshold - b.threshold);
|
||||||
|
}, [row, allRows]);
|
||||||
const modelSummaryCards = useMemo(() => {
|
const modelSummaryCards = useMemo(() => {
|
||||||
const cards = buildModelSummaryCards(row);
|
const cards = buildModelSummaryCards(row);
|
||||||
if (!hourly?.modelCurves) return cards;
|
if (!hourly?.modelCurves) return cards;
|
||||||
@@ -449,15 +522,28 @@ export function LiveTemperatureThresholdChart({
|
|||||||
domain={chartDomain}
|
domain={chartDomain}
|
||||||
ticks={marketTicks ?? undefined}
|
ticks={marketTicks ?? undefined}
|
||||||
/>
|
/>
|
||||||
{threshold !== null && (
|
{cityThresholds.map((t, idx) => {
|
||||||
<ReferenceLine
|
const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold);
|
||||||
y={threshold}
|
const labelText = isEn
|
||||||
stroke="#f97316"
|
? `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "Excluded" : "Active"}]`
|
||||||
strokeDasharray="4 3"
|
: `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "已排除" : "活跃"}]`;
|
||||||
strokeWidth={2}
|
|
||||||
label={{ value: `${threshold.toFixed(1)}°`, fill: "#f97316", fontSize: 10, position: "left" }}
|
return (
|
||||||
/>
|
<ReferenceLine
|
||||||
)}
|
key={idx}
|
||||||
|
y={t.threshold}
|
||||||
|
stroke={isSelected ? "#3b82f6" : t.isBreached ? "#ef4444" : "#f97316"}
|
||||||
|
strokeDasharray={isSelected ? undefined : "4 4"}
|
||||||
|
strokeWidth={isSelected ? 2 : 1}
|
||||||
|
label={{
|
||||||
|
value: labelText,
|
||||||
|
fill: isSelected ? "#3b82f6" : t.isBreached ? "#ef4444" : "#f97316",
|
||||||
|
fontSize: 9,
|
||||||
|
position: isSelected ? "left" : "insideBottomRight",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
border: "1px solid #cbd5e1",
|
border: "1px solid #cbd5e1",
|
||||||
|
|||||||
@@ -1834,12 +1834,7 @@ class PolymarketReadOnlyLayer:
|
|||||||
return ws_data
|
return ws_data
|
||||||
|
|
||||||
self._ws_cache.subscribe([token_id])
|
self._ws_cache.subscribe([token_id])
|
||||||
|
return {}
|
||||||
data = self._fetch_token_market_data(token_id)
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
self._price_cache[token_id] = {"data": data, "t": now}
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _fetch_token_market_data(self, token_id: str) -> Dict[str, Any]:
|
def _fetch_token_market_data(self, token_id: str) -> Dict[str, Any]:
|
||||||
# REST-only path: CLOB public endpoints.
|
# REST-only path: CLOB public endpoints.
|
||||||
@@ -2723,128 +2718,7 @@ class PolymarketReadOnlyLayer:
|
|||||||
results[token_id] = ws_data
|
results[token_id] = ws_data
|
||||||
missing.remove(token_id)
|
missing.remove(token_id)
|
||||||
|
|
||||||
if not missing:
|
# No REST fallback — prices exclusively from WebSocket.
|
||||||
return results
|
|
||||||
|
|
||||||
buy_map: Dict[str, float] = {}
|
|
||||||
sell_map: Dict[str, float] = {}
|
|
||||||
midpoint_map: Dict[str, float] = {}
|
|
||||||
spread_map: Dict[str, float] = {}
|
|
||||||
last_trade_map: Dict[str, float] = {}
|
|
||||||
book_map: Dict[str, Dict[str, Any]] = {}
|
|
||||||
|
|
||||||
for chunk in self._batch_chunks(missing):
|
|
||||||
buy_payload = self._clob_post(
|
|
||||||
"/prices",
|
|
||||||
[{"token_id": token_id, "side": "BUY"} for token_id in chunk],
|
|
||||||
)
|
|
||||||
sell_payload = self._clob_post(
|
|
||||||
"/prices",
|
|
||||||
[{"token_id": token_id, "side": "SELL"} for token_id in chunk],
|
|
||||||
)
|
|
||||||
midpoint_payload = (
|
|
||||||
self._clob_post(
|
|
||||||
"/midpoints",
|
|
||||||
[{"token_id": token_id} for token_id in chunk],
|
|
||||||
)
|
|
||||||
if not self.fast_price_only
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
spread_payload = (
|
|
||||||
self._clob_post(
|
|
||||||
"/spreads",
|
|
||||||
[{"token_id": token_id} for token_id in chunk],
|
|
||||||
)
|
|
||||||
if not self.fast_price_only
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
last_trade_payload = (
|
|
||||||
self._clob_post(
|
|
||||||
"/last-trade-prices",
|
|
||||||
[{"token_id": token_id} for token_id in chunk],
|
|
||||||
)
|
|
||||||
if not self.fast_price_only
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
books_payload = (
|
|
||||||
self._clob_post(
|
|
||||||
"/books",
|
|
||||||
[{"token_id": token_id} for token_id in chunk],
|
|
||||||
)
|
|
||||||
if include_books and not self.fast_price_only
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
buy_map.update(self._extract_batch_price_map(buy_payload, "BUY"))
|
|
||||||
sell_map.update(self._extract_batch_price_map(sell_payload, "SELL"))
|
|
||||||
midpoint_map.update(self._extract_batch_scalar_map(midpoint_payload))
|
|
||||||
spread_map.update(self._extract_batch_scalar_map(spread_payload))
|
|
||||||
last_trade_map.update(self._extract_batch_scalar_map(last_trade_payload))
|
|
||||||
if include_books:
|
|
||||||
book_map.update(self._extract_batch_book_map(books_payload))
|
|
||||||
|
|
||||||
fetched: Dict[str, Dict[str, Any]] = {}
|
|
||||||
unresolved: List[str] = []
|
|
||||||
for token_id in missing:
|
|
||||||
raw_book = book_map.get(token_id)
|
|
||||||
book, book_liquidity = self._normalize_orderbook(raw_book)
|
|
||||||
buy_price = _extract_price(buy_map.get(token_id))
|
|
||||||
sell_price = _extract_price(sell_map.get(token_id))
|
|
||||||
midpoint = _extract_price(midpoint_map.get(token_id))
|
|
||||||
spread = _extract_price(spread_map.get(token_id))
|
|
||||||
last_trade = _extract_price(last_trade_map.get(token_id))
|
|
||||||
|
|
||||||
buy, sell = self._resolve_trade_prices(
|
|
||||||
buy=buy_price,
|
|
||||||
sell=sell_price,
|
|
||||||
book=book,
|
|
||||||
)
|
|
||||||
if midpoint is None and buy is not None and sell is not None:
|
|
||||||
midpoint = (buy + sell) / 2.0
|
|
||||||
if midpoint is None:
|
|
||||||
midpoint = _extract_price(raw_book.get("last_trade_price") if isinstance(raw_book, dict) else None)
|
|
||||||
if spread is None and buy is not None and sell is not None:
|
|
||||||
spread = max(0.0, float(buy) - float(sell))
|
|
||||||
if spread is not None and midpoint is not None:
|
|
||||||
midpoint = _clamp_probability(midpoint)
|
|
||||||
if buy is None and midpoint is not None and spread is not None:
|
|
||||||
buy = _clamp_probability(midpoint + spread / 2.0)
|
|
||||||
if sell is None and midpoint is not None and spread is not None:
|
|
||||||
sell = _clamp_probability(midpoint - spread / 2.0)
|
|
||||||
|
|
||||||
if (
|
|
||||||
buy is None
|
|
||||||
and sell is None
|
|
||||||
and midpoint is None
|
|
||||||
and last_trade is None
|
|
||||||
and book is None
|
|
||||||
):
|
|
||||||
unresolved.append(token_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
fetched[token_id] = {
|
|
||||||
"buy": buy,
|
|
||||||
"sell": sell,
|
|
||||||
"midpoint": midpoint,
|
|
||||||
"spread": spread,
|
|
||||||
"last_trade_price": last_trade,
|
|
||||||
"quote_source": (
|
|
||||||
"polymarket_clob_fast_batch"
|
|
||||||
if self.fast_price_only
|
|
||||||
else "polymarket_clob_rest_batch"
|
|
||||||
),
|
|
||||||
"quote_age_ms": 0,
|
|
||||||
"book": book,
|
|
||||||
"book_liquidity": book_liquidity,
|
|
||||||
}
|
|
||||||
|
|
||||||
for token_id in unresolved:
|
|
||||||
fetched[token_id] = self._fetch_token_market_data(token_id)
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
for token_id, data in fetched.items():
|
|
||||||
self._price_cache[token_id] = {"data": data, "t": now}
|
|
||||||
|
|
||||||
results.update(fetched)
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _normalize_scan_filters(self, scan_filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
def _normalize_scan_filters(self, scan_filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class PolymarketWsQuoteCache:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "PolymarketWsQuoteCache":
|
def from_env(cls) -> "PolymarketWsQuoteCache":
|
||||||
return cls(
|
return cls(
|
||||||
enabled=_env_bool("POLYMARKET_WS_PRICE_ENABLED", False),
|
enabled=_env_bool("POLYMARKET_WS_PRICE_ENABLED", True),
|
||||||
endpoint=os.getenv("POLYMARKET_WS_MARKET_URL"),
|
endpoint=os.getenv("POLYMARKET_WS_MARKET_URL"),
|
||||||
quote_ttl_sec=int(os.getenv("POLYMARKET_WS_QUOTE_TTL_SEC", "8")),
|
quote_ttl_sec=int(os.getenv("POLYMARKET_WS_QUOTE_TTL_SEC", "8")),
|
||||||
max_assets=int(os.getenv("POLYMARKET_WS_MAX_ASSETS", "256")),
|
max_assets=int(os.getenv("POLYMARKET_WS_MAX_ASSETS", "256")),
|
||||||
|
|||||||
Reference in New Issue
Block a user