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_CLOB_URL=https://clob.polymarket.com
|
||||||
POLYMARKET_CHAIN_ID=137
|
POLYMARKET_CHAIN_ID=137
|
||||||
POLYMARKET_HTTP_TIMEOUT_SEC=8
|
POLYMARKET_HTTP_TIMEOUT_SEC=8
|
||||||
POLYMARKET_MARKET_CACHE_TTL_SEC=180
|
POLYMARKET_MARKET_CACHE_TTL_SEC=60
|
||||||
POLYMARKET_PRICE_CACHE_TTL_SEC=10
|
POLYMARKET_PRICE_CACHE_TTL_SEC=30
|
||||||
|
POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC=30
|
||||||
POLYMARKET_WS_PRICE_ENABLED=false
|
POLYMARKET_WS_PRICE_ENABLED=false
|
||||||
POLYMARKET_WS_MARKET_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market
|
POLYMARKET_WS_MARKET_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market
|
||||||
POLYMARKET_WS_QUOTE_TTL_SEC=8
|
POLYMARKET_WS_QUOTE_TTL_SEC=8
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export async function GET(
|
|||||||
params.set("market_slug", marketSlug);
|
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()}`;
|
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -746,6 +746,7 @@ export function FutureForecastModal() {
|
|||||||
dashboardClient
|
dashboardClient
|
||||||
.getCityMarketScan(cityName, {
|
.getCityMarketScan(cityName, {
|
||||||
force: false,
|
force: false,
|
||||||
|
lite: false,
|
||||||
marketSlug: detail.market_scan?.primary_market?.slug || null,
|
marketSlug: detail.market_scan?.primary_market?.slug || null,
|
||||||
targetDate: dateStr,
|
targetDate: dateStr,
|
||||||
})
|
})
|
||||||
@@ -766,7 +767,7 @@ export function FutureForecastModal() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
refreshMarketScan();
|
refreshMarketScan();
|
||||||
}, 3000);
|
}, 30_000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ type AssistantMessage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type OpportunityScanState = "pending" | "complete" | "empty" | "error";
|
type OpportunityScanState = "pending" | "complete" | "empty" | "error";
|
||||||
|
const HOME_OPPORTUNITY_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
type AssistantDockPosition = {
|
type AssistantDockPosition = {
|
||||||
right: number;
|
right: number;
|
||||||
@@ -2395,6 +2396,7 @@ function DashboardScreen() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const didAutoFocusRef = useRef(false);
|
const didAutoFocusRef = useRef(false);
|
||||||
const marketScanInflightRef = useRef<Set<string>>(new Set());
|
const marketScanInflightRef = useRef<Set<string>>(new Set());
|
||||||
|
const marketScanPollingRef = useRef(false);
|
||||||
const [marketScanStatusByCity, setMarketScanStatusByCity] = useState<
|
const [marketScanStatusByCity, setMarketScanStatusByCity] = useState<
|
||||||
Record<string, OpportunityScanState>
|
Record<string, OpportunityScanState>
|
||||||
>({});
|
>({});
|
||||||
@@ -2517,7 +2519,7 @@ function DashboardScreen() {
|
|||||||
const existingDetail = store.cityDetailsByName[cityName];
|
const existingDetail = store.cityDetailsByName[cityName];
|
||||||
const marketScan =
|
const marketScan =
|
||||||
existingDetail?.market_scan ||
|
existingDetail?.market_scan ||
|
||||||
(await store.ensureCityMarketScan(cityName, false));
|
(await store.ensureCityMarketScan(cityName, false, { lite: true }));
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setMarketScanStatusByCity((current) => ({
|
setMarketScanStatusByCity((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -2552,6 +2554,57 @@ function DashboardScreen() {
|
|||||||
store.proAccess.loading,
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ interface DashboardStoreValue extends DashboardState {
|
|||||||
ensureCityMarketScan: (
|
ensureCityMarketScan: (
|
||||||
cityName: string,
|
cityName: string,
|
||||||
force?: boolean,
|
force?: boolean,
|
||||||
|
options?: {
|
||||||
|
lite?: boolean;
|
||||||
|
marketSlug?: string | null;
|
||||||
|
targetDate?: string | null;
|
||||||
|
},
|
||||||
) => Promise<CityDetail["market_scan"] | null>;
|
) => Promise<CityDetail["market_scan"] | null>;
|
||||||
focusCity: (cityName: string) => Promise<void>;
|
focusCity: (cityName: string) => Promise<void>;
|
||||||
forecastModalMode: ForecastModalMode | null;
|
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 {
|
function toHistoryMeta(payload: HistoryPayload): HistoryPayloadMeta {
|
||||||
const history = Array.isArray(payload.history) ? payload.history : [];
|
const history = Array.isArray(payload.history) ? payload.history : [];
|
||||||
const previewCount = Number(payload.preview_count || history.length || 0);
|
const previewCount = Number(payload.preview_count || history.length || 0);
|
||||||
@@ -569,7 +607,15 @@ export function DashboardStoreProvider({
|
|||||||
return detail;
|
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];
|
let cached = cityDetailsByName[cityName];
|
||||||
try {
|
try {
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
@@ -577,7 +623,10 @@ export function DashboardStoreProvider({
|
|||||||
}
|
}
|
||||||
const payload = await dashboardClient.getCityMarketScan(cityName, {
|
const payload = await dashboardClient.getCityMarketScan(cityName, {
|
||||||
force,
|
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;
|
if (!payload.market_scan) return null;
|
||||||
setCityDetailsByName((current) => {
|
setCityDetailsByName((current) => {
|
||||||
@@ -587,7 +636,7 @@ export function DashboardStoreProvider({
|
|||||||
...current,
|
...current,
|
||||||
[cityName]: {
|
[cityName]: {
|
||||||
...detail,
|
...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,
|
cityName: string,
|
||||||
options?: {
|
options?: {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
lite?: boolean;
|
||||||
marketSlug?: string | null;
|
marketSlug?: string | null;
|
||||||
targetDate?: string | null;
|
targetDate?: string | null;
|
||||||
},
|
},
|
||||||
@@ -318,9 +319,13 @@ export const dashboardClient = {
|
|||||||
if (options?.marketSlug) {
|
if (options?.marketSlug) {
|
||||||
params.set("market_slug", options.marketSlug);
|
params.set("market_slug", options.marketSlug);
|
||||||
}
|
}
|
||||||
|
if (options?.lite) {
|
||||||
|
params.set("lite", "true");
|
||||||
|
}
|
||||||
const requestKey = [
|
const requestKey = [
|
||||||
cityName,
|
cityName,
|
||||||
force ? "force" : "cached",
|
force ? "force" : "cached",
|
||||||
|
options?.lite ? "lite" : "full",
|
||||||
options?.targetDate || "",
|
options?.targetDate || "",
|
||||||
options?.marketSlug || "",
|
options?.marketSlug || "",
|
||||||
].join("::");
|
].join("::");
|
||||||
|
|||||||
@@ -369,23 +369,34 @@ export interface MarketScan {
|
|||||||
temperature_bucket?: ProbabilityBucket | null;
|
temperature_bucket?: ProbabilityBucket | null;
|
||||||
model_probability?: number | null;
|
model_probability?: number | null;
|
||||||
market_price?: number | null;
|
market_price?: number | null;
|
||||||
|
midpoint?: number | null;
|
||||||
|
spread?: number | null;
|
||||||
edge_percent?: number | null;
|
edge_percent?: number | null;
|
||||||
signal_label?: string | null;
|
signal_label?: string | null;
|
||||||
confidence?: string | null;
|
confidence?: string | null;
|
||||||
|
probability_engine?: string | null;
|
||||||
|
probability_calibration_mode?: string | null;
|
||||||
yes_token?: MarketToken | null;
|
yes_token?: MarketToken | null;
|
||||||
no_token?: MarketToken | null;
|
no_token?: MarketToken | null;
|
||||||
yes_buy?: number | null;
|
yes_buy?: number | null;
|
||||||
yes_sell?: number | null;
|
yes_sell?: number | null;
|
||||||
|
yes_midpoint?: number | null;
|
||||||
|
yes_spread?: number | null;
|
||||||
no_buy?: number | null;
|
no_buy?: number | null;
|
||||||
no_sell?: number | null;
|
no_sell?: number | null;
|
||||||
|
no_midpoint?: number | null;
|
||||||
|
no_spread?: number | null;
|
||||||
last_trade_price?: number | null;
|
last_trade_price?: number | null;
|
||||||
liquidity?: number | null;
|
liquidity?: number | null;
|
||||||
volume?: number | null;
|
volume?: number | null;
|
||||||
|
quote_source?: string | null;
|
||||||
|
quote_age_ms?: number | null;
|
||||||
price_analysis?: MarketPriceAnalysis | null;
|
price_analysis?: MarketPriceAnalysis | null;
|
||||||
sparkline?: number[];
|
sparkline?: number[];
|
||||||
top_buckets?: MarketTopBucket[] | null;
|
top_buckets?: MarketTopBucket[] | null;
|
||||||
all_buckets?: MarketTopBucket[] | null;
|
all_buckets?: MarketTopBucket[] | null;
|
||||||
recent_trades?: unknown[];
|
recent_trades?: unknown[];
|
||||||
|
scan_scope?: "lite" | "full" | string | null;
|
||||||
websocket?: Record<string, unknown>;
|
websocket?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -283,18 +283,28 @@ export interface MarketScan {
|
|||||||
temperature_bucket: any | null;
|
temperature_bucket: any | null;
|
||||||
model_probability: number | null;
|
model_probability: number | null;
|
||||||
market_price: number | null;
|
market_price: number | null;
|
||||||
|
midpoint?: number | null;
|
||||||
|
spread?: number | null;
|
||||||
edge_percent: number | null;
|
edge_percent: number | null;
|
||||||
signal_label: "BUY YES" | "BUY NO" | "MONITOR";
|
signal_label: "BUY YES" | "BUY NO" | "MONITOR";
|
||||||
confidence: "low" | "medium" | "high";
|
confidence: "low" | "medium" | "high";
|
||||||
|
probability_engine?: string | null;
|
||||||
|
probability_calibration_mode?: string | null;
|
||||||
yes_token: MarketToken | null;
|
yes_token: MarketToken | null;
|
||||||
no_token: MarketToken | null;
|
no_token: MarketToken | null;
|
||||||
yes_buy: number | null;
|
yes_buy: number | null;
|
||||||
yes_sell: number | null;
|
yes_sell: number | null;
|
||||||
|
yes_midpoint?: number | null;
|
||||||
|
yes_spread?: number | null;
|
||||||
no_buy: number | null;
|
no_buy: number | null;
|
||||||
no_sell: number | null;
|
no_sell: number | null;
|
||||||
|
no_midpoint?: number | null;
|
||||||
|
no_spread?: number | null;
|
||||||
last_trade_price: number | null;
|
last_trade_price: number | null;
|
||||||
liquidity: number | null;
|
liquidity: number | null;
|
||||||
volume: number | null;
|
volume: number | null;
|
||||||
|
quote_source?: string | null;
|
||||||
|
quote_age_ms?: number | null;
|
||||||
sparkline: number[];
|
sparkline: number[];
|
||||||
top_buckets?: Array<{
|
top_buckets?: Array<{
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
@@ -311,6 +321,7 @@ export interface MarketScan {
|
|||||||
is_primary?: boolean;
|
is_primary?: boolean;
|
||||||
}>;
|
}>;
|
||||||
recent_trades: Trade[];
|
recent_trades: Trade[];
|
||||||
|
scan_scope?: "lite" | "full" | string | null;
|
||||||
websocket: any;
|
websocket: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Polymarket read-only market layer.
|
|||||||
|
|
||||||
P0 scope:
|
P0 scope:
|
||||||
- Market discovery from Gamma REST
|
- 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
|
- No signing, no order placement
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -23,12 +23,6 @@ import httpx
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
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]:
|
def _safe_float(value: Any) -> Optional[float]:
|
||||||
@@ -382,15 +376,14 @@ class PolymarketReadOnlyLayer:
|
|||||||
.strip()
|
.strip()
|
||||||
.rstrip("/")
|
.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.http_timeout = _safe_float(os.getenv("POLYMARKET_HTTP_TIMEOUT_SEC")) or 8.0
|
||||||
self.market_cache_ttl = _safe_int(
|
self.market_cache_ttl = _safe_int(
|
||||||
os.getenv("POLYMARKET_MARKET_CACHE_TTL_SEC", "180"),
|
os.getenv("POLYMARKET_MARKET_CACHE_TTL_SEC", "60"),
|
||||||
180,
|
60,
|
||||||
)
|
)
|
||||||
self.price_cache_ttl = _safe_int(
|
self.price_cache_ttl = _safe_int(
|
||||||
os.getenv("POLYMARKET_PRICE_CACHE_TTL_SEC", "10"),
|
os.getenv("POLYMARKET_PRICE_CACHE_TTL_SEC", "30"),
|
||||||
10,
|
30,
|
||||||
)
|
)
|
||||||
self.discovery_pages = _safe_int(
|
self.discovery_pages = _safe_int(
|
||||||
os.getenv("POLYMARKET_DISCOVERY_PAGES", "6"),
|
os.getenv("POLYMARKET_DISCOVERY_PAGES", "6"),
|
||||||
@@ -414,9 +407,6 @@ class PolymarketReadOnlyLayer:
|
|||||||
self._broad_markets_cache: Dict[str, Any] = {"data": [], "t": 0.0}
|
self._broad_markets_cache: Dict[str, Any] = {"data": [], "t": 0.0}
|
||||||
self._price_cache: Dict[str, Dict[str, Any]] = {}
|
self._price_cache: Dict[str, Dict[str, Any]] = {}
|
||||||
self._lock = threading.Lock()
|
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:
|
def _market_scan_debug_enabled(self) -> bool:
|
||||||
return (
|
return (
|
||||||
@@ -441,6 +431,7 @@ class PolymarketReadOnlyLayer:
|
|||||||
model_probability: Optional[float] = None,
|
model_probability: Optional[float] = None,
|
||||||
fallback_sparkline: Optional[List[float]] = None,
|
fallback_sparkline: Optional[List[float]] = None,
|
||||||
forced_market_slug: Optional[str] = None,
|
forced_market_slug: Optional[str] = None,
|
||||||
|
include_related_buckets: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
date_str = _extract_iso_date(target_date) or str(target_date or "")
|
date_str = _extract_iso_date(target_date) or str(target_date or "")
|
||||||
city_key = _normalize_city_key(city)
|
city_key = _normalize_city_key(city)
|
||||||
@@ -459,6 +450,8 @@ class PolymarketReadOnlyLayer:
|
|||||||
"temperature_bucket": temperature_bucket,
|
"temperature_bucket": temperature_bucket,
|
||||||
"model_probability": model_probability,
|
"model_probability": model_probability,
|
||||||
"market_price": None,
|
"market_price": None,
|
||||||
|
"midpoint": None,
|
||||||
|
"spread": None,
|
||||||
"edge_percent": None,
|
"edge_percent": None,
|
||||||
"signal_label": "MONITOR",
|
"signal_label": "MONITOR",
|
||||||
"confidence": "low",
|
"confidence": "low",
|
||||||
@@ -466,16 +459,27 @@ class PolymarketReadOnlyLayer:
|
|||||||
"no_token": None,
|
"no_token": None,
|
||||||
"yes_buy": None,
|
"yes_buy": None,
|
||||||
"yes_sell": None,
|
"yes_sell": None,
|
||||||
|
"yes_midpoint": None,
|
||||||
|
"yes_spread": None,
|
||||||
"no_buy": None,
|
"no_buy": None,
|
||||||
"no_sell": None,
|
"no_sell": None,
|
||||||
|
"no_midpoint": None,
|
||||||
|
"no_spread": None,
|
||||||
"last_trade_price": None,
|
"last_trade_price": None,
|
||||||
"liquidity": None,
|
"liquidity": None,
|
||||||
"volume": None,
|
"volume": None,
|
||||||
|
"quote_source": None,
|
||||||
|
"quote_age_ms": None,
|
||||||
"price_analysis": None,
|
"price_analysis": None,
|
||||||
"sparkline": fallback_sparkline or [],
|
"sparkline": fallback_sparkline or [],
|
||||||
"top_buckets": [],
|
"top_buckets": [],
|
||||||
|
"all_buckets": [],
|
||||||
"recent_trades": [],
|
"recent_trades": [],
|
||||||
"websocket": {},
|
"scan_scope": "full" if include_related_buckets else "lite",
|
||||||
|
"websocket": {
|
||||||
|
"enabled": False,
|
||||||
|
"status": "disabled_rest_only",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
@@ -632,21 +636,24 @@ class PolymarketReadOnlyLayer:
|
|||||||
|
|
||||||
signal_label, confidence = self._derive_signal(edge_percent, liquidity)
|
signal_label, confidence = self._derive_signal(edge_percent, liquidity)
|
||||||
|
|
||||||
top_bucket_limit = max(
|
top_buckets: List[Dict[str, Any]] = []
|
||||||
1,
|
all_buckets: List[Dict[str, Any]] = []
|
||||||
_safe_int(os.getenv("POLYMARKET_TOP_BUCKET_LIMIT", "4"), 4),
|
if include_related_buckets:
|
||||||
)
|
top_bucket_limit = max(
|
||||||
all_bucket_limit = max(
|
1,
|
||||||
top_bucket_limit,
|
_safe_int(os.getenv("POLYMARKET_TOP_BUCKET_LIMIT", "4"), 4),
|
||||||
_safe_int(os.getenv("POLYMARKET_ALL_BUCKET_LIMIT", "8"), 8),
|
)
|
||||||
)
|
all_bucket_limit = max(
|
||||||
all_buckets = self._build_top_temperature_buckets(
|
top_bucket_limit,
|
||||||
city_key=market_city_key,
|
_safe_int(os.getenv("POLYMARKET_ALL_BUCKET_LIMIT", "8"), 8),
|
||||||
target_date=date_str,
|
)
|
||||||
primary_market=market,
|
all_buckets = self._build_top_temperature_buckets(
|
||||||
limit=all_bucket_limit,
|
city_key=market_city_key,
|
||||||
)
|
target_date=date_str,
|
||||||
top_buckets = list(all_buckets[:top_bucket_limit])
|
primary_market=market,
|
||||||
|
limit=all_bucket_limit,
|
||||||
|
)
|
||||||
|
top_buckets = list(all_buckets[:top_bucket_limit])
|
||||||
|
|
||||||
yes_payload = {
|
yes_payload = {
|
||||||
"outcome": yes_token.get("outcome") or "Yes",
|
"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),
|
"quote_age_ms": _safe_int(no_prices.get("quote_age_ms"), 0),
|
||||||
"book": no_prices.get("book"),
|
"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(
|
price_analysis = self._build_price_analysis(
|
||||||
model_probability=model_probability,
|
model_probability=model_probability,
|
||||||
yes_buy=_extract_price(yes_payload.get("buy_price")),
|
yes_buy=yes_buy,
|
||||||
yes_sell=_extract_price(yes_payload.get("sell_price")),
|
yes_sell=yes_sell,
|
||||||
no_buy=_extract_price(no_payload.get("buy_price")),
|
no_buy=no_buy,
|
||||||
no_sell=_extract_price(no_payload.get("sell_price")),
|
no_sell=no_sell,
|
||||||
)
|
)
|
||||||
|
|
||||||
sparkline_values: List[float] = []
|
sparkline_values: List[float] = []
|
||||||
@@ -702,27 +725,35 @@ class PolymarketReadOnlyLayer:
|
|||||||
"selected_condition_id": condition_id,
|
"selected_condition_id": condition_id,
|
||||||
"selected_slug": market_slug,
|
"selected_slug": market_slug,
|
||||||
"market_price": market_price,
|
"market_price": market_price,
|
||||||
|
"midpoint": yes_midpoint if yes_midpoint is not None else market_price,
|
||||||
|
"spread": yes_spread,
|
||||||
"edge_percent": edge_percent,
|
"edge_percent": edge_percent,
|
||||||
"signal_label": signal_label,
|
"signal_label": signal_label,
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"yes_token": yes_payload,
|
"yes_token": yes_payload,
|
||||||
"no_token": no_payload,
|
"no_token": no_payload,
|
||||||
"yes_buy": _extract_price(yes_payload.get("buy_price")),
|
"yes_buy": yes_buy,
|
||||||
"yes_sell": _extract_price(yes_payload.get("sell_price")),
|
"yes_sell": yes_sell,
|
||||||
"no_buy": _extract_price(no_payload.get("buy_price")),
|
"yes_midpoint": yes_midpoint,
|
||||||
"no_sell": _extract_price(no_payload.get("sell_price")),
|
"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,
|
"last_trade_price": last_trade_price,
|
||||||
"liquidity": liquidity,
|
"liquidity": liquidity,
|
||||||
"volume": volume,
|
"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,
|
"price_analysis": price_analysis,
|
||||||
"sparkline": sparkline_values,
|
"sparkline": sparkline_values,
|
||||||
"top_buckets": top_buckets,
|
"top_buckets": top_buckets,
|
||||||
"all_buckets": all_buckets,
|
"all_buckets": all_buckets,
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": self._ws_quote_cache.enabled,
|
"enabled": False,
|
||||||
"status": self._ws_quote_cache.status(),
|
"status": "disabled_rest_only",
|
||||||
"market_url": market_url,
|
"market_url": market_url,
|
||||||
"asset_ids": [
|
"asset_ids": [
|
||||||
token
|
token
|
||||||
for token in [
|
for token in [
|
||||||
yes_payload.get("token_id"),
|
yes_payload.get("token_id"),
|
||||||
@@ -1488,32 +1519,11 @@ class PolymarketReadOnlyLayer:
|
|||||||
|
|
||||||
return None, None
|
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]:
|
def _get_token_market_data(self, token_id: str) -> Dict[str, Any]:
|
||||||
token_id = str(token_id or "").strip()
|
token_id = str(token_id or "").strip()
|
||||||
if not token_id:
|
if not token_id:
|
||||||
return {}
|
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()
|
now = time.time()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
cached = self._price_cache.get(token_id)
|
cached = self._price_cache.get(token_id)
|
||||||
@@ -1527,33 +1537,7 @@ class PolymarketReadOnlyLayer:
|
|||||||
return data
|
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]:
|
||||||
# 1) Preferred path: py-clob-client public methods.
|
# REST-only path: CLOB public endpoints.
|
||||||
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.
|
|
||||||
buy = _extract_price(self._clob_get("/price", {"token_id": token_id, "side": "BUY"}))
|
buy = _extract_price(self._clob_get("/price", {"token_id": token_id, "side": "BUY"}))
|
||||||
sell = _extract_price(
|
sell = _extract_price(
|
||||||
self._clob_get("/price", {"token_id": token_id, "side": "SELL"})
|
self._clob_get("/price", {"token_id": token_id, "side": "SELL"})
|
||||||
@@ -1576,12 +1560,6 @@ class PolymarketReadOnlyLayer:
|
|||||||
"book_liquidity": book_liquidity,
|
"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:
|
def _clob_get(self, path: str, params: Dict[str, Any]) -> Any:
|
||||||
url = f"{self.clob_url}{path}"
|
url = f"{self.clob_url}{path}"
|
||||||
try:
|
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"
|
assert layer._extract_market_bucket_label(market, 80.5) == "80-81F"
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_token_market_data_prefers_orderbook_executable_prices():
|
def test_fetch_token_market_data_uses_rest_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"}],
|
|
||||||
}
|
|
||||||
|
|
||||||
layer = PolymarketReadOnlyLayer()
|
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")
|
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["sell"] == 0.24
|
||||||
assert data["midpoint"] == 0.5
|
assert data["midpoint"] == 0.5
|
||||||
assert data["last_trade_price"] == 0.49
|
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()
|
layer = PolymarketReadOnlyLayer()
|
||||||
|
calls = []
|
||||||
|
|
||||||
class FakeWsCache:
|
def _fake_fetch(_token_id):
|
||||||
enabled = True
|
calls.append(_token_id)
|
||||||
|
return {"buy": 0.33, "sell": 0.31, "midpoint": 0.32}
|
||||||
|
|
||||||
def subscribe(self, asset_ids):
|
layer._fetch_token_market_data = _fake_fetch
|
||||||
self.asset_ids = list(asset_ids)
|
|
||||||
|
|
||||||
@staticmethod
|
first = layer._get_token_market_data("token-1")
|
||||||
def get_market_data(_token_id):
|
second = layer._get_token_market_data("token-1")
|
||||||
return {
|
|
||||||
"buy": 0.33,
|
|
||||||
"sell": 0.31,
|
|
||||||
"midpoint": 0.32,
|
|
||||||
"quote_source": "polymarket_ws",
|
|
||||||
"quote_age_ms": 80,
|
|
||||||
}
|
|
||||||
|
|
||||||
layer._ws_quote_cache = FakeWsCache()
|
assert first["buy"] == 0.33
|
||||||
layer._fetch_token_market_data = lambda _token_id: {"buy": 0.99}
|
assert second["midpoint"] == 0.32
|
||||||
|
assert calls == ["token-1"]
|
||||||
data = layer._get_token_market_data("token-1")
|
|
||||||
|
|
||||||
assert data["buy"] == 0.33
|
|
||||||
assert data["sell"] == 0.31
|
|
||||||
assert data["quote_source"] == "polymarket_ws"
|
|
||||||
|
|
||||||
|
|
||||||
def test_price_analysis_computes_edge_kelly_and_lock():
|
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"
|
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():
|
def test_hydrate_bucket_prices_uses_executable_quotes_without_midpoint():
|
||||||
layer = PolymarketReadOnlyLayer()
|
layer = PolymarketReadOnlyLayer()
|
||||||
buckets = [
|
buckets = [
|
||||||
|
|||||||
@@ -2801,6 +2801,7 @@ def _build_city_market_scan_payload(
|
|||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
|
lite: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
city = str(data.get("name") or "").strip().lower()
|
city = str(data.get("name") or "").strip().lower()
|
||||||
local_date = str(data.get("local_date") or "").strip()
|
local_date = str(data.get("local_date") or "").strip()
|
||||||
@@ -2882,12 +2883,20 @@ def _build_city_market_scan_payload(
|
|||||||
model_probability=model_probability,
|
model_probability=model_probability,
|
||||||
fallback_sparkline=fallback_sparkline,
|
fallback_sparkline=fallback_sparkline,
|
||||||
forced_market_slug=market_slug,
|
forced_market_slug=market_slug,
|
||||||
|
include_related_buckets=not lite,
|
||||||
)
|
)
|
||||||
if isinstance(market_scan, dict):
|
if isinstance(market_scan, dict):
|
||||||
market_scan["anchor_model"] = anchor_model
|
market_scan["anchor_model"] = anchor_model
|
||||||
market_scan["anchor_high"] = anchor_temp
|
market_scan["anchor_high"] = anchor_temp
|
||||||
market_scan["anchor_settlement"] = anchor_settlement
|
market_scan["anchor_settlement"] = anchor_settlement
|
||||||
market_scan["open_meteo_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 {
|
return {
|
||||||
"market_scan": market_scan,
|
"market_scan": market_scan,
|
||||||
"selected_date": selected_date or data.get("local_date"),
|
"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")))
|
CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "900")))
|
||||||
MARKET_SCAN_PAYLOAD_TTL_SEC = max(
|
MARKET_SCAN_PAYLOAD_TTL_SEC = max(
|
||||||
5,
|
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(
|
CITY_HISTORY_PREVIEW_CACHE_TTL_SEC = max(
|
||||||
60,
|
60,
|
||||||
@@ -181,6 +181,7 @@ def _market_scan_cache_key(
|
|||||||
data: dict,
|
data: dict,
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
|
lite: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
local_date = str(data.get("local_date") or "").strip()
|
local_date = str(data.get("local_date") or "").strip()
|
||||||
requested_date = str(target_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:
|
if requested_date and isinstance(multi_model_daily, dict) and requested_date not in multi_model_daily:
|
||||||
selected_date = local_date
|
selected_date = local_date
|
||||||
normalized_slug = str(market_slug or "").strip().lower()
|
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(
|
def _attach_market_scan_payload(
|
||||||
@@ -197,6 +198,7 @@ def _attach_market_scan_payload(
|
|||||||
*,
|
*,
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
|
lite: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return payload
|
return payload
|
||||||
@@ -204,6 +206,7 @@ def _attach_market_scan_payload(
|
|||||||
payload,
|
payload,
|
||||||
market_slug=market_slug,
|
market_slug=market_slug,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
lite=lite,
|
||||||
)
|
)
|
||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
payload["market_scan_payload"] = scan_payload
|
payload["market_scan_payload"] = scan_payload
|
||||||
@@ -213,6 +216,7 @@ def _attach_market_scan_payload(
|
|||||||
payload,
|
payload,
|
||||||
market_slug=market_slug,
|
market_slug=market_slug,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
lite=lite,
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -222,6 +226,7 @@ def _get_cached_market_scan_payload(
|
|||||||
*,
|
*,
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
|
lite: bool = False,
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return None
|
return None
|
||||||
@@ -232,6 +237,7 @@ def _get_cached_market_scan_payload(
|
|||||||
payload,
|
payload,
|
||||||
market_slug=market_slug,
|
market_slug=market_slug,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
lite=lite,
|
||||||
)
|
)
|
||||||
cached_key = str(payload.get("market_scan_cache_key") or "")
|
cached_key = str(payload.get("market_scan_cache_key") or "")
|
||||||
if cached_key != expected_key:
|
if cached_key != expected_key:
|
||||||
@@ -250,11 +256,13 @@ def _refresh_market_scan_payload_from_cached_analysis(
|
|||||||
*,
|
*,
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
|
lite: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
_attach_market_scan_payload(
|
_attach_market_scan_payload(
|
||||||
payload,
|
payload,
|
||||||
market_slug=market_slug,
|
market_slug=market_slug,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
lite=lite,
|
||||||
)
|
)
|
||||||
_CACHE_DB.set_city_cache(
|
_CACHE_DB.set_city_cache(
|
||||||
"market",
|
"market",
|
||||||
@@ -1629,6 +1637,7 @@ async def city_market_scan(
|
|||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
market_slug: Optional[str] = None,
|
market_slug: Optional[str] = None,
|
||||||
target_date: Optional[str] = None,
|
target_date: Optional[str] = None,
|
||||||
|
lite: bool = False,
|
||||||
):
|
):
|
||||||
_assert_entitlement(request)
|
_assert_entitlement(request)
|
||||||
city = _normalize_city_or_404(name)
|
city = _normalize_city_or_404(name)
|
||||||
@@ -1638,6 +1647,7 @@ async def city_market_scan(
|
|||||||
data,
|
data,
|
||||||
market_slug=market_slug,
|
market_slug=market_slug,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
lite=lite,
|
||||||
)
|
)
|
||||||
if cached_scan is not None:
|
if cached_scan is not None:
|
||||||
return cached_scan
|
return cached_scan
|
||||||
@@ -1649,6 +1659,7 @@ async def city_market_scan(
|
|||||||
data,
|
data,
|
||||||
market_slug=market_slug,
|
market_slug=market_slug,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
lite=lite,
|
||||||
)
|
)
|
||||||
if cached_scan is not None:
|
if cached_scan is not None:
|
||||||
if not _market_analysis_cache_is_fresh(cached_entry):
|
if not _market_analysis_cache_is_fresh(cached_entry):
|
||||||
@@ -1661,6 +1672,7 @@ async def city_market_scan(
|
|||||||
data,
|
data,
|
||||||
market_slug=market_slug,
|
market_slug=market_slug,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
lite=lite,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_schedule_cache_refresh(background_tasks, kind="market", city=city)
|
_schedule_cache_refresh(background_tasks, kind="market", city=city)
|
||||||
@@ -1670,6 +1682,7 @@ async def city_market_scan(
|
|||||||
data,
|
data,
|
||||||
market_slug=market_slug,
|
market_slug=market_slug,
|
||||||
target_date=target_date,
|
target_date=target_date,
|
||||||
|
lite=lite,
|
||||||
)
|
)
|
||||||
if cached_scan is not None:
|
if cached_scan is not None:
|
||||||
return cached_scan
|
return cached_scan
|
||||||
@@ -1678,5 +1691,6 @@ async def city_market_scan(
|
|||||||
data,
|
data,
|
||||||
market_slug,
|
market_slug,
|
||||||
target_date,
|
target_date,
|
||||||
|
lite,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user