feat: implement comprehensive Polymarket weather analysis service with frontend dashboard and market scanning capabilities

This commit is contained in:
2569718930@qq.com
2026-04-23 20:45:32 +08:00
parent 4f57d4ed1a
commit 54b326ff42
12 changed files with 323 additions and 154 deletions
+3 -2
View File
@@ -175,8 +175,9 @@ POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
POLYMARKET_CLOB_URL=https://clob.polymarket.com
POLYMARKET_CHAIN_ID=137
POLYMARKET_HTTP_TIMEOUT_SEC=8
POLYMARKET_MARKET_CACHE_TTL_SEC=180
POLYMARKET_PRICE_CACHE_TTL_SEC=10
POLYMARKET_MARKET_CACHE_TTL_SEC=60
POLYMARKET_PRICE_CACHE_TTL_SEC=30
POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC=30
POLYMARKET_WS_PRICE_ENABLED=false
POLYMARKET_WS_MARKET_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market
POLYMARKET_WS_QUOTE_TTL_SEC=8
@@ -33,6 +33,11 @@ export async function GET(
params.set("market_slug", marketSlug);
}
const lite = req.nextUrl.searchParams.get("lite");
if (lite) {
params.set("lite", lite);
}
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`;
try {
@@ -746,6 +746,7 @@ export function FutureForecastModal() {
dashboardClient
.getCityMarketScan(cityName, {
force: false,
lite: false,
marketSlug: detail.market_scan?.primary_market?.slug || null,
targetDate: dateStr,
})
@@ -766,7 +767,7 @@ export function FutureForecastModal() {
return;
}
refreshMarketScan();
}, 3000);
}, 30_000);
return () => {
cancelled = true;
@@ -346,6 +346,7 @@ type AssistantMessage = {
};
type OpportunityScanState = "pending" | "complete" | "empty" | "error";
const HOME_OPPORTUNITY_REFRESH_MS = 30_000;
type AssistantDockPosition = {
right: number;
@@ -2395,6 +2396,7 @@ function DashboardScreen() {
const { t } = useI18n();
const didAutoFocusRef = useRef(false);
const marketScanInflightRef = useRef<Set<string>>(new Set());
const marketScanPollingRef = useRef(false);
const [marketScanStatusByCity, setMarketScanStatusByCity] = useState<
Record<string, OpportunityScanState>
>({});
@@ -2517,7 +2519,7 @@ function DashboardScreen() {
const existingDetail = store.cityDetailsByName[cityName];
const marketScan =
existingDetail?.market_scan ||
(await store.ensureCityMarketScan(cityName, false));
(await store.ensureCityMarketScan(cityName, false, { lite: true }));
if (cancelled) return;
setMarketScanStatusByCity((current) => ({
...current,
@@ -2552,6 +2554,57 @@ function DashboardScreen() {
store.proAccess.loading,
]);
useEffect(() => {
if (!showHomepageChrome) return;
if (store.proAccess.loading || !store.proAccess.authenticated) return;
if (!marketScanTargetNames.length) return;
let cancelled = false;
let intervalId: number | null = null;
const refreshAllMarketScans = async () => {
if (cancelled || marketScanPollingRef.current) return;
marketScanPollingRef.current = true;
const queue = [...marketScanTargetNames];
try {
const runWorker = async () => {
while (!cancelled) {
const cityName = queue.shift();
if (!cityName) return;
await store.ensureCityMarketScan(cityName, false, { lite: true });
}
};
await Promise.allSettled(
Array.from({ length: Math.min(3, queue.length) }, () => runWorker()),
);
} finally {
marketScanPollingRef.current = false;
}
};
void refreshAllMarketScans();
intervalId = window.setInterval(() => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
return;
}
void refreshAllMarketScans();
}, HOME_OPPORTUNITY_REFRESH_MS);
return () => {
cancelled = true;
marketScanPollingRef.current = false;
if (intervalId != null) {
window.clearInterval(intervalId);
}
};
}, [
marketScanTargetNames,
showHomepageChrome,
store.ensureCityMarketScan,
store.proAccess.authenticated,
store.proAccess.loading,
]);
return (
<div
className={clsx(
+52 -3
View File
@@ -45,6 +45,11 @@ interface DashboardStoreValue extends DashboardState {
ensureCityMarketScan: (
cityName: string,
force?: boolean,
options?: {
lite?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
) => Promise<CityDetail["market_scan"] | null>;
focusCity: (cityName: string) => Promise<void>;
forecastModalMode: ForecastModalMode | null;
@@ -298,6 +303,39 @@ function mergeCityDetail(
};
}
function mergeMarketScan(
current: CityDetail["market_scan"] | undefined,
incoming: CityDetail["market_scan"] | null | undefined,
): CityDetail["market_scan"] | undefined {
if (!incoming) return current;
if (!current) return incoming || undefined;
const preserveHeavySlices = incoming.scan_scope === "lite";
const nextTopBuckets =
preserveHeavySlices &&
(!Array.isArray(incoming.top_buckets) || incoming.top_buckets.length === 0)
? current.top_buckets
: incoming.top_buckets;
const nextAllBuckets =
preserveHeavySlices &&
(!Array.isArray(incoming.all_buckets) || incoming.all_buckets.length === 0)
? current.all_buckets
: incoming.all_buckets;
const nextRecentTrades =
preserveHeavySlices &&
(!Array.isArray(incoming.recent_trades) || incoming.recent_trades.length === 0)
? current.recent_trades
: incoming.recent_trades;
return {
...current,
...incoming,
top_buckets: nextTopBuckets,
all_buckets: nextAllBuckets,
recent_trades: nextRecentTrades,
};
}
function toHistoryMeta(payload: HistoryPayload): HistoryPayloadMeta {
const history = Array.isArray(payload.history) ? payload.history : [];
const previewCount = Number(payload.preview_count || history.length || 0);
@@ -569,7 +607,15 @@ export function DashboardStoreProvider({
return detail;
};
const ensureCityMarketScan = async (cityName: string, force = false) => {
const ensureCityMarketScan = async (
cityName: string,
force = false,
options?: {
lite?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
) => {
let cached = cityDetailsByName[cityName];
try {
if (!cached) {
@@ -577,7 +623,10 @@ export function DashboardStoreProvider({
}
const payload = await dashboardClient.getCityMarketScan(cityName, {
force,
targetDate: cached?.local_date || selectedForecastDate || null,
lite: options?.lite === true,
marketSlug: options?.marketSlug || null,
targetDate:
options?.targetDate || cached?.local_date || selectedForecastDate || null,
});
if (!payload.market_scan) return null;
setCityDetailsByName((current) => {
@@ -587,7 +636,7 @@ export function DashboardStoreProvider({
...current,
[cityName]: {
...detail,
market_scan: payload.market_scan || undefined,
market_scan: mergeMarketScan(detail.market_scan, payload.market_scan),
},
};
});
+5
View File
@@ -303,6 +303,7 @@ export const dashboardClient = {
cityName: string,
options?: {
force?: boolean;
lite?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
@@ -318,9 +319,13 @@ export const dashboardClient = {
if (options?.marketSlug) {
params.set("market_slug", options.marketSlug);
}
if (options?.lite) {
params.set("lite", "true");
}
const requestKey = [
cityName,
force ? "force" : "cached",
options?.lite ? "lite" : "full",
options?.targetDate || "",
options?.marketSlug || "",
].join("::");
+11
View File
@@ -369,23 +369,34 @@ export interface MarketScan {
temperature_bucket?: ProbabilityBucket | null;
model_probability?: number | null;
market_price?: number | null;
midpoint?: number | null;
spread?: number | null;
edge_percent?: number | null;
signal_label?: string | null;
confidence?: string | null;
probability_engine?: string | null;
probability_calibration_mode?: string | null;
yes_token?: MarketToken | null;
no_token?: MarketToken | null;
yes_buy?: number | null;
yes_sell?: number | null;
yes_midpoint?: number | null;
yes_spread?: number | null;
no_buy?: number | null;
no_sell?: number | null;
no_midpoint?: number | null;
no_spread?: number | null;
last_trade_price?: number | null;
liquidity?: number | null;
volume?: number | null;
quote_source?: string | null;
quote_age_ms?: number | null;
price_analysis?: MarketPriceAnalysis | null;
sparkline?: number[];
top_buckets?: MarketTopBucket[] | null;
all_buckets?: MarketTopBucket[] | null;
recent_trades?: unknown[];
scan_scope?: "lite" | "full" | string | null;
websocket?: Record<string, unknown>;
}
+11
View File
@@ -283,18 +283,28 @@ export interface MarketScan {
temperature_bucket: any | null;
model_probability: number | null;
market_price: number | null;
midpoint?: number | null;
spread?: number | null;
edge_percent: number | null;
signal_label: "BUY YES" | "BUY NO" | "MONITOR";
confidence: "low" | "medium" | "high";
probability_engine?: string | null;
probability_calibration_mode?: string | null;
yes_token: MarketToken | null;
no_token: MarketToken | null;
yes_buy: number | null;
yes_sell: number | null;
yes_midpoint?: number | null;
yes_spread?: number | null;
no_buy: number | null;
no_sell: number | null;
no_midpoint?: number | null;
no_spread?: number | null;
last_trade_price: number | null;
liquidity: number | null;
volume: number | null;
quote_source?: string | null;
quote_age_ms?: number | null;
sparkline: number[];
top_buckets?: Array<{
label?: string | null;
@@ -311,6 +321,7 @@ export interface MarketScan {
is_primary?: boolean;
}>;
recent_trades: Trade[];
scan_scope?: "lite" | "full" | string | null;
websocket: any;
}
+76 -98
View File
@@ -3,7 +3,7 @@ Polymarket read-only market layer.
P0 scope:
- Market discovery from Gamma REST
- Price / orderbook read from py-clob-client public methods (fallback to CLOB REST)
- Price / midpoint / spread / orderbook read from CLOB REST
- No signing, no order placement
"""
@@ -23,12 +23,6 @@ import httpx
from loguru import logger
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
from src.data_collection.polymarket_ws_cache import PolymarketWsQuoteCache
try:
from py_clob_client.client import ClobClient # type: ignore
except Exception: # pragma: no cover - optional dependency in P0
ClobClient = None
def _safe_float(value: Any) -> Optional[float]:
@@ -382,15 +376,14 @@ class PolymarketReadOnlyLayer:
.strip()
.rstrip("/")
)
self.chain_id = _safe_int(os.getenv("POLYMARKET_CHAIN_ID", "137"), 137)
self.http_timeout = _safe_float(os.getenv("POLYMARKET_HTTP_TIMEOUT_SEC")) or 8.0
self.market_cache_ttl = _safe_int(
os.getenv("POLYMARKET_MARKET_CACHE_TTL_SEC", "180"),
180,
os.getenv("POLYMARKET_MARKET_CACHE_TTL_SEC", "60"),
60,
)
self.price_cache_ttl = _safe_int(
os.getenv("POLYMARKET_PRICE_CACHE_TTL_SEC", "10"),
10,
os.getenv("POLYMARKET_PRICE_CACHE_TTL_SEC", "30"),
30,
)
self.discovery_pages = _safe_int(
os.getenv("POLYMARKET_DISCOVERY_PAGES", "6"),
@@ -414,9 +407,6 @@ class PolymarketReadOnlyLayer:
self._broad_markets_cache: Dict[str, Any] = {"data": [], "t": 0.0}
self._price_cache: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock()
self._clob_client: Any = None
self._clob_unavailable_reason: Optional[str] = None
self._ws_quote_cache = PolymarketWsQuoteCache.from_env()
def _market_scan_debug_enabled(self) -> bool:
return (
@@ -441,6 +431,7 @@ class PolymarketReadOnlyLayer:
model_probability: Optional[float] = None,
fallback_sparkline: Optional[List[float]] = None,
forced_market_slug: Optional[str] = None,
include_related_buckets: bool = True,
) -> Dict[str, Any]:
date_str = _extract_iso_date(target_date) or str(target_date or "")
city_key = _normalize_city_key(city)
@@ -459,6 +450,8 @@ class PolymarketReadOnlyLayer:
"temperature_bucket": temperature_bucket,
"model_probability": model_probability,
"market_price": None,
"midpoint": None,
"spread": None,
"edge_percent": None,
"signal_label": "MONITOR",
"confidence": "low",
@@ -466,16 +459,27 @@ class PolymarketReadOnlyLayer:
"no_token": None,
"yes_buy": None,
"yes_sell": None,
"yes_midpoint": None,
"yes_spread": None,
"no_buy": None,
"no_sell": None,
"no_midpoint": None,
"no_spread": None,
"last_trade_price": None,
"liquidity": None,
"volume": None,
"quote_source": None,
"quote_age_ms": None,
"price_analysis": None,
"sparkline": fallback_sparkline or [],
"top_buckets": [],
"all_buckets": [],
"recent_trades": [],
"websocket": {},
"scan_scope": "full" if include_related_buckets else "lite",
"websocket": {
"enabled": False,
"status": "disabled_rest_only",
},
}
if not self.enabled:
@@ -632,21 +636,24 @@ class PolymarketReadOnlyLayer:
signal_label, confidence = self._derive_signal(edge_percent, liquidity)
top_bucket_limit = max(
1,
_safe_int(os.getenv("POLYMARKET_TOP_BUCKET_LIMIT", "4"), 4),
)
all_bucket_limit = max(
top_bucket_limit,
_safe_int(os.getenv("POLYMARKET_ALL_BUCKET_LIMIT", "8"), 8),
)
all_buckets = self._build_top_temperature_buckets(
city_key=market_city_key,
target_date=date_str,
primary_market=market,
limit=all_bucket_limit,
)
top_buckets = list(all_buckets[:top_bucket_limit])
top_buckets: List[Dict[str, Any]] = []
all_buckets: List[Dict[str, Any]] = []
if include_related_buckets:
top_bucket_limit = max(
1,
_safe_int(os.getenv("POLYMARKET_TOP_BUCKET_LIMIT", "4"), 4),
)
all_bucket_limit = max(
top_bucket_limit,
_safe_int(os.getenv("POLYMARKET_ALL_BUCKET_LIMIT", "8"), 8),
)
all_buckets = self._build_top_temperature_buckets(
city_key=market_city_key,
target_date=date_str,
primary_market=market,
limit=all_bucket_limit,
)
top_buckets = list(all_buckets[:top_bucket_limit])
yes_payload = {
"outcome": yes_token.get("outcome") or "Yes",
@@ -672,12 +679,28 @@ class PolymarketReadOnlyLayer:
"quote_age_ms": _safe_int(no_prices.get("quote_age_ms"), 0),
"book": no_prices.get("book"),
}
yes_midpoint = _extract_price(yes_payload.get("midpoint"))
no_midpoint = _extract_price(no_payload.get("midpoint"))
yes_buy = _extract_price(yes_payload.get("buy_price"))
yes_sell = _extract_price(yes_payload.get("sell_price"))
no_buy = _extract_price(no_payload.get("buy_price"))
no_sell = _extract_price(no_payload.get("sell_price"))
yes_spread = (
max(0.0, float(yes_buy) - float(yes_sell))
if yes_buy is not None and yes_sell is not None
else None
)
no_spread = (
max(0.0, float(no_buy) - float(no_sell))
if no_buy is not None and no_sell is not None
else None
)
price_analysis = self._build_price_analysis(
model_probability=model_probability,
yes_buy=_extract_price(yes_payload.get("buy_price")),
yes_sell=_extract_price(yes_payload.get("sell_price")),
no_buy=_extract_price(no_payload.get("buy_price")),
no_sell=_extract_price(no_payload.get("sell_price")),
yes_buy=yes_buy,
yes_sell=yes_sell,
no_buy=no_buy,
no_sell=no_sell,
)
sparkline_values: List[float] = []
@@ -702,27 +725,35 @@ class PolymarketReadOnlyLayer:
"selected_condition_id": condition_id,
"selected_slug": market_slug,
"market_price": market_price,
"midpoint": yes_midpoint if yes_midpoint is not None else market_price,
"spread": yes_spread,
"edge_percent": edge_percent,
"signal_label": signal_label,
"confidence": confidence,
"yes_token": yes_payload,
"no_token": no_payload,
"yes_buy": _extract_price(yes_payload.get("buy_price")),
"yes_sell": _extract_price(yes_payload.get("sell_price")),
"no_buy": _extract_price(no_payload.get("buy_price")),
"no_sell": _extract_price(no_payload.get("sell_price")),
"yes_buy": yes_buy,
"yes_sell": yes_sell,
"yes_midpoint": yes_midpoint,
"yes_spread": yes_spread,
"no_buy": no_buy,
"no_sell": no_sell,
"no_midpoint": no_midpoint,
"no_spread": no_spread,
"last_trade_price": last_trade_price,
"liquidity": liquidity,
"volume": volume,
"quote_source": yes_prices.get("quote_source"),
"quote_age_ms": _safe_int(yes_prices.get("quote_age_ms"), 0),
"price_analysis": price_analysis,
"sparkline": sparkline_values,
"top_buckets": top_buckets,
"all_buckets": all_buckets,
"websocket": {
"enabled": self._ws_quote_cache.enabled,
"status": self._ws_quote_cache.status(),
"market_url": market_url,
"asset_ids": [
"websocket": {
"enabled": False,
"status": "disabled_rest_only",
"market_url": market_url,
"asset_ids": [
token
for token in [
yes_payload.get("token_id"),
@@ -1488,32 +1519,11 @@ class PolymarketReadOnlyLayer:
return None, None
def _get_clob_client(self) -> Optional[Any]:
if self._clob_unavailable_reason:
return None
if self._clob_client is not None:
return self._clob_client
if ClobClient is None:
self._clob_unavailable_reason = "py-clob-client is not installed."
return None
try:
self._clob_client = ClobClient(host=self.clob_url, chain_id=self.chain_id)
return self._clob_client
except Exception as exc:
self._clob_unavailable_reason = f"ClobClient init failed: {exc}"
logger.warning(self._clob_unavailable_reason)
return None
def _get_token_market_data(self, token_id: str) -> Dict[str, Any]:
token_id = str(token_id or "").strip()
if not token_id:
return {}
self._ws_quote_cache.subscribe([token_id])
ws_data = self._ws_quote_cache.get_market_data(token_id)
if ws_data is not None:
return ws_data
now = time.time()
with self._lock:
cached = self._price_cache.get(token_id)
@@ -1527,33 +1537,7 @@ class PolymarketReadOnlyLayer:
return data
def _fetch_token_market_data(self, token_id: str) -> Dict[str, Any]:
# 1) Preferred path: py-clob-client public methods.
clob = self._get_clob_client()
if clob is not None:
try:
buy = _extract_price(self._safe_call(clob, "get_price", token_id, "BUY"))
sell = _extract_price(self._safe_call(clob, "get_price", token_id, "SELL"))
midpoint = _extract_price(self._safe_call(clob, "get_midpoint", token_id))
last_trade = _extract_price(
self._safe_call(clob, "get_last_trade_price", token_id)
)
orderbook_raw = self._safe_call(clob, "get_order_book", token_id)
book, book_liquidity = self._normalize_orderbook(orderbook_raw)
buy, sell = self._resolve_trade_prices(buy=buy, sell=sell, book=book)
return {
"buy": buy,
"sell": sell,
"midpoint": midpoint,
"last_trade_price": last_trade,
"quote_source": "polymarket_clob_client",
"quote_age_ms": 0,
"book": book,
"book_liquidity": book_liquidity,
}
except Exception as exc:
logger.warning(f"py-clob-client read failed for {token_id}: {exc}")
# 2) Fallback path: direct CLOB REST.
# REST-only path: CLOB public endpoints.
buy = _extract_price(self._clob_get("/price", {"token_id": token_id, "side": "BUY"}))
sell = _extract_price(
self._clob_get("/price", {"token_id": token_id, "side": "SELL"})
@@ -1576,12 +1560,6 @@ class PolymarketReadOnlyLayer:
"book_liquidity": book_liquidity,
}
def _safe_call(self, client: Any, method: str, *args: Any) -> Any:
fn = getattr(client, method, None)
if not callable(fn):
return None
return fn(*args)
def _clob_get(self, path: str, params: Dict[str, Any]) -> Any:
url = f"{self.clob_url}{path}"
try:
+79 -47
View File
@@ -37,31 +37,25 @@ def test_extract_market_bucket_range_supports_fahrenheit_ranges():
assert layer._extract_market_bucket_label(market, 80.5) == "80-81F"
def test_fetch_token_market_data_prefers_orderbook_executable_prices():
class FakeClob:
@staticmethod
def get_price(_token_id: str, side: str):
if side == "BUY":
return {"price": "0.11"}
return {"price": "0.88"}
@staticmethod
def get_midpoint(_token_id: str):
return {"midpoint": "0.50"}
@staticmethod
def get_last_trade_price(_token_id: str):
return {"price": "0.49"}
@staticmethod
def get_order_book(_token_id: str):
return {
"bids": [{"price": "0.24", "size": "10"}],
"asks": [{"price": "0.26", "size": "12"}],
}
def test_fetch_token_market_data_uses_rest_orderbook_executable_prices():
layer = PolymarketReadOnlyLayer()
layer._get_clob_client = lambda: FakeClob()
payloads = {
("/price", "BUY"): {"price": "0.11"},
("/price", "SELL"): {"price": "0.88"},
("/midpoint", None): {"midpoint": "0.50"},
("/last-trade-price", None): {"price": "0.49"},
("/book", None): {
"bids": [{"price": "0.24", "size": "10"}],
"asks": [{"price": "0.26", "size": "12"}],
},
}
def _fake_clob_get(path, params):
if path == "/price":
return payloads[(path, params.get("side"))]
return payloads[(path, None)]
layer._clob_get = _fake_clob_get
data = layer._fetch_token_market_data("token-1")
@@ -71,36 +65,25 @@ def test_fetch_token_market_data_prefers_orderbook_executable_prices():
assert data["sell"] == 0.24
assert data["midpoint"] == 0.5
assert data["last_trade_price"] == 0.49
assert data["quote_source"] == "polymarket_clob_client"
assert data["quote_source"] == "polymarket_clob_rest"
def test_get_token_market_data_prefers_fresh_ws_cache():
def test_get_token_market_data_uses_price_cache_within_ttl():
layer = PolymarketReadOnlyLayer()
calls = []
class FakeWsCache:
enabled = True
def _fake_fetch(_token_id):
calls.append(_token_id)
return {"buy": 0.33, "sell": 0.31, "midpoint": 0.32}
def subscribe(self, asset_ids):
self.asset_ids = list(asset_ids)
layer._fetch_token_market_data = _fake_fetch
@staticmethod
def get_market_data(_token_id):
return {
"buy": 0.33,
"sell": 0.31,
"midpoint": 0.32,
"quote_source": "polymarket_ws",
"quote_age_ms": 80,
}
first = layer._get_token_market_data("token-1")
second = layer._get_token_market_data("token-1")
layer._ws_quote_cache = FakeWsCache()
layer._fetch_token_market_data = lambda _token_id: {"buy": 0.99}
data = layer._get_token_market_data("token-1")
assert data["buy"] == 0.33
assert data["sell"] == 0.31
assert data["quote_source"] == "polymarket_ws"
assert first["buy"] == 0.33
assert second["midpoint"] == 0.32
assert calls == ["token-1"]
def test_price_analysis_computes_edge_kelly_and_lock():
@@ -200,6 +183,55 @@ def test_lau_fau_shan_uses_shenzhen_market_city():
assert scan["selected_slug"] == "highest-temperature-in-shenzhen-on-april-23-2026-30c-or-higher"
def test_build_market_scan_lite_skips_related_buckets():
layer = PolymarketReadOnlyLayer()
layer._find_primary_market = lambda *_args, **_kwargs: (
{
"id": "market-1",
"question": "Will the highest temperature in Shenzhen be 30C or higher on April 23?",
"slug": "highest-temperature-in-shenzhen-on-april-23-2026-30c-or-higher",
"conditionId": "condition-1",
"active": True,
"closed": False,
"acceptingOrders": True,
},
None,
)
layer._extract_market_tokens = lambda _market: [
{"outcome": "Yes", "token_id": "yes-token"},
{"outcome": "No", "token_id": "no-token"},
]
layer._get_token_market_data = lambda token_id: (
{"buy": 0.42, "sell": 0.40, "midpoint": 0.41}
if token_id == "yes-token"
else {"buy": 0.61, "sell": 0.59, "midpoint": 0.60}
)
called = {"bucket": 0}
def _fake_build_top_temperature_buckets(**_kwargs):
called["bucket"] += 1
return [{"value": 30.0, "market_price": 0.41}]
layer._build_top_temperature_buckets = _fake_build_top_temperature_buckets
scan = layer.build_market_scan(
city="Shenzhen",
target_date="2026-04-23",
temperature_bucket={"temp": 30, "probability": 0.58},
model_probability=0.58,
include_related_buckets=False,
)
assert scan["scan_scope"] == "lite"
assert scan["midpoint"] == 0.41
assert round(scan["spread"], 6) == 0.02
assert scan["top_buckets"] == []
assert scan["all_buckets"] == []
assert called["bucket"] == 0
def test_hydrate_bucket_prices_uses_executable_quotes_without_midpoint():
layer = PolymarketReadOnlyLayer()
buckets = [
+9
View File
@@ -2801,6 +2801,7 @@ def _build_city_market_scan_payload(
data: Dict[str, Any],
market_slug: Optional[str] = None,
target_date: Optional[str] = None,
lite: bool = False,
) -> Dict[str, Any]:
city = str(data.get("name") or "").strip().lower()
local_date = str(data.get("local_date") or "").strip()
@@ -2882,12 +2883,20 @@ def _build_city_market_scan_payload(
model_probability=model_probability,
fallback_sparkline=fallback_sparkline,
forced_market_slug=market_slug,
include_related_buckets=not lite,
)
if isinstance(market_scan, dict):
market_scan["anchor_model"] = anchor_model
market_scan["anchor_high"] = anchor_temp
market_scan["anchor_settlement"] = anchor_settlement
market_scan["open_meteo_settlement"] = anchor_settlement
probabilities = data.get("probabilities") or {}
market_scan["probability_engine"] = str(
probabilities.get("engine") or "legacy"
).strip() or "legacy"
market_scan["probability_calibration_mode"] = str(
probabilities.get("calibration_mode") or "legacy"
).strip() or "legacy"
return {
"market_scan": market_scan,
"selected_date": selected_date or data.get("local_date"),
+16 -2
View File
@@ -148,7 +148,7 @@ CITY_NEARBY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_NEARBY_CACHE
CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "900")))
MARKET_SCAN_PAYLOAD_TTL_SEC = max(
5,
int(os.getenv("POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC", "20")),
int(os.getenv("POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC", "30")),
)
CITY_HISTORY_PREVIEW_CACHE_TTL_SEC = max(
60,
@@ -181,6 +181,7 @@ def _market_scan_cache_key(
data: dict,
market_slug: Optional[str] = None,
target_date: Optional[str] = None,
lite: bool = False,
) -> str:
local_date = str(data.get("local_date") or "").strip()
requested_date = str(target_date or "").strip()
@@ -189,7 +190,7 @@ def _market_scan_cache_key(
if requested_date and isinstance(multi_model_daily, dict) and requested_date not in multi_model_daily:
selected_date = local_date
normalized_slug = str(market_slug or "").strip().lower()
return f"{selected_date}|{normalized_slug}"
return f"{selected_date}|{normalized_slug}|lite={1 if lite else 0}"
def _attach_market_scan_payload(
@@ -197,6 +198,7 @@ def _attach_market_scan_payload(
*,
market_slug: Optional[str] = None,
target_date: Optional[str] = None,
lite: bool = False,
) -> dict:
if not isinstance(payload, dict):
return payload
@@ -204,6 +206,7 @@ def _attach_market_scan_payload(
payload,
market_slug=market_slug,
target_date=target_date,
lite=lite,
)
now_ts = time.time()
payload["market_scan_payload"] = scan_payload
@@ -213,6 +216,7 @@ def _attach_market_scan_payload(
payload,
market_slug=market_slug,
target_date=target_date,
lite=lite,
)
return payload
@@ -222,6 +226,7 @@ def _get_cached_market_scan_payload(
*,
market_slug: Optional[str] = None,
target_date: Optional[str] = None,
lite: bool = False,
) -> Optional[dict]:
if not isinstance(payload, dict):
return None
@@ -232,6 +237,7 @@ def _get_cached_market_scan_payload(
payload,
market_slug=market_slug,
target_date=target_date,
lite=lite,
)
cached_key = str(payload.get("market_scan_cache_key") or "")
if cached_key != expected_key:
@@ -250,11 +256,13 @@ def _refresh_market_scan_payload_from_cached_analysis(
*,
market_slug: Optional[str] = None,
target_date: Optional[str] = None,
lite: bool = False,
) -> dict:
_attach_market_scan_payload(
payload,
market_slug=market_slug,
target_date=target_date,
lite=lite,
)
_CACHE_DB.set_city_cache(
"market",
@@ -1629,6 +1637,7 @@ async def city_market_scan(
force_refresh: bool = False,
market_slug: Optional[str] = None,
target_date: Optional[str] = None,
lite: bool = False,
):
_assert_entitlement(request)
city = _normalize_city_or_404(name)
@@ -1638,6 +1647,7 @@ async def city_market_scan(
data,
market_slug=market_slug,
target_date=target_date,
lite=lite,
)
if cached_scan is not None:
return cached_scan
@@ -1649,6 +1659,7 @@ async def city_market_scan(
data,
market_slug=market_slug,
target_date=target_date,
lite=lite,
)
if cached_scan is not None:
if not _market_analysis_cache_is_fresh(cached_entry):
@@ -1661,6 +1672,7 @@ async def city_market_scan(
data,
market_slug=market_slug,
target_date=target_date,
lite=lite,
)
else:
_schedule_cache_refresh(background_tasks, kind="market", city=city)
@@ -1670,6 +1682,7 @@ async def city_market_scan(
data,
market_slug=market_slug,
target_date=target_date,
lite=lite,
)
if cached_scan is not None:
return cached_scan
@@ -1678,5 +1691,6 @@ async def city_market_scan(
data,
market_slug,
target_date,
lite,
)