feat: Implement initial PolyWeather application with interactive map UI, backend API, and Polymarket data client.

This commit is contained in:
2569718930@qq.com
2026-03-06 09:38:28 +08:00
parent b308709edb
commit d9876256b3
18 changed files with 755 additions and 1097 deletions
-5
View File
@@ -1,5 +0,0 @@
import { PolyWeatherDashboard } from "@/components/polyweather-dashboard";
export default function ModernPage() {
return <PolyWeatherDashboard />;
}
-168
View File
@@ -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 (
<Card className="h-full">
<CardHeader>
<CardTitle>{text.cityDetail}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-slate-400">
{text.selectCityHint}
</CardContent>
</Card>
);
}
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 (
<Card className="glass h-full fade-up">
<CardHeader className="space-y-3">
<div className="flex items-center justify-between gap-2">
<CardTitle
className={`text-lg text-cyan-200 ${locale === "zh" ? "tracking-[0.06em]" : "uppercase tracking-[0.16em]"}`}
>
{detail.display_name}
</CardTitle>
<Button
size="sm"
variant="secondary"
onClick={onRefresh}
disabled={loading}
>
{loading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{text.refresh}
</Button>
</div>
<div className="flex items-center gap-2">
<Badge variant="default">{detail.local_time || "--:--"}</Badge>
{detail.current?.wu_settlement != null ? (
<Badge variant="warning">WU {detail.current.wu_settlement}</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="rounded-2xl border border-cyan-900/40 bg-gradient-to-br from-cyan-950/40 to-slate-900/80 p-4">
<div className="mb-1 flex items-center gap-2 text-slate-300">
<Thermometer className="h-4 w-4" />
<span>{text.currentMax}</span>
</div>
<div className="text-3xl font-semibold text-white">
{displayTemp ?? "--"}
{symbol}
</div>
<div className="mt-1 text-xs text-slate-300">
DEB: {detail.deb?.prediction ?? "--"}
{symbol}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="mb-1 flex items-center gap-2 text-slate-400">
<CloudSun className="h-4 w-4" />
<span>{text.cloud}</span>
</div>
<p className="font-medium">{detail.current?.cloud_desc || "N/A"}</p>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="mb-1 flex items-center gap-2 text-slate-400">
<Gauge className="h-4 w-4" />
<span>{text.wind}</span>
</div>
<p className="font-medium">
{detail.current?.wind_speed_kt != null
? `${detail.current.wind_speed_kt} kt`
: "N/A"}
</p>
</div>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<p className="mb-2 text-slate-300">{text.topProb}</p>
{topProbabilities.length === 0 ? (
<p className="text-xs text-slate-500">{text.noProb}</p>
) : (
<div className="space-y-2">
{topProbabilities.map((item) => (
<div key={item.value} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-200">
{item.value}
{symbol}
</span>
<span className="text-cyan-300">{item.pct}%</span>
</div>
<div className="h-1.5 rounded-full bg-slate-800">
<div
className="h-full rounded-full bg-gradient-to-r from-cyan-400 to-blue-400"
style={{ width: `${Math.max(item.pct, 6)}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<p className="mb-2 text-slate-400">{text.aiSummary}</p>
<div
className="max-h-44 overflow-y-auto leading-6 text-slate-200"
dangerouslySetInnerHTML={{
__html: detail.ai_analysis || text.noAnalysis,
}}
/>
</div>
</CardContent>
</Card>
);
}
-109
View File
@@ -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 (
<Card className="glass h-full overflow-hidden">
<CardHeader className="space-y-3 pb-3">
<CardTitle className="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-slate-300">
<span className={locale === "zh" ? "tracking-[0.08em]" : ""}>
{text.monitoredCities}
</span>
<Badge variant="default">{cities.length}</Badge>
</CardTitle>
<div className="grid grid-cols-3 gap-2 text-[11px]">
<div className="rounded-lg border border-red-900/70 bg-red-950/40 p-2 text-red-200">
<div className="mb-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
<span>{text.high}</span>
</div>
<p className="text-base font-semibold">{high}</p>
</div>
<div className="rounded-lg border border-amber-900/70 bg-amber-950/40 p-2 text-amber-200">
<div className="mb-1 flex items-center gap-1">
<GaugeCircle className="h-3 w-3" />
<span>{text.medium}</span>
</div>
<p className="text-base font-semibold">{medium}</p>
</div>
<div className="rounded-lg border border-emerald-900/70 bg-emerald-950/40 p-2 text-emerald-200">
<div className="mb-1 flex items-center gap-1">
<ShieldCheck className="h-3 w-3" />
<span>{text.low}</span>
</div>
<p className="text-base font-semibold">{low}</p>
</div>
</div>
</CardHeader>
<CardContent className="h-[calc(100%-128px)] overflow-y-auto pb-3">
<div className="space-y-1.5">
{cities.map((city) => (
<button
key={city.name}
onClick={() => onSelectCity(city.name)}
className={cn(
"w-full rounded-xl border px-3 py-2 text-left transition-all",
selectedCity === city.name
? "translate-x-1 border-cyan-400/60 bg-cyan-950/40 shadow-[inset_0_0_0_1px_rgba(34,211,238,0.28)]"
: "border-slate-800/90 bg-slate-900/60 hover:border-slate-700 hover:bg-slate-800/80",
)}
>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-semibold text-slate-100">
{city.display_name}
</div>
<Badge variant={riskVariant(city.risk_level)}>{city.risk_level}</Badge>
</div>
<div className="mt-1.5 flex items-center gap-1 text-[11px] text-slate-400">
<ThermometerSun className="h-3.5 w-3.5" />
<span>
{city.temp_unit === "fahrenheit"
? text.fahrenheit
: text.celsius}
</span>
</div>
</button>
))}
</div>
</CardContent>
</Card>
);
}
-54
View File
@@ -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: `<div class="map-pill ${city.risk_level} ${selected ? "active" : ""}">${shortName(city.display_name)}</div>`,
iconSize: [78, 30],
iconAnchor: [39, 15],
});
export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps) {
return (
<MapContainer center={[30, 10]} zoom={3} minZoom={2} className="h-full w-full">
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{cities.map((city) => (
<Marker
key={city.name}
position={[city.lat, city.lon]}
icon={tempIcon(city, selectedCity === city.name)}
eventHandlers={{ click: () => onSelectCity(city.name) }}
>
<Popup>
<div className="text-sm">
<p className="font-semibold">{city.display_name}</p>
<p>Risk: {city.risk_level}</p>
<p>{selectedCity === city.name ? "Selected" : "Click to open"}</p>
</div>
</Popup>
</Marker>
))}
</MapContainer>
);
}
-23
View File
@@ -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 (
<div className="h-full w-full overflow-hidden rounded-2xl border border-slate-800">
<DynamicMap {...props} />
</div>
);
}
@@ -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<Locale>("zh");
const [cities, setCities] = useState<CitySummary[]>([]);
const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [selectedDetail, setSelectedDetail] = useState<CityDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<main className="h-screen w-full overflow-hidden p-2 md:p-4">
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[290px_1fr_370px]">
<section className="min-h-0 lg:block">
<CityList
cities={orderedCities}
selectedCity={selectedCity}
onSelectCity={setSelectedCity}
locale={locale}
text={{
monitoredCities: t.monitoredCities,
high: t.high,
medium: t.medium,
low: t.low,
fahrenheit: t.fahrenheit,
celsius: t.celsius,
}}
/>
</section>
<section className="relative min-h-0">
<div className="glass fade-up absolute left-2 right-2 top-2 z-[900] rounded-2xl border border-slate-700/80 p-2.5 md:left-4 md:right-4 md:top-4 md:p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Globe2 className="h-4 w-4 shrink-0 text-cyan-300" />
<h1 className="shrink-0 text-base font-semibold uppercase tracking-[0.16em] text-cyan-100 md:text-lg">
PolyWeather
</h1>
<span className="hidden truncate text-xs text-slate-400 xl:inline">
{t.brandSubtitle}
</span>
</div>
<div className="flex items-center gap-2">
<div className="hidden items-center gap-2 rounded-full border border-slate-700 bg-slate-900/70 px-2 py-1 text-[11px] text-emerald-300 md:flex">
<Signal className="h-3 w-3" />
{t.live}
</div>
<Button
size="sm"
variant="secondary"
onClick={() => setLocale((v) => (v === "zh" ? "en" : "zh"))}
>
<Languages className="mr-1 h-4 w-4" />
{locale === "zh" ? "EN" : "ZH"}
</Button>
<Button size="sm" variant="secondary">
<Bot className="mr-1 h-4 w-4" />
{t.technicalGuide}
</Button>
</div>
</div>
</div>
<div className="absolute bottom-2 left-2 right-2 z-[900] md:bottom-4 md:left-4 md:right-auto">
<div className="glass fade-up inline-flex flex-wrap items-center gap-2 rounded-xl border border-slate-700/80 p-2">
<div className="rounded-lg border border-red-900/60 bg-red-950/50 px-2.5 py-1 text-xs text-red-200">
H {riskStats.high}
</div>
<div className="rounded-lg border border-amber-900/60 bg-amber-950/50 px-2.5 py-1 text-xs text-amber-200">
M {riskStats.medium}
</div>
<div className="rounded-lg border border-emerald-900/60 bg-emerald-950/50 px-2.5 py-1 text-xs text-emerald-200">
L {riskStats.low}
</div>
<div className="hidden items-center gap-1 rounded-lg border border-cyan-900/60 bg-cyan-950/40 px-2.5 py-1 text-xs text-cyan-200 sm:flex">
<Radar className="h-3.5 w-3.5" />
{cities.length} {t.cities}
</div>
<div className="hidden items-center gap-1 rounded-lg border border-slate-700 px-2.5 py-1 text-xs text-slate-300 sm:flex">
<Waves className="h-3.5 w-3.5" />
AI + DEB
</div>
</div>
</div>
<MapView
cities={orderedCities}
selectedCity={selectedCity}
onSelectCity={setSelectedCity}
/>
</section>
<section className="min-h-0 hidden lg:block">
<CityDetailPanel
detail={selectedDetail}
loading={loading}
onRefresh={refreshCurrentCity}
locale={locale}
text={{
cityDetail: t.cityDetail,
selectCityHint: t.selectCityHint,
refresh: t.refresh,
currentMax: t.currentMax,
cloud: t.cloud,
wind: t.wind,
topProb: t.topProb,
noProb: t.noProb,
aiSummary: t.aiSummary,
noAnalysis: t.noAnalysis,
}}
/>
</section>
<section className="min-h-0 lg:hidden">
<CityDetailPanel
detail={selectedDetail}
loading={loading}
onRefresh={refreshCurrentCity}
locale={locale}
text={{
cityDetail: t.cityDetail,
selectCityHint: t.selectCityHint,
refresh: t.refresh,
currentMax: t.currentMax,
cloud: t.cloud,
wind: t.wind,
topProb: t.topProb,
noProb: t.noProb,
aiSummary: t.aiSummary,
noAnalysis: t.noAnalysis,
}}
/>
</section>
</div>
{error ? (
<div className="pointer-events-none fixed bottom-4 left-1/2 z-[1200] -translate-x-1/2 rounded-lg border border-red-900 bg-red-950/90 px-3 py-2 text-sm text-red-100">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
<span>{localizeError(error)}</span>
</div>
</div>
) : null}
</main>
);
}
-33
View File
@@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
-51
View File
@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
-59
View File
@@ -1,59 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-slate-800 bg-slate-950/70 text-slate-100 shadow-glow backdrop-blur-sm",
className,
)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-4 md:p-5", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-slate-400", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0 md:p-5 md:pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
export { Card, CardHeader, CardTitle, CardDescription, CardContent };
-40
View File
@@ -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<CitySummary[]> {
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<CityDetail> {
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();
}
-55
View File
@@ -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;
-35
View File
@@ -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;
};
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+4 -9
View File
@@ -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);
});
}
+572 -194
View File
@@ -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
+90
View File
@@ -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"]
+85
View File
@@ -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."""
+4 -9
View File
@@ -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);
});
}