feat: implement comprehensive Polymarket weather analysis service with frontend dashboard and market scanning capabilities
This commit is contained in:
+3
-2
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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("::");
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user