From d9876256b30306c075e045fe779b1a8b36b41f11 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Fri, 6 Mar 2026 09:38:28 +0800
Subject: [PATCH] feat: Implement initial PolyWeather application with
interactive map UI, backend API, and Polymarket data client.
---
frontend/app/modern/page.tsx | 5 -
frontend/components/city-detail-panel.tsx | 168 ----
frontend/components/city-list.tsx | 109 ---
frontend/components/map-canvas.tsx | 54 --
frontend/components/map-view.tsx | 23 -
frontend/components/polyweather-dashboard.tsx | 247 ------
frontend/components/ui/badge.tsx | 33 -
frontend/components/ui/button.tsx | 51 --
frontend/components/ui/card.tsx | 59 --
frontend/lib/api.ts | 40 -
frontend/lib/i18n.ts | 55 --
frontend/lib/types.ts | 35 -
frontend/lib/utils.ts | 6 -
frontend/public/static/app.js | 13 +-
src/data_collection/polymarket_client.py | 766 +++++++++++++-----
tests/test_polymarket_client.py | 90 ++
web/app.py | 85 ++
web/static/app.js | 13 +-
18 files changed, 755 insertions(+), 1097 deletions(-)
delete mode 100644 frontend/app/modern/page.tsx
delete mode 100644 frontend/components/city-detail-panel.tsx
delete mode 100644 frontend/components/city-list.tsx
delete mode 100644 frontend/components/map-canvas.tsx
delete mode 100644 frontend/components/map-view.tsx
delete mode 100644 frontend/components/polyweather-dashboard.tsx
delete mode 100644 frontend/components/ui/badge.tsx
delete mode 100644 frontend/components/ui/button.tsx
delete mode 100644 frontend/components/ui/card.tsx
delete mode 100644 frontend/lib/api.ts
delete mode 100644 frontend/lib/i18n.ts
delete mode 100644 frontend/lib/types.ts
delete mode 100644 frontend/lib/utils.ts
create mode 100644 tests/test_polymarket_client.py
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}%
-
-
-
- ))}
-
- )}
-
-
-
-
-
- );
-}
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.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);
});
}