feat: Implement initial PolyWeather application with interactive map UI, backend API, and Polymarket data client.
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
import { PolyWeatherDashboard } from "@/components/polyweather-dashboard";
|
||||
|
||||
export default function ModernPage() {
|
||||
return <PolyWeatherDashboard />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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='© <a href="https://www.openstreetmap.org/">OSM</a> © <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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user