瀏覽代碼

feat: add Hong Kong city page with forecast chart, sidebar nav, and bug fixes

- Add /cities/hong-kong page with server-side signal/market fetching
- Add HKO 9-day forecast API route and temperature chart component
- Add loading skeleton for Hong Kong page
- Add expandable Cities nav group in Sidebar with Hong Kong link
- Add city filter param to signals API route
- Fix volume NaN bug in market-scanner and trade persistence (Number() coercion)
- Add Hong Kong reference doc (HKO API docs, station info)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Horace Zhang 7 月之前
父節點
當前提交
269f59c3b9

+ 181 - 0
doc/HONG_KONG.md

@@ -0,0 +1,181 @@
+# Hong Kong — Polymarket Weather Trading Reference
+
+## Overview
+
+Hong Kong is a priority city for the Poly Weather bot. The Hong Kong Observatory (HKO) is the **official settlement source** for Polymarket Hong Kong temperature markets, making HKO data uniquely valuable — it's both the forecast provider and the ground truth.
+
+- **City slug**: `hong_kong`
+- **Temperature unit**: °C
+- **Timezone**: `Asia/Hong_Kong` (UTC+8)
+- **Polymarket URL pattern**: `https://polymarket.com/event/highest-temperature-in-hong-kong-on-{month}-{day}-{year}`
+- **Dashboard page**: `/cities/hong-kong`
+
+---
+
+## HKO Open Data API
+
+Base URL: `https://data.weather.gov.hk/weatherAPI/opendata`
+
+No API key required. No documented rate limits. Free and open.
+
+### 1. 1-Minute Temperature CSV (Primary — 0.1°C precision)
+
+**Endpoint**: `GET https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather/latest_1min_temperature.csv`
+
+Returns real-time readings from **30+ automatic weather stations** with **0.1°C precision**, updated every minute.
+
+**CSV format**:
+```csv
+Date time,Automatic Weather Station,Air Temperature(degree Celsius)
+202603191730,HK Observatory,26.4
+202603191730,King's Park,25.8
+202603191730,Sha Tin,26.8
+202603191730,Tai Mo Shan,18.2
+```
+
+**Reference station**: "HK Observatory" (尖沙咀天文台總部) — this is the station used for official temperature records and Polymarket settlement.
+
+**Notes**:
+- Some stations may report `N/A` when sensors are offline
+- Includes additional stations not in the JSON API (e.g. Ngong Ping, Tai Mo Shan, The Peak, Tate's Cairn)
+- Updates every 1 minute (much faster than the JSON API's ~10 min interval)
+
+### 2. Current Observations JSON (Fallback — integer precision)
+
+**Endpoint**: `GET /weather.php?dataType=rhrread&lang=en`
+
+Returns real-time readings from **26 monitoring stations** across Hong Kong, updated approximately every 10 minutes. **Integer precision only** (e.g. 27°C instead of 26.4°C).
+
+**Key response fields**:
+```json
+{
+  "temperature": {
+    "data": [
+      { "place": "Hong Kong Observatory", "value": 27, "unit": "C" },
+      { "place": "King's Park", "value": 26, "unit": "C" }
+    ],
+    "recordTime": "2026-03-19T17:00:00+08:00"
+  },
+  "humidity": {
+    "data": [{ "place": "Hong Kong Observatory", "value": 58 }],
+    "recordTime": "2026-03-19T17:00:00+08:00"
+  }
+}
+```
+
+**Note**: Station names differ between CSV and JSON (e.g. "HK Observatory" in CSV vs "Hong Kong Observatory" in JSON).
+
+### 2. 9-Day Forecast (fnd)
+
+**Endpoint**: `GET /weather.php?dataType=fnd&lang=en`
+
+Returns daily max/min temperature forecasts for the next 9 days. Updated ~4 times daily (around 00:00, 06:00, 12:00, 18:00 HKT).
+
+**Key response fields**:
+```json
+{
+  "updateTime": "2026-03-19T16:30:00+08:00",
+  "weatherForecast": [
+    {
+      "forecastDate": "20260320",
+      "week": "Friday",
+      "forecastMaxtemp": { "value": 28, "unit": "C" },
+      "forecastMintemp": { "value": 23, "unit": "C" },
+      "forecastMaxrh": { "value": 85, "unit": "percent" },
+      "forecastMinrh": { "value": 60, "unit": "percent" },
+      "ForecastIcon": 51,
+      "forecastWeather": "Mainly fine. Hot during the day.",
+      "forecastWind": "South force 3 to 4.",
+      "forecastDate": "20260320"
+    },
+    ...
+  ]
+}
+```
+
+### 3. Other Useful Endpoints
+
+| dataType | Description | Use Case |
+|----------|-------------|----------|
+| `flw` | Local Weather Forecast (text) | General outlook |
+| `warningInfo` | Weather Warnings | Extreme conditions |
+| `sundata` | Sunrise/Sunset times | Daytime determination |
+
+---
+
+## Ensemble Model Integration
+
+HKO is one of 4 models in the ensemble probability calculation:
+
+| Model | Weight | Source | Coverage |
+|-------|--------|--------|----------|
+| ECMWF | 30% | Open-Meteo | Global |
+| GFS | 25% | Open-Meteo | Global |
+| **HKO** | **25%** | HKO API | **Hong Kong only** |
+| ICON | 20% | Open-Meteo | Global |
+
+For non-Hong Kong cities, HKO is excluded and remaining weights are re-normalized.
+
+HKO has **lower standard errors** than other models for Hong Kong forecasts:
+
+| Lead Time | HKO σ | GFS σ | ECMWF σ | ICON σ |
+|-----------|--------|-------|---------|--------|
+| 24h | 0.8°C | 1.2°C | 1.0°C | 1.3°C |
+| 48h | 1.0°C | 1.5°C | 1.3°C | 1.6°C |
+| 72h | 1.2°C | 1.8°C | 1.5°C | 1.9°C |
+
+---
+
+## Strategy Considerations for Hong Kong
+
+### OBSERVED_YES / OBSERVED_NO
+
+These strategies are especially effective for Hong Kong because:
+1. **HKO updates every ~hour** — faster than market repricing
+2. **HKO is the settlement source** — no discrepancy between observation and settlement
+3. **Peak hours 11:00–14:00 HKT** — best window for OBSERVED_YES signals
+4. **Daily high usually occurs 13:00–15:00 HKT** — after peak, OBSERVED_NO confidence is highest
+
+### TAIL_NO
+
+Hong Kong temperature distribution is narrow (maritime subtropical climate), so tail buckets (e.g. "32°C or higher" in winter) are often overpriced by the market.
+
+### LATENCY_ARB
+
+HKO forecast updates at ~00:00, 06:00, 12:00, 18:00 HKT. The 06:00 and 12:00 updates are most likely to shift probabilities, creating latency arbitrage windows.
+
+---
+
+## Settlement Rules
+
+- Market question: "Will the high temperature in Hong Kong be X°C on [date]?"
+- Settlement source: Hong Kong Observatory official daily maximum temperature
+- Settlement time: After midnight HKT on the market date (once daily max is finalized)
+- The daily max is typically recorded between 13:00–15:00 HKT
+
+---
+
+## Key File Paths
+
+### Weather Engine (backend)
+- `packages/weather-engine/src/providers/hko-api.ts` — HKO provider (fetch + fetchCurrentMax)
+- `packages/weather-engine/src/providers/open-meteo.ts` — fetchAllModels() conditionally includes HKO for HK
+- `packages/weather-engine/src/ensemble.ts` — Ensemble weights and probability calculation
+
+### Dashboard (frontend)
+- `packages/dashboard/src/app/cities/hong-kong/page.tsx` — HK page server component
+- `packages/dashboard/src/app/cities/hong-kong/HongKongClient.tsx` — HK page client component
+- `packages/dashboard/src/app/api/hko-current/route.ts` — HKO real-time temperature API
+- `packages/dashboard/src/app/api/hko-forecast/route.ts` — HKO 9-day forecast API
+
+### Core
+- `packages/core/src/constants.ts` — City config (lat/lon, timezone, priority)
+- `packages/core/prisma/schema.prisma` — Database schema
+
+---
+
+## HKO Weather History (for verification)
+
+Official daily extract: `https://www.weather.gov.hk/en/cis/dailyExtract.htm?y={year}&m={month}`
+
+Use this to verify settlement temperatures and backtest strategy performance.

+ 90 - 0
packages/dashboard/src/app/api/hko-forecast/route.ts

@@ -0,0 +1,90 @@
+/**
+ * HKO 9-day forecast API.
+ * Fetches daily max/min temperature forecasts from Hong Kong Observatory.
+ * GET /api/hko-forecast
+ */
+
+import { NextResponse } from 'next/server';
+import type { HKOForecast, HKOForecastDay } from '@/lib/types';
+
+export const dynamic = 'force-dynamic';
+
+const HKO_BASE = 'https://data.weather.gov.hk/weatherAPI/opendata';
+
+// In-memory cache (30-min TTL — HKO forecasts update ~4x daily)
+let cache: { data: HKOForecast; expiresAt: number } | null = null;
+const CACHE_TTL = 30 * 60 * 1000;
+
+interface HKOFndDay {
+  forecastDate: string; // "YYYYMMDD"
+  week: string;
+  forecastMaxtemp: { value: number; unit: string };
+  forecastMintemp: { value: number; unit: string };
+  forecastWeather: string;
+  ForecastIcon: number;
+}
+
+interface HKOFndResponse {
+  updateTime: string;
+  weatherForecast: HKOFndDay[];
+}
+
+export async function GET() {
+  try {
+    // Return cached if fresh
+    if (cache && cache.expiresAt > Date.now()) {
+      return NextResponse.json(cache.data);
+    }
+
+    const res = await fetch(`${HKO_BASE}/weather.php?dataType=fnd&lang=en`, {
+      headers: { Accept: 'application/json' },
+      signal: AbortSignal.timeout(10_000),
+      cache: 'no-store',
+    });
+
+    if (!res.ok) {
+      return NextResponse.json(
+        { error: `HKO API error: ${res.status}` },
+        { status: 502 },
+      );
+    }
+
+    const json = (await res.json()) as HKOFndResponse;
+
+    if (!json.weatherForecast?.length) {
+      return NextResponse.json(
+        { error: 'No forecast data returned' },
+        { status: 502 },
+      );
+    }
+
+    const forecast: HKOForecastDay[] = json.weatherForecast.map((day) => {
+      const y = day.forecastDate.slice(0, 4);
+      const m = day.forecastDate.slice(4, 6);
+      const d = day.forecastDate.slice(6, 8);
+      return {
+        date: `${y}-${m}-${d}`,
+        weekDay: day.week,
+        maxTemp: day.forecastMaxtemp.value,
+        minTemp: day.forecastMintemp.value,
+        weather: day.forecastWeather,
+        icon: day.ForecastIcon,
+      };
+    });
+
+    const result: HKOForecast = {
+      updateTime: json.updateTime,
+      forecast,
+    };
+
+    // Update cache
+    cache = { data: result, expiresAt: Date.now() + CACHE_TTL };
+
+    return NextResponse.json(result);
+  } catch (err) {
+    return NextResponse.json(
+      { error: 'Failed to fetch HKO forecast data' },
+      { status: 500 },
+    );
+  }
+}

+ 2 - 1
packages/dashboard/src/app/api/signals/route.ts

@@ -7,7 +7,8 @@ export async function GET(request: Request) {
   try {
     const { searchParams } = new URL(request.url);
     const type = searchParams.get('type') ?? undefined;
-    const signals = await getSignals(type);
+    const city = searchParams.get('city') ?? undefined;
+    const signals = await getSignals(type, city);
     return NextResponse.json({ signals });
   } catch (err) {
     console.error('Signals API error:', err);

+ 171 - 0
packages/dashboard/src/app/cities/hong-kong/components/HKOForecastChart.tsx

@@ -0,0 +1,171 @@
+'use client';
+
+import {
+  LineChart,
+  Line,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+  ReferenceDot,
+  Legend,
+} from 'recharts';
+import type { HKOForecast, HKOCurrentTemp } from '@/lib/types';
+
+interface HKOForecastChartProps {
+  forecast: HKOForecast | null;
+  currentTemp: HKOCurrentTemp | null;
+  loading: boolean;
+}
+
+export function HKOForecastChart({ forecast, currentTemp, loading }: HKOForecastChartProps) {
+  if (loading) {
+    return (
+      <div className="card p-6 animate-pulse">
+        <div className="h-4 w-48 bg-gray-200 dark:bg-slate-700 rounded mb-4" />
+        <div className="h-64 bg-gray-200 dark:bg-slate-700 rounded" />
+      </div>
+    );
+  }
+
+  if (!forecast || !forecast.forecast.length) {
+    return (
+      <div className="card p-6">
+        <h3 className="text-sm font-medium text-sub mb-2">9-Day Forecast</h3>
+        <p className="text-dim text-sm">Unable to fetch forecast data</p>
+      </div>
+    );
+  }
+
+  const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Hong_Kong' });
+
+  const chartData = forecast.forecast.map((day) => {
+    const isToday = day.date === todayStr;
+    const shortDate = new Date(day.date + 'T00:00:00+08:00').toLocaleDateString('en-HK', {
+      timeZone: 'Asia/Hong_Kong',
+      month: 'short',
+      day: 'numeric',
+      weekday: 'short',
+    });
+
+    return {
+      date: shortDate,
+      rawDate: day.date,
+      maxTemp: day.maxTemp,
+      minTemp: day.minTemp,
+      isToday,
+      weather: day.weather,
+    };
+  });
+
+  // Find today's index for the current temp dot
+  const todayIndex = chartData.findIndex((d) => d.isToday);
+
+  const updateTime = new Date(forecast.updateTime).toLocaleTimeString('en-HK', {
+    timeZone: 'Asia/Hong_Kong',
+    hour: '2-digit',
+    minute: '2-digit',
+  });
+
+  // Temperature range for Y axis
+  const allTemps = chartData.flatMap((d) => [d.maxTemp, d.minTemp]);
+  if (currentTemp) allTemps.push(currentTemp.current.temp);
+  const yMin = Math.floor(Math.min(...allTemps) - 2);
+  const yMax = Math.ceil(Math.max(...allTemps) + 2);
+
+  return (
+    <div className="card p-6">
+      <div className="flex items-start justify-between mb-4">
+        <div>
+          <h3 className="text-sm font-medium text-heading">9-Day Temperature Forecast</h3>
+          <p className="text-xs text-dim mt-0.5">
+            HKO forecast updated {updateTime} HKT
+          </p>
+        </div>
+      </div>
+
+      <ResponsiveContainer width="100%" height={280}>
+        <LineChart data={chartData} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
+          <CartesianGrid strokeDasharray="3 3" className="opacity-30" />
+          <XAxis
+            dataKey="date"
+            tick={{ fontSize: 11 }}
+            className="text-dim"
+          />
+          <YAxis
+            domain={[yMin, yMax]}
+            tick={{ fontSize: 11 }}
+            className="text-dim"
+            tickFormatter={(v: number) => `${v}°`}
+          />
+          <Tooltip
+            contentStyle={{
+              backgroundColor: 'var(--tooltip-bg, #fff)',
+              border: '1px solid var(--tooltip-border, #e5e7eb)',
+              borderRadius: '8px',
+              fontSize: '12px',
+            }}
+            formatter={(value: number, name: string) => [
+              `${value}°C`,
+              name === 'maxTemp' ? 'High' : name === 'minTemp' ? 'Low' : 'Current',
+            ]}
+          />
+          <Legend
+            formatter={(value: string) =>
+              value === 'maxTemp' ? 'High' : value === 'minTemp' ? 'Low' : value
+            }
+          />
+          <Line
+            type="monotone"
+            dataKey="maxTemp"
+            stroke="#ef4444"
+            strokeWidth={2}
+            dot={{ r: 4 }}
+            activeDot={{ r: 6 }}
+          />
+          <Line
+            type="monotone"
+            dataKey="minTemp"
+            stroke="#3b82f6"
+            strokeWidth={2}
+            dot={{ r: 4 }}
+            activeDot={{ r: 6 }}
+          />
+          {/* Current observed temperature as a reference dot */}
+          {currentTemp && todayIndex >= 0 && (
+            <ReferenceDot
+              x={chartData[todayIndex]?.date}
+              y={currentTemp.current.temp}
+              r={7}
+              fill="#f59e0b"
+              stroke="#fff"
+              strokeWidth={2}
+            />
+          )}
+        </LineChart>
+      </ResponsiveContainer>
+
+      {/* Forecast cards row */}
+      <div className="mt-4 flex gap-2 overflow-x-auto pb-1">
+        {chartData.map((day) => (
+          <div
+            key={day.rawDate}
+            className={`flex-shrink-0 rounded-lg px-3 py-2 text-center text-xs min-w-[72px] ${
+              day.isToday
+                ? 'bg-blue-50 border border-blue-200 dark:bg-blue-500/10 dark:border-blue-500/20'
+                : 'bg-gray-50 dark:bg-slate-800/50'
+            }`}
+          >
+            <div className="font-medium text-heading">{day.date.split(',')[0]}</div>
+            <div className="mt-1">
+              <span className="text-red-500 font-medium">{day.maxTemp}°</span>
+              <span className="text-dim mx-0.5">/</span>
+              <span className="text-blue-500 font-medium">{day.minTemp}°</span>
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 38 - 0
packages/dashboard/src/app/cities/hong-kong/loading.tsx

@@ -0,0 +1,38 @@
+export default function HongKongLoading() {
+  return (
+    <div className="p-6 space-y-6 max-w-[1600px] mx-auto animate-pulse">
+      {/* Header skeleton */}
+      <div className="flex items-center gap-3">
+        <div className="w-8 h-8 bg-gray-200 dark:bg-slate-700 rounded" />
+        <div className="h-7 w-40 bg-gray-200 dark:bg-slate-700 rounded" />
+      </div>
+
+      {/* Top row: Temp card + Chart */}
+      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+        <div className="card p-6">
+          <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>
+        <div className="card p-6 lg:col-span-2">
+          <div className="h-4 w-48 bg-gray-200 dark:bg-slate-700 rounded mb-4" />
+          <div className="h-64 bg-gray-200 dark:bg-slate-700 rounded" />
+        </div>
+      </div>
+
+      {/* Markets skeleton */}
+      <div className="card p-6">
+        <div className="h-4 w-36 bg-gray-200 dark:bg-slate-700 rounded mb-4" />
+        <div className="h-32 bg-gray-200 dark:bg-slate-700 rounded" />
+      </div>
+
+      {/* Signals skeleton */}
+      <div className="card p-6">
+        <div className="h-4 w-24 bg-gray-200 dark:bg-slate-700 rounded mb-4" />
+        {[1, 2, 3].map((i) => (
+          <div key={i} className="h-12 bg-gray-200 dark:bg-slate-700 rounded mb-2" />
+        ))}
+      </div>
+    </div>
+  );
+}

+ 28 - 0
packages/dashboard/src/app/cities/hong-kong/page.tsx

@@ -0,0 +1,28 @@
+/**
+ * Hong Kong City Command Center.
+ * Server component that fetches HK-specific signals and live markets.
+ */
+
+import { getSignals } from '@/lib/queries';
+import { fetchLiveMarkets } from '@/lib/polymarket-api';
+import { HongKongClient } from './HongKongClient';
+
+export const dynamic = 'force-dynamic';
+
+export default async function HongKongPage() {
+  const [signals, allMarkets] = await Promise.all([
+    getSignals(undefined, 'hong_kong'),
+    fetchLiveMarkets().catch(() => []),
+  ]);
+
+  // Filter to Hong Kong markets only
+  const markets = allMarkets.filter(
+    (m) => m.city.toLowerCase().includes('hong kong'),
+  );
+
+  return (
+    <div className="p-6 space-y-6 max-w-[1600px] mx-auto">
+      <HongKongClient signals={signals} markets={markets} />
+    </div>
+  );
+}

+ 101 - 3
packages/dashboard/src/components/Sidebar.tsx

@@ -8,7 +8,14 @@ import { ThemeToggle } from './ThemeToggle';
 import { Switch } from '@/components/ui/switch';
 import { Badge } from '@/components/ui/badge';
 
-const navItems = [
+interface NavItem {
+  label: string;
+  href?: string;
+  icon: React.ReactNode;
+  children?: { label: string; href: string; icon?: React.ReactNode }[];
+}
+
+const navItems: NavItem[] = [
   {
     label: 'Dashboard',
     href: '/',
@@ -27,6 +34,21 @@ const navItems = [
       </svg>
     ),
   },
+  {
+    label: 'Cities',
+    icon: (
+      <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+      </svg>
+    ),
+    children: [
+      {
+        label: 'Hong Kong',
+        href: '/cities/hong-kong',
+        icon: <span className="text-sm">🇭🇰</span>,
+      },
+    ],
+  },
   {
     label: 'Signals',
     href: '/signals',
@@ -63,6 +85,30 @@ export function Sidebar() {
     isRunning: false,
   });
   const [toggling, setToggling] = useState(false);
+  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
+    // Auto-expand Cities if we're on a city page
+    if (typeof window !== 'undefined') return new Set<string>();
+    return new Set<string>();
+  });
+
+  // Auto-expand the Cities group when navigating to a city page
+  useEffect(() => {
+    if (pathname.startsWith('/cities')) {
+      setExpandedGroups((prev) => new Set(prev).add('Cities'));
+    }
+  }, [pathname]);
+
+  const toggleGroup = (label: string) => {
+    setExpandedGroups((prev) => {
+      const next = new Set(prev);
+      if (next.has(label)) {
+        next.delete(label);
+      } else {
+        next.add(label);
+      }
+      return next;
+    });
+  };
 
   const fetchStatus = useCallback(async () => {
     try {
@@ -121,14 +167,66 @@ export function Sidebar() {
 
       <nav className="flex-1 p-4 space-y-1">
         {navItems.map((item) => {
+          // Group with children (expandable)
+          if (item.children) {
+            const isGroupActive = pathname.startsWith('/cities');
+            const isExpanded = expandedGroups.has(item.label);
+
+            return (
+              <div key={item.label}>
+                <button
+                  onClick={() => toggleGroup(item.label)}
+                  className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
+                    isGroupActive
+                      ? 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'
+                      : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/50'
+                  }`}
+                >
+                  {item.icon}
+                  <span className="flex-1 text-left">{item.label}</span>
+                  <svg
+                    className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
+                    fill="none"
+                    stroke="currentColor"
+                    viewBox="0 0 24 24"
+                  >
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
+                  </svg>
+                </button>
+                {isExpanded && (
+                  <div className="ml-4 mt-1 space-y-1">
+                    {item.children.map((child) => {
+                      const isChildActive = pathname === child.href;
+                      return (
+                        <Link
+                          key={child.href}
+                          href={child.href}
+                          className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
+                            isChildActive
+                              ? 'bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20'
+                              : 'text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-slate-500 dark:hover:text-slate-200 dark:hover:bg-slate-800/50'
+                          }`}
+                        >
+                          {child.icon}
+                          {child.label}
+                        </Link>
+                      );
+                    })}
+                  </div>
+                )}
+              </div>
+            );
+          }
+
+          // Regular nav item
           const isActive =
             item.href === '/'
               ? pathname === '/'
-              : pathname.startsWith(item.href);
+              : pathname.startsWith(item.href!);
           return (
             <Link
               key={item.href}
-              href={item.href}
+              href={item.href!}
               className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
                 isActive
                   ? 'bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20'

+ 1 - 1
packages/market-engine/src/market-scanner.ts

@@ -152,7 +152,7 @@ export function groupMarketsByEvent(
       tempUnit,
       buckets,
       marketGroupId: groupId,
-      totalVolume: groupMarkets.reduce((s, m) => s + m.volume, 0),
+      totalVolume: groupMarkets.reduce((s, m) => s + (Number(m.volume) || 0), 0),
     });
   }
 

+ 2 - 2
packages/trading-engine/src/db.ts

@@ -196,7 +196,7 @@ export async function persistMarketSnapshots(market: {
           noPrice: bucket.noPrice,
           yesTokenId: bucket.yesTokenId,
           noTokenId: bucket.noTokenId,
-          volume24h: market.totalVolume,
+          volume24h: Number(market.totalVolume) || 0,
           createdAt: new Date(),
         },
         create: {
@@ -208,7 +208,7 @@ export async function persistMarketSnapshots(market: {
           bucketLabel: bucket.label,
           yesPrice: bucket.yesPrice,
           noPrice: bucket.noPrice,
-          volume24h: market.totalVolume,
+          volume24h: Number(market.totalVolume) || 0,
           liquidity: 0,
           yesTokenId: bucket.yesTokenId,
           noTokenId: bucket.noTokenId,