Parcourir la source

feat: add daily high temp card for Hong Kong with DB persistence

- Add ObservedTemperature model to Prisma schema for storing HKO readings
- Persist HKO Observatory temperature to DB on each API call (deduped by recordTime)
- Query daily high from DB first, fallback to HKO flw API text parsing
- Display Today's High card with HKO weather icon and forecast description
- Remove 38-station list from HKO temp card for cleaner layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Horace Zhang il y a 7 mois
Parent
commit
5b9c78c829

+ 13 - 0
packages/core/prisma/schema.prisma

@@ -83,6 +83,19 @@ model Trade {
   @@index([status, createdAt])
 }
 
+model ObservedTemperature {
+  id         String   @id @default(cuid())
+  city       String
+  date       DateTime // target date (date-only, midnight UTC)
+  temp       Float    // observed temperature in city's unit
+  unit       String   // 'C' or 'F'
+  recordTime DateTime // when this reading was taken
+  source     String   // 'hko', 'open-meteo', 'wttr'
+  createdAt  DateTime @default(now())
+
+  @@index([city, date])
+}
+
 model DailyMetrics {
   id              String   @id @default(cuid())
   date            DateTime @unique

+ 248 - 0
packages/dashboard/src/app/api/hko-current/route.ts

@@ -0,0 +1,248 @@
+/**
+ * HKO real-time temperature API.
+ *
+ * Primary source: 1-minute CSV with 0.1°C precision
+ *   https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather/latest_1min_temperature.csv
+ *
+ * Fallback: JSON rhrread API (integer precision only)
+ *   https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=rhrread&lang=en
+ *
+ * GET /api/hko-current
+ */
+
+import { NextResponse } from 'next/server';
+import type { HKOCurrentTemp, HKOStation } from '@/lib/types';
+import { getObservedDailyHigh, persistObservedTemp } from '@/lib/queries';
+
+export const dynamic = 'force-dynamic';
+
+const CSV_URL = 'https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather/latest_1min_temperature.csv';
+const JSON_URL = 'https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=rhrread&lang=en';
+const FLW_URL = 'https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=flw&lang=en';
+const REFERENCE_STATION_CSV = 'HK Observatory';
+const REFERENCE_STATION_JSON = 'Hong Kong Observatory';
+
+// In-memory cache (1-min TTL — CSV updates every minute)
+let cache: { data: HKOCurrentTemp; expiresAt: number } | null = null;
+const CACHE_TTL = 60 * 1000;
+
+// FLW cache (30-min TTL — forecast text updates ~4x daily)
+let flwCache: { temp: number | null; icon: number | null; forecastDesc: string; expiresAt: number } | null = null;
+const FLW_CACHE_TTL = 30 * 60 * 1000;
+
+/** Get today's date in HKT as a Date object (midnight HKT → UTC) */
+function getTodayHKT(): Date {
+  const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Hong_Kong' });
+  return new Date(dateStr + 'T00:00:00+08:00');
+}
+
+/**
+ * Fetch daily high + weather info from HKO APIs.
+ * - flw API: daily high from generalSituation text + forecastDesc
+ * - rhrread API: current weather icon code
+ */
+async function fetchWeatherInfo(): Promise<{ temp: number | null; icon: number | null; forecastDesc: string }> {
+  if (flwCache && flwCache.expiresAt > Date.now()) {
+    return { temp: flwCache.temp, icon: flwCache.icon, forecastDesc: flwCache.forecastDesc };
+  }
+
+  let dailyHigh: number | null = null;
+  let icon: number | null = null;
+  let forecastDesc = '';
+
+  // Fetch flw + rhrread in parallel
+  const [flwRes, rhrRes] = await Promise.allSettled([
+    fetch(FLW_URL, { signal: AbortSignal.timeout(10_000), cache: 'no-store' }),
+    fetch(JSON_URL, { signal: AbortSignal.timeout(10_000), cache: 'no-store' }),
+  ]);
+
+  // Parse flw for daily high + forecast description
+  if (flwRes.status === 'fulfilled' && flwRes.value.ok) {
+    const json = await flwRes.value.json();
+    forecastDesc = json.forecastDesc ?? '';
+    const text: string = json.generalSituation ?? '';
+    const match = text.match(
+      /(?:rose to|reached|recorded a high of|maximum[^.]*?)\s*(?:about|around|approximately)?\s*(\d+)\s*degrees?/i,
+    );
+    if (match) dailyHigh = parseInt(match[1]);
+  }
+
+  // Parse rhrread for weather icon
+  if (rhrRes.status === 'fulfilled' && rhrRes.value.ok) {
+    const json = await rhrRes.value.json();
+    const icons: number[] = json.icon ?? [];
+    if (icons.length > 0) icon = icons[0];
+  }
+
+  flwCache = { temp: dailyHigh, icon, forecastDesc, expiresAt: Date.now() + FLW_CACHE_TTL };
+  return { temp: dailyHigh, icon, forecastDesc };
+}
+
+/**
+ * Parse the HKO 1-minute temperature CSV.
+ * Format: "Date time,Automatic Weather Station,Air Temperature(degree Celsius)"
+ * Example: "202603191730,HK Observatory,26.4"
+ */
+function parseTemperatureCSV(csv: string): { stations: HKOStation[]; recordTime: string } | null {
+  const lines = csv.trim().split('\n');
+  if (lines.length < 2) return null;
+
+  const stations: HKOStation[] = [];
+  let recordTime = '';
+
+  for (let i = 1; i < lines.length; i++) {
+    const parts = lines[i].split(',');
+    if (parts.length < 3) continue;
+
+    const [datetime, place, tempStr] = parts;
+    if (!datetime || !place || !tempStr || tempStr.trim() === 'N/A') continue;
+
+    const temp = parseFloat(tempStr);
+    if (isNaN(temp)) continue;
+
+    stations.push({ place: place.trim(), value: temp, unit: 'C' });
+
+    if (!recordTime && datetime) {
+      // Convert "202603191730" to ISO format
+      const y = datetime.slice(0, 4);
+      const m = datetime.slice(4, 6);
+      const d = datetime.slice(6, 8);
+      const h = datetime.slice(8, 10);
+      const min = datetime.slice(10, 12);
+      recordTime = `${y}-${m}-${d}T${h}:${min}:00+08:00`;
+    }
+  }
+
+  return stations.length > 0 ? { stations, recordTime } : null;
+}
+
+async function fetchFromCSV(): Promise<HKOCurrentTemp | null> {
+  const res = await fetch(CSV_URL, {
+    signal: AbortSignal.timeout(10_000),
+    cache: 'no-store',
+  });
+
+  if (!res.ok) return null;
+
+  const text = await res.text();
+  const parsed = parseTemperatureCSV(text);
+  if (!parsed) return null;
+
+  const refStation = parsed.stations.find((s) => s.place === REFERENCE_STATION_CSV);
+
+  return {
+    current: {
+      temp: refStation?.value ?? parsed.stations[0]?.value ?? 0,
+      unit: 'C',
+      recordTime: parsed.recordTime,
+    },
+    stations: parsed.stations,
+  };
+}
+
+async function fetchFromJSON(): Promise<HKOCurrentTemp | null> {
+  const res = await fetch(JSON_URL, {
+    headers: { Accept: 'application/json' },
+    signal: AbortSignal.timeout(10_000),
+    cache: 'no-store',
+  });
+
+  if (!res.ok) return null;
+
+  const json = await res.json();
+  const stations: HKOStation[] = (json.temperature?.data ?? []).map(
+    (s: { place: string; value: number; unit: string }) => ({
+      place: s.place,
+      value: s.value,
+      unit: s.unit,
+    }),
+  );
+
+  const refStation = stations.find((s) => s.place === REFERENCE_STATION_JSON);
+
+  const result: HKOCurrentTemp = {
+    current: {
+      temp: refStation?.value ?? stations[0]?.value ?? 0,
+      unit: refStation?.unit ?? 'C',
+      recordTime: json.temperature?.recordTime ?? new Date().toISOString(),
+    },
+    stations,
+  };
+
+  // Add humidity if available
+  if (json.humidity?.data?.length) {
+    const hkoHumidity = json.humidity.data.find(
+      (h: { place: string; value: number }) => h.place === REFERENCE_STATION_JSON,
+    );
+    if (hkoHumidity) {
+      result.humidity = {
+        value: hkoHumidity.value,
+        recordTime: json.humidity.recordTime,
+      };
+    }
+  }
+
+  return result;
+}
+
+export async function GET() {
+  try {
+    // Return cached if fresh
+    if (cache && cache.expiresAt > Date.now()) {
+      return NextResponse.json(cache.data);
+    }
+
+    // Try CSV first (0.1°C precision), fallback to JSON (integer)
+    let result = await fetchFromCSV().catch(() => null);
+
+    if (!result) {
+      result = await fetchFromJSON().catch(() => null);
+    }
+
+    if (!result) {
+      return NextResponse.json(
+        { error: 'Failed to fetch HKO data from all sources' },
+        { status: 502 },
+      );
+    }
+
+    // Persist current reading to DB (fire-and-forget)
+    persistObservedTemp({
+      city: 'hong_kong',
+      temp: result.current.temp,
+      unit: 'C',
+      recordTime: new Date(result.current.recordTime),
+      source: 'hko',
+    }).catch(() => {});
+
+    // Daily high: DB first, then flw API fallback
+    const todayHKT = getTodayHKT();
+    const [dbHigh, weatherInfo] = await Promise.all([
+      getObservedDailyHigh('hong_kong', todayHKT).catch(() => null),
+      fetchWeatherInfo().catch(() => ({ temp: null, icon: null, forecastDesc: '' })),
+    ]);
+
+    if (dbHigh) {
+      result.dailyHigh = { temp: dbHigh.temp, recordTime: dbHigh.recordTime.toISOString() };
+    } else if (weatherInfo.temp !== null) {
+      result.dailyHigh = { temp: weatherInfo.temp, recordTime: result.current.recordTime };
+    } else {
+      // Last resort: use current temp
+      result.dailyHigh = { temp: result.current.temp, recordTime: result.current.recordTime };
+    }
+
+    if (weatherInfo.icon !== null) {
+      result.weather = { icon: weatherInfo.icon, forecastDesc: weatherInfo.forecastDesc };
+    }
+
+    // Update cache
+    cache = { data: result, expiresAt: Date.now() + CACHE_TTL };
+
+    return NextResponse.json(result);
+  } catch {
+    return NextResponse.json(
+      { error: 'Failed to fetch HKO current data' },
+      { status: 500 },
+    );
+  }
+}

+ 319 - 0
packages/dashboard/src/app/cities/hong-kong/HongKongClient.tsx

@@ -0,0 +1,319 @@
+'use client';
+
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import { SignalTable } from '@/components/SignalTable';
+import { OrderPanel } from '@/components/OrderPanel';
+import { HKOTempCard, HKODailyHighCard } from './components/HKOTempCard';
+import { HKOForecastChart } from './components/HKOForecastChart';
+import type { Signal, WeatherMarket, HKOCurrentTemp, HKOForecast } from '@/lib/types';
+
+const strategyTypes = ['ALL', 'TAIL_NO', 'OBSERVED_YES', 'OBSERVED_NO'] as const;
+const strategyInfo: Record<string, { icon: string; label: string }> = {
+  ALL: { icon: '📊', label: 'All' },
+  TAIL_NO: { icon: '🎯', label: 'Tail NO' },
+  OBSERVED_YES: { icon: '🌡️', label: 'Obs YES' },
+  OBSERVED_NO: { icon: '❄️', label: 'Obs NO' },
+};
+
+interface Props {
+  signals: Signal[];
+  markets: WeatherMarket[];
+}
+
+export function HongKongClient({ signals, markets }: Props) {
+  const [currentTemp, setCurrentTemp] = useState<HKOCurrentTemp | null>(null);
+  const [forecast, setForecast] = useState<HKOForecast | null>(null);
+  const [tempLoading, setTempLoading] = useState(true);
+  const [forecastLoading, setForecastLoading] = useState(true);
+  const [filterType, setFilterType] = useState<string>('ALL');
+  const [selectionMode, setSelectionMode] = useState(false);
+  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
+  const [showOrderPanel, setShowOrderPanel] = useState(false);
+  const [hktTime, setHktTime] = useState('');
+
+  // Fetch HKO current temperature
+  const fetchCurrent = useCallback(async () => {
+    try {
+      const res = await fetch('/api/hko-current');
+      if (res.ok) {
+        const data = await res.json();
+        setCurrentTemp(data);
+      }
+    } catch {
+      // ignore
+    } finally {
+      setTempLoading(false);
+    }
+  }, []);
+
+  // Fetch HKO forecast
+  const fetchForecast = useCallback(async () => {
+    try {
+      const res = await fetch('/api/hko-forecast');
+      if (res.ok) {
+        const data = await res.json();
+        setForecast(data);
+      }
+    } catch {
+      // ignore
+    } finally {
+      setForecastLoading(false);
+    }
+  }, []);
+
+  // Initialize data + refresh intervals
+  useEffect(() => {
+    fetchCurrent();
+    fetchForecast();
+    const tempInterval = setInterval(fetchCurrent, 3 * 60 * 1000); // 3 min
+    const forecastInterval = setInterval(fetchForecast, 30 * 60 * 1000); // 30 min
+    return () => {
+      clearInterval(tempInterval);
+      clearInterval(forecastInterval);
+    };
+  }, [fetchCurrent, fetchForecast]);
+
+  // Live HKT clock
+  useEffect(() => {
+    const update = () => {
+      setHktTime(
+        new Date().toLocaleTimeString('en-HK', {
+          timeZone: 'Asia/Hong_Kong',
+          hour: '2-digit',
+          minute: '2-digit',
+          second: '2-digit',
+          hour12: false,
+        }),
+      );
+    };
+    update();
+    const interval = setInterval(update, 1000);
+    return () => clearInterval(interval);
+  }, []);
+
+  // Filter signals by strategy type
+  const filteredSignals = useMemo(() => {
+    if (filterType === 'ALL') return signals;
+    return signals.filter((s) => s.type === filterType);
+  }, [signals, filterType]);
+
+  // Selection handlers
+  const handleToggleSelect = (id: string) => {
+    setSelectedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  };
+
+  const selectedSignals = useMemo(
+    () => filteredSignals.filter((s) => selectedIds.has(s.id)),
+    [filteredSignals, selectedIds],
+  );
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <div>
+          <div className="flex items-center gap-3">
+            <span className="text-2xl">🇭🇰</span>
+            <h1 className="text-2xl font-bold text-heading">Hong Kong</h1>
+          </div>
+          <p className="text-sm text-dim mt-1">
+            HKO-powered temperature trading &bull; {hktTime} HKT
+          </p>
+        </div>
+        <a
+          href="https://www.weather.gov.hk/en/wxinfo/currwx/current.htm"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="btn-secondary text-xs px-3 py-1.5 rounded-lg"
+        >
+          HKO Website &rarr;
+        </a>
+      </div>
+
+      {/* Top row: Temp card + Daily High + Forecast chart */}
+      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+        <div className="lg:col-span-1 flex flex-col gap-6">
+          <HKOTempCard data={currentTemp} loading={tempLoading} />
+          <div className="flex-1">
+            <HKODailyHighCard data={currentTemp} loading={tempLoading} />
+          </div>
+        </div>
+        <div className="lg:col-span-2">
+          <HKOForecastChart
+            forecast={forecast}
+            currentTemp={currentTemp}
+            loading={forecastLoading}
+          />
+        </div>
+      </div>
+
+      {/* Markets section */}
+      {markets.length > 0 && (
+        <div className="card p-6">
+          <h3 className="text-sm font-medium text-heading mb-4">
+            Active Markets ({markets.length})
+          </h3>
+          <div className="space-y-4">
+            {markets.map((market) => (
+              <div key={market.id} className="border border-gray-200 dark:border-slate-700 rounded-lg p-4">
+                <div className="flex items-center justify-between mb-3">
+                  <div>
+                    <span className="text-sm font-medium text-heading">{market.date}</span>
+                    <span className="text-xs text-dim ml-2">
+                      Vol: ${market.totalVolume.toLocaleString()}
+                    </span>
+                  </div>
+                  {market.polymarketUrl && (
+                    <a
+                      href={market.polymarketUrl}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
+                    >
+                      Polymarket &rarr;
+                    </a>
+                  )}
+                </div>
+                <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
+                  {market.buckets
+                    .sort((a, b) => {
+                      const aNum = parseFloat(a.label.match(/\d+/)?.[0] ?? '0');
+                      const bNum = parseFloat(b.label.match(/\d+/)?.[0] ?? '0');
+                      return aNum - bNum;
+                    })
+                    .map((bucket) => {
+                      const pricePct = (bucket.marketPrice * 100).toFixed(0);
+                      const isHighlighted =
+                        currentTemp &&
+                        bucket.label.includes('°C') &&
+                        (() => {
+                          const match = bucket.label.match(/(\d+)-(\d+)/);
+                          if (!match) return false;
+                          const lo = parseInt(match[1]);
+                          const hi = parseInt(match[2]);
+                          return currentTemp.current.temp >= lo && currentTemp.current.temp <= hi;
+                        })();
+
+                      return (
+                        <div
+                          key={bucket.label}
+                          className={`rounded-lg px-2 py-1.5 text-xs ${
+                            isHighlighted
+                              ? 'bg-amber-50 border border-amber-300 dark:bg-amber-500/10 dark:border-amber-500/30'
+                              : 'bg-gray-50 dark:bg-slate-800/50'
+                          }`}
+                        >
+                          <div className="font-medium text-heading truncate" title={bucket.label}>
+                            {bucket.label}
+                          </div>
+                          <div className="mt-1 flex items-center gap-1">
+                            <div
+                              className="h-1.5 rounded-full bg-blue-500"
+                              style={{ width: `${Math.min(bucket.marketPrice * 100, 100)}%`, minWidth: '2px' }}
+                            />
+                            <span className="text-dim tabular-nums">{pricePct}¢</span>
+                          </div>
+                        </div>
+                      );
+                    })}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* Signals section */}
+      <div className="card p-6">
+        <div className="flex items-center justify-between mb-4">
+          <h3 className="text-sm font-medium text-heading">
+            Signals ({filteredSignals.length})
+          </h3>
+          <div className="flex items-center gap-2">
+            {/* Strategy filter pills */}
+            <div className="flex gap-1">
+              {strategyTypes.map((type) => (
+                <button
+                  key={type}
+                  onClick={() => setFilterType(type)}
+                  className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
+                    filterType === type
+                      ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'
+                      : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700'
+                  }`}
+                >
+                  {strategyInfo[type].icon} {strategyInfo[type].label}
+                </button>
+              ))}
+            </div>
+            {/* Selection mode toggle */}
+            <button
+              onClick={() => {
+                setSelectionMode(!selectionMode);
+                setSelectedIds(new Set());
+                setShowOrderPanel(false);
+              }}
+              className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
+                selectionMode
+                  ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400'
+                  : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-slate-800 dark:text-slate-400'
+              }`}
+            >
+              {selectionMode ? 'Cancel' : 'Manual Order'}
+            </button>
+          </div>
+        </div>
+
+        {filteredSignals.length > 0 ? (
+          <SignalTable
+            signals={filteredSignals}
+            selectionMode={selectionMode}
+            selectedIds={selectedIds}
+            onToggleSelect={handleToggleSelect}
+          />
+        ) : (
+          <p className="text-dim text-sm text-center py-8">
+            No Hong Kong signals detected yet.
+          </p>
+        )}
+
+        {/* Selection action bar */}
+        {selectionMode && selectedIds.size > 0 && (
+          <div className="mt-4 flex items-center justify-between p-3 rounded-lg bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20">
+            <span className="text-sm text-blue-700 dark:text-blue-400">
+              {selectedIds.size} signal{selectedIds.size > 1 ? 's' : ''} selected
+            </span>
+            <button
+              onClick={() => setShowOrderPanel(true)}
+              className="btn-primary text-xs px-4 py-1.5 rounded-lg"
+            >
+              Place Orders &rarr;
+            </button>
+          </div>
+        )}
+      </div>
+
+      {/* Order Panel Modal */}
+      {showOrderPanel && selectedSignals.length > 0 && (
+        <OrderPanel
+          signals={selectedSignals}
+          onClose={() => {
+            setShowOrderPanel(false);
+            setSelectedIds(new Set());
+            setSelectionMode(false);
+          }}
+          onComplete={() => {
+            setShowOrderPanel(false);
+            setSelectedIds(new Set());
+            setSelectionMode(false);
+          }}
+        />
+      )}
+    </div>
+  );
+}

+ 127 - 0
packages/dashboard/src/app/cities/hong-kong/components/HKOTempCard.tsx

@@ -0,0 +1,127 @@
+'use client';
+
+import type { HKOCurrentTemp } from '@/lib/types';
+
+interface HKOTempCardProps {
+  data: HKOCurrentTemp | null;
+  loading: boolean;
+}
+
+/** Format temperature with 1 decimal place */
+function fmtTemp(value: number): string {
+  return Number.isInteger(value) ? `${value}.0` : value.toFixed(1);
+}
+
+function fmtTime(isoTime: string): string {
+  return new Date(isoTime).toLocaleTimeString('en-HK', {
+    timeZone: 'Asia/Hong_Kong',
+    hour: '2-digit',
+    minute: '2-digit',
+  });
+}
+
+export function HKOTempCard({ data, loading }: HKOTempCardProps) {
+  if (loading) {
+    return (
+      <div className="card p-6 animate-pulse">
+        <div className="h-4 w-32 bg-gray-200 dark:bg-slate-700 rounded mb-4" />
+        <div className="h-16 w-24 bg-gray-200 dark:bg-slate-700 rounded mb-2" />
+        <div className="h-3 w-48 bg-gray-200 dark:bg-slate-700 rounded" />
+      </div>
+    );
+  }
+
+  if (!data) {
+    return (
+      <div className="card p-6">
+        <h3 className="text-sm font-medium text-sub mb-2">HKO Temperature</h3>
+        <p className="text-dim text-sm">Unable to fetch HKO data</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="card p-6">
+      <div className="flex items-start justify-between mb-1">
+        <h3 className="text-sm font-medium text-sub">HKO Observatory</h3>
+        <a
+          href="https://www.hko.gov.hk/en/wxinfo/currwx/current.htm"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
+        >
+          hko.gov.hk &rarr;
+        </a>
+      </div>
+
+      <div className="flex items-end gap-3 mb-3">
+        <span className="text-5xl font-bold text-heading tabular-nums">
+          {fmtTemp(data.current.temp)}
+        </span>
+        <span className="text-2xl text-sub mb-1">°{data.current.unit}</span>
+        {data.humidity && (
+          <span className="text-sm text-dim mb-2 ml-auto">
+            💧 {data.humidity.value}%
+          </span>
+        )}
+      </div>
+
+      <p className="text-xs text-dim">
+        Updated {fmtTime(data.current.recordTime)} HKT
+      </p>
+    </div>
+  );
+}
+
+/** HKO weather icon URL */
+function hkoIconUrl(iconCode: number): string {
+  return `https://www.hko.gov.hk/images/HKOWxIconOutline/pic${iconCode}.png`;
+}
+
+/** Separate card showing today's high + current weather conditions */
+export function HKODailyHighCard({ data, loading }: HKOTempCardProps) {
+  if (loading) {
+    return (
+      <div className="card p-6 h-full animate-pulse">
+        <div className="h-4 w-32 bg-gray-200 dark:bg-slate-700 rounded mb-4" />
+        <div className="h-12 w-20 bg-gray-200 dark:bg-slate-700 rounded" />
+      </div>
+    );
+  }
+
+  if (!data?.dailyHigh) {
+    return (
+      <div className="card p-6 h-full">
+        <h3 className="text-sm font-medium text-sub mb-2">Today&apos;s High</h3>
+        <p className="text-dim text-sm">No data yet</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="card p-6 h-full flex flex-col">
+      <h3 className="text-sm font-medium text-sub mb-1">Today&apos;s High</h3>
+      <div className="flex items-end gap-2 mb-1">
+        <span className="text-4xl font-bold tabular-nums text-red-500 dark:text-red-400">
+          {fmtTemp(data.dailyHigh.temp)}
+        </span>
+        <span className="text-xl text-sub mb-0.5">°{data.current.unit}</span>
+      </div>
+
+      {/* Weather conditions */}
+      {data.weather && (
+        <div className="mt-auto pt-4 flex items-start gap-3">
+          {/* eslint-disable-next-line @next/next/no-img-element */}
+          <img
+            src={hkoIconUrl(data.weather.icon)}
+            alt="Weather icon"
+            className="w-14 h-14 flex-shrink-0"
+          />
+          <p className="text-xs text-body leading-relaxed">
+            {data.weather.forecastDesc}
+          </p>
+        </div>
+      )}
+    </div>
+  );
+}

+ 71 - 5
packages/dashboard/src/lib/queries.ts

@@ -88,8 +88,10 @@ export async function getRecentSignals(limit = 20): Promise<Signal[]> {
   return rows.map(mapSignal);
 }
 
-export async function getSignals(type?: string): Promise<Signal[]> {
-  const where = type && type !== 'ALL' ? { type } : {};
+export async function getSignals(type?: string, city?: string): Promise<Signal[]> {
+  const where: Record<string, unknown> = {};
+  if (type && type !== 'ALL') where.type = type;
+  if (city) where.city = city;
 
   const rows = await prisma.signal.findMany({
     where,
@@ -227,12 +229,16 @@ export async function getRecentTrades(limit = 50): Promise<TradeRecord[]> {
 
 // ─── Markets ────────────────────────────────────────────────────────────────
 
-export async function getMarkets(): Promise<WeatherMarket[]> {
+export async function getMarkets(city?: string): Promise<WeatherMarket[]> {
   // Get the latest snapshot per city+targetDate+bucket
+  const where: Record<string, unknown> = {
+    createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
+  };
+  if (city) where.city = city;
+
   const snapshots = await prisma.marketSnapshot.findMany({
     orderBy: { createdAt: 'desc' },
-    // Get recent snapshots (last 24h)
-    where: { createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
+    where,
   });
 
   // Group by city+date
@@ -336,3 +342,63 @@ export async function getPnLData(): Promise<{ data: PnLDataPoint[]; metrics: PnL
 
   return { data, metrics };
 }
+
+// ─── Observed Temperature ───────────────────────────────────────────────────
+
+/**
+ * Get today's highest observed temperature for a city from the database.
+ * Returns the single highest reading for the given date.
+ */
+export async function getObservedDailyHigh(
+  city: string,
+  date: Date,
+): Promise<{ temp: number; recordTime: Date } | null> {
+  const startOfDay = new Date(date);
+  startOfDay.setHours(0, 0, 0, 0);
+  const endOfDay = new Date(date);
+  endOfDay.setHours(23, 59, 59, 999);
+
+  const result = await prisma.observedTemperature.findFirst({
+    where: {
+      city,
+      recordTime: { gte: startOfDay, lte: endOfDay },
+    },
+    orderBy: { temp: 'desc' },
+    select: { temp: true, recordTime: true },
+  });
+
+  return result;
+}
+
+/**
+ * Persist an observed temperature reading (deduped by city + recordTime).
+ * Fire-and-forget — callers should not await this.
+ */
+export async function persistObservedTemp(data: {
+  city: string;
+  temp: number;
+  unit: string;
+  recordTime: Date;
+  source: string;
+}): Promise<void> {
+  // Skip if we already have a record for this exact reading
+  const existing = await prisma.observedTemperature.findFirst({
+    where: { city: data.city, recordTime: data.recordTime },
+    select: { id: true },
+  });
+  if (existing) return;
+
+  const date = new Date(data.recordTime);
+  date.setHours(0, 0, 0, 0);
+
+  await prisma.observedTemperature.create({
+    data: {
+      city: data.city,
+      date,
+      temp: data.temp,
+      unit: data.unit,
+      recordTime: data.recordTime,
+      source: data.source,
+    },
+  });
+}

+ 30 - 0
packages/dashboard/src/lib/types.ts

@@ -114,3 +114,33 @@ export interface PnLMetrics {
   totalTrades: number;
   winningTrades: number;
 }
+
+// ─── HKO (Hong Kong Observatory) Types ──────────────────────────────────────
+
+export interface HKOStation {
+  place: string;
+  value: number;
+  unit: string;
+}
+
+export interface HKOCurrentTemp {
+  current: { temp: number; unit: string; recordTime: string };
+  humidity?: { value: number; recordTime: string };
+  dailyHigh?: { temp: number; recordTime: string };
+  weather?: { icon: number; forecastDesc: string };
+  stations: HKOStation[];
+}
+
+export interface HKOForecastDay {
+  date: string;
+  weekDay: string;
+  maxTemp: number;
+  minTemp: number;
+  weather?: string;
+  icon?: number;
+}
+
+export interface HKOForecast {
+  updateTime: string;
+  forecast: HKOForecastDay[];
+}