diff --git a/frontend/app/modern/page.tsx b/frontend/app/modern/page.tsx deleted file mode 100644 index d4abf2bc..00000000 --- a/frontend/app/modern/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PolyWeatherDashboard } from "@/components/polyweather-dashboard"; - -export default function ModernPage() { - return ; -} diff --git a/frontend/components/city-detail-panel.tsx b/frontend/components/city-detail-panel.tsx deleted file mode 100644 index 9652d247..00000000 --- a/frontend/components/city-detail-panel.tsx +++ /dev/null @@ -1,168 +0,0 @@ -"use client"; - -import { CloudSun, Gauge, Loader2, RefreshCw, Thermometer } from "lucide-react"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { Locale } from "@/lib/i18n"; -import type { CityDetail } from "@/lib/types"; - -type CityDetailPanelProps = { - detail: CityDetail | null; - loading: boolean; - onRefresh: () => void; - locale: Locale; - text: { - cityDetail: string; - selectCityHint: string; - refresh: string; - currentMax: string; - cloud: string; - wind: string; - topProb: string; - noProb: string; - aiSummary: string; - noAnalysis: string; - }; -}; - -export function CityDetailPanel({ - detail, - loading, - onRefresh, - locale, - text, -}: CityDetailPanelProps) { - if (!detail) { - return ( - - - {text.cityDetail} - - - {text.selectCityHint} - - - ); - } - - const symbol = detail.temp_symbol || "C"; - const displayTemp = - detail.current?.max_so_far != null - ? detail.current.max_so_far - : detail.current?.temp; - const topProbabilities = (detail.probabilities?.distribution ?? []) - .slice(0, 3) - .map((item) => ({ - value: item.value, - pct: Math.round(item.probability * 100), - })); - - return ( - - -
- - {detail.display_name} - - -
-
- {detail.local_time || "--:--"} - {detail.current?.wu_settlement != null ? ( - WU {detail.current.wu_settlement} - ) : null} -
-
- -
-
- - {text.currentMax} -
-
- {displayTemp ?? "--"} - {symbol} -
-
- DEB: {detail.deb?.prediction ?? "--"} - {symbol} -
-
- -
-
-
- - {text.cloud} -
-

{detail.current?.cloud_desc || "N/A"}

-
-
-
- - {text.wind} -
-

- {detail.current?.wind_speed_kt != null - ? `${detail.current.wind_speed_kt} kt` - : "N/A"} -

-
-
- -
-

{text.topProb}

- {topProbabilities.length === 0 ? ( -

{text.noProb}

- ) : ( -
- {topProbabilities.map((item) => ( -
-
- - {item.value} - {symbol} - - {item.pct}% -
-
-
-
-
- ))} -
- )} -
- -
-

{text.aiSummary}

-
-
- - - ); -} diff --git a/frontend/components/city-list.tsx b/frontend/components/city-list.tsx deleted file mode 100644 index d7093677..00000000 --- a/frontend/components/city-list.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; - -import { AlertTriangle, GaugeCircle, ShieldCheck, ThermometerSun } from "lucide-react"; - -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { Locale } from "@/lib/i18n"; -import { cn } from "@/lib/utils"; -import type { CitySummary } from "@/lib/types"; - -type CityListProps = { - cities: CitySummary[]; - selectedCity: string | null; - onSelectCity: (cityName: string) => void; - locale: Locale; - text: { - monitoredCities: string; - high: string; - medium: string; - low: string; - fahrenheit: string; - celsius: string; - }; -}; - -function riskVariant(level: CitySummary["risk_level"]): "success" | "warning" | "danger" { - if (level === "high") return "danger"; - if (level === "medium") return "warning"; - return "success"; -} - -export function CityList({ - cities, - selectedCity, - onSelectCity, - locale, - text, -}: CityListProps) { - const high = cities.filter((c) => c.risk_level === "high").length; - const medium = cities.filter((c) => c.risk_level === "medium").length; - const low = cities.filter((c) => c.risk_level === "low").length; - - return ( - - - - - {text.monitoredCities} - - {cities.length} - -
-
-
- - {text.high} -
-

{high}

-
-
-
- - {text.medium} -
-

{medium}

-
-
-
- - {text.low} -
-

{low}

-
-
-
- -
- {cities.map((city) => ( - - ))} -
-
-
- ); -} diff --git a/frontend/components/map-canvas.tsx b/frontend/components/map-canvas.tsx deleted file mode 100644 index d55ef4f2..00000000 --- a/frontend/components/map-canvas.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet"; -import L from "leaflet"; -import "leaflet/dist/leaflet.css"; -import type { CitySummary } from "@/lib/types"; - -type MapCanvasProps = { - cities: CitySummary[]; - selectedCity: string | null; - onSelectCity: (cityName: string) => void; -}; - -function shortName(name: string) { - return name.length > 8 ? `${name.slice(0, 7)}.` : name; -} - -const tempIcon = ( - city: CitySummary, - selected: boolean, -) => - L.divIcon({ - className: "", - html: `
${shortName(city.display_name)}
`, - iconSize: [78, 30], - iconAnchor: [39, 15], - }); - -export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps) { - return ( - - - {cities.map((city) => ( - onSelectCity(city.name) }} - > - -
-

{city.display_name}

-

Risk: {city.risk_level}

-

{selectedCity === city.name ? "Selected" : "Click to open"}

-
-
-
- ))} -
- ); -} diff --git a/frontend/components/map-view.tsx b/frontend/components/map-view.tsx deleted file mode 100644 index f095c729..00000000 --- a/frontend/components/map-view.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import type { CitySummary } from "@/lib/types"; - -const DynamicMap = dynamic( - () => import("@/components/map-canvas").then((m) => m.MapCanvas), - { ssr: false }, -); - -type MapViewProps = { - cities: CitySummary[]; - selectedCity: string | null; - onSelectCity: (cityName: string) => void; -}; - -export function MapView(props: MapViewProps) { - return ( -
- -
- ); -} diff --git a/frontend/components/polyweather-dashboard.tsx b/frontend/components/polyweather-dashboard.tsx deleted file mode 100644 index 0a54e44d..00000000 --- a/frontend/components/polyweather-dashboard.tsx +++ /dev/null @@ -1,247 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { - AlertTriangle, - Bot, - Globe2, - Languages, - Radar, - Signal, - Waves, -} from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { CityDetailPanel } from "@/components/city-detail-panel"; -import { CityList } from "@/components/city-list"; -import { MapView } from "@/components/map-view"; -import { getCities, getCityDetail } from "@/lib/api"; -import { copy, type Locale } from "@/lib/i18n"; -import type { CityDetail, CitySummary } from "@/lib/types"; - -export function PolyWeatherDashboard() { - const [locale, setLocale] = useState("zh"); - const [cities, setCities] = useState([]); - const [selectedCity, setSelectedCity] = useState(null); - const [selectedDetail, setSelectedDetail] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const t = copy[locale]; - - useEffect(() => { - let mounted = true; - (async () => { - try { - setError(null); - const data = await getCities(); - if (!mounted) return; - setCities(data); - } catch (err) { - if (!mounted) return; - setError(String(err)); - } - })(); - return () => { - mounted = false; - }; - }, []); - - useEffect(() => { - if (!selectedCity) return; - let mounted = true; - (async () => { - try { - setLoading(true); - setError(null); - const detail = await getCityDetail(selectedCity); - if (!mounted) return; - setSelectedDetail(detail); - } catch (err) { - if (!mounted) return; - setError(String(err)); - } finally { - if (mounted) setLoading(false); - } - })(); - return () => { - mounted = false; - }; - }, [selectedCity]); - - const orderedCities = useMemo(() => { - const order = { high: 0, medium: 1, low: 2 }; - return [...cities].sort( - (a, b) => (order[a.risk_level] ?? 99) - (order[b.risk_level] ?? 99), - ); - }, [cities]); - - const riskStats = useMemo(() => { - return { - high: cities.filter((c) => c.risk_level === "high").length, - medium: cities.filter((c) => c.risk_level === "medium").length, - low: cities.filter((c) => c.risk_level === "low").length, - }; - }, [cities]); - - async function refreshCurrentCity() { - if (!selectedCity) return; - setLoading(true); - try { - const detail = await getCityDetail(selectedCity, true); - setSelectedDetail(detail); - } catch (err) { - setError(String(err)); - } finally { - setLoading(false); - } - } - - function localizeError(raw: string) { - if (raw.includes("POLYWEATHER_API_BASE_URL")) { - return t.backendConfigMissing; - } - if (raw.toLowerCase().includes("failed to load cities")) { - return `${t.loadCitiesFailed}: ${raw}`; - } - if (raw.toLowerCase().includes("failed to load city detail")) { - return `${t.loadCityDetailFailed}: ${raw}`; - } - return raw; - } - - return ( -
-
-
- -
- -
-
-
-
- -

- PolyWeather -

- - {t.brandSubtitle} - -
-
-
- - {t.live} -
- - -
-
-
- -
-
-
- H {riskStats.high} -
-
- M {riskStats.medium} -
-
- L {riskStats.low} -
-
- - {cities.length} {t.cities} -
-
- - AI + DEB -
-
-
- - -
- -
- -
- -
- -
-
- - {error ? ( -
-
- - {localizeError(error)} -
-
- ) : null} -
- ); -} diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx deleted file mode 100644 index 6171234d..00000000 --- a/frontend/components/ui/badge.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import * as React from "react"; -import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "@/lib/utils"; - -const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors", - { - variants: { - variant: { - default: "border-cyan-700 bg-cyan-950 text-cyan-200", - success: "border-emerald-700 bg-emerald-950 text-emerald-200", - warning: "border-amber-700 bg-amber-950 text-amber-200", - danger: "border-red-700 bg-red-950 text-red-200", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} - -function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ); -} - -export { Badge, badgeVariants }; diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx deleted file mode 100644 index b4e07cb6..00000000 --- a/frontend/components/ui/button.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "@/lib/utils"; - -const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400", - { - variants: { - variant: { - default: "bg-cyan-400 text-slate-950 hover:bg-cyan-300", - secondary: - "bg-slate-800 text-slate-100 border border-slate-700 hover:bg-slate-700", - ghost: "hover:bg-slate-800 hover:text-slate-100", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3", - lg: "h-10 rounded-md px-6", - icon: "h-9 w-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - -export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean; -} - -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button"; - return ( - - ); - }, -); -Button.displayName = "Button"; - -export { Button, buttonVariants }; diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx deleted file mode 100644 index 5cae6ef1..00000000 --- a/frontend/components/ui/card.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from "react"; - -import { cn } from "@/lib/utils"; - -const Card = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ), -); -Card.displayName = "Card"; - -const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); -CardHeader.displayName = "CardHeader"; - -const CardTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -

-)); -CardTitle.displayName = "CardTitle"; - -const CardDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -

-)); -CardDescription.displayName = "CardDescription"; - -const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -

-)); -CardContent.displayName = "CardContent"; - -export { Card, CardHeader, CardTitle, CardDescription, CardContent }; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts deleted file mode 100644 index 184a478a..00000000 --- a/frontend/lib/api.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { CityDetail, CitySummary } from "@/lib/types"; - -async function readError(res: Response, fallback: string) { - try { - const data = await res.json(); - const msg = data?.error ? String(data.error) : fallback; - const detail = data?.detail ? ` ${String(data.detail)}` : ""; - return `${msg}${detail}`.trim(); - } catch { - return `${fallback}: ${res.status}`; - } -} - -export async function getCities(): Promise { - const res = await fetch("/api/cities", { cache: "no-store" }); - if (!res.ok) { - throw new Error(await readError(res, `Failed to load cities (${res.status})`)); - } - const data = await res.json(); - return data.cities ?? []; -} - -export async function getCityDetail( - cityName: string, - forceRefresh = false, -): Promise { - const slug = cityName.replace(/\s/g, "-"); - const res = await fetch( - `/api/city/${encodeURIComponent(slug)}?force_refresh=${forceRefresh}`, - { - cache: "no-store", - }, - ); - if (!res.ok) { - throw new Error( - await readError(res, `Failed to load city detail (${res.status})`), - ); - } - return await res.json(); -} diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts deleted file mode 100644 index 2706ea08..00000000 --- a/frontend/lib/i18n.ts +++ /dev/null @@ -1,55 +0,0 @@ -export type Locale = "zh" | "en"; - -export const copy = { - en: { - brandSubtitle: "Global Weather Risk Intelligence", - technicalGuide: "Technical Guide", - monitoredCities: "Monitored Cities", - cityDetail: "City Detail", - selectCityHint: "Select a city from the left list or map marker.", - live: "Live", - cities: "Cities", - currentMax: "Current / Max", - cloud: "Cloud", - wind: "Wind", - topProb: "Top Settlement Probabilities", - noProb: "No probability data yet.", - aiSummary: "AI Summary", - noAnalysis: "No analysis yet.", - refresh: "Refresh", - fahrenheit: "Fahrenheit", - celsius: "Celsius", - high: "High", - medium: "Med", - low: "Low", - loadCitiesFailed: "Failed to load cities", - loadCityDetailFailed: "Failed to load city detail", - backendConfigMissing: - "Server config missing: POLYWEATHER_API_BASE_URL is not set.", - }, - zh: { - brandSubtitle: "全球天气风险智能分析", - technicalGuide: "技术说明", - monitoredCities: "监控城市", - cityDetail: "城市详情", - selectCityHint: "请从左侧列表或地图标记选择城市。", - live: "实时", - cities: "城市", - currentMax: "当前 / 最高", - cloud: "云量", - wind: "风速", - topProb: "结算概率Top3", - noProb: "暂无概率数据", - aiSummary: "AI 分析", - noAnalysis: "暂无分析", - refresh: "刷新", - fahrenheit: "华氏", - celsius: "摄氏", - high: "高", - medium: "中", - low: "低", - loadCitiesFailed: "城市列表加载失败", - loadCityDetailFailed: "城市详情加载失败", - backendConfigMissing: "服务端未配置 POLYWEATHER_API_BASE_URL。", - }, -} as const; diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts deleted file mode 100644 index 3502ecc9..00000000 --- a/frontend/lib/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type CitySummary = { - name: string; - display_name: string; - lat: number; - lon: number; - risk_level: "low" | "medium" | "high"; - risk_emoji?: string; - temp_unit: "fahrenheit" | "celsius"; - is_major?: boolean; -}; - -export type CityDetail = { - name: string; - display_name: string; - lat: number; - lon: number; - temp_symbol: string; - local_time: string; - current?: { - temp?: number | null; - max_so_far?: number | null; - wu_settlement?: number | null; - cloud_desc?: string | null; - wind_speed_kt?: number | null; - obs_time?: string | null; - }; - deb?: { - prediction?: number | null; - }; - probabilities?: { - mu?: number | null; - distribution?: Array<{ value: number; probability: number }>; - }; - ai_analysis?: string; -}; diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts deleted file mode 100644 index a5ef1935..00000000 --- a/frontend/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/frontend/public/static/app.js b/frontend/public/static/app.js index fa4c329e..5905496b 100644 --- a/frontend/public/static/app.js +++ b/frontend/public/static/app.js @@ -88,15 +88,10 @@ function updateMapVisibility() { if (!map.hasLayer(nearbyLayerGroup)) map.addLayer(nearbyLayerGroup); } - // 2. Handle Primary City Markers (Major vs Minor) - Object.values(markers).forEach(({ marker, city }) => { - const isMajor = city.is_major !== false; - // Hide minor cities (like Ankara/Atlanta) when zoomed way out - if (zoom < 4 && !isMajor) { - if (map.hasLayer(marker)) map.removeLayer(marker); - } else { - if (!map.hasLayer(marker)) map.addLayer(marker); - } + // 2. Keep all primary city markers visible at all zoom levels. + // This avoids cities like Ankara disappearing when zoomed out. + Object.values(markers).forEach(({ marker }) => { + if (!map.hasLayer(marker)) map.addLayer(marker); }); } diff --git a/src/data_collection/polymarket_client.py b/src/data_collection/polymarket_client.py index 80259b72..de278db6 100644 --- a/src/data_collection/polymarket_client.py +++ b/src/data_collection/polymarket_client.py @@ -1,274 +1,649 @@ -""" +""" Polymarket Weather Market Client -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Fetches real-time odds from Polymarket's Gamma API for weather contracts. -Used by the web dashboard only (not the Telegram bot). +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Market discovery + orderbook snapshot + anomaly detection for weather markets. """ +import json +import logging import re import time -import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + import requests -from typing import Dict, List, Optional, Any -from datetime import datetime, timedelta logger = logging.getLogger(__name__) GAMMA_API = "https://gamma-api.polymarket.com" +CLOB_API = "https://clob.polymarket.com" -# Map our city names → Polymarket contract keywords CITY_KEYWORDS = { - "ankara": ["ankara", "Ankara"], - "london": ["london", "London"], - "paris": ["paris", "Paris"], - "seoul": ["seoul", "Seoul"], - "toronto": ["toronto", "Toronto"], - "buenos aires": ["buenos aires", "Buenos Aires"], - "wellington": ["wellington", "Wellington"], - "new york": ["new york", "New York", "NYC"], - "chicago": ["chicago", "Chicago"], - "dallas": ["dallas", "Dallas"], - "miami": ["miami", "Miami"], - "atlanta": ["atlanta", "Atlanta"], - "seattle": ["seattle", "Seattle"], + "ankara": ["ankara"], + "london": ["london"], + "paris": ["paris"], + "seoul": ["seoul"], + "toronto": ["toronto"], + "buenos aires": ["buenos aires"], + "wellington": ["wellington"], + "new york": ["new york", "nyc", "new york city"], + "chicago": ["chicago"], + "dallas": ["dallas"], + "miami": ["miami"], + "atlanta": ["atlanta"], + "seattle": ["seattle"], } -# In-memory cache: {city: {date: data, ...}} +CACHE_TTL_MARKETS = 300 +CACHE_TTL_BOOKS = 20 +SNAPSHOT_RETENTION_SEC = 48 * 3600 + _market_cache: Dict[str, Any] = {} -_cache_ts: float = 0 -CACHE_TTL = 300 # 5 minutes +_market_cache_ts: float = 0.0 +_book_cache: Dict[str, Dict[str, Any]] = {} +_book_cache_ts: Dict[str, float] = {} +_prev_snapshots: Dict[str, Dict[str, Any]] = {} -def _parse_threshold_from_question(question: str) -> Optional[dict]: - """ - Parse a Polymarket weather question to extract city, threshold, and date. +def _build_session(proxy: Optional[str] = None) -> requests.Session: + """Build a requests session with optional explicit proxy.""" + session = requests.Session() + # Disable implicit system/environment proxies for deterministic behavior. + session.trust_env = False - Examples: - "Will the high temperature in Ankara exceed 8°C on March 5?" - "Highest temperature in London on March 4?" - "Will the high in New York City exceed 45°F on March 5, 2026?" - """ - # Pattern 1: "exceed X°F/°C" - m = re.search( - r"exceed\s+([\d.]+)\s*°\s*([FC])", question, re.IGNORECASE - ) + if proxy: + if not proxy.startswith("http"): + proxy = f"http://{proxy}" + session.proxies = {"http": proxy, "https": proxy} + + return session + + +def _safe_float(v: Any) -> Optional[float]: + if v is None: + return None + try: + return float(v) + except Exception: + return None + + +def _parse_json_list(v: Any) -> List[Any]: + """Parse value into list. Gamma often returns JSON-encoded strings.""" + if isinstance(v, list): + return v + if isinstance(v, str): + s = v.strip() + if not s: + return [] + try: + parsed = json.loads(s) + return parsed if isinstance(parsed, list) else [] + except Exception: + return [] + return [] + + +def _parse_threshold_from_question(question: str) -> Optional[Dict[str, Any]]: + """Extract simple threshold contracts like: exceed 45°F/7°C.""" + m = re.search(r"exceed\s+([\d.]+)\s*[°掳]?\s*([FC])", question, re.IGNORECASE) if m: - value = float(m.group(1)) - unit = m.group(2).upper() - return {"threshold": value, "unit": unit, "type": "exceed"} + return { + "threshold": float(m.group(1)), + "unit": m.group(2).upper(), + "type": "exceed", + } - # Pattern 2: "Highest temperature in City on Date?" (multi-outcome) - m = re.search(r"[Hh]ighest\s+temperature", question) - if m: + if re.search(r"highest\s+temperature", question, re.IGNORECASE): return {"type": "range"} return None -def _match_city(question: str) -> Optional[str]: - """Match a Polymarket question to one of our tracked cities.""" - q_lower = question.lower() - for city, keywords in CITY_KEYWORDS.items(): - for kw in keywords: - if kw.lower() in q_lower: - return city +def _match_city(text: str) -> Optional[str]: + text_l = (text or "").lower() + for city, aliases in CITY_KEYWORDS.items(): + if any(alias in text_l for alias in aliases): + return city return None -def _parse_date_from_question(question: str) -> Optional[str]: - """Extract date from question, return as YYYY-MM-DD.""" - # "on March 5, 2026" or "on March 5" - m = re.search( - r"on\s+(\w+)\s+(\d{1,2})(?:,?\s*(\d{4}))?", question, re.IGNORECASE - ) - if m: - month_str, day_str, year_str = m.group(1), m.group(2), m.group(3) - month_map = { - "january": 1, "february": 2, "march": 3, "april": 4, - "may": 5, "june": 6, "july": 7, "august": 8, - "september": 9, "october": 10, "november": 11, "december": 12, - } - month = month_map.get(month_str.lower()) - if month: - year = int(year_str) if year_str else datetime.now().year - return f"{year}-{month:02d}-{int(day_str):02d}" - return None +def _parse_date_from_question(text: str) -> Optional[str]: + """Extract date from market question, return YYYY-MM-DD.""" + m = re.search(r"on\s+(\w+)\s+(\d{1,2})(?:,?\s*(\d{4}))?", text, re.IGNORECASE) + if not m: + return None + + month_map = { + "january": 1, + "february": 2, + "march": 3, + "april": 4, + "may": 5, + "june": 6, + "july": 7, + "august": 8, + "september": 9, + "october": 10, + "november": 11, + "december": 12, + } + + month = month_map.get(m.group(1).lower()) + if month is None: + return None + + day = int(m.group(2)) + year = int(m.group(3)) if m.group(3) else datetime.utcnow().year + return f"{year:04d}-{month:02d}-{day:02d}" + + +def _parse_iso_date(dt: Optional[str]) -> Optional[str]: + if not dt: + return None + try: + return dt[:10] + except Exception: + return None + + +def _sort_by_volume(markets: List[Dict[str, Any]]) -> None: + markets.sort(key=lambda x: _safe_float(x.get("volume")) or 0.0, reverse=True) + + +def _cleanup_old_snapshots(now_ts: float) -> None: + stale = [ + token for token, rec in _prev_snapshots.items() + if now_ts - (_safe_float(rec.get("ts")) or 0.0) > SNAPSHOT_RETENTION_SEC + ] + for token in stale: + _prev_snapshots.pop(token, None) def fetch_weather_markets( - proxy: Optional[str] = None, timeout: int = 15 -) -> List[Dict]: - """ - Fetch all active weather markets from Polymarket. + proxy: Optional[str] = None, + timeout: int = 15, + force_refresh: bool = False, +) -> List[Dict[str, Any]]: + """Fetch active weather markets and normalize outcome/token metadata.""" + global _market_cache, _market_cache_ts - Returns a list of dicts, each representing a market with: - - question, city, date, odds, volume, etc. - """ - global _market_cache, _cache_ts - - if time.time() - _cache_ts < CACHE_TTL and _market_cache: + now_ts = time.time() + if ( + not force_refresh + and _market_cache + and now_ts - _market_cache_ts < CACHE_TTL_MARKETS + ): return _market_cache.get("_all", []) - try: - session = requests.Session() - if proxy: - if not proxy.startswith("http"): - proxy = f"http://{proxy}" - session.proxies = {"http": proxy, "https": proxy} + session = _build_session(proxy) - # Fetch weather-tagged events + try: resp = session.get( f"{GAMMA_API}/events", params={ "tag": "weather", "active": "true", "closed": "false", - "limit": 50, + "limit": 200, }, timeout=timeout, headers={"Accept": "application/json"}, ) resp.raise_for_status() events = resp.json() + except Exception as exc: + logger.warning(f"Polymarket fetch_weather_markets failed: {exc}") + return _market_cache.get("_all", []) - all_markets = [] - for event in events: - markets = event.get("markets", []) - event_title = event.get("title", "") + all_markets: List[Dict[str, Any]] = [] - for mkt in markets: - question = mkt.get("question", event_title) - city = _match_city(question) - if not city: - continue + for event in events: + event_title = event.get("title", "") + event_slug = event.get("slug", "") + event_end_date = _parse_iso_date(event.get("endDate")) - date_str = _parse_date_from_question(question) - parsed = _parse_threshold_from_question(question) + for mkt in event.get("markets", []) or []: + question = mkt.get("question") or event_title + city = _match_city(question) or _match_city(event_title) + if not city: + continue - # Extract outcome prices - outcome_prices = mkt.get("outcomePrices", "") - outcomes = mkt.get("outcomes", "") - yes_price = None - no_price = None + target_date = ( + _parse_date_from_question(question) + or _parse_date_from_question(event_title) + or _parse_iso_date(mkt.get("endDate")) + or event_end_date + ) - try: - if isinstance(outcome_prices, str) and outcome_prices: - import json - prices = json.loads(outcome_prices) - if len(prices) >= 2: - yes_price = float(prices[0]) - no_price = float(prices[1]) - elif isinstance(outcome_prices, list) and len(outcome_prices) >= 2: - yes_price = float(outcome_prices[0]) - no_price = float(outcome_prices[1]) - except Exception: - pass + parsed = _parse_threshold_from_question(question) + outcomes = [str(x) for x in _parse_json_list(mkt.get("outcomes"))] + outcome_prices = [ + _safe_float(x) for x in _parse_json_list(mkt.get("outcomePrices")) + ] + token_ids = [str(x) for x in _parse_json_list(mkt.get("clobTokenIds"))] - market_info = { + outcome_rows: List[Dict[str, Any]] = [] + for idx, name in enumerate(outcomes): + outcome_rows.append( + { + "name": name, + "token_id": token_ids[idx] if idx < len(token_ids) else None, + "last_price": ( + outcome_prices[idx] if idx < len(outcome_prices) else None + ), + } + ) + + yes_price = None + no_price = None + for row in outcome_rows: + name_l = row["name"].strip().lower() + if name_l == "yes": + yes_price = row.get("last_price") + elif name_l == "no": + no_price = row.get("last_price") + + all_markets.append( + { "id": mkt.get("id"), "question": question, "city": city, - "date": date_str, + "date": target_date, "threshold": parsed.get("threshold") if parsed else None, "threshold_unit": parsed.get("unit") if parsed else None, - "contract_type": parsed.get("type", "unknown") if parsed else "unknown", - "yes_price": yes_price, # 0.00-1.00 = market probability + "contract_type": ( + parsed.get("type", "unknown") if parsed else "unknown" + ), + "yes_price": yes_price, "no_price": no_price, - "volume": mkt.get("volume"), - "liquidity": mkt.get("liquidityNum"), + "volume": _safe_float(mkt.get("volume")), + "liquidity": _safe_float(mkt.get("liquidityNum") or mkt.get("liquidity")), "slug": mkt.get("slug", ""), - "url": f"https://polymarket.com/event/{event.get('slug', '')}", + "event_slug": event_slug, + "url": f"https://polymarket.com/event/{event_slug}" if event_slug else None, + "outcomes": outcome_rows, + "enable_order_book": bool(mkt.get("enableOrderBook", True)), } - all_markets.append(market_info) + ) - # Organize by city - _market_cache = {"_all": all_markets} - for m in all_markets: - c = m["city"] - if c not in _market_cache: - _market_cache[c] = [] - _market_cache[c].append(m) + by_city: Dict[str, List[Dict[str, Any]]] = {} + for m in all_markets: + by_city.setdefault(m["city"], []).append(m) - _cache_ts = time.time() - logger.info(f"📊 Polymarket: 获取 {len(all_markets)} 个天气合约") - return all_markets + for city in by_city: + _sort_by_volume(by_city[city]) - except Exception as e: - logger.warning(f"Polymarket API 请求失败: {e}") - return _market_cache.get("_all", []) + _sort_by_volume(all_markets) + _market_cache = {"_all": all_markets, **by_city} + _market_cache_ts = now_ts + logger.info(f"Polymarket fetched {len(all_markets)} weather markets") + return all_markets -def get_city_markets(city: str, target_date: Optional[str] = None) -> List[Dict]: - """ - Get Polymarket contracts for a specific city. +def get_city_markets( + city: str, + target_date: Optional[str] = None, + proxy: Optional[str] = None, + timeout: int = 15, + force_refresh: bool = False, +) -> List[Dict[str, Any]]: + """Get city markets, optionally filtered by YYYY-MM-DD target date.""" + if not _market_cache or force_refresh or (time.time() - _market_cache_ts >= CACHE_TTL_MARKETS): + fetch_weather_markets(proxy=proxy, timeout=timeout, force_refresh=force_refresh) - Args: - city: City name (lowercase) - target_date: Optional date filter (YYYY-MM-DD) - - Returns: - List of market dicts for this city, sorted by volume desc. - """ - # Ensure markets are fetched - if not _market_cache or time.time() - _cache_ts >= CACHE_TTL: - fetch_weather_markets() - - markets = _market_cache.get(city, []) + rows = list(_market_cache.get(city, [])) if target_date: - markets = [m for m in markets if m.get("date") == target_date] + rows = [m for m in rows if m.get("date") == target_date] - # Sort by volume (descending) - markets.sort(key=lambda m: float(m.get("volume") or 0), reverse=True) - return markets + _sort_by_volume(rows) + return rows + + +def _extract_best_prices(orderbook: Dict[str, Any]) -> Dict[str, Optional[float]]: + bids = orderbook.get("bids") or [] + asks = orderbook.get("asks") or [] + + best_bid_price = None + best_bid_size = None + best_ask_price = None + best_ask_size = None + + for level in bids: + p = _safe_float(level.get("price")) + if p is None: + continue + s = _safe_float(level.get("size")) + if best_bid_price is None or p > best_bid_price: + best_bid_price = p + best_bid_size = s + + for level in asks: + p = _safe_float(level.get("price")) + if p is None: + continue + s = _safe_float(level.get("size")) + if best_ask_price is None or p < best_ask_price: + best_ask_price = p + best_ask_size = s + + spread = None + if best_bid_price is not None and best_ask_price is not None: + spread = best_ask_price - best_bid_price + + return { + "best_bid": best_bid_price, + "best_bid_size": best_bid_size, + "best_ask": best_ask_price, + "best_ask_size": best_ask_size, + "spread": spread, + "last_trade_price": _safe_float(orderbook.get("last_trade_price")), + } + + +def fetch_order_books( + token_ids: List[str], + proxy: Optional[str] = None, + timeout: int = 12, + force_refresh: bool = False, +) -> Dict[str, Dict[str, Any]]: + """Fetch order books for token IDs (prefer POST /books, fallback GET /book).""" + now_ts = time.time() + session = _build_session(proxy) + + # Deduplicate while keeping order + seen = set() + normalized: List[str] = [] + for token_id in token_ids: + tid = str(token_id or "").strip() + if not tid or tid in seen: + continue + seen.add(tid) + normalized.append(tid) + + books: Dict[str, Dict[str, Any]] = {} + to_fetch: List[str] = [] + + for tid in normalized: + cached_ok = ( + (not force_refresh) + and (tid in _book_cache) + and (now_ts - _book_cache_ts.get(tid, 0) < CACHE_TTL_BOOKS) + ) + if cached_ok: + books[tid] = _book_cache[tid] + else: + to_fetch.append(tid) + + if to_fetch: + try: + payload = [{"token_id": tid} for tid in to_fetch] + resp = session.post( + f"{CLOB_API}/books", + json=payload, + timeout=timeout, + headers={"Accept": "application/json"}, + ) + resp.raise_for_status() + rows = resp.json() or [] + + for row in rows: + tid = str(row.get("asset_id") or row.get("token_id") or "").strip() + if not tid: + continue + books[tid] = row + _book_cache[tid] = row + _book_cache_ts[tid] = now_ts + except Exception as exc: + logger.warning(f"Polymarket POST /books failed, fallback to /book: {exc}") + + # Fallback for missing tokens + for tid in to_fetch: + if tid in books: + continue + try: + resp = session.get( + f"{CLOB_API}/book", + params={"token_id": tid}, + timeout=timeout, + headers={"Accept": "application/json"}, + ) + resp.raise_for_status() + row = resp.json() + books[tid] = row + _book_cache[tid] = row + _book_cache_ts[tid] = now_ts + except Exception as exc: + logger.debug(f"Polymarket GET /book failed token={tid}: {exc}") + + return books + + +def _detect_anomaly_flags( + token_id: str, + best_bid: Optional[float], + best_ask: Optional[float], + spread: Optional[float], + last_trade_price: Optional[float], + best_bid_size: Optional[float], + best_ask_size: Optional[float], + now_ts: float, +) -> List[str]: + flags: List[str] = [] + + if best_bid is None or best_ask is None: + flags.append("one_sided_orderbook") + + if spread is not None and spread >= 0.08: + flags.append("wide_spread") + + if (best_bid_size is not None and best_bid_size < 25) or ( + best_ask_size is not None and best_ask_size < 25 + ): + flags.append("thin_liquidity") + + prev = _prev_snapshots.get(token_id) + if prev: + prev_bid = _safe_float(prev.get("best_bid")) + prev_ask = _safe_float(prev.get("best_ask")) + prev_trade = _safe_float(prev.get("last_trade_price")) + prev_spread = _safe_float(prev.get("spread")) + + if ( + best_bid is not None + and prev_bid is not None + and abs(best_bid - prev_bid) >= 0.06 + ): + flags.append("bid_price_jump") + + if ( + best_ask is not None + and prev_ask is not None + and abs(best_ask - prev_ask) >= 0.06 + ): + flags.append("ask_price_jump") + + if ( + last_trade_price is not None + and prev_trade is not None + and abs(last_trade_price - prev_trade) >= 0.06 + ): + flags.append("last_trade_jump") + + if ( + spread is not None + and prev_spread is not None + and spread - prev_spread >= 0.05 + ): + flags.append("spread_widening") + + _prev_snapshots[token_id] = { + "ts": now_ts, + "best_bid": best_bid, + "best_ask": best_ask, + "spread": spread, + "last_trade_price": last_trade_price, + } + + return flags + + +def build_city_market_snapshot( + city: str, + target_date: Optional[str] = None, + proxy: Optional[str] = None, + timeout: int = 15, + force_refresh: bool = False, +) -> Dict[str, Any]: + """ + Build city/date market snapshot with buy/sell prices and anomaly flags. + + buy_price = best ask (what you pay to buy) + sell_price = best bid (what you receive when selling) + """ + now_ts = time.time() + _cleanup_old_snapshots(now_ts) + + markets = get_city_markets( + city=city, + target_date=target_date, + proxy=proxy, + timeout=timeout, + force_refresh=force_refresh, + ) + + token_ids: List[str] = [] + for market in markets: + for outcome in market.get("outcomes", []): + tid = outcome.get("token_id") + if tid: + token_ids.append(str(tid)) + + books_by_token = fetch_order_books( + token_ids, + proxy=proxy, + timeout=timeout, + force_refresh=force_refresh, + ) + + snapshot_markets: List[Dict[str, Any]] = [] + alerts: List[Dict[str, Any]] = [] + + for market in markets: + market_outcomes: List[Dict[str, Any]] = [] + market_alerts: List[Dict[str, Any]] = [] + + for outcome in market.get("outcomes", []): + token_id = outcome.get("token_id") + orderbook = books_by_token.get(str(token_id), {}) if token_id else {} + top = _extract_best_prices(orderbook) + + buy_price = top["best_ask"] + sell_price = top["best_bid"] + spread = top["spread"] + last_trade_price = top["last_trade_price"] + + flags = _detect_anomaly_flags( + token_id=str(token_id or ""), + best_bid=top["best_bid"], + best_ask=top["best_ask"], + spread=spread, + last_trade_price=last_trade_price, + best_bid_size=top["best_bid_size"], + best_ask_size=top["best_ask_size"], + now_ts=now_ts, + ) if token_id else [] + + row = { + "name": outcome.get("name"), + "token_id": token_id, + "last_price": outcome.get("last_price"), + "buy_price": buy_price, + "sell_price": sell_price, + "buy_size": top["best_ask_size"], + "sell_size": top["best_bid_size"], + "spread": spread, + "last_trade_price": last_trade_price, + "book_timestamp": orderbook.get("timestamp"), + "anomaly_flags": flags, + } + market_outcomes.append(row) + + if flags: + market_alert = { + "market_id": market.get("id"), + "question": market.get("question"), + "outcome": outcome.get("name"), + "token_id": token_id, + "flags": flags, + "buy_price": buy_price, + "sell_price": sell_price, + "spread": spread, + "last_trade_price": last_trade_price, + } + market_alerts.append(market_alert) + alerts.append(market_alert) + + snapshot_markets.append( + { + "id": market.get("id"), + "question": market.get("question"), + "city": market.get("city"), + "date": market.get("date"), + "slug": market.get("slug"), + "url": market.get("url"), + "volume": market.get("volume"), + "liquidity": market.get("liquidity"), + "outcomes": market_outcomes, + "market_alerts": market_alerts, + } + ) + + return { + "city": city, + "target_date": target_date, + "updated_at": datetime.utcnow().isoformat() + "Z", + "summary": { + "market_count": len(snapshot_markets), + "outcome_count": sum(len(m.get("outcomes", [])) for m in snapshot_markets), + "alert_count": len(alerts), + }, + "markets": snapshot_markets, + "alerts": alerts, + } def compute_divergence( - city_markets: List[Dict], - prob_distribution: List[Dict], + city_markets: List[Dict[str, Any]], + prob_distribution: List[Dict[str, Any]], temp_symbol: str = "°C", use_fahrenheit: bool = False, -) -> List[Dict]: - """ - Compare our probability engine output with Polymarket odds. - - Args: - city_markets: Markets from get_city_markets() - prob_distribution: Our engine's [{value, probability}, ...] - temp_symbol: "°C" or "°F" - use_fahrenheit: Whether our data is in Fahrenheit - - Returns: - List of divergence signals: - [{threshold, our_prob, market_prob, divergence, signal}, ...] - """ - signals = [] +) -> List[Dict[str, Any]]: + """Compare probability-engine output with Polymarket yes/no pricing.""" + signals: List[Dict[str, Any]] = [] for mkt in city_markets: if mkt.get("contract_type") != "exceed" or mkt.get("yes_price") is None: continue - threshold = mkt.get("threshold") + threshold = _safe_float(mkt.get("threshold")) + market_prob = _safe_float(mkt.get("yes_price")) mkt_unit = mkt.get("threshold_unit", "F") - if threshold is None: + if threshold is None or market_prob is None: continue - # Convert threshold to match our unit + # Convert threshold to our unit scale if mkt_unit == "F" and not use_fahrenheit: - threshold_c = (threshold - 32) * 5 / 9 - elif mkt_unit == "C" and use_fahrenheit: - threshold_c = threshold # keep as-is, our data is F + threshold_v = (threshold - 32) * 5 / 9 else: - threshold_c = threshold + threshold_v = threshold - # Calculate our probability of exceeding this threshold - # Sum probabilities for all values >= threshold (rounded) - threshold_wu = round(threshold_c) + threshold_wu = round(threshold_v) our_exceed_prob = 0.0 for p in prob_distribution: - if p.get("value", 0) >= threshold_wu: - our_exceed_prob += p.get("probability", 0) + if (p.get("value") or 0) >= threshold_wu: + our_exceed_prob += _safe_float(p.get("probability")) or 0.0 - market_prob = mkt["yes_price"] divergence = our_exceed_prob - market_prob signal = "neutral" @@ -277,16 +652,19 @@ def compute_divergence( elif abs(divergence) > 0.05: signal = "slight_under" if divergence > 0 else "slight_over" - signals.append({ - "question": mkt["question"], - "threshold": threshold, - "threshold_unit": mkt_unit, - "our_prob": round(our_exceed_prob, 3), - "market_prob": round(market_prob, 3), - "divergence": round(divergence, 3), - "signal": signal, - "volume": mkt.get("volume"), - "url": mkt.get("url", ""), - }) + signals.append( + { + "question": mkt.get("question"), + "threshold": threshold, + "threshold_unit": mkt_unit, + "our_prob": round(our_exceed_prob, 3), + "market_prob": round(market_prob, 3), + "divergence": round(divergence, 3), + "signal": signal, + "volume": mkt.get("volume"), + "url": mkt.get("url"), + } + ) return signals + diff --git a/tests/test_polymarket_client.py b/tests/test_polymarket_client.py new file mode 100644 index 00000000..063ecc0d --- /dev/null +++ b/tests/test_polymarket_client.py @@ -0,0 +1,90 @@ +from src.data_collection import polymarket_client as pm + + +def test_extract_best_prices(): + book = { + "bids": [{"price": "0.41", "size": "100"}, {"price": "0.39", "size": "80"}], + "asks": [{"price": "0.45", "size": "90"}, {"price": "0.47", "size": "70"}], + "last_trade_price": "0.44", + } + out = pm._extract_best_prices(book) + assert out["best_bid"] == 0.41 + assert out["best_ask"] == 0.45 + assert out["spread"] == 0.04 + assert out["last_trade_price"] == 0.44 + + +def test_build_city_market_snapshot_buy_sell_and_alerts(monkeypatch): + pm._prev_snapshots.clear() + + markets = [ + { + "id": "m1", + "question": "Highest temperature in Ankara on March 7?", + "city": "ankara", + "date": "2026-03-07", + "slug": "m1", + "url": "https://polymarket.com/event/m1", + "volume": 1000.0, + "liquidity": 500.0, + "outcomes": [ + {"name": "6-7°C", "token_id": "t1", "last_price": 0.32}, + {"name": "8-9°C", "token_id": "t2", "last_price": 0.40}, + ], + } + ] + + books = { + "t1": { + "bids": [{"price": "0.30", "size": "50"}], + "asks": [{"price": "0.36", "size": "55"}], + "last_trade_price": "0.34", + "timestamp": "2026-03-06T10:00:00Z", + }, + # one-sided book + thin liquidity to trigger anomaly + "t2": { + "bids": [], + "asks": [{"price": "0.52", "size": "10"}], + "last_trade_price": "0.51", + "timestamp": "2026-03-06T10:00:00Z", + }, + } + + def fake_get_city_markets(**kwargs): + return markets + + def fake_fetch_order_books(*args, **kwargs): + return books + + monkeypatch.setattr(pm, "get_city_markets", fake_get_city_markets) + monkeypatch.setattr(pm, "fetch_order_books", fake_fetch_order_books) + + # Seed previous snapshot for token t1, so we can detect a price jump alert + pm._prev_snapshots["t1"] = { + "ts": 1.0, + "best_bid": 0.20, + "best_ask": 0.24, + "spread": 0.04, + "last_trade_price": 0.22, + } + + snap = pm.build_city_market_snapshot(city="ankara", target_date="2026-03-07") + + assert snap["city"] == "ankara" + assert snap["target_date"] == "2026-03-07" + assert snap["summary"]["market_count"] == 1 + assert snap["summary"]["outcome_count"] == 2 + + first_market = snap["markets"][0] + row_t1 = next(x for x in first_market["outcomes"] if x["token_id"] == "t1") + row_t2 = next(x for x in first_market["outcomes"] if x["token_id"] == "t2") + + # Buy uses ask, sell uses bid + assert row_t1["buy_price"] == 0.36 + assert row_t1["sell_price"] == 0.30 + assert round(row_t1["spread"], 2) == 0.06 + + # one-sided orderbook has no sell price + assert row_t2["buy_price"] == 0.52 + assert row_t2["sell_price"] is None + assert "one_sided_orderbook" in row_t2["anomaly_flags"] diff --git a/web/app.py b/web/app.py index 5c9438c9..bd001414 100644 --- a/web/app.py +++ b/web/app.py @@ -577,6 +577,91 @@ async def city_detail(name: str, force_refresh: bool = False): return _analyze(name, force_refresh=force_refresh) +def _normalize_city_or_404(name: str) -> str: + city = name.lower().strip().replace("-", " ") + city = ALIASES.get(city, city) + if city not in CITIES: + raise HTTPException(404, detail=f"Unknown city: {city}") + return city + + +def _resolve_target_date(city: str, target_date: Optional[str]) -> str: + """ + Resolve requested market date. If absent, default to current local date of city. + """ + if target_date: + try: + datetime.strptime(target_date, "%Y-%m-%d") + except Exception: + raise HTTPException(400, detail="target_date must be YYYY-MM-DD") + return target_date + + tz_seconds = CITIES.get(city, {}).get("tz", 0) + return (datetime.now(timezone.utc) + timedelta(seconds=tz_seconds)).strftime( + "%Y-%m-%d" + ) + + +@app.get("/api/polymarket/{name}") +async def city_polymarket_snapshot( + name: str, + target_date: Optional[str] = None, + force_refresh: bool = False, +): + """ + Return Polymarket city/date market snapshot with buy/sell prices and spreads. + """ + city = _normalize_city_or_404(name) + resolved_date = _resolve_target_date(city, target_date) + + from src.data_collection.polymarket_client import build_city_market_snapshot + + proxy = ( + (_config.get("polymarket", {}) or {}).get("proxy") + or (_config.get("app", {}) or {}).get("proxy") + ) + snapshot = build_city_market_snapshot( + city=city, + target_date=resolved_date, + proxy=proxy, + force_refresh=force_refresh, + ) + return snapshot + + +@app.get("/api/polymarket/{name}/alerts") +async def city_polymarket_alerts( + name: str, + target_date: Optional[str] = None, + force_refresh: bool = False, +): + """ + Return only anomaly alerts for Polymarket city/date orderbooks. + """ + city = _normalize_city_or_404(name) + resolved_date = _resolve_target_date(city, target_date) + + from src.data_collection.polymarket_client import build_city_market_snapshot + + proxy = ( + (_config.get("polymarket", {}) or {}).get("proxy") + or (_config.get("app", {}) or {}).get("proxy") + ) + snapshot = build_city_market_snapshot( + city=city, + target_date=resolved_date, + proxy=proxy, + force_refresh=force_refresh, + ) + return { + "city": snapshot.get("city"), + "target_date": snapshot.get("target_date"), + "updated_at": snapshot.get("updated_at"), + "summary": snapshot.get("summary"), + "alerts": snapshot.get("alerts", []), + } + + @app.get("/api/history/{name}") async def city_history(name: str): """Return historical accuracy data (DEB, mu, actuals) for a city.""" diff --git a/web/static/app.js b/web/static/app.js index fa4c329e..5905496b 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -88,15 +88,10 @@ function updateMapVisibility() { if (!map.hasLayer(nearbyLayerGroup)) map.addLayer(nearbyLayerGroup); } - // 2. Handle Primary City Markers (Major vs Minor) - Object.values(markers).forEach(({ marker, city }) => { - const isMajor = city.is_major !== false; - // Hide minor cities (like Ankara/Atlanta) when zoomed way out - if (zoom < 4 && !isMajor) { - if (map.hasLayer(marker)) map.removeLayer(marker); - } else { - if (!map.hasLayer(marker)) map.addLayer(marker); - } + // 2. Keep all primary city markers visible at all zoom levels. + // This avoids cities like Ankara disappearing when zoomed out. + Object.values(markers).forEach(({ marker }) => { + if (!map.hasLayer(marker)) map.addLayer(marker); }); }