Browse Source

feat: add HKO data source, fix signal dedup, price lookup, Redis, and UI overhaul

Major changes:
- Add Hong Kong Observatory (HKO) as 4th weather model for HK markets
  (9-day forecast + real-time observations from settlement source)
- Fix signal deduplication with unique constraint on [type, city, date, bucket]
- Fix price lookup using date-keyed tokens to prevent cross-date collisions
- Enable Redis for latency arb ensemble caching
- Add trade sync API and auto-sync for pending orders
- Replace native HTML components with shadcn/ui (Select, Table, Checkbox,
  Input, Dialog, Badge, Switch, Button)
- Fix date parsing to use UTC consistently (off-by-one bug)
- Add position sync service for order status tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Horace Zhang 7 tháng trước cách đây
mục cha
commit
0a2634d4f8
45 tập tin đã thay đổi với 2413 bổ sung và 667 xóa
  1. 1 0
      .gitignore
  2. 1 1
      packages/core/package.json
  3. 1 0
      packages/core/prisma/schema.prisma
  4. 4 4
      packages/core/src/__tests__/temperature.test.ts
  5. 7 3
      packages/core/src/constants.ts
  6. 2 1
      packages/core/src/types/weather.ts
  7. 1 1
      packages/core/src/utils/temperature.ts
  8. 16 0
      packages/dashboard/components.json
  9. 5 1
      packages/dashboard/package.json
  10. 4 3
      packages/dashboard/src/app/api/dashboard/route.ts
  11. 7 2
      packages/dashboard/src/app/api/orders/route.ts
  12. 66 43
      packages/dashboard/src/app/api/prices/route.ts
  13. 99 0
      packages/dashboard/src/app/api/trade-sync/route.ts
  14. 0 53
      packages/dashboard/src/app/globals.css
  15. 36 40
      packages/dashboard/src/app/markets/MarketsClient.tsx
  16. 4 93
      packages/dashboard/src/app/page.tsx
  17. 1 1
      packages/dashboard/src/app/pnl/page.tsx
  18. 59 51
      packages/dashboard/src/app/signals/SignalsClient.tsx
  19. 4 3
      packages/dashboard/src/components/MarketCard.tsx
  20. 171 193
      packages/dashboard/src/components/OrderPanel.tsx
  21. 11 19
      packages/dashboard/src/components/Sidebar.tsx
  22. 127 128
      packages/dashboard/src/components/SignalTable.tsx
  23. 171 0
      packages/dashboard/src/components/TradeTableClient.tsx
  24. 38 0
      packages/dashboard/src/components/ui/badge.tsx
  25. 55 0
      packages/dashboard/src/components/ui/button.tsx
  26. 29 0
      packages/dashboard/src/components/ui/checkbox.tsx
  27. 114 0
      packages/dashboard/src/components/ui/dialog.tsx
  28. 22 0
      packages/dashboard/src/components/ui/input.tsx
  29. 156 0
      packages/dashboard/src/components/ui/select.tsx
  30. 28 0
      packages/dashboard/src/components/ui/switch.tsx
  31. 122 0
      packages/dashboard/src/components/ui/table.tsx
  32. 65 8
      packages/dashboard/src/lib/gamma-tokens.ts
  33. 6 0
      packages/dashboard/src/lib/utils.ts
  34. 2 1
      packages/dashboard/tailwind.config.ts
  35. 82 2
      packages/market-engine/src/clob-client.ts
  36. 18 0
      packages/market-engine/src/gamma-client.ts
  37. 1 0
      packages/market-engine/src/index.ts
  38. 265 0
      packages/market-engine/src/position-sync.ts
  39. 0 0
      packages/market-engine/tsconfig.tsbuildinfo
  40. 66 2
      packages/trading-engine/src/bot.ts
  41. 209 2
      packages/trading-engine/src/db.ts
  42. 1 0
      packages/weather-engine/src/index.ts
  43. 184 0
      packages/weather-engine/src/providers/hko-api.ts
  44. 18 12
      packages/weather-engine/src/providers/open-meteo.ts
  45. 134 0
      pnpm-lock.yaml

+ 1 - 0
.gitignore

@@ -6,6 +6,7 @@ dist/
 .turbo/
 .next/
 out/
+*.tsbuildinfo
 
 # Environment
 .env

+ 1 - 1
packages/core/package.json

@@ -12,7 +12,7 @@
     }
   },
   "scripts": {
-    "build": "tsc -b",
+    "build": "prisma generate && tsc -b",
     "dev": "tsc -b --watch",
     "test": "vitest run",
     "db:generate": "prisma generate",

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

@@ -58,6 +58,7 @@ model Signal {
   trade               Trade?    @relation(fields: [tradeId], references: [id])
   createdAt           DateTime  @default(now())
 
+  @@unique([type, city, targetDate, bucketLabel], name: "type_city_date_bucket")
   @@index([type, city, targetDate])
 }
 

+ 4 - 4
packages/core/src/__tests__/temperature.test.ts

@@ -82,14 +82,14 @@ describe('extractDailyHigh', () => {
 describe('parseMarketDate', () => {
   it('should parse "March 17"', () => {
     const date = parseMarketDate('March 17');
-    expect(date.getMonth()).toBe(2); // March = 2
-    expect(date.getDate()).toBe(17);
+    expect(date.getUTCMonth()).toBe(2); // March = 2
+    expect(date.getUTCDate()).toBe(17);
   });
 
   it('should parse "January 1"', () => {
     const date = parseMarketDate('January 1');
-    expect(date.getMonth()).toBe(0);
-    expect(date.getDate()).toBe(1);
+    expect(date.getUTCMonth()).toBe(0);
+    expect(date.getUTCDate()).toBe(1);
   });
 
   it('should throw for invalid format', () => {

+ 7 - 3
packages/core/src/constants.ts

@@ -62,6 +62,7 @@ export const GFS_URL = `${OPEN_METEO_BASE}/gfs`;
 export const ECMWF_URL = `${OPEN_METEO_BASE}/ecmwf`;
 export const ICON_URL = `${OPEN_METEO_BASE}/dwd-icon`;
 export const NOAA_BASE = 'https://api.weather.gov';
+export const HKO_API_BASE = 'https://data.weather.gov.hk/weatherAPI/opendata';
 
 export const GAMMA_BASE = 'https://gamma-api.polymarket.com';
 export const CLOB_HOST = 'https://clob.polymarket.com';
@@ -73,9 +74,10 @@ export const POLYGON_CHAIN_ID = 137;
 // ─── Ensemble Weights ────────────────────────────────────────────────────────
 
 export const DEFAULT_ENSEMBLE_WEIGHTS: Record<WeatherModel, number> = {
-  ecmwf: 0.40,
-  gfs: 0.35,
-  icon: 0.25,
+  ecmwf: 0.30,
+  gfs: 0.25,
+  hko: 0.25,
+  icon: 0.20,
 };
 
 // ─── Model Forecast Standard Error ───────────────────────────────────────────
@@ -85,6 +87,7 @@ export const MODEL_STDERR: ModelStdErrTable = {
   gfs:   { '24': 1.2, '48': 1.8, '72': 2.5, '96': 3.2, '120': 4.0 },
   ecmwf: { '24': 1.0, '48': 1.5, '72': 2.2, '96': 2.8, '120': 3.5 },
   icon:  { '24': 1.3, '48': 1.9, '72': 2.7, '96': 3.5, '120': 4.2 },
+  hko:   { '24': 0.8, '48': 1.2, '72': 1.8, '96': 2.5, '120': 3.2 },
 };
 
 /**
@@ -132,6 +135,7 @@ export const MODEL_SCHEDULES = {
   ecmwf: ['40 4,10,16,22 * * *'],    // Check 4h40 after each run
   icon:  ['55 4,10,16,22 * * *'],    // Check 4h55 after each run
   noaa:  ['*/15 * * * *'],            // Every 15 minutes
+  hko:   ['15 0,6,12,18 * * *'],     // HKO updates ~4x daily
 };
 
 // ─── Default Risk Config ─────────────────────────────────────────────────────

+ 2 - 1
packages/core/src/types/weather.ts

@@ -2,7 +2,7 @@
  * Weather data types for forecast models and temperature processing.
  */
 
-export type WeatherModel = 'gfs' | 'ecmwf' | 'icon';
+export type WeatherModel = 'gfs' | 'ecmwf' | 'icon' | 'hko';
 
 export interface CityConfig {
   name: string;
@@ -28,6 +28,7 @@ export interface ModelForecasts {
   gfs: HourlyForecast | null;
   ecmwf: HourlyForecast | null;
   icon: HourlyForecast | null;
+  hko: HourlyForecast | null;
   fetchedAt: Date;
 }
 

+ 1 - 1
packages/core/src/utils/temperature.ts

@@ -138,5 +138,5 @@ export function parseMarketDate(dateStr: string): Date {
   const day = parseInt(match[2], 10);
   const year = new Date().getFullYear();
 
-  return new Date(year, month, day);
+  return new Date(Date.UTC(year, month, day));
 }

+ 16 - 0
packages/dashboard/components.json

@@ -0,0 +1,16 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "default",
+  "rsc": true,
+  "tsx": true,
+  "tailwind": {
+    "config": "tailwind.config.ts",
+    "css": "src/app/globals.css",
+    "baseColor": "slate",
+    "cssVariables": false
+  },
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils"
+  }
+}

+ 5 - 1
packages/dashboard/package.json

@@ -16,6 +16,9 @@
     "@radix-ui/react-checkbox": "^1.3.3",
     "@radix-ui/react-dialog": "^1.1.15",
     "@radix-ui/react-dropdown-menu": "^2.1.16",
+    "@radix-ui/react-select": "^2.2.6",
+    "@radix-ui/react-slot": "^1.2.4",
+    "@radix-ui/react-switch": "^1.2.6",
     "@radix-ui/react-toggle": "^1.1.10",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
@@ -28,7 +31,8 @@
     "react-dom": "^18.3.0",
     "recharts": "^2.13.0",
     "socket.io-client": "^4.8.0",
-    "tailwind-merge": "^3.5.0"
+    "tailwind-merge": "^3.5.0",
+    "tailwindcss-animate": "^1.0.7"
   },
   "devDependencies": {
     "@types/node": "^22.0.0",

+ 4 - 3
packages/dashboard/src/app/api/dashboard/route.ts

@@ -1,15 +1,16 @@
 import { NextResponse } from 'next/server';
-import { getDashboardSummary, getRecentSignals } from '@/lib/queries';
+import { getDashboardSummary, getRecentSignals, getRecentTrades } from '@/lib/queries';
 
 export const dynamic = 'force-dynamic';
 
 export async function GET() {
   try {
-    const [summary, recentSignals] = await Promise.all([
+    const [summary, recentSignals, recentTrades] = await Promise.all([
       getDashboardSummary(),
       getRecentSignals(20),
+      getRecentTrades(20),
     ]);
-    return NextResponse.json({ summary, recentSignals });
+    return NextResponse.json({ summary, recentSignals, recentTrades });
   } catch (err) {
     console.error('Dashboard API error:', err);
     return NextResponse.json(

+ 7 - 2
packages/dashboard/src/app/api/orders/route.ts

@@ -135,7 +135,9 @@ export async function POST(request: Request) {
             const client = await getClobClient();
 
             // CLOB side: for both BUY_YES and BUY_NO we send BUY with the respective token
-            const clobResult = await client.placeLimitOrder(tokenId, 'BUY', order.price, order.size);
+            const clobResult = order.orderType === 'MARKET'
+              ? await client.placeMarketOrder(tokenId, 'BUY', order.price, order.size)
+              : await client.placeLimitOrder(tokenId, 'BUY', order.price, order.size);
 
             // Persist trade to DB
             const trade = await prisma.trade.create({
@@ -147,7 +149,10 @@ export async function POST(request: Request) {
                 size: order.size,
                 costBasis,
                 orderId: clobResult.orderId,
-                status: clobResult.status === 'MATCHED' || clobResult.status === 'LIVE' ? 'FILLED' : 'PENDING',
+                status: clobResult.status === 'MATCHED' ? 'FILLED'
+                  : clobResult.status === 'LIVE' ? 'PENDING'
+                  : clobResult.status === 'CANCELLED' ? 'CANCELLED'
+                  : 'PENDING',
                 dryRun: false,
               },
             });

+ 66 - 43
packages/dashboard/src/app/api/prices/route.ts

@@ -22,19 +22,21 @@ interface BookLevel {
 
 interface PriceResult {
   signalId: string;
-  // YES side (direct from YES token orderbook)
+  // YES side
   yesPrice: number;
   yesBestBid: number;
   yesBestAsk: number;
   yesSpread: number;
-  // NO side (direct from NO token orderbook)
+  yesMidpoint: number;
+  // NO side
   noPrice: number;
   noBestBid: number;
   noBestAsk: number;
   noSpread: number;
+  noMidpoint: number;
   // Top of book for display
-  noAsks: BookLevel[];  // top 3 NO asks (what you'd pay to buy NO)
-  yesAsks: BookLevel[]; // top 3 YES asks (what you'd pay to buy YES)
+  noAsks: BookLevel[];
+  yesAsks: BookLevel[];
   // Legacy fields
   bestBid: number;
   bestAsk: number;
@@ -69,8 +71,8 @@ async function fetchBook(tokenId: string): Promise<RawBookResponse | null> {
 function emptyResult(signalId: string, error?: string): PriceResult {
   return {
     signalId, yesPrice: 0, noPrice: 0,
-    yesBestBid: 0, yesBestAsk: 0, yesSpread: 0,
-    noBestBid: 0, noBestAsk: 0, noSpread: 0,
+    yesBestBid: 0, yesBestAsk: 0, yesSpread: 0, yesMidpoint: 0,
+    noBestBid: 0, noBestAsk: 0, noSpread: 0, noMidpoint: 0,
     noAsks: [], yesAsks: [],
     bestBid: 0, bestAsk: 0, spread: 0, midpoint: 0,
     lastUpdated: new Date().toISOString(),
@@ -90,17 +92,30 @@ export async function POST(request: Request) {
       return NextResponse.json({ error: 'No signals provided' }, { status: 400 });
     }
 
-    // Build token map: try DB first, then Gamma fallback for any missing
+    // Build token map: keyed by city|date|bucket to avoid cross-date collisions
     const tokenMap = new Map<string, TokenPair>();
 
-    // 1. Try DB
+    // 1. Try DB (with date filter)
     for (const signal of signals) {
       if (signal.type === 'PARITY_ARB') continue;
-      const key = `${signal.city}|${signal.bucket}`;
+      const dateStr = signal.targetDate?.slice(0, 10) || '';
+      const key = `${signal.city}|${dateStr}|${signal.bucket}`;
       if (tokenMap.has(key)) continue;
 
+      // Build date range filter
+      const whereClause: Record<string, unknown> = {
+        city: signal.city,
+        bucketLabel: signal.bucket,
+      };
+      if (dateStr) {
+        const dateStart = new Date(dateStr + 'T00:00:00Z');
+        const dateEnd = new Date(dateStart);
+        dateEnd.setUTCDate(dateEnd.getUTCDate() + 1);
+        whereClause.targetDate = { gte: dateStart, lt: dateEnd };
+      }
+
       const snapshot = await prisma.marketSnapshot.findFirst({
-        where: { city: signal.city, bucketLabel: signal.bucket },
+        where: whereClause,
         orderBy: { createdAt: 'desc' },
         select: { yesTokenId: true, noTokenId: true },
       });
@@ -110,15 +125,18 @@ export async function POST(request: Request) {
       }
     }
 
-    // 2. Gamma fallback for missing
-    const hasMissing = signals.some(
-      (s) => s.type !== 'PARITY_ARB' && !tokenMap.has(`${s.city}|${s.bucket}`),
-    );
+    // 2. Gamma fallback for missing (now keyed by city|date|bucket)
+    const hasMissing = signals.some((s) => {
+      if (s.type === 'PARITY_ARB') return false;
+      const dateStr = s.targetDate?.slice(0, 10) || '';
+      return !tokenMap.has(`${s.city}|${dateStr}|${s.bucket}`);
+    });
 
     if (hasMissing) {
       const gammaTokens = await fetchAllGammaTokens();
       for (const signal of signals) {
-        const key = `${signal.city}|${signal.bucket}`;
+        const dateStr = signal.targetDate?.slice(0, 10) || '';
+        const key = `${signal.city}|${dateStr}|${signal.bucket}`;
         if (!tokenMap.has(key) && gammaTokens.has(key)) {
           tokenMap.set(key, gammaTokens.get(key)!);
         }
@@ -132,7 +150,8 @@ export async function POST(request: Request) {
           return emptyResult(signal.signalId, 'Parity signals use aggregate pricing');
         }
 
-        const tokenInfo = tokenMap.get(`${signal.city}|${signal.bucket}`);
+        const dateStr = signal.targetDate?.slice(0, 10) || '';
+        const tokenInfo = tokenMap.get(`${signal.city}|${dateStr}|${signal.bucket}`);
         if (!tokenInfo?.yesTokenId) {
           return emptyResult(
             signal.signalId,
@@ -151,50 +170,54 @@ export async function POST(request: Request) {
             return emptyResult(signal.signalId, 'Orderbook unavailable');
           }
 
-          // Parse YES orderbook directly
-          const yesBids = (yesBook?.bids || [])
-            .map((b) => ({ price: parseFloat(b.price), size: parseFloat(b.size) }))
-            .sort((a, b) => b.price - a.price);
-          const yesAsks = (yesBook?.asks || [])
-            .map((a) => ({ price: parseFloat(a.price), size: parseFloat(a.size) }))
-            .sort((a, b) => a.price - b.price);
-
-          const yesBestBid = yesBids[0]?.price ?? 0;
-          const yesBestAsk = yesAsks[0]?.price ?? 1;
+          // Parse raw YES orderbook
+          const rawYesBids: BookLevel[] = (yesBook?.bids || [])
+            .map((b) => ({ price: parseFloat(b.price), size: parseFloat(b.size) }));
+          const rawYesAsks: BookLevel[] = (yesBook?.asks || [])
+            .map((a) => ({ price: parseFloat(a.price), size: parseFloat(a.size) }));
+
+          // Parse raw NO orderbook
+          const rawNoBids: BookLevel[] = (noBook?.bids || [])
+            .map((b) => ({ price: parseFloat(b.price), size: parseFloat(b.size) }));
+          const rawNoAsks: BookLevel[] = (noBook?.asks || [])
+            .map((a) => ({ price: parseFloat(a.price), size: parseFloat(a.size) }));
+
+          // Use DIRECT orderbook prices (matches Polymarket website)
+          rawYesBids.sort((a, b) => b.price - a.price);
+          rawYesAsks.sort((a, b) => a.price - b.price);
+          rawNoBids.sort((a, b) => b.price - a.price);
+          rawNoAsks.sort((a, b) => a.price - b.price);
+
+          const yesBestBid = rawYesBids[0]?.price ?? 0;
+          const yesBestAsk = rawYesAsks[0]?.price ?? 1;
           const yesSpread = yesBestAsk - yesBestBid;
 
-          // Parse NO orderbook directly
-          const noBids = (noBook?.bids || [])
-            .map((b) => ({ price: parseFloat(b.price), size: parseFloat(b.size) }))
-            .sort((a, b) => b.price - a.price);
-          const noAsks = (noBook?.asks || [])
-            .map((a) => ({ price: parseFloat(a.price), size: parseFloat(a.size) }))
-            .sort((a, b) => a.price - b.price);
-
-          const noBestBid = noBids[0]?.price ?? 0;
-          const noBestAsk = noAsks[0]?.price ?? 1;
+          const noBestBid = rawNoBids[0]?.price ?? 0;
+          const noBestAsk = rawNoAsks[0]?.price ?? 1;
           const noSpread = noBestAsk - noBestBid;
 
-          // Midpoint from YES book for legacy compatibility
-          const midpoint = (yesBestBid + yesBestAsk) / 2;
+          const yesMidpoint = (yesBestBid + yesBestAsk) / 2;
+          const noMidpoint = (noBestBid + noBestAsk) / 2;
 
           return {
             signalId: signal.signalId,
-            yesPrice: midpoint,
+            yesPrice: yesMidpoint,
             yesBestBid,
             yesBestAsk,
             yesSpread,
-            noPrice: noBook ? (noBestBid + noBestAsk) / 2 : 1 - midpoint,
+            yesMidpoint,
+            noPrice: noMidpoint,
             noBestBid,
             noBestAsk,
             noSpread,
-            noAsks: noAsks.slice(0, 5),
-            yesAsks: yesAsks.slice(0, 5),
+            noMidpoint,
+            noAsks: rawNoAsks.slice(0, 5),
+            yesAsks: rawYesAsks.slice(0, 5),
             // Legacy
             bestBid: yesBestBid,
             bestAsk: yesBestAsk,
             spread: yesSpread,
-            midpoint,
+            midpoint: yesMidpoint,
             lastUpdated: new Date().toISOString(),
           };
         } catch {

+ 99 - 0
packages/dashboard/src/app/api/trade-sync/route.ts

@@ -0,0 +1,99 @@
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+const CLOB_BASE = 'https://clob.polymarket.com';
+
+/**
+ * Sync PENDING trades against Polymarket CLOB.
+ * Checks each pending (non-dry-run) trade's order status and updates DB accordingly.
+ */
+export async function POST() {
+  try {
+    const pendingTrades = await prisma.trade.findMany({
+      where: {
+        status: 'PENDING',
+        dryRun: false,
+        orderId: { not: null },
+      },
+      select: {
+        id: true,
+        orderId: true,
+        tokenId: true,
+        side: true,
+        price: true,
+        size: true,
+      },
+    });
+
+    if (pendingTrades.length === 0) {
+      return NextResponse.json({ synced: 0, message: 'No pending trades to sync' });
+    }
+
+    let filled = 0;
+    let cancelled = 0;
+    let unchanged = 0;
+    let errors = 0;
+
+    for (const trade of pendingTrades) {
+      try {
+        // Query CLOB for order status
+        const res = await fetch(`${CLOB_BASE}/order/${trade.orderId}`, {
+          headers: { Accept: 'application/json' },
+          signal: AbortSignal.timeout(8000),
+        });
+
+        if (!res.ok) {
+          // Order not found on CLOB — likely cancelled
+          await prisma.trade.update({
+            where: { id: trade.id },
+            data: { status: 'CANCELLED', resolvedAt: new Date() },
+          });
+          cancelled++;
+          continue;
+        }
+
+        const order = await res.json();
+        const sizeMatched = parseFloat(order.size_matched || '0');
+        const originalSize = parseFloat(order.original_size || order.size || '0');
+
+        if (sizeMatched >= originalSize && originalSize > 0) {
+          // Fully filled
+          await prisma.trade.update({
+            where: { id: trade.id },
+            data: {
+              status: 'FILLED',
+              filledPrice: parseFloat(order.price || '0') || trade.price,
+              resolvedAt: new Date(),
+            },
+          });
+          filled++;
+        } else if (order.status === 'CANCELLED' || order.status === 'CANCELED') {
+          await prisma.trade.update({
+            where: { id: trade.id },
+            data: { status: 'CANCELLED', resolvedAt: new Date() },
+          });
+          cancelled++;
+        } else {
+          // Still open/pending
+          unchanged++;
+        }
+      } catch {
+        errors++;
+      }
+    }
+
+    return NextResponse.json({
+      synced: pendingTrades.length,
+      filled,
+      cancelled,
+      unchanged,
+      errors,
+      syncedAt: new Date().toISOString(),
+    });
+  } catch (err) {
+    console.error('Trade sync error:', err);
+    return NextResponse.json({ error: 'Failed to sync trades' }, { status: 500 });
+  }
+}

+ 0 - 53
packages/dashboard/src/app/globals.css

@@ -33,59 +33,6 @@
     @apply hover:border-slate-600;
   }
 
-  /* ─── Badges ─── */
-  .badge {
-    @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
-  }
-  .badge-green {
-    @apply badge bg-green-100 text-green-700 border border-green-200;
-  }
-  .dark .badge-green {
-    @apply bg-green-500/10 text-green-400 border-green-500/20;
-  }
-  .badge-red {
-    @apply badge bg-red-100 text-red-700 border border-red-200;
-  }
-  .dark .badge-red {
-    @apply bg-red-500/10 text-red-400 border-red-500/20;
-  }
-  .badge-blue {
-    @apply badge bg-blue-100 text-blue-700 border border-blue-200;
-  }
-  .dark .badge-blue {
-    @apply bg-blue-500/10 text-blue-400 border-blue-500/20;
-  }
-  .badge-yellow {
-    @apply badge bg-yellow-100 text-yellow-700 border border-yellow-200;
-  }
-  .dark .badge-yellow {
-    @apply bg-yellow-500/10 text-yellow-400 border-yellow-500/20;
-  }
-
-  /* ─── Table ─── */
-  .table-header {
-    @apply px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
-  }
-  .dark .table-header { @apply text-slate-400; }
-  .table-cell {
-    @apply px-4 py-3 text-sm text-gray-700 whitespace-nowrap;
-  }
-  .dark .table-cell { @apply text-slate-300; }
-
-  /* ─── Buttons ─── */
-  .btn {
-    @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none;
-  }
-  .btn-primary { @apply btn bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500; }
-  .btn-secondary {
-    @apply btn bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-300 focus:ring-gray-500;
-  }
-  .dark .btn-secondary { @apply bg-slate-700 text-slate-200 hover:bg-slate-600 border-slate-600; }
-  .btn-danger { @apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500; }
-  .btn-ghost { @apply btn bg-transparent text-gray-600 hover:bg-gray-100; }
-  .dark .btn-ghost { @apply text-slate-400 hover:bg-slate-800; }
-  .btn-sm { @apply px-3 py-1.5 text-xs; }
-
   /* ─── Theme-aware text ─── */
   .text-heading { @apply text-gray-900; }
   .dark .text-heading { @apply text-white; }

+ 36 - 40
packages/dashboard/src/app/markets/MarketsClient.tsx

@@ -1,6 +1,8 @@
 'use client';
 
 import { useState } from 'react';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
 import type { WeatherMarket } from '@/lib/types';
 import { ProbabilityChart } from '@/components/ProbabilityChart';
 
@@ -9,19 +11,19 @@ export function MarketsClient({ markets }: { markets: WeatherMarket[] }) {
 
   return (
     <div className="card overflow-hidden">
-      <table className="w-full">
-        <thead>
-          <tr className="border-b border-slate-700">
-            <th className="table-header">City</th>
-            <th className="table-header">Date</th>
-            <th className="table-header">Question</th>
-            <th className="table-header text-center"># Buckets</th>
-            <th className="table-header text-right">Total Volume</th>
-            <th className="table-header text-center">Max Edge</th>
-            <th className="table-header w-10" />
-          </tr>
-        </thead>
-        <tbody className="divide-y divide-slate-700/50">
+      <Table>
+        <TableHeader>
+          <TableRow className="border-b border-slate-700">
+            <TableHead>City</TableHead>
+            <TableHead>Date</TableHead>
+            <TableHead>Question</TableHead>
+            <TableHead className="text-center"># Buckets</TableHead>
+            <TableHead className="text-right">Total Volume</TableHead>
+            <TableHead className="text-center">Max Edge</TableHead>
+            <TableHead className="w-10" />
+          </TableRow>
+        </TableHeader>
+        <TableBody>
           {markets.map((market) => {
             const maxEdge = Math.max(
               ...market.buckets.map((b) =>
@@ -32,39 +34,33 @@ export function MarketsClient({ markets }: { markets: WeatherMarket[] }) {
 
             return (
               <Fragment key={market.id}>
-                <tr
-                  className="hover:bg-slate-800/50 cursor-pointer transition-colors"
+                <TableRow
+                  className="cursor-pointer"
                   onClick={() =>
                     setExpandedId(isExpanded ? null : market.id)
                   }
                 >
-                  <td className="table-cell font-medium text-white">
+                  <TableCell className="font-medium text-white">
                     {market.city}
-                  </td>
-                  <td className="table-cell font-mono text-xs">
+                  </TableCell>
+                  <TableCell className="font-mono text-xs">
                     {market.date}
-                  </td>
-                  <td className="table-cell text-slate-300">
+                  </TableCell>
+                  <TableCell className="text-slate-300">
                     {market.question}
-                  </td>
-                  <td className="table-cell text-center">
+                  </TableCell>
+                  <TableCell className="text-center">
                     {market.buckets.length}
-                  </td>
-                  <td className="table-cell text-right font-mono">
+                  </TableCell>
+                  <TableCell className="text-right font-mono">
                     ${market.totalVolume.toLocaleString()}
-                  </td>
-                  <td className="table-cell text-center">
-                    <span
-                      className={
-                        maxEdge > 0.03
-                          ? 'badge-green'
-                          : 'badge-blue'
-                      }
-                    >
+                  </TableCell>
+                  <TableCell className="text-center">
+                    <Badge variant={maxEdge > 0.03 ? 'green' : 'blue'}>
                       {(maxEdge * 100).toFixed(1)}%
-                    </span>
-                  </td>
-                  <td className="table-cell text-center">
+                    </Badge>
+                  </TableCell>
+                  <TableCell className="text-center">
                     <span
                       className={`inline-block transition-transform ${
                         isExpanded ? 'rotate-180' : ''
@@ -74,8 +70,8 @@ export function MarketsClient({ markets }: { markets: WeatherMarket[] }) {
                         <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
                       </svg>
                     </span>
-                  </td>
-                </tr>
+                  </TableCell>
+                </TableRow>
                 {isExpanded && (
                   <tr>
                     <td colSpan={7} className="bg-slate-800/30 p-6">
@@ -137,8 +133,8 @@ export function MarketsClient({ markets }: { markets: WeatherMarket[] }) {
               </Fragment>
             );
           })}
-        </tbody>
-      </table>
+        </TableBody>
+      </Table>
     </div>
   );
 }

+ 4 - 93
packages/dashboard/src/app/page.tsx

@@ -1,6 +1,7 @@
 import { getDashboardSummary, getRecentSignals, getRecentTrades } from '@/lib/queries';
 import { RecentSignalsSection } from './RecentSignals';
-import type { TradeRecord } from '@/lib/types';
+import { Badge } from '@/components/ui/badge';
+import { TradeTableClient } from '@/components/TradeTableClient';
 
 function StatusIndicator({ running }: { running: boolean }) {
   return (
@@ -55,7 +56,7 @@ export default async function DashboardPage() {
         </div>
         <div className="flex items-center gap-4">
           {summary.dryRun && (
-            <span className="badge-yellow">DRY RUN</span>
+            <Badge variant="yellow">DRY RUN</Badge>
           )}
           <StatusIndicator running={summary.botStatus === 'running'} />
         </div>
@@ -112,13 +113,7 @@ export default async function DashboardPage() {
       {/* Recent Trades */}
       {recentTrades.length > 0 && (
         <div className="card">
-          <div className="flex items-center justify-between mb-4">
-            <h2 className="text-lg font-semibold text-heading">Recent Trades</h2>
-            <span className="text-sm text-sub">
-              {recentTrades.filter((t) => !t.dryRun).length} live / {recentTrades.filter((t) => t.dryRun).length} dry run
-            </span>
-          </div>
-          <TradeTable trades={recentTrades} />
+          <TradeTableClient trades={recentTrades} />
         </div>
       )}
 
@@ -142,87 +137,3 @@ export default async function DashboardPage() {
   );
 }
 
-function formatCity(city: string): string {
-  return city.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
-}
-
-function TradeTable({ trades }: { trades: TradeRecord[] }) {
-  return (
-    <div className="overflow-x-auto">
-      <table className="w-full">
-        <thead>
-          <tr className="border-b border-gray-200 dark:border-slate-700">
-            <th className="table-header">Time</th>
-            <th className="table-header">Market</th>
-            <th className="table-header">Side</th>
-            <th className="table-header text-right">Price</th>
-            <th className="table-header text-right">Shares</th>
-            <th className="table-header text-right">Cost</th>
-            <th className="table-header">Status</th>
-          </tr>
-        </thead>
-        <tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
-          {trades.map((trade) => (
-            <tr key={trade.id} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
-              <td className="table-cell font-mono text-xs whitespace-nowrap">
-                {new Date(trade.createdAt).toLocaleString('en-US', {
-                  month: 'short',
-                  day: 'numeric',
-                  hour: '2-digit',
-                  minute: '2-digit',
-                })}
-              </td>
-              <td className="table-cell">
-                <div className="flex flex-col gap-0.5">
-                  {trade.city && (
-                    <span className="text-heading text-sm font-medium">
-                      {formatCity(trade.city)}
-                    </span>
-                  )}
-                  {trade.bucket && (
-                    <span className="text-xs text-sub font-mono">{trade.bucket}</span>
-                  )}
-                  {trade.targetDate && (
-                    <span className="text-xs text-dim">{trade.targetDate}</span>
-                  )}
-                </div>
-              </td>
-              <td className="table-cell">
-                <span className={`text-xs font-semibold ${
-                  trade.side === 'BUY_YES'
-                    ? 'text-green-600 dark:text-green-400'
-                    : 'text-red-600 dark:text-red-400'
-                }`}>
-                  {trade.side === 'BUY_YES' ? 'BUY YES' : 'BUY NO'}
-                </span>
-              </td>
-              <td className="table-cell text-right font-mono text-sm">
-                {(trade.price * 100).toFixed(1)}&cent;
-              </td>
-              <td className="table-cell text-right font-mono text-sm">
-                {trade.size.toFixed(1)}
-              </td>
-              <td className="table-cell text-right font-mono text-sm font-medium">
-                ${trade.costBasis.toFixed(2)}
-              </td>
-              <td className="table-cell">
-                <span className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded ${
-                  trade.status === 'FILLED'
-                    ? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
-                    : trade.status === 'PENDING'
-                    ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
-                    : 'bg-gray-100 text-gray-600 dark:bg-slate-500/20 dark:text-slate-400'
-                }`}>
-                  {trade.status}
-                  {trade.dryRun && (
-                    <span className="text-[10px] opacity-70">(sim)</span>
-                  )}
-                </span>
-              </td>
-            </tr>
-          ))}
-        </tbody>
-      </table>
-    </div>
-  );
-}

+ 1 - 1
packages/dashboard/src/app/pnl/page.tsx

@@ -26,7 +26,7 @@ export default async function PnLPage() {
   return (
     <div className="space-y-8">
       <div>
-        <h1 className="text-2xl font-bold text-white">Profit &amp; Loss</h1>
+        <h1 className="text-2xl font-bold">Profit &amp; Loss</h1>
         <p className="text-sm text-slate-400 mt-1">
           Performance metrics and PnL tracking
         </p>

+ 59 - 51
packages/dashboard/src/app/signals/SignalsClient.tsx

@@ -3,6 +3,14 @@
 import { useState, useMemo } from 'react';
 import { SignalTable } from '@/components/SignalTable';
 import { OrderPanel } from '@/components/OrderPanel';
+import { Button } from '@/components/ui/button';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select';
 import type { Signal } from '@/lib/types';
 
 const strategyTypes = ['ALL', 'TAIL_NO', 'LATENCY_ARB', 'PARITY_ARB'] as const;
@@ -40,6 +48,12 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
     return dates;
   }, [signals]);
 
+  // Clear selection when filter type changes
+  const handleFilterType = (type: Signal['type'] | 'ALL') => {
+    setFilterType(type);
+    setSelectedIds(new Set());
+  };
+
   const counts = {
     ALL: signals.length,
     TAIL_NO: signals.filter((s) => s.type === 'TAIL_NO').length,
@@ -79,19 +93,11 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
 
   const toggleAll = () => {
     const allIds = filteredSignals.map((s) => s.id);
-    const allSelected = allIds.every((id) => selectedIds.has(id));
+    const allSelected = allIds.length > 0 && allIds.every((id) => selectedIds.has(id));
     if (allSelected) {
-      setSelectedIds((prev) => {
-        const next = new Set(prev);
-        allIds.forEach((id) => next.delete(id));
-        return next;
-      });
+      setSelectedIds(new Set());
     } else {
-      setSelectedIds((prev) => {
-        const next = new Set(prev);
-        allIds.forEach((id) => next.add(id));
-        return next;
-      });
+      setSelectedIds(new Set(allIds));
     }
   };
 
@@ -115,7 +121,6 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
     setShowOrderPanel(false);
     setSelectionMode(false);
     setSelectedIds(new Set());
-    // Could trigger a page refresh here
   };
 
   return (
@@ -127,7 +132,7 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
           return (
             <button
               key={type}
-              onClick={() => setFilterType(type)}
+              onClick={() => handleFilterType(type)}
               className={`card text-left transition-colors ${
                 filterType === type
                   ? 'border-blue-500/50 bg-blue-500/5'
@@ -164,23 +169,26 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
                 Showing {filteredSignals.length} signals
               </span>
             {!selectionMode ? (
-              <button
+              <Button
                 onClick={enterSelectionMode}
-                className="btn-secondary btn-sm"
+                variant="outline"
+                size="sm"
                 disabled={signals.length === 0}
               >
                 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                   <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
                 </svg>
                 Manual Order
-              </button>
+              </Button>
             ) : (
-              <button
+              <Button
                 onClick={exitSelectionMode}
-                className="btn-ghost btn-sm text-red-600 dark:text-red-400"
+                variant="ghost"
+                size="sm"
+                className="text-red-600 dark:text-red-400"
               >
                 Cancel Selection
-              </button>
+              </Button>
             )}
           </div>
           </div>
@@ -189,34 +197,36 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
           <div className="flex items-center gap-3 flex-wrap">
             <div className="flex items-center gap-2">
               <label className="text-xs text-dim font-medium uppercase tracking-wider">City</label>
-              <select
-                value={filterCity}
-                onChange={(e) => setFilterCity(e.target.value)}
-                className="text-sm rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-heading px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
-              >
-                <option value="ALL">All Cities</option>
-                {uniqueCities.map((city) => (
-                  <option key={city} value={city}>
-                    {formatCity(city)}
-                  </option>
-                ))}
-              </select>
+              <Select value={filterCity} onValueChange={setFilterCity}>
+                <SelectTrigger className="w-[160px] h-8 text-sm">
+                  <SelectValue placeholder="All Cities" />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="ALL">All Cities</SelectItem>
+                  {uniqueCities.map((city) => (
+                    <SelectItem key={city} value={city}>
+                      {formatCity(city)}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
             </div>
 
             <div className="flex items-center gap-2">
               <label className="text-xs text-dim font-medium uppercase tracking-wider">Date</label>
-              <select
-                value={filterDate}
-                onChange={(e) => setFilterDate(e.target.value)}
-                className="text-sm rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-heading px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
-              >
-                <option value="ALL">All Dates</option>
-                {uniqueDates.map((date) => (
-                  <option key={date} value={date}>
-                    {date}
-                  </option>
-                ))}
-              </select>
+              <Select value={filterDate} onValueChange={setFilterDate}>
+                <SelectTrigger className="w-[160px] h-8 text-sm">
+                  <SelectValue placeholder="All Dates" />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="ALL">All Dates</SelectItem>
+                  {uniqueDates.map((date) => (
+                    <SelectItem key={date} value={date}>
+                      {date}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
             </div>
 
             {(filterCity !== 'ALL' || filterDate !== 'ALL') && (
@@ -255,18 +265,16 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
             <div className="text-sm text-sub whitespace-nowrap">
               ${selectedSignals.reduce((sum, s) => sum + s.recommendedSize, 0).toFixed(2)} total
             </div>
-            <button
-              onClick={openOrderPanel}
-              className="btn-primary btn-sm whitespace-nowrap"
-            >
+            <Button onClick={openOrderPanel} size="sm">
               Place Orders
-            </button>
-            <button
+            </Button>
+            <Button
               onClick={() => setSelectedIds(new Set())}
-              className="btn-ghost btn-sm text-xs"
+              variant="ghost"
+              size="sm"
             >
               Clear
-            </button>
+            </Button>
           </div>
         </div>
       )}

+ 4 - 3
packages/dashboard/src/components/MarketCard.tsx

@@ -1,13 +1,14 @@
+import { Badge } from '@/components/ui/badge';
 import type { WeatherMarket } from '@/lib/types';
 
 function EdgeBadge({ edge }: { edge: number }) {
   const pct = (edge * 100).toFixed(1);
   if (edge > 0.02) {
-    return <span className="badge-green">+{pct}%</span>;
+    return <Badge variant="green">+{pct}%</Badge>;
   } else if (edge < -0.02) {
-    return <span className="badge-red">{pct}%</span>;
+    return <Badge variant="red">{pct}%</Badge>;
   }
-  return <span className="badge-blue">{pct}%</span>;
+  return <Badge variant="blue">{pct}%</Badge>;
 }
 
 export function MarketCard({ market }: { market: WeatherMarket }) {

+ 171 - 193
packages/dashboard/src/components/OrderPanel.tsx

@@ -1,6 +1,12 @@
 'use client';
 
 import { useState, useCallback, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
 import type { Signal } from '@/lib/types';
 
 type OrderStep = 'review' | 'pricing' | 'confirm' | 'result';
@@ -15,17 +21,16 @@ interface PriceData {
   signalId: string;
   yesPrice: number;
   noPrice: number;
-  // YES side
   yesBestBid: number;
   yesBestAsk: number;
   yesSpread: number;
-  // NO side (direct from NO token orderbook)
+  yesMidpoint: number;
   noBestBid: number;
   noBestAsk: number;
   noSpread: number;
+  noMidpoint: number;
   noAsks: BookLevel[];
   yesAsks: BookLevel[];
-  // Legacy
   bestBid: number;
   bestAsk: number;
   spread: number;
@@ -75,7 +80,6 @@ export function OrderPanel({
   const [error, setError] = useState<string | null>(null);
   const [isDryRun, setIsDryRun] = useState<boolean | null>(null);
 
-  // Fetch actual DRY_RUN status from server
   useEffect(() => {
     fetch('/api/bot-control')
       .then((r) => r.json())
@@ -115,7 +119,6 @@ export function OrderPanel({
     }
   };
 
-  // Step 1 → Step 2: Fetch latest prices
   const fetchPrices = useCallback(async () => {
     setLoading(true);
     setError(null);
@@ -150,7 +153,6 @@ export function OrderPanel({
     }
   }, [selectedSignals]);
 
-  // Step 3: Place orders
   const placeOrders = useCallback(async () => {
     setLoading(true);
     setError(null);
@@ -159,16 +161,12 @@ export function OrderPanel({
         const priceData = prices.get(signal.id);
         let price: number;
         if (orderMode === 'MARKET') {
-          // Market order: use actual best ask from the respective orderbook
           if (signal.side === 'BUY_NO') {
-            // Use real NO best ask from NO token orderbook
             price = priceData?.noBestAsk ?? (1 - signal.marketYesPrice);
           } else {
-            // Use real YES best ask from YES token orderbook
             price = priceData?.yesBestAsk ?? signal.marketYesPrice;
           }
         } else {
-          // Limit order: use suggested price from signal
           price = signal.suggestedPrice;
         }
 
@@ -199,28 +197,18 @@ export function OrderPanel({
   }, [selectedSignals, prices, orderMode]);
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
-      <div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl border border-gray-200 dark:border-slate-700 w-full max-w-3xl max-h-[85vh] flex flex-col mx-4">
+    <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
+      <DialogContent>
         {/* Header */}
-        <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-slate-700">
-          <div>
-            <h2 className="text-lg font-semibold text-heading">Manual Order</h2>
-            <p className="text-xs text-sub mt-0.5">
-              {step === 'review' && 'Step 1: Review selected signals'}
-              {step === 'pricing' && 'Step 2: Review live prices & select order type'}
-              {step === 'confirm' && 'Step 3: Confirm and place orders'}
-              {step === 'result' && 'Order results'}
-            </p>
-          </div>
-          <button
-            onClick={onClose}
-            className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
-          >
-            <svg className="w-5 h-5 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
-            </svg>
-          </button>
-        </div>
+        <DialogHeader>
+          <DialogTitle>Manual Order</DialogTitle>
+          <DialogDescription>
+            {step === 'review' && 'Step 1: Review selected signals'}
+            {step === 'pricing' && 'Step 2: Review live prices & select order type'}
+            {step === 'confirm' && 'Step 3: Confirm and place orders'}
+            {step === 'result' && 'Order results'}
+          </DialogDescription>
+        </DialogHeader>
 
         {/* Step indicator */}
         <div className="px-6 py-3 border-b border-gray-100 dark:border-slate-700/50">
@@ -286,11 +274,9 @@ export function OrderPanel({
                           : 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
                       }`}
                     >
-                      <input
-                        type="checkbox"
+                      <Checkbox
                         checked={checked}
-                        onChange={() => toggleSignal(signal.id)}
-                        className="w-4 h-4 rounded border-gray-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
+                        onCheckedChange={() => toggleSignal(signal.id)}
                       />
                       <div className="flex-1 min-w-0">
                         <div className="flex items-center gap-2">
@@ -308,13 +294,13 @@ export function OrderPanel({
                       </div>
                       <div className="flex items-center gap-1.5" onClick={(e) => e.preventDefault()}>
                         <span className="text-xs text-dim">Shares:</span>
-                        <input
+                        <Input
                           type="number"
-                          min="1"
-                          step="1"
+                          min={1}
+                          step={1}
                           value={getSize(signal.id, signal.recommendedSize)}
                           onChange={(e) => updateSize(signal.id, e.target.value)}
-                          className="w-20 text-right text-sm font-mono px-2 py-1 rounded border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-heading focus:outline-none focus:ring-1 focus:ring-blue-500"
+                          className="w-20 text-right text-sm font-mono h-8"
                         />
                       </div>
                     </label>
@@ -327,135 +313,123 @@ export function OrderPanel({
           {/* Step 2: Live Prices */}
           {step === 'pricing' && (
             <div className="space-y-4">
-              <div className="overflow-x-auto">
-                <table className="w-full text-sm">
-                  <thead>
-                    <tr className="border-b border-gray-200 dark:border-slate-700">
-                      <th className="table-header">
-                        <input
-                          type="checkbox"
-                          checked={selectedIds.size === signals.length}
-                          onChange={toggleAll}
-                          className="w-3.5 h-3.5 rounded border-gray-300 dark:border-slate-600 text-blue-600"
-                        />
-                      </th>
-                      <th className="table-header">Signal</th>
-                      <th className="table-header text-right">Best Ask</th>
-                      <th className="table-header text-right">Depth</th>
-                      <th className="table-header text-right">Spread</th>
-                      <th className="table-header text-right">Edge Now</th>
-                      <th className="table-header text-right">Shares</th>
-                    </tr>
-                  </thead>
-                  <tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
-                    {selectedSignals.map((signal) => {
-                      const priceData = prices.get(signal.id);
-                      const checked = selectedIds.has(signal.id);
-                      const side = sideLabels[signal.side];
-                      const isBuyNo = signal.side === 'BUY_NO';
-                      const isParity = signal.type === 'PARITY_ARB';
-
-                      // Live best ask from the actual orderbook for the side we're buying
-                      const liveBestAsk = priceData
-                        ? (isBuyNo ? priceData.noBestAsk : priceData.yesBestAsk)
-                        : 0;
-                      const liveSpread = priceData
-                        ? (isBuyNo ? priceData.noSpread : priceData.yesSpread)
-                        : 0;
-
-                      // Top asks for depth display
-                      const topAsks = priceData
-                        ? (isBuyNo ? priceData.noAsks : priceData.yesAsks)
-                        : [];
-
-                      // Compute live edge using YES midpoint
-                      const liveYesPrice = priceData?.midpoint ?? signal.marketYesPrice;
-                      let edgeNow = 0;
-                      if (signal.type === 'TAIL_NO') {
-                        edgeNow = liveYesPrice - signal.modelProbability;
-                      } else if (signal.type === 'LATENCY_ARB') {
-                        edgeNow = Math.abs(signal.modelProbability - liveYesPrice);
-                      } else {
-                        edgeNow = signal.edge;
-                      }
-
-                      // Cost per share = best ask price, return per share = $1 if win
-                      // ROI = (1 - bestAsk) / bestAsk
-                      const roi = liveBestAsk > 0 && liveBestAsk < 1
-                        ? (1 - liveBestAsk) / liveBestAsk
-                        : 0;
-
-                      return (
-                        <tr key={signal.id} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
-                          <td className="table-cell">
-                            <input
-                              type="checkbox"
-                              checked={checked}
-                              onChange={() => toggleSignal(signal.id)}
-                              className="w-3.5 h-3.5 rounded border-gray-300 dark:border-slate-600 text-blue-600"
-                            />
-                          </td>
-                          <td className="table-cell">
-                            <div className="flex items-center gap-1.5">
-                              <span className={`text-xs font-semibold ${side.color}`}>{side.text}</span>
-                              <span className="text-xs text-heading">{formatCity(signal.city)}</span>
-                            </div>
-                            <span className="text-xs text-sub font-mono">{signal.bucket}</span>
-                          </td>
-                          <td className="table-cell text-right font-mono">
-                            {priceData?.error ? (
-                              <span className="text-red-500 text-xs">{priceData.error}</span>
-                            ) : priceData ? (
+              <Table>
+                <TableHeader>
+                  <TableRow className="border-b border-gray-200 dark:border-slate-700">
+                    <TableHead>
+                      <Checkbox
+                        checked={selectedIds.size === signals.length}
+                        onCheckedChange={toggleAll}
+                      />
+                    </TableHead>
+                    <TableHead>Signal</TableHead>
+                    <TableHead className="text-right">Best Ask</TableHead>
+                    <TableHead className="text-right">Depth</TableHead>
+                    <TableHead className="text-right">Spread</TableHead>
+                    <TableHead className="text-right">Edge Now</TableHead>
+                    <TableHead className="text-right">Shares</TableHead>
+                  </TableRow>
+                </TableHeader>
+                <TableBody>
+                  {selectedSignals.map((signal) => {
+                    const priceData = prices.get(signal.id);
+                    const checked = selectedIds.has(signal.id);
+                    const side = sideLabels[signal.side];
+                    const isBuyNo = signal.side === 'BUY_NO';
+
+                    const liveBestAsk = priceData
+                      ? (isBuyNo ? priceData.noBestAsk : priceData.yesBestAsk)
+                      : 0;
+                    const liveMidpoint = priceData
+                      ? (isBuyNo ? (priceData.noMidpoint ?? 0) : (priceData.yesMidpoint ?? 0))
+                      : 0;
+                    const liveSpread = priceData
+                      ? (isBuyNo ? priceData.noSpread : priceData.yesSpread)
+                      : 0;
+
+                    const topAsks = priceData
+                      ? (isBuyNo ? priceData.noAsks : priceData.yesAsks)
+                      : [];
+
+                    const roi = liveBestAsk > 0 && liveBestAsk < 1
+                      ? (1 - liveBestAsk) / liveBestAsk
+                      : 0;
+
+                    return (
+                      <TableRow key={signal.id}>
+                        <TableCell>
+                          <Checkbox
+                            checked={checked}
+                            onCheckedChange={() => toggleSignal(signal.id)}
+                          />
+                        </TableCell>
+                        <TableCell>
+                          <div className="flex items-center gap-1.5">
+                            <span className={`text-xs font-semibold ${side.color}`}>{side.text}</span>
+                            <span className="text-xs text-heading">{formatCity(signal.city)}</span>
+                          </div>
+                          <span className="text-xs text-sub font-mono">{signal.bucket}</span>
+                        </TableCell>
+                        <TableCell className="text-right font-mono">
+                          {priceData?.error ? (
+                            <span className="text-red-500 text-xs">{priceData.error}</span>
+                          ) : priceData ? (
+                            <div>
                               <span className="text-heading font-medium">
                                 {(liveBestAsk * 100).toFixed(1)}c
                               </span>
-                            ) : (
-                              <span className="text-dim">--</span>
-                            )}
-                          </td>
-                          <td className="table-cell text-right font-mono text-xs">
-                            {topAsks.length > 0 ? (
-                              <div className="space-y-0.5">
-                                {topAsks.slice(0, 3).map((ask, i) => (
-                                  <div key={i} className={i === 0 ? 'text-heading font-medium' : 'text-dim'}>
-                                    {(ask.price * 100).toFixed(1)}c × {ask.size.toFixed(0)}
-                                  </div>
-                                ))}
-                              </div>
-                            ) : (
-                              <span className="text-dim">--</span>
-                            )}
-                          </td>
-                          <td className="table-cell text-right font-mono text-xs text-sub">
-                            {priceData && !priceData.error
-                              ? `${(liveSpread * 100).toFixed(1)}c`
-                              : '--'}
-                          </td>
-                          <td className="table-cell text-right font-mono">
-                            {roi > 0 ? (
-                              <span className={roi > 0.05 ? 'text-green-600 dark:text-green-400 font-medium' : 'text-yellow-600 dark:text-yellow-400'}>
-                                +{(roi * 100).toFixed(0)}% ROI
-                              </span>
-                            ) : (
-                              <span className="text-dim">--</span>
-                            )}
-                          </td>
-                          <td className="table-cell text-right">
-                            <input
-                              type="number"
-                              min="1"
-                              step="1"
-                              value={getSize(signal.id, signal.recommendedSize)}
-                              onChange={(e) => updateSize(signal.id, e.target.value)}
-                              className="w-20 text-right text-sm font-mono px-2 py-1 rounded border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-heading focus:outline-none focus:ring-1 focus:ring-blue-500"
-                            />
-                          </td>
-                        </tr>
-                      );
-                    })}
-                  </tbody>
-                </table>
-              </div>
+                              {liveMidpoint > 0 && (
+                                <div className="text-[10px] text-dim">
+                                  mid {(liveMidpoint * 100).toFixed(1)}c
+                                </div>
+                              )}
+                            </div>
+                          ) : (
+                            <span className="text-dim">--</span>
+                          )}
+                        </TableCell>
+                        <TableCell className="text-right font-mono text-xs">
+                          {topAsks.length > 0 ? (
+                            <div className="space-y-0.5">
+                              {topAsks.slice(0, 3).map((ask, i) => (
+                                <div key={i} className={i === 0 ? 'text-heading font-medium' : 'text-dim'}>
+                                  {(ask.price * 100).toFixed(1)}c × {ask.size.toFixed(0)}
+                                </div>
+                              ))}
+                            </div>
+                          ) : (
+                            <span className="text-dim">--</span>
+                          )}
+                        </TableCell>
+                        <TableCell className="text-right font-mono text-xs text-sub">
+                          {priceData && !priceData.error
+                            ? `${(liveSpread * 100).toFixed(1)}c`
+                            : '--'}
+                        </TableCell>
+                        <TableCell className="text-right font-mono">
+                          {roi > 0 ? (
+                            <span className={roi > 0.05 ? 'text-green-600 dark:text-green-400 font-medium' : 'text-yellow-600 dark:text-yellow-400'}>
+                              +{(roi * 100).toFixed(0)}% ROI
+                            </span>
+                          ) : (
+                            <span className="text-dim">--</span>
+                          )}
+                        </TableCell>
+                        <TableCell className="text-right">
+                          <Input
+                            type="number"
+                            min={1}
+                            step={1}
+                            value={getSize(signal.id, signal.recommendedSize)}
+                            onChange={(e) => updateSize(signal.id, e.target.value)}
+                            className="w-20 text-right text-sm font-mono h-8"
+                          />
+                        </TableCell>
+                      </TableRow>
+                    );
+                  })}
+                </TableBody>
+              </Table>
 
               {/* Order type selection */}
               <div className="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-4 border border-gray-200 dark:border-slate-700/50">
@@ -606,15 +580,17 @@ export function OrderPanel({
                           {signal ? `${formatCity(signal.city)} · ${signal.bucket}` : result.signalId}
                         </span>
                       </div>
-                      <span className={`text-xs font-medium px-2 py-0.5 rounded ${
-                        result.status === 'DRY_RUN'
-                          ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
-                          : result.status === 'SUBMITTED'
-                          ? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
-                          : 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400'
-                      }`}>
+                      <Badge
+                        variant={
+                          result.status === 'DRY_RUN'
+                            ? 'yellow'
+                            : result.status === 'SUBMITTED'
+                            ? 'green'
+                            : 'red'
+                        }
+                      >
                         {result.status}
-                      </span>
+                      </Badge>
                     </div>
                     <div className="mt-1 text-xs text-sub">
                       {result.side} @ {(result.price * 100).toFixed(1)}c
@@ -635,36 +611,37 @@ export function OrderPanel({
         </div>
 
         {/* Footer actions */}
-        <div className="px-6 py-4 border-t border-gray-200 dark:border-slate-700 flex items-center justify-between">
+        <DialogFooter>
           <div>
             {step !== 'review' && step !== 'result' && (
-              <button
+              <Button
                 onClick={() => {
                   if (step === 'pricing') setStep('review');
                   if (step === 'confirm') setStep('pricing');
                 }}
-                className="btn-ghost btn-sm"
+                variant="ghost"
+                size="sm"
                 disabled={loading}
               >
                 Back
-              </button>
+              </Button>
             )}
           </div>
           <div className="flex items-center gap-3">
             {step === 'result' ? (
-              <button onClick={onComplete} className="btn-primary btn-sm">
+              <Button onClick={onComplete} size="sm">
                 Done
-              </button>
+              </Button>
             ) : (
               <>
-                <button onClick={onClose} className="btn-ghost btn-sm">
+                <Button onClick={onClose} variant="ghost" size="sm">
                   Cancel
-                </button>
+                </Button>
                 {step === 'review' && (
-                  <button
+                  <Button
                     onClick={fetchPrices}
                     disabled={selectedIds.size === 0 || loading}
-                    className="btn-primary btn-sm"
+                    size="sm"
                   >
                     {loading ? (
                       <span className="flex items-center gap-2">
@@ -673,22 +650,23 @@ export function OrderPanel({
                     ) : (
                       `Fetch Prices (${selectedIds.size})`
                     )}
-                  </button>
+                  </Button>
                 )}
                 {step === 'pricing' && (
-                  <button
+                  <Button
                     onClick={() => setStep('confirm')}
                     disabled={selectedIds.size === 0}
-                    className="btn-primary btn-sm"
+                    size="sm"
                   >
                     Review Orders ({selectedIds.size})
-                  </button>
+                  </Button>
                 )}
                 {step === 'confirm' && (
-                  <button
+                  <Button
                     onClick={placeOrders}
                     disabled={loading}
-                    className={`btn-sm ${orderMode === 'MARKET' ? 'btn-danger' : 'btn-primary'}`}
+                    variant={orderMode === 'MARKET' ? 'destructive' : 'default'}
+                    size="sm"
                   >
                     {loading ? (
                       <span className="flex items-center gap-2">
@@ -697,14 +675,14 @@ export function OrderPanel({
                     ) : (
                       `Place ${selectedIds.size} ${orderMode === 'LIMIT' ? 'Limit' : 'Market'} Order${selectedIds.size > 1 ? 's' : ''}`
                     )}
-                  </button>
+                  </Button>
                 )}
               </>
             )}
           </div>
-        </div>
-      </div>
-    </div>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
   );
 }
 

+ 11 - 19
packages/dashboard/src/components/Sidebar.tsx

@@ -5,6 +5,8 @@ import Image from 'next/image';
 import Link from 'next/link';
 import { usePathname } from 'next/navigation';
 import { ThemeToggle } from './ThemeToggle';
+import { Switch } from '@/components/ui/switch';
+import { Badge } from '@/components/ui/badge';
 
 const navItems = [
   {
@@ -76,7 +78,7 @@ export function Sidebar() {
 
   useEffect(() => {
     fetchStatus();
-    const interval = setInterval(fetchStatus, 10000); // poll every 10s
+    const interval = setInterval(fetchStatus, 10000);
     return () => clearInterval(interval);
   }, [fetchStatus]);
 
@@ -146,14 +148,14 @@ export function Sidebar() {
           <div className={`w-2 h-2 rounded-full ${botStatus.connected ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />
           <span>{botStatus.connected ? 'Bot Connected' : 'Bot Offline'}</span>
           {!botStatus.dryRun && (
-            <span className="ml-auto px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400">
+            <Badge variant="red" className="ml-auto text-[10px] font-bold px-1.5 py-0.5">
               LIVE
-            </span>
+            </Badge>
           )}
           {botStatus.dryRun && (
-            <span className="ml-auto px-1.5 py-0.5 rounded text-[10px] font-bold bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400">
+            <Badge variant="yellow" className="ml-auto text-[10px] font-bold px-1.5 py-0.5">
               DRY RUN
-            </span>
+            </Badge>
           )}
         </div>
 
@@ -163,21 +165,11 @@ export function Sidebar() {
             <span className="text-xs text-gray-600 dark:text-slate-400 font-medium">
               Auto-Execute
             </span>
-            <button
-              onClick={toggleAutoExecute}
+            <Switch
+              checked={botStatus.autoExecute}
+              onCheckedChange={toggleAutoExecute}
               disabled={toggling}
-              className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${
-                botStatus.autoExecute
-                  ? 'bg-blue-600'
-                  : 'bg-gray-300 dark:bg-slate-600'
-              } ${toggling ? 'opacity-50' : ''}`}
-            >
-              <span
-                className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform shadow-sm ${
-                  botStatus.autoExecute ? 'translate-x-4' : 'translate-x-0.5'
-                }`}
-              />
-            </button>
+            />
           </div>
         )}
 

+ 127 - 128
packages/dashboard/src/components/SignalTable.tsx

@@ -1,15 +1,24 @@
 'use client';
 
 import { useState, useMemo, Fragment } from 'react';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Badge } from '@/components/ui/badge';
 import type { Signal } from '@/lib/types';
 
 type SortKey = 'timestamp' | 'type' | 'city' | 'edge' | 'expectedReturn' | 'confidence';
 type SortDir = 'asc' | 'desc';
 
-const typeColors: Record<Signal['type'], string> = {
-  TAIL_NO: 'badge-yellow',
-  LATENCY_ARB: 'badge-blue',
-  PARITY_ARB: 'badge-green',
+const typeVariants: Record<Signal['type'], 'yellow' | 'blue' | 'green'> = {
+  TAIL_NO: 'yellow',
+  LATENCY_ARB: 'blue',
+  PARITY_ARB: 'green',
+};
+
+const typeLabels: Record<Signal['type'], string> = {
+  TAIL_NO: 'TAIL',
+  LATENCY_ARB: 'LATENCY',
+  PARITY_ARB: 'PARITY',
 };
 
 const sideLabels: Record<Signal['side'], { text: string; color: string }> = {
@@ -81,8 +90,8 @@ export function SignalTable({
   function SortHeader({ label, field }: { label: string; field: SortKey }) {
     const active = sortKey === field;
     return (
-      <th
-        className="table-header cursor-pointer hover:text-gray-700 dark:hover:text-slate-200 select-none"
+      <TableHead
+        className="cursor-pointer hover:text-gray-700 dark:hover:text-slate-200 select-none"
         onClick={() => handleSort(field)}
       >
         <span className="flex items-center gap-1">
@@ -93,7 +102,7 @@ export function SignalTable({
             </span>
           )}
         </span>
-      </th>
+      </TableHead>
     );
   }
 
@@ -107,126 +116,115 @@ export function SignalTable({
         </div>
       )}
 
-      <div className="overflow-x-auto">
-        <table className="w-full">
-          <thead>
-            <tr className="border-b border-gray-200 dark:border-slate-700">
-              {selectionMode && (
-                <th className="table-header w-10">
-                  <input
-                    type="checkbox"
-                    checked={!!allFilteredSelected}
-                    onChange={onToggleAll}
-                    className="w-4 h-4 rounded border-gray-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
-                  />
-                </th>
-              )}
-              <SortHeader label="Time" field="timestamp" />
-              <th className="table-header">Signal</th>
-              <th className="table-header">Return</th>
-              <th className="table-header text-right">Trade</th>
-            </tr>
-          </thead>
-          <tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
-            {sorted.map((signal) => {
-              const isExpanded = expandedId === signal.id;
-              const sideInfo = sideLabels[signal.side];
-              const isSelected = selectedIds?.has(signal.id);
+      <Table>
+        <TableHeader>
+          <TableRow className="border-b border-gray-200 dark:border-slate-700">
+            {selectionMode && (
+              <TableHead className="w-10">
+                <Checkbox
+                  checked={!!allFilteredSelected}
+                  onCheckedChange={onToggleAll}
+                />
+              </TableHead>
+            )}
+            <SortHeader label="Time" field="timestamp" />
+            <TableHead>Signal</TableHead>
+            <TableHead>Return</TableHead>
+            <TableHead className="text-right">Trade</TableHead>
+          </TableRow>
+        </TableHeader>
+        <TableBody>
+          {sorted.map((signal) => {
+            const isExpanded = expandedId === signal.id;
+            const sideInfo = sideLabels[signal.side];
+            const isSelected = selectedIds?.has(signal.id);
 
-              return (
-                <Fragment key={signal.id}>
-                  {/* Main row */}
-                  <tr
-                    className={`hover:bg-gray-50 dark:hover:bg-slate-800/50 transition-colors cursor-pointer ${
-                      isSelected ? 'bg-blue-50/50 dark:bg-blue-500/5' : ''
-                    }`}
-                    onClick={() => setExpandedId(isExpanded ? null : signal.id)}
-                  >
-                    {selectionMode && (
-                      <td className="table-cell w-10" onClick={(e) => e.stopPropagation()}>
-                        <input
-                          type="checkbox"
-                          checked={!!isSelected}
-                          onChange={() => onToggleSelect?.(signal.id)}
-                          className="w-4 h-4 rounded border-gray-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
-                        />
-                      </td>
-                    )}
-                    <td className="table-cell font-mono text-xs whitespace-nowrap">
-                      <span className="text-dim mr-1.5">{isExpanded ? '\u25BC' : '\u25B6'}</span>
-                      {new Date(signal.timestamp).toLocaleTimeString('en-US', {
-                        hour: '2-digit',
-                        minute: '2-digit',
-                      })}
-                    </td>
-                    <td className="table-cell">
-                      <div className="flex flex-col gap-0.5">
-                        <div className="flex items-center gap-1.5">
-                          <span className={typeColors[signal.type]}>
-                            {signal.type === 'TAIL_NO'
-                              ? 'TAIL'
-                              : signal.type === 'LATENCY_ARB'
-                              ? 'LATENCY'
-                              : 'PARITY'}
+            return (
+              <Fragment key={signal.id}>
+                {/* Main row */}
+                <TableRow
+                  className={`cursor-pointer ${
+                    isSelected ? 'bg-blue-50/50 dark:bg-blue-500/5' : ''
+                  }`}
+                  onClick={() => setExpandedId(isExpanded ? null : signal.id)}
+                >
+                  {selectionMode && (
+                    <TableCell className="w-10" onClick={(e) => e.stopPropagation()}>
+                      <Checkbox
+                        checked={!!isSelected}
+                        onCheckedChange={() => onToggleSelect?.(signal.id)}
+                      />
+                    </TableCell>
+                  )}
+                  <TableCell className="font-mono text-xs whitespace-nowrap">
+                    <span className="text-dim mr-1.5">{isExpanded ? '\u25BC' : '\u25B6'}</span>
+                    {new Date(signal.timestamp).toLocaleTimeString('en-US', {
+                      hour: '2-digit',
+                      minute: '2-digit',
+                    })}
+                  </TableCell>
+                  <TableCell>
+                    <div className="flex flex-col gap-0.5">
+                      <div className="flex items-center gap-1.5">
+                        <Badge variant={typeVariants[signal.type]}>
+                          {typeLabels[signal.type]}
+                        </Badge>
+                        <span className={`font-semibold text-xs ${sideInfo.color}`}>
+                          {sideInfo.text}
+                        </span>
+                      </div>
+                      <div className="text-xs">
+                        <span className="text-heading">{formatCity(signal.city)}</span>
+                        <span className="text-dim mx-1">{signal.targetDate.slice(5)}</span>
+                        <span className="text-sub font-mono">{signal.bucket}</span>
+                      </div>
+                    </div>
+                  </TableCell>
+                  <TableCell>
+                    {(() => {
+                      const buyPrice = signal.suggestedPrice;
+                      const roi = buyPrice > 0 && buyPrice < 1 ? (1 - buyPrice) / buyPrice : 0;
+                      return (
+                        <div className="flex flex-col">
+                          <span className={`font-semibold ${roi > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
+                            {roi > 0 ? '+' : ''}{(roi * 100).toFixed(0)}% ROI
                           </span>
-                          <span className={`font-semibold text-xs ${sideInfo.color}`}>
-                            {sideInfo.text}
+                          <span className="text-xs text-dim">
+                            @ {(buyPrice * 100).toFixed(1)}c
                           </span>
                         </div>
-                        <div className="text-xs">
-                          <span className="text-heading">{formatCity(signal.city)}</span>
-                          <span className="text-dim mx-1">{signal.targetDate.slice(5)}</span>
-                          <span className="text-sub font-mono">{signal.bucket}</span>
-                        </div>
-                      </div>
-                    </td>
-                    <td className="table-cell">
-                      {(() => {
-                        // Show actual ROI: buy at suggestedPrice, win $1
-                        const buyPrice = signal.suggestedPrice;
-                        const roi = buyPrice > 0 && buyPrice < 1 ? (1 - buyPrice) / buyPrice : 0;
-                        return (
-                          <div className="flex flex-col">
-                            <span className={`font-semibold ${roi > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
-                              {roi > 0 ? '+' : ''}{(roi * 100).toFixed(0)}% ROI
-                            </span>
-                            <span className="text-xs text-dim">
-                              @ {(buyPrice * 100).toFixed(1)}c
-                            </span>
-                          </div>
-                        );
-                      })()}
-                    </td>
-                    <td className="table-cell text-right">
-                      <a
-                        href={signal.polymarketUrl}
-                        target="_blank"
-                        rel="noopener noreferrer"
-                        className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-100 text-blue-700 dark:bg-blue-600/20 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-600/30 text-xs font-medium transition-colors"
-                        onClick={(e) => e.stopPropagation()}
-                      >
-                        Trade
-                        <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
-                        </svg>
-                      </a>
+                      );
+                    })()}
+                  </TableCell>
+                  <TableCell className="text-right">
+                    <a
+                      href={signal.polymarketUrl}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-100 text-blue-700 dark:bg-blue-600/20 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-600/30 text-xs font-medium transition-colors"
+                      onClick={(e) => e.stopPropagation()}
+                    >
+                      Trade
+                      <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+                      </svg>
+                    </a>
+                  </TableCell>
+                </TableRow>
+
+                {/* Expanded detail panel */}
+                {isExpanded && (
+                  <tr>
+                    <td colSpan={selectionMode ? 5 : 4} className="p-0">
+                      <SignalDetail signal={signal} />
                     </td>
                   </tr>
-
-                  {/* Expanded detail panel */}
-                  {isExpanded && (
-                    <tr>
-                      <td colSpan={selectionMode ? 5 : 4} className="p-0">
-                        <SignalDetail signal={signal} />
-                      </td>
-                    </tr>
-                  )}
-                </Fragment>
-              );
-            })}
-          </tbody>
-        </table>
-      </div>
+                )}
+              </Fragment>
+            );
+          })}
+        </TableBody>
+      </Table>
     </div>
   );
 }
@@ -427,17 +425,18 @@ function DetailItem({
     <div>
       <p className="text-xs text-dim">{label}</p>
       {badge ? (
-        <span
-          className={`inline-block mt-0.5 text-xs font-semibold px-2 py-0.5 rounded ${
+        <Badge
+          variant={
             value === 'HIGH'
-              ? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
+              ? 'green'
               : value === 'MEDIUM'
-              ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
-              : 'bg-gray-100 text-gray-500 dark:bg-slate-500/20 dark:text-slate-400'
-          }`}
+              ? 'yellow'
+              : 'default'
+          }
+          className="mt-0.5"
         >
           {value}
-        </span>
+        </Badge>
       ) : (
         <p className={valueClass}>{value}</p>
       )}

+ 171 - 0
packages/dashboard/src/components/TradeTableClient.tsx

@@ -0,0 +1,171 @@
+'use client';
+
+import { useState } from 'react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import type { TradeRecord } from '@/lib/types';
+
+function formatCity(city: string): string {
+  return city.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
+}
+
+export function TradeTableClient({ trades: initialTrades }: { trades: TradeRecord[] }) {
+  const [trades, setTrades] = useState(initialTrades);
+  const [syncing, setSyncing] = useState(false);
+  const [syncResult, setSyncResult] = useState<string | null>(null);
+
+  const hasPending = trades.some((t) => t.status === 'PENDING' && !t.dryRun);
+
+  const syncTrades = async () => {
+    setSyncing(true);
+    setSyncResult(null);
+    try {
+      const res = await fetch('/api/trade-sync', { method: 'POST' });
+      const data = await res.json();
+      if (data.error) {
+        setSyncResult(`Error: ${data.error}`);
+      } else {
+        setSyncResult(
+          `Synced ${data.synced}: ${data.filled} filled, ${data.cancelled} cancelled, ${data.unchanged} unchanged`
+        );
+        // Refresh trades from server
+        const tradesRes = await fetch('/api/dashboard');
+        const dashData = await tradesRes.json();
+        if (dashData.recentTrades) {
+          setTrades(dashData.recentTrades);
+        } else {
+          // Fallback: reload page
+          window.location.reload();
+        }
+      }
+    } catch {
+      setSyncResult('Sync failed');
+    } finally {
+      setSyncing(false);
+    }
+  };
+
+  return (
+    <div>
+      <div className="flex items-center justify-between mb-4">
+        <h2 className="text-lg font-semibold text-heading">Recent Trades</h2>
+        <div className="flex items-center gap-3">
+          <span className="text-sm text-sub">
+            {trades.filter((t) => !t.dryRun).length} live / {trades.filter((t) => t.dryRun).length} dry run
+          </span>
+          {hasPending && (
+            <Button
+              onClick={syncTrades}
+              variant="outline"
+              size="sm"
+              disabled={syncing}
+            >
+              {syncing ? (
+                <span className="flex items-center gap-1.5">
+                  <svg className="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
+                    <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+                    <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
+                  </svg>
+                  Syncing...
+                </span>
+              ) : (
+                <span className="flex items-center gap-1.5">
+                  <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+                  </svg>
+                  Sync Status
+                </span>
+              )}
+            </Button>
+          )}
+        </div>
+      </div>
+
+      {syncResult && (
+        <div className="mb-3 px-3 py-2 rounded-lg bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 text-xs text-blue-700 dark:text-blue-400">
+          {syncResult}
+        </div>
+      )}
+
+      <Table>
+        <TableHeader>
+          <TableRow className="border-b border-gray-200 dark:border-slate-700">
+            <TableHead>Time</TableHead>
+            <TableHead>Market</TableHead>
+            <TableHead>Side</TableHead>
+            <TableHead className="text-right">Price</TableHead>
+            <TableHead className="text-right">Shares</TableHead>
+            <TableHead className="text-right">Cost</TableHead>
+            <TableHead>Status</TableHead>
+          </TableRow>
+        </TableHeader>
+        <TableBody>
+          {trades.map((trade) => (
+            <TableRow key={trade.id}>
+              <TableCell className="font-mono text-xs whitespace-nowrap">
+                {new Date(trade.createdAt).toLocaleString('en-US', {
+                  month: 'short',
+                  day: 'numeric',
+                  hour: '2-digit',
+                  minute: '2-digit',
+                })}
+              </TableCell>
+              <TableCell>
+                <div className="flex flex-col gap-0.5">
+                  {trade.city && (
+                    <span className="text-heading text-sm font-medium">
+                      {formatCity(trade.city)}
+                    </span>
+                  )}
+                  {trade.bucket && (
+                    <span className="text-xs text-sub font-mono">{trade.bucket}</span>
+                  )}
+                  {trade.targetDate && (
+                    <span className="text-xs text-dim">{trade.targetDate}</span>
+                  )}
+                </div>
+              </TableCell>
+              <TableCell>
+                <span className={`text-xs font-semibold ${
+                  trade.side === 'BUY_YES'
+                    ? 'text-green-600 dark:text-green-400'
+                    : 'text-red-600 dark:text-red-400'
+                }`}>
+                  {trade.side === 'BUY_YES' ? 'BUY YES' : 'BUY NO'}
+                </span>
+              </TableCell>
+              <TableCell className="text-right font-mono text-sm">
+                {(trade.price * 100).toFixed(1)}&cent;
+              </TableCell>
+              <TableCell className="text-right font-mono text-sm">
+                {trade.size.toFixed(1)}
+              </TableCell>
+              <TableCell className="text-right font-mono text-sm font-medium">
+                ${trade.costBasis.toFixed(2)}
+              </TableCell>
+              <TableCell>
+                <Badge
+                  variant={
+                    trade.status === 'FILLED'
+                      ? 'green'
+                      : trade.status === 'PENDING'
+                      ? 'yellow'
+                      : trade.status === 'CANCELLED'
+                      ? 'red'
+                      : 'default'
+                  }
+                >
+                  {trade.status}
+                  {trade.dryRun && (
+                    <span className="text-[10px] opacity-70 ml-1">(sim)</span>
+                  )}
+                </Badge>
+              </TableCell>
+            </TableRow>
+          ))}
+        </TableBody>
+      </Table>
+    </div>
+  );
+}

+ 38 - 0
packages/dashboard/src/components/ui/badge.tsx

@@ -0,0 +1,38 @@
+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-medium transition-colors",
+  {
+    variants: {
+      variant: {
+        default:
+          "border-gray-200 bg-gray-100 text-gray-700 dark:border-slate-500/20 dark:bg-slate-500/20 dark:text-slate-400",
+        green:
+          "border-green-200 bg-green-100 text-green-700 dark:border-green-500/20 dark:bg-green-500/10 dark:text-green-400",
+        red:
+          "border-red-200 bg-red-100 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-400",
+        blue:
+          "border-blue-200 bg-blue-100 text-blue-700 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-400",
+        yellow:
+          "border-yellow-200 bg-yellow-100 text-yellow-700 dark:border-yellow-500/20 dark:bg-yellow-500/10 dark:text-yellow-400",
+      },
+    },
+    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 };

+ 55 - 0
packages/dashboard/src/components/ui/button.tsx

@@ -0,0 +1,55 @@
+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 gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+  {
+    variants: {
+      variant: {
+        default:
+          "bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500",
+        destructive:
+          "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500",
+        outline:
+          "border border-gray-300 bg-gray-100 text-gray-700 hover:bg-gray-200 focus-visible:ring-gray-500 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600 dark:border-slate-600",
+        ghost:
+          "bg-transparent text-gray-600 hover:bg-gray-100 dark:text-slate-400 dark:hover:bg-slate-800",
+        link: "text-blue-600 underline-offset-4 hover:underline dark:text-blue-400",
+      },
+      size: {
+        default: "h-10 px-4 py-2",
+        sm: "h-8 rounded-lg px-3 py-1.5 text-xs",
+        lg: "h-11 rounded-lg px-8",
+        icon: "h-10 w-10",
+      },
+    },
+    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 };

+ 29 - 0
packages/dashboard/src/components/ui/checkbox.tsx

@@ -0,0 +1,29 @@
+"use client";
+
+import * as React from "react";
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
+import { Check } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const Checkbox = React.forwardRef<
+  React.ComponentRef<typeof CheckboxPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
+>(({ className, ...props }, ref) => (
+  <CheckboxPrimitive.Root
+    ref={ref}
+    className={cn(
+      "peer h-4 w-4 shrink-0 rounded border border-gray-300 shadow-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600 data-[state=checked]:text-white dark:border-slate-600 dark:ring-offset-slate-900 dark:data-[state=checked]:bg-blue-600 dark:data-[state=checked]:border-blue-600",
+      className
+    )}
+    {...props}
+  >
+    <CheckboxPrimitive.Indicator
+      className={cn("flex items-center justify-center text-current")}
+    >
+      <Check className="h-3.5 w-3.5" />
+    </CheckboxPrimitive.Indicator>
+  </CheckboxPrimitive.Root>
+));
+Checkbox.displayName = CheckboxPrimitive.Root.displayName;
+
+export { Checkbox };

+ 114 - 0
packages/dashboard/src/components/ui/dialog.tsx

@@ -0,0 +1,114 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+const DialogTrigger = DialogPrimitive.Trigger;
+const DialogPortal = DialogPrimitive.Portal;
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+  React.ComponentRef<typeof DialogPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Overlay
+    ref={ref}
+    className={cn(
+      "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      className
+    )}
+    {...props}
+  />
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+  React.ComponentRef<typeof DialogPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+  <DialogPortal>
+    <DialogOverlay />
+    <DialogPrimitive.Content
+      ref={ref}
+      className={cn(
+        "fixed left-[50%] top-[50%] z-50 grid w-full max-w-3xl translate-x-[-50%] translate-y-[-50%] border border-gray-200 bg-white shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-xl dark:border-slate-700 dark:bg-slate-800 max-h-[85vh] flex flex-col mx-4",
+        className
+      )}
+      {...props}
+    >
+      {children}
+    </DialogPrimitive.Content>
+  </DialogPortal>
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col space-y-1.5 px-6 py-4 border-b border-gray-200 dark:border-slate-700",
+      className
+    )}
+    {...props}
+  />
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-slate-700",
+      className
+    )}
+    {...props}
+  />
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+  React.ComponentRef<typeof DialogPrimitive.Title>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Title
+    ref={ref}
+    className={cn(
+      "text-lg font-semibold leading-none tracking-tight text-heading",
+      className
+    )}
+    {...props}
+  />
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+  React.ComponentRef<typeof DialogPrimitive.Description>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Description
+    ref={ref}
+    className={cn("text-sm text-sub", className)}
+    {...props}
+  />
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+  Dialog,
+  DialogPortal,
+  DialogOverlay,
+  DialogClose,
+  DialogTrigger,
+  DialogContent,
+  DialogHeader,
+  DialogFooter,
+  DialogTitle,
+  DialogDescription,
+};

+ 22 - 0
packages/dashboard/src/components/ui/input.tsx

@@ -0,0 +1,22 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Input = React.forwardRef<
+  HTMLInputElement,
+  React.InputHTMLAttributes<HTMLInputElement>
+>(({ className, type, ...props }, ref) => {
+  return (
+    <input
+      type={type}
+      className={cn(
+        "flex h-9 w-full rounded-lg border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder:text-slate-400",
+        className
+      )}
+      ref={ref}
+      {...props}
+    />
+  );
+});
+Input.displayName = "Input";
+
+export { Input };

+ 156 - 0
packages/dashboard/src/components/ui/select.tsx

@@ -0,0 +1,156 @@
+"use client";
+
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+const SelectGroup = SelectPrimitive.Group;
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+  <SelectPrimitive.Trigger
+    ref={ref}
+    className={cn(
+      "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm ring-offset-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 dark:border-slate-700 dark:bg-slate-800 dark:ring-offset-slate-900 dark:placeholder:text-slate-400 dark:text-slate-100",
+      className
+    )}
+    {...props}
+  >
+    {children}
+    <SelectPrimitive.Icon asChild>
+      <ChevronDown className="h-4 w-4 opacity-50" />
+    </SelectPrimitive.Icon>
+  </SelectPrimitive.Trigger>
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.ScrollUpButton
+    ref={ref}
+    className={cn(
+      "flex cursor-default items-center justify-center py-1",
+      className
+    )}
+    {...props}
+  >
+    <ChevronUp className="h-4 w-4" />
+  </SelectPrimitive.ScrollUpButton>
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.ScrollDownButton
+    ref={ref}
+    className={cn(
+      "flex cursor-default items-center justify-center py-1",
+      className
+    )}
+    {...props}
+  >
+    <ChevronDown className="h-4 w-4" />
+  </SelectPrimitive.ScrollDownButton>
+));
+SelectScrollDownButton.displayName =
+  SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
+>(({ className, children, position = "popper", ...props }, ref) => (
+  <SelectPrimitive.Portal>
+    <SelectPrimitive.Content
+      ref={ref}
+      className={cn(
+        "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-gray-200 bg-white text-gray-900 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100",
+        position === "popper" &&
+          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+        className
+      )}
+      position={position}
+      {...props}
+    >
+      <SelectScrollUpButton />
+      <SelectPrimitive.Viewport
+        className={cn(
+          "p-1",
+          position === "popper" &&
+            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
+        )}
+      >
+        {children}
+      </SelectPrimitive.Viewport>
+      <SelectScrollDownButton />
+    </SelectPrimitive.Content>
+  </SelectPrimitive.Portal>
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.Label>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.Label
+    ref={ref}
+    className={cn("px-2 py-1.5 text-sm font-semibold", className)}
+    {...props}
+  />
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.Item>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
+>(({ className, children, ...props }, ref) => (
+  <SelectPrimitive.Item
+    ref={ref}
+    className={cn(
+      "relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-700 dark:focus:text-slate-100",
+      className
+    )}
+    {...props}
+  >
+    <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
+      <SelectPrimitive.ItemIndicator>
+        <Check className="h-4 w-4" />
+      </SelectPrimitive.ItemIndicator>
+    </span>
+    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+  </SelectPrimitive.Item>
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+  React.ComponentRef<typeof SelectPrimitive.Separator>,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+  <SelectPrimitive.Separator
+    ref={ref}
+    className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-slate-700", className)}
+    {...props}
+  />
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+  Select,
+  SelectGroup,
+  SelectValue,
+  SelectTrigger,
+  SelectContent,
+  SelectLabel,
+  SelectItem,
+  SelectSeparator,
+  SelectScrollUpButton,
+  SelectScrollDownButton,
+};

+ 28 - 0
packages/dashboard/src/components/ui/switch.tsx

@@ -0,0 +1,28 @@
+"use client";
+
+import * as React from "react";
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+import { cn } from "@/lib/utils";
+
+const Switch = React.forwardRef<
+  React.ComponentRef<typeof SwitchPrimitives.Root>,
+  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
+>(({ className, ...props }, ref) => (
+  <SwitchPrimitives.Root
+    className={cn(
+      "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-gray-300 dark:focus-visible:ring-offset-slate-900 dark:data-[state=unchecked]:bg-slate-600",
+      className
+    )}
+    {...props}
+    ref={ref}
+  >
+    <SwitchPrimitives.Thumb
+      className={cn(
+        "pointer-events-none block h-3.5 w-3.5 rounded-full bg-white shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
+      )}
+    />
+  </SwitchPrimitives.Root>
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };

+ 122 - 0
packages/dashboard/src/components/ui/table.tsx

@@ -0,0 +1,122 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Table = React.forwardRef<
+  HTMLTableElement,
+  React.HTMLAttributes<HTMLTableElement>
+>(({ className, ...props }, ref) => (
+  <div className="relative w-full overflow-auto">
+    <table
+      ref={ref}
+      className={cn("w-full caption-bottom text-sm", className)}
+      {...props}
+    />
+  </div>
+));
+Table.displayName = "Table";
+
+const TableHeader = React.forwardRef<
+  HTMLTableSectionElement,
+  React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+  <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
+));
+TableHeader.displayName = "TableHeader";
+
+const TableBody = React.forwardRef<
+  HTMLTableSectionElement,
+  React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+  <tbody
+    ref={ref}
+    className={cn("[&_tr:last-child]:border-0", className)}
+    {...props}
+  />
+));
+TableBody.displayName = "TableBody";
+
+const TableFooter = React.forwardRef<
+  HTMLTableSectionElement,
+  React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+  <tfoot
+    ref={ref}
+    className={cn(
+      "border-t bg-gray-100/50 font-medium dark:bg-slate-800/50 [&>tr]:last:border-b-0",
+      className
+    )}
+    {...props}
+  />
+));
+TableFooter.displayName = "TableFooter";
+
+const TableRow = React.forwardRef<
+  HTMLTableRowElement,
+  React.HTMLAttributes<HTMLTableRowElement>
+>(({ className, ...props }, ref) => (
+  <tr
+    ref={ref}
+    className={cn(
+      "border-b border-gray-100 transition-colors hover:bg-gray-50 data-[state=selected]:bg-gray-100 dark:border-slate-700/50 dark:hover:bg-slate-800/50 dark:data-[state=selected]:bg-slate-800",
+      className
+    )}
+    {...props}
+  />
+));
+TableRow.displayName = "TableRow";
+
+const TableHead = React.forwardRef<
+  HTMLTableCellElement,
+  React.ThHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+  <th
+    ref={ref}
+    className={cn(
+      "h-10 px-4 text-left align-middle text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-slate-400 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+      className
+    )}
+    {...props}
+  />
+));
+TableHead.displayName = "TableHead";
+
+const TableCell = React.forwardRef<
+  HTMLTableCellElement,
+  React.TdHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+  <td
+    ref={ref}
+    className={cn(
+      "px-4 py-3 align-middle text-sm text-gray-700 dark:text-slate-300 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+      className
+    )}
+    {...props}
+  />
+));
+TableCell.displayName = "TableCell";
+
+const TableCaption = React.forwardRef<
+  HTMLTableCaptionElement,
+  React.HTMLAttributes<HTMLTableCaptionElement>
+>(({ className, ...props }, ref) => (
+  <caption
+    ref={ref}
+    className={cn(
+      "mt-4 text-sm text-gray-500 dark:text-slate-400",
+      className
+    )}
+    {...props}
+  />
+));
+TableCaption.displayName = "TableCaption";
+
+export {
+  Table,
+  TableHeader,
+  TableBody,
+  TableFooter,
+  TableHead,
+  TableRow,
+  TableCell,
+  TableCaption,
+};

+ 65 - 8
packages/dashboard/src/lib/gamma-tokens.ts

@@ -1,6 +1,10 @@
 /**
  * Fetch token IDs from Gamma events API (temperature markets).
  * Used as fallback when MarketSnapshot table is empty.
+ *
+ * Key format: "city_slug|YYYY-MM-DD|bucketLabel"
+ * Date is required to avoid collisions when the same city has identical
+ * bucket labels across different event dates.
  */
 
 import { CITY_ALIASES } from '@poly-weather/core';
@@ -25,6 +29,33 @@ export interface TokenPair {
   noTokenId: string;
 }
 
+// Month name -> number mapping for parsing event titles
+const MONTH_MAP: Record<string, string> = {
+  january: '01', february: '02', march: '03', april: '04',
+  may: '05', june: '06', july: '07', august: '08',
+  september: '09', october: '10', november: '11', december: '12',
+};
+
+/**
+ * Parse date from Gamma event title.
+ * E.g. "Highest temperature in Atlanta on March 18?" -> "2026-03-18"
+ */
+function parseDateFromTitle(title: string): string | null {
+  const match = title.match(/on\s+(\w+)\s+(\d{1,2})/i);
+  if (!match) return null;
+  const monthStr = match[1].toLowerCase();
+  const day = match[2].padStart(2, '0');
+  const month = MONTH_MAP[monthStr];
+  if (!month) return null;
+  // Determine year: use current year, or next year if the month has passed
+  const now = new Date();
+  const currentYear = now.getFullYear();
+  const currentMonth = now.getMonth() + 1;
+  const parsedMonth = parseInt(month, 10);
+  const year = parsedMonth < currentMonth - 1 ? currentYear + 1 : currentYear;
+  return `${year}-${month}-${day}`;
+}
+
 // In-memory cache, refreshed every minute
 let cache: Map<string, TokenPair> | null = null;
 let cacheTime = 0;
@@ -32,7 +63,7 @@ const CACHE_TTL = 60_000;
 
 /**
  * Fetch ALL temperature event markets from Gamma.
- * Returns a map of "city_slug|bucketLabel" -> { yesTokenId, noTokenId }
+ * Returns a map of "city_slug|YYYY-MM-DD|bucketLabel" -> { yesTokenId, noTokenId }
  */
 export async function fetchAllGammaTokens(): Promise<Map<string, TokenPair>> {
   if (cache && Date.now() - cacheTime < CACHE_TTL) {
@@ -67,8 +98,9 @@ export async function fetchAllGammaTokens(): Promise<Map<string, TokenPair>> {
         if (!titleMatch) continue;
 
         const rawCity = titleMatch[1].trim();
-        // Use CITY_ALIASES to map Gamma titles (e.g. "NYC") to bot slugs (e.g. "new_york")
         const citySlug = CITY_ALIASES[rawCity] ?? rawCity.toLowerCase().replace(/\s+/g, '_');
+        const eventDate = parseDateFromTitle(event.title);
+        if (!eventDate) continue;
 
         for (const m of event.markets || []) {
           if (m.closed) continue;
@@ -78,7 +110,7 @@ export async function fetchAllGammaTokens(): Promise<Map<string, TokenPair>> {
           try {
             const tokenIds = JSON.parse(m.clobTokenIds || '[]');
             if (tokenIds.length >= 1) {
-              tokenMap.set(`${citySlug}|${bucketLabel}`, {
+              tokenMap.set(`${citySlug}|${eventDate}|${bucketLabel}`, {
                 yesTokenId: tokenIds[0] || '',
                 noTokenId: tokenIds[1] || '',
               });
@@ -102,17 +134,28 @@ export async function fetchAllGammaTokens(): Promise<Map<string, TokenPair>> {
 }
 
 /**
- * Look up token pair for a specific city + bucket.
+ * Look up token pair for a specific city + date + bucket.
  * Tries MarketSnapshot DB first, then Gamma API fallback.
  */
 export async function lookupTokenPair(
   city: string,
   bucket: string,
   prismaClient: { marketSnapshot: { findFirst: (...args: any[]) => Promise<any> } },
+  targetDate?: string,
 ): Promise<TokenPair | null> {
-  // Try DB first
+  // Try DB first (with date filter if available)
+  const whereClause: Record<string, unknown> = { city, bucketLabel: bucket };
+  if (targetDate) {
+    // Match the date portion of targetDate
+    const dateStart = new Date(targetDate);
+    dateStart.setUTCHours(0, 0, 0, 0);
+    const dateEnd = new Date(dateStart);
+    dateEnd.setUTCDate(dateEnd.getUTCDate() + 1);
+    whereClause.targetDate = { gte: dateStart, lt: dateEnd };
+  }
+
   const snapshot = await prismaClient.marketSnapshot.findFirst({
-    where: { city, bucketLabel: bucket },
+    where: whereClause,
     orderBy: { createdAt: 'desc' },
     select: { yesTokenId: true, noTokenId: true },
   });
@@ -124,7 +167,21 @@ export async function lookupTokenPair(
     };
   }
 
-  // Fallback to Gamma
+  // Fallback to Gamma (with date in key)
   const gammaTokens = await fetchAllGammaTokens();
-  return gammaTokens.get(`${city}|${bucket}`) || null;
+  if (targetDate) {
+    const dateKey = targetDate.slice(0, 10); // "YYYY-MM-DD"
+    const result = gammaTokens.get(`${city}|${dateKey}|${bucket}`);
+    if (result) return result;
+  }
+
+  // Last resort: try without date (legacy compat)
+  for (const [key, pair] of gammaTokens) {
+    const parts = key.split('|');
+    if (parts.length === 3 && parts[0] === city && parts[2] === bucket) {
+      return pair;
+    }
+  }
+
+  return null;
 }

+ 6 - 0
packages/dashboard/src/lib/utils.ts

@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}

+ 2 - 1
packages/dashboard/tailwind.config.ts

@@ -1,4 +1,5 @@
 import type { Config } from 'tailwindcss';
+import tailwindcssAnimate from 'tailwindcss-animate';
 
 const config: Config = {
   content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
@@ -21,7 +22,7 @@ const config: Config = {
       },
     },
   },
-  plugins: [],
+  plugins: [tailwindcssAnimate],
 };
 
 export default config;

+ 82 - 2
packages/market-engine/src/clob-client.ts

@@ -13,7 +13,7 @@
 import { ClobClient } from '@polymarket/clob-client';
 import { Side as ClobSide, OrderType } from '@polymarket/clob-client';
 import { Wallet } from '@ethersproject/wallet';
-import type { ApiKeyCreds } from '@polymarket/clob-client';
+import type { ApiKeyCreds, OpenOrder, Trade as ClobTrade, TradeParams } from '@polymarket/clob-client';
 import type { Orderbook, OrderSide } from '@poly-weather/core';
 import type { PolymarketConfig } from '@poly-weather/core';
 import { createChildLogger, CLOB_HOST } from '@poly-weather/core';
@@ -216,6 +216,49 @@ export class CLOBTradingClient extends CLOBReadClient {
     return { orderId: result?.orderID || '', status: result?.status || 'UNKNOWN' };
   }
 
+  /**
+   * Place a FOK (Fill or Kill) market order — immediate execution or cancel.
+   */
+  async placeMarketOrder(
+    tokenId: string,
+    side: OrderSide,
+    price: number,
+    size: number,
+  ): Promise<{ orderId: string; status: string }> {
+    const orderParams = { tokenId, side, price: +price.toFixed(2), size: +size.toFixed(2) };
+
+    if (this.dryRun) {
+      const mockId = `dry_mkt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+      logger.info({ orderId: mockId, ...orderParams }, '[DRY RUN] Would place market order');
+      return { orderId: mockId, status: 'DRY_RUN' };
+    }
+
+    await this.initialize();
+    if (!this.clobClient) {
+      throw new Error('CLOB client not initialized — check PRIVATE_KEY');
+    }
+
+    logger.info(orderParams, 'Placing market (FOK) order');
+
+    const clobSide = side === 'BUY' ? ClobSide.BUY : ClobSide.SELL;
+    const signedOrder = await this.clobClient.createOrder({
+      tokenID: tokenId,
+      price,
+      size,
+      side: clobSide,
+    });
+
+    // Post as FOK (Fill or Kill) — executes immediately or cancels entirely
+    const result = await this.clobClient.postOrder(signedOrder, OrderType.FOK);
+
+    logger.info(
+      { orderId: result?.orderID, status: result?.status },
+      'Market order placed',
+    );
+
+    return { orderId: result?.orderID || '', status: result?.status || 'UNKNOWN' };
+  }
+
   /**
    * Cancel an open order by order ID.
    */
@@ -253,12 +296,49 @@ export class CLOBTradingClient extends CLOBReadClient {
   /**
    * Get all open orders.
    */
-  async getOpenOrders(): Promise<unknown[]> {
+  async getOpenOrders(): Promise<OpenOrder[]> {
+    if (this.dryRun) return [];
     await this.initialize();
     if (!this.clobClient) return [];
     return this.clobClient.getOpenOrders();
   }
 
+  /**
+   * Get a single order by ID.
+   * Returns null if not found or in DRY_RUN mode.
+   */
+  async getOrder(orderId: string): Promise<OpenOrder | null> {
+    if (this.dryRun) return null;
+
+    await this.initialize();
+    if (!this.clobClient) return null;
+
+    try {
+      return await this.clobClient.getOrder(orderId);
+    } catch (err) {
+      logger.warn({ err, orderId }, 'Failed to fetch order');
+      return null;
+    }
+  }
+
+  /**
+   * Get trade/fill history.
+   * Returns empty array in DRY_RUN mode.
+   */
+  async getTradeHistory(params?: TradeParams): Promise<ClobTrade[]> {
+    if (this.dryRun) return [];
+
+    await this.initialize();
+    if (!this.clobClient) return [];
+
+    try {
+      return await this.clobClient.getTrades(params);
+    } catch (err) {
+      logger.warn({ err }, 'Failed to fetch trade history');
+      return [];
+    }
+  }
+
   /**
    * Check if client is ready for trading.
    */

+ 18 - 0
packages/market-engine/src/gamma-client.ts

@@ -145,6 +145,24 @@ export async function getMarketByConditionId(
   return markets[0] || null;
 }
 
+/**
+ * Fetch a market by CLOB token ID.
+ * Returns the market with closed/outcomePrices for settlement checks.
+ */
+export async function getMarketByTokenId(tokenId: string): Promise<GammaEventMarket | null> {
+  const url = `${GAMMA_BASE}/markets?clob_token_ids=${tokenId}&closed=true`;
+
+  const res = await fetch(url, {
+    headers: { Accept: 'application/json' },
+    signal: AbortSignal.timeout(15000),
+  });
+
+  if (!res.ok) return null;
+
+  const markets = (await res.json()) as GammaEventMarket[];
+  return markets[0] || null;
+}
+
 /**
  * Search markets by text query.
  */

+ 1 - 0
packages/market-engine/src/index.ts

@@ -4,3 +4,4 @@ export * from './clob-client.js';
 export * from './market-scanner.js';
 export * from './order-manager.js';
 export * from './websocket-feed.js';
+export * from './position-sync.js';

+ 265 - 0
packages/market-engine/src/position-sync.ts

@@ -0,0 +1,265 @@
+/**
+ * Position sync service.
+ * Reconciles local DB trade records against Polymarket CLOB order status.
+ */
+
+import type { OpenOrder } from '@polymarket/clob-client';
+import { createChildLogger } from '@poly-weather/core';
+import type { CLOBTradingClient } from './clob-client.js';
+import { getMarketByTokenId } from './gamma-client.js';
+
+const logger = createChildLogger('position-sync');
+
+export interface SyncResult {
+  synced: number;
+  filled: number;
+  cancelled: number;
+  errors: number;
+}
+
+export interface SettlementResult {
+  checked: number;
+  settled: number;
+  errors: number;
+}
+
+export interface PendingTrade {
+  id: string;
+  orderId: string;
+  tokenId: string;
+  side: string;
+  price: number;
+  size: number;
+  createdAt: Date;
+}
+
+export interface UnsettledTrade {
+  id: string;
+  tokenId: string;
+  side: string;
+  price: number;
+  size: number;
+  costBasis: number;
+}
+
+/** DB adapter — injected by trading-engine to avoid circular deps. */
+export interface SyncDbAdapter {
+  getPendingTrades(): Promise<PendingTrade[]>;
+  getUnsettledTrades(): Promise<UnsettledTrade[]>;
+  updateTradeStatus(tradeId: string, status: 'FILLED' | 'CANCELLED', filledPrice?: number): Promise<void>;
+  settleTradePnl(tradeId: string, pnl: number): Promise<void>;
+}
+
+export class PositionSyncService {
+  private clobClient: CLOBTradingClient;
+  private db: SyncDbAdapter;
+  private dryRun: boolean;
+
+  constructor(clobClient: CLOBTradingClient, db: SyncDbAdapter, dryRun: boolean) {
+    this.clobClient = clobClient;
+    this.db = db;
+    this.dryRun = dryRun;
+  }
+
+  /**
+   * Sync all PENDING trades against Polymarket order status.
+   */
+  async syncPendingOrders(): Promise<SyncResult> {
+    const result: SyncResult = { synced: 0, filled: 0, cancelled: 0, errors: 0 };
+
+    if (this.dryRun) {
+      logger.debug('Skipping position sync in DRY_RUN mode');
+      return result;
+    }
+
+    const pending = await this.db.getPendingTrades();
+    if (pending.length === 0) return result;
+
+    logger.info({ count: pending.length }, 'Syncing pending orders');
+
+    for (const trade of pending) {
+      try {
+        const order = await this.clobClient.getOrder(trade.orderId);
+        result.synced++;
+
+        if (!order) {
+          // Order not found on Polymarket — mark cancelled
+          await this.db.updateTradeStatus(trade.id, 'CANCELLED');
+          result.cancelled++;
+          logger.info({ tradeId: trade.id, orderId: trade.orderId }, 'Order not found, marked CANCELLED');
+          continue;
+        }
+
+        if (this.isFullyFilled(order)) {
+          const filledPrice = parseFloat(order.price);
+          await this.db.updateTradeStatus(trade.id, 'FILLED', filledPrice);
+          result.filled++;
+          logger.info(
+            { tradeId: trade.id, orderId: trade.orderId, filledPrice },
+            'Order filled',
+          );
+        } else if (parseFloat(order.size_matched) > 0) {
+          // Partially filled — leave as PENDING, log for awareness
+          logger.info(
+            {
+              tradeId: trade.id,
+              orderId: trade.orderId,
+              matched: order.size_matched,
+              original: order.original_size,
+            },
+            'Order partially filled',
+          );
+        }
+        // else still open — no action needed
+      } catch (err) {
+        result.errors++;
+        logger.error({ err, tradeId: trade.id, orderId: trade.orderId }, 'Failed to sync order');
+      }
+    }
+
+    logger.info(result, 'Position sync complete');
+    return result;
+  }
+
+  /**
+   * Run on bot startup: reconcile stale orders and check for orphans.
+   */
+  async syncOnStartup(): Promise<void> {
+    if (this.dryRun) {
+      logger.info('Skipping startup sync in DRY_RUN mode');
+      return;
+    }
+
+    logger.info('Running startup position sync...');
+
+    // 1. Reconcile pending trades
+    const result = await this.syncPendingOrders();
+    logger.info(result, 'Startup sync result');
+
+    // 2. Check for orphan orders (on Polymarket but not in DB)
+    try {
+      const openOrders = await this.clobClient.getOpenOrders();
+      const pendingTrades = await this.db.getPendingTrades();
+      const knownOrderIds = new Set(pendingTrades.map((t) => t.orderId));
+
+      const orphans = openOrders.filter((o: OpenOrder) => !knownOrderIds.has(o.id));
+      if (orphans.length > 0) {
+        logger.warn(
+          {
+            count: orphans.length,
+            orderIds: orphans.map((o: OpenOrder) => o.id),
+          },
+          'Found orphan orders on Polymarket not tracked in DB',
+        );
+      }
+    } catch (err) {
+      logger.error({ err }, 'Failed to check for orphan orders');
+    }
+  }
+
+  /**
+   * Settle FILLED trades whose markets have resolved.
+   * Checks Gamma API for market resolution, then calculates PnL.
+   *
+   * PnL logic:
+   *  - BUY_NO trade holds NO token. If NO wins (outcomePrices=["0","1"]):
+   *    pnl = (1 - filledPrice) * size  (token pays $1, cost was filledPrice)
+   *  - BUY_NO trade, YES wins (outcomePrices=["1","0"]):
+   *    pnl = -costBasis  (token pays $0)
+   *  - BUY_YES / SELL_YES follow analogous logic for YES token.
+   */
+  async settleTrades(): Promise<SettlementResult> {
+    const result: SettlementResult = { checked: 0, settled: 0, errors: 0 };
+
+    const unsettled = await this.db.getUnsettledTrades();
+    if (unsettled.length === 0) return result;
+
+    logger.info({ count: unsettled.length }, 'Checking trades for settlement');
+
+    // Batch by tokenId to avoid duplicate API calls
+    const tokenCache = new Map<string, { closed: boolean; yesPays: number; noPays: number } | null>();
+
+    for (const trade of unsettled) {
+      try {
+        result.checked++;
+
+        if (!tokenCache.has(trade.tokenId)) {
+          const market = await getMarketByTokenId(trade.tokenId);
+          if (!market) {
+            tokenCache.set(trade.tokenId, null);
+          } else {
+            let prices: number[] = [];
+            try {
+              prices = JSON.parse(market.outcomePrices || '[]').map(Number);
+            } catch { /* ignore */ }
+
+            tokenCache.set(trade.tokenId, {
+              closed: market.closed,
+              yesPays: prices[0] ?? 0,
+              noPays: prices[1] ?? 0,
+            });
+          }
+        }
+
+        const info = tokenCache.get(trade.tokenId);
+        if (!info || !info.closed) continue;
+
+        // Only settle if prices are terminal (0 or 1)
+        const isTerminal =
+          (info.yesPays === 0 || info.yesPays === 1) &&
+          (info.noPays === 0 || info.noPays === 1);
+        if (!isTerminal) continue;
+
+        const pnl = this.calculatePnl(trade, info.yesPays, info.noPays);
+        await this.db.settleTradePnl(trade.id, pnl);
+        result.settled++;
+
+        logger.info(
+          { tradeId: trade.id, side: trade.side, price: trade.price, size: trade.size, pnl },
+          'Trade settled',
+        );
+      } catch (err) {
+        result.errors++;
+        logger.error({ err, tradeId: trade.id }, 'Failed to settle trade');
+      }
+    }
+
+    if (result.settled > 0) {
+      logger.info(result, 'Settlement complete');
+    }
+    return result;
+  }
+
+  private calculatePnl(
+    trade: UnsettledTrade,
+    yesPays: number,
+    noPays: number,
+  ): number {
+    const filledPrice = trade.price;
+    const size = trade.size;
+
+    if (trade.side === 'BUY_NO') {
+      // Holding NO token
+      if (noPays === 1) {
+        // NO wins: receive $1 per share, paid filledPrice
+        return (1 - filledPrice) * size;
+      } else {
+        // YES wins: NO token worthless
+        return -trade.costBasis;
+      }
+    } else {
+      // BUY_YES or SELL_YES — holding YES token
+      if (yesPays === 1) {
+        return (1 - filledPrice) * size;
+      } else {
+        return -trade.costBasis;
+      }
+    }
+  }
+
+  private isFullyFilled(order: OpenOrder): boolean {
+    const matched = parseFloat(order.size_matched || '0');
+    const original = parseFloat(order.original_size || '0');
+    return original > 0 && matched >= original;
+  }
+}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
packages/market-engine/tsconfig.tsbuildinfo


+ 66 - 2
packages/trading-engine/src/bot.ts

@@ -12,10 +12,20 @@ import {
   WebSocketFeed,
   scanWeatherMarkets,
   groupMarketsByEvent,
+  PositionSyncService,
 } from '@poly-weather/market-engine';
+import { Redis as IORedis } from 'ioredis';
+import type { Redis } from 'ioredis';
 import { TradingPipeline } from './pipeline.js';
 import { HealthCheckServer } from './health-check.js';
-import { disconnectDb } from './db.js';
+import {
+  disconnectDb,
+  getPendingTrades,
+  updateTradeStatus,
+  getUnsettledTrades,
+  settleTradePnl,
+  aggregateDailyMetrics,
+} from './db.js';
 
 const logger = createChildLogger('bot');
 
@@ -27,6 +37,8 @@ export class WeatherArbitrageBot {
   private config: BotConfig;
   private pipeline!: TradingPipeline;
   private healthCheck!: HealthCheckServer;
+  private positionSync!: PositionSyncService;
+  private redis: Redis | null = null;
   private wsFeed: WebSocketFeed | null = null;
   private stopModelPolling: (() => void) | null = null;
   private running = false;
@@ -63,12 +75,37 @@ export class WeatherArbitrageBot {
     // Initialize order manager
     const orderManager = new OrderManager(clobClient, this.config.dryRun);
 
+    // Initialize position sync
+    this.positionSync = new PositionSyncService(
+      clobClient,
+      { getPendingTrades, updateTradeStatus, getUnsettledTrades, settleTradePnl },
+      this.config.dryRun,
+    );
+
+    // Initialize Redis for latency arb ensemble caching
+    const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
+    try {
+      this.redis = new IORedis(redisUrl, {
+        maxRetriesPerRequest: 3,
+        retryStrategy(times: number) {
+          if (times > 5) return null; // stop retrying
+          return Math.min(times * 500, 3000);
+        },
+        lazyConnect: true,
+      });
+      await this.redis!.connect();
+      logger.info({ url: redisUrl }, 'Redis connected (latency arb enabled)');
+    } catch (err) {
+      logger.warn({ err }, 'Redis connection failed — latency arb will be disabled');
+      this.redis = null;
+    }
+
     // Initialize pipeline
     this.pipeline = new TradingPipeline(
       orderManager,
       this.config.cities,
       this.config.dryRun,
-      null, // Redis — optional, can be added later
+      this.redis,
       this.config.autoExecute,
     );
 
@@ -82,6 +119,11 @@ export class WeatherArbitrageBot {
 
     this.running = true;
 
+    // Sync positions, settle resolved markets, aggregate metrics on startup
+    await this.positionSync.syncOnStartup();
+    await this.positionSync.settleTrades();
+    await aggregateDailyMetrics();
+
     // Run initial cycle
     logger.info('Running initial pipeline cycle...');
     await this.runCycle();
@@ -106,6 +148,22 @@ export class WeatherArbitrageBot {
     }, 120_000);
     this.intervals.push(scanInterval);
 
+    // Start position sync + settlement + metrics aggregation (every 60 seconds)
+    const syncInterval = setInterval(async () => {
+      if (this.running) {
+        try {
+          if (!this.config.dryRun) {
+            await this.positionSync.syncPendingOrders();
+          }
+          await this.positionSync.settleTrades();
+          await aggregateDailyMetrics();
+        } catch (err) {
+          logger.error({ err }, 'Position sync / settlement / metrics failed');
+        }
+      }
+    }, 60_000);
+    this.intervals.push(syncInterval);
+
     // Start market re-discovery (every 30 minutes)
     const discoveryInterval = setInterval(async () => {
       if (this.running) {
@@ -193,6 +251,12 @@ export class WeatherArbitrageBot {
     // Stop health check
     this.healthCheck.stop();
 
+    // Disconnect Redis
+    if (this.redis) {
+      await this.redis.quit().catch(() => {});
+      this.redis = null;
+    }
+
     // Disconnect database
     await disconnectDb();
 

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

@@ -69,8 +69,25 @@ export async function persistSignal(signal: Signal): Promise<string | null> {
       recommendedSize = 10;
     }
 
-    const record = await db.signal.create({
-      data: {
+    const record = await db.signal.upsert({
+      where: {
+        type_city_date_bucket: {
+          type: signal.type,
+          city: signal.city,
+          targetDate: signal.date,
+          bucketLabel,
+        },
+      },
+      update: {
+        marketYesPrice,
+        modelProbability,
+        edge,
+        expectedReturn,
+        agreementScore,
+        confidence,
+        recommendedSize,
+      },
+      create: {
         type: signal.type,
         city: signal.city,
         targetDate: signal.date,
@@ -208,6 +225,196 @@ export async function persistMarketSnapshots(market: {
   }
 }
 
+/**
+ * Get all pending (non-dry-run) trades that need status sync.
+ */
+export async function getPendingTrades(): Promise<
+  Array<{
+    id: string;
+    orderId: string;
+    tokenId: string;
+    side: string;
+    price: number;
+    size: number;
+    createdAt: Date;
+  }>
+> {
+  const db = getDb();
+  const trades = await db.trade.findMany({
+    where: {
+      status: 'PENDING',
+      dryRun: false,
+      orderId: { not: null },
+    },
+    select: {
+      id: true,
+      orderId: true,
+      tokenId: true,
+      side: true,
+      price: true,
+      size: true,
+      createdAt: true,
+    },
+  });
+  // orderId is guaranteed non-null by the where clause
+  return trades as Array<{
+    id: string;
+    orderId: string;
+    tokenId: string;
+    side: string;
+    price: number;
+    size: number;
+    createdAt: Date;
+  }>;
+}
+
+/**
+ * Update a trade's status and optionally set filledPrice.
+ */
+export async function updateTradeStatus(
+  tradeId: string,
+  status: 'FILLED' | 'CANCELLED',
+  filledPrice?: number,
+): Promise<void> {
+  const db = getDb();
+  await db.trade.update({
+    where: { id: tradeId },
+    data: {
+      status,
+      ...(filledPrice !== undefined && { filledPrice }),
+      resolvedAt: new Date(),
+    },
+  });
+}
+
+/**
+ * Get FILLED trades that have no pnl yet (need settlement).
+ */
+export async function getUnsettledTrades(): Promise<
+  Array<{
+    id: string;
+    tokenId: string;
+    side: string;
+    price: number;
+    size: number;
+    costBasis: number;
+  }>
+> {
+  const db = getDb();
+  return db.trade.findMany({
+    where: {
+      status: 'FILLED',
+      pnl: null,
+      dryRun: false,
+    },
+    select: {
+      id: true,
+      tokenId: true,
+      side: true,
+      price: true,
+      size: true,
+      costBasis: true,
+    },
+  });
+}
+
+/**
+ * Set the pnl on a settled trade.
+ */
+export async function settleTradePnl(tradeId: string, pnl: number): Promise<void> {
+  const db = getDb();
+  await db.trade.update({
+    where: { id: tradeId },
+    data: {
+      pnl,
+      resolvedAt: new Date(),
+    },
+  });
+}
+
+/**
+ * Aggregate FILLED trades into DailyMetrics.
+ * Groups by resolvedAt date, upserts one row per day.
+ */
+export async function aggregateDailyMetrics(): Promise<number> {
+  const db = getDb();
+
+  // Get all FILLED trades with pnl set, grouped by resolved date
+  const trades = await db.trade.findMany({
+    where: {
+      status: 'FILLED',
+      pnl: { not: null },
+    },
+    select: {
+      pnl: true,
+      resolvedAt: true,
+      createdAt: true,
+    },
+  });
+
+  if (trades.length === 0) return 0;
+
+  // Group by date (use resolvedAt if available, else createdAt)
+  const byDate = new Map<string, number[]>();
+  for (const t of trades) {
+    const d = (t.resolvedAt ?? t.createdAt).toISOString().slice(0, 10);
+    if (!byDate.has(d)) byDate.set(d, []);
+    byDate.get(d)!.push(t.pnl!);
+  }
+
+  let updated = 0;
+  for (const [dateStr, pnls] of byDate) {
+    const total = pnls.length;
+    const winning = pnls.filter((p) => p > 0).length;
+    const losing = pnls.filter((p) => p < 0).length;
+    const grossPnl = pnls.reduce((s, p) => s + p, 0);
+    const winRate = total > 0 ? winning / total : 0;
+    const avgEdge = total > 0 ? pnls.reduce((s, p) => s + Math.abs(p), 0) / total : 0;
+
+    // Max drawdown within the day
+    let peak = 0;
+    let maxDd = 0;
+    let running = 0;
+    for (const p of pnls) {
+      running += p;
+      if (running > peak) peak = running;
+      const dd = running - peak;
+      if (dd < maxDd) maxDd = dd;
+    }
+
+    const date = new Date(dateStr + 'T00:00:00Z');
+
+    await db.dailyMetrics.upsert({
+      where: { date },
+      update: {
+        totalTrades: total,
+        winningTrades: winning,
+        losingTrades: losing,
+        grossPnl,
+        netPnl: grossPnl,
+        winRate,
+        avgEdge,
+        maxDrawdown: maxDd,
+      },
+      create: {
+        date,
+        totalTrades: total,
+        winningTrades: winning,
+        losingTrades: losing,
+        grossPnl,
+        netPnl: grossPnl,
+        winRate,
+        avgEdge,
+        maxDrawdown: maxDd,
+      },
+    });
+    updated++;
+  }
+
+  logger.info({ days: updated, totalTrades: trades.length }, 'Daily metrics aggregated');
+  return updated;
+}
+
 /**
  * Disconnect Prisma client (for graceful shutdown).
  */

+ 1 - 0
packages/weather-engine/src/index.ts

@@ -1,6 +1,7 @@
 // @poly-weather/weather-engine
 export * from './providers/open-meteo.js';
 export * from './providers/noaa-api.js';
+export * from './providers/hko-api.js';
 export * from './providers/provider-types.js';
 export * from './ensemble.js';
 export * from './probability-model.js';

+ 184 - 0
packages/weather-engine/src/providers/hko-api.ts

@@ -0,0 +1,184 @@
+/**
+ * Hong Kong Observatory (HKO) weather provider.
+ *
+ * Fetches forecast and current observation data from the HKO Open Data API.
+ * Only applicable to Hong Kong (city slug: 'hong_kong').
+ *
+ * HKO is the settlement source for Polymarket Hong Kong temperature markets,
+ * so its forecast data is especially valuable for probability estimation.
+ *
+ * Endpoints used:
+ *   - 9-day forecast: /weather.php?dataType=fnd  (daily max/min temps)
+ *   - Current obs:    /weather.php?dataType=rhrread (real-time station readings)
+ */
+
+import type { CityConfig, HourlyForecast } from '@poly-weather/core';
+import { HKO_API_BASE, createChildLogger } from '@poly-weather/core';
+import type { IWeatherProvider } from './provider-types.js';
+
+const logger = createChildLogger('hko');
+
+// ─── HKO API Response Types ─────────────────────────────────────────────────
+
+interface HKOForecastDay {
+  forecastDate: string;        // "YYYYMMDD"
+  forecastMaxtemp: { value: number; unit: string };
+  forecastMintemp: { value: number; unit: string };
+}
+
+interface HKOForecastResponse {
+  updateTime: string;          // ISO 8601 with timezone
+  weatherForecast: HKOForecastDay[];
+}
+
+interface HKOTempStation {
+  place: string;
+  value: number;
+  unit: string;
+}
+
+interface HKOCurrentResponse {
+  temperature: {
+    data: HKOTempStation[];
+    recordTime: string;        // ISO 8601 with timezone
+  };
+}
+
+// ─── Provider ────────────────────────────────────────────────────────────────
+
+export class HKOProvider implements IWeatherProvider {
+  readonly model = 'hko' as const;
+
+  /**
+   * Fetch HKO 9-day forecast and convert to HourlyForecast format.
+   * Only works for Hong Kong — throws for other cities.
+   */
+  async fetch(city: CityConfig): Promise<HourlyForecast> {
+    if (city.slug !== 'hong_kong') {
+      throw new Error('HKO provider only supports Hong Kong');
+    }
+
+    const url = `${HKO_API_BASE}/weather.php?dataType=fnd&lang=en`;
+    const res = await fetch(url, {
+      headers: { Accept: 'application/json' },
+      signal: AbortSignal.timeout(15_000),
+    });
+
+    if (!res.ok) {
+      throw new Error(`HKO forecast API error: ${res.status} ${res.statusText}`);
+    }
+
+    const data = (await res.json()) as HKOForecastResponse;
+
+    if (!data.weatherForecast?.length) {
+      throw new Error('HKO forecast: no forecast data returned');
+    }
+
+    const updateTime = new Date(data.updateTime);
+
+    // Convert daily forecasts to synthetic hourly arrays
+    const times: string[] = [];
+    const temperatures: number[] = [];
+
+    for (const day of data.weatherForecast) {
+      const year = day.forecastDate.slice(0, 4);
+      const month = day.forecastDate.slice(4, 6);
+      const dayNum = day.forecastDate.slice(6, 8);
+      const dateStr = `${year}-${month}-${dayNum}`;
+
+      const maxTemp = day.forecastMaxtemp.value;
+      const minTemp = day.forecastMintemp.value;
+
+      // Generate 24 hourly entries with sinusoidal interpolation
+      // Min at 06:00 HKT (22:00 UTC prev day), Max at 14:00 HKT (06:00 UTC)
+      for (let hour = 0; hour < 24; hour++) {
+        // HKT = UTC+8, so 14:00 HKT = 06:00 UTC
+        // Using UTC hours directly (the forecast date in HKT)
+        const utcHour = (hour - 8 + 24) % 24;
+        const isoTime = hour >= 8
+          ? `${dateStr}T${String(utcHour).padStart(2, '0')}:00:00Z`
+          : (() => {
+              // Hours 0-7 HKT = previous day 16-23 UTC
+              const prevDate = new Date(`${dateStr}T00:00:00Z`);
+              prevDate.setUTCDate(prevDate.getUTCDate() - 1);
+              const pd = prevDate.toISOString().slice(0, 10);
+              return `${pd}T${String(utcHour).padStart(2, '0')}:00:00Z`;
+            })();
+
+        // Sinusoidal temperature curve: min at hour 6, max at hour 14
+        // T(h) = midpoint + amplitude * sin(pi * (h - 6) / 16)  for h in [6, 22]
+        // T(h) ~ minTemp for nighttime hours
+        const midpoint = (maxTemp + minTemp) / 2;
+        const amplitude = (maxTemp - minTemp) / 2;
+
+        let temp: number;
+        if (hour >= 6 && hour <= 22) {
+          // Daytime: sinusoidal curve peaking at hour 14
+          const phase = Math.PI * (hour - 6) / 16;
+          temp = midpoint + amplitude * Math.sin(phase);
+        } else {
+          // Nighttime: near minimum
+          temp = minTemp;
+        }
+
+        times.push(isoTime);
+        temperatures.push(Math.round(temp * 10) / 10);
+      }
+    }
+
+    logger.info(
+      {
+        days: data.weatherForecast.length,
+        updateTime: data.updateTime,
+        firstDayMax: data.weatherForecast[0].forecastMaxtemp.value,
+      },
+      'HKO forecast fetched',
+    );
+
+    return {
+      model: 'hko',
+      city: city.slug,
+      times,
+      temperatures,
+      modelRunTime: updateTime,
+      fetchedAt: new Date(),
+      tempUnit: '°C',
+    };
+  }
+
+  /**
+   * Fetch current temperature from the HKO Observatory station.
+   * Returns the latest reading and timestamp.
+   */
+  async fetchCurrentMax(): Promise<{ temp: number; recordTime: string } | null> {
+    try {
+      const url = `${HKO_API_BASE}/weather.php?dataType=rhrread&lang=en`;
+      const res = await fetch(url, {
+        headers: { Accept: 'application/json' },
+        signal: AbortSignal.timeout(10_000),
+      });
+
+      if (!res.ok) return null;
+
+      const data = (await res.json()) as HKOCurrentResponse;
+      const hkoStation = data.temperature.data.find(
+        (s) => s.place === 'Hong Kong Observatory',
+      );
+
+      if (!hkoStation) {
+        logger.warn('HKO station not found in current observations');
+        return null;
+      }
+
+      return {
+        temp: hkoStation.value,
+        recordTime: data.temperature.recordTime,
+      };
+    } catch (err) {
+      logger.warn({ err }, 'Failed to fetch HKO current observations');
+      return null;
+    }
+  }
+}
+
+export const hkoProvider = new HKOProvider();

+ 18 - 12
packages/weather-engine/src/providers/open-meteo.ts

@@ -3,9 +3,10 @@
  * Free API — no key required for <10K requests/day.
  */
 
-import type { CityConfig, HourlyForecast, WeatherModel } from '@poly-weather/core';
+import type { CityConfig, HourlyForecast, ModelForecasts, WeatherModel } from '@poly-weather/core';
 import { createChildLogger, OPEN_METEO_BASE } from '@poly-weather/core';
 import type { IWeatherProvider } from './provider-types.js';
+import { hkoProvider } from './hko-api.js';
 
 const logger = createChildLogger('open-meteo');
 
@@ -121,21 +122,25 @@ export const ecmwfProvider = new OpenMeteoProvider('ecmwf', 'ecmwf');
 export const iconProvider = new OpenMeteoProvider('icon', 'dwd-icon');
 
 /**
- * Fetch all three models in parallel for a single city.
+ * Fetch all models in parallel for a single city.
+ * Includes HKO as 4th model for Hong Kong.
  */
 export async function fetchAllModels(
   city: CityConfig,
-): Promise<{
-  gfs: HourlyForecast | null;
-  ecmwf: HourlyForecast | null;
-  icon: HourlyForecast | null;
-  fetchedAt: Date;
-}> {
-  const results = await Promise.allSettled([
+): Promise<ModelForecasts> {
+  const fetches: Promise<HourlyForecast>[] = [
     gfsProvider.fetch(city),
     ecmwfProvider.fetch(city),
     iconProvider.fetch(city),
-  ]);
+  ];
+
+  // Add HKO for Hong Kong
+  const isHongKong = city.slug === 'hong_kong';
+  if (isHongKong) {
+    fetches.push(hkoProvider.fetch(city));
+  }
+
+  const results = await Promise.allSettled(fetches);
 
   const extract = (r: PromiseSettledResult<HourlyForecast>): HourlyForecast | null => {
     if (r.status === 'fulfilled') return r.value;
@@ -147,6 +152,7 @@ export async function fetchAllModels(
     gfs: extract(results[0]),
     ecmwf: extract(results[1]),
     icon: extract(results[2]),
+    hko: isHongKong ? extract(results[3]) : null,
     fetchedAt: new Date(),
   };
 }
@@ -156,7 +162,7 @@ export async function fetchAllModels(
  */
 export async function fetchAllModelsAllCities(
   cities: CityConfig[],
-): Promise<Map<string, { gfs: HourlyForecast | null; ecmwf: HourlyForecast | null; icon: HourlyForecast | null; fetchedAt: Date }>> {
+): Promise<Map<string, ModelForecasts>> {
   const results = await Promise.allSettled(
     cities.map(async (city) => ({
       slug: city.slug,
@@ -164,7 +170,7 @@ export async function fetchAllModelsAllCities(
     })),
   );
 
-  const map = new Map<string, { gfs: HourlyForecast | null; ecmwf: HourlyForecast | null; icon: HourlyForecast | null; fetchedAt: Date }>();
+  const map = new Map<string, ModelForecasts>();
   for (const r of results) {
     if (r.status === 'fulfilled') {
       map.set(r.value.slug, r.value.data);

+ 134 - 0
pnpm-lock.yaml

@@ -66,6 +66,15 @@ importers:
       '@radix-ui/react-dropdown-menu':
         specifier: ^2.1.16
         version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-select':
+        specifier: ^2.2.6
+        version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-slot':
+        specifier: ^1.2.4
+        version: 1.2.4(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-switch':
+        specifier: ^1.2.6
+        version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
       '@radix-ui/react-toggle':
         specifier: ^1.1.10
         version: 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -105,6 +114,9 @@ importers:
       tailwind-merge:
         specifier: ^3.5.0
         version: 3.5.0
+      tailwindcss-animate:
+        specifier: ^1.0.7
+        version: 1.0.7(tailwindcss@3.4.19)
     devDependencies:
       '@types/node':
         specifier: ^22.0.0
@@ -648,6 +660,9 @@ packages:
   '@prisma/get-platform@5.22.0':
     resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==}
 
+  '@radix-ui/number@1.1.1':
+    resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
+
   '@radix-ui/primitive@1.1.3':
     resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
 
@@ -865,6 +880,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-select@2.2.6':
+    resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-slot@1.2.3':
     resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
     peerDependencies:
@@ -874,6 +902,28 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-slot@1.2.4':
+    resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  '@radix-ui/react-switch@1.2.6':
+    resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-toggle@1.1.10':
     resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==}
     peerDependencies:
@@ -959,6 +1009,19 @@ packages:
       '@types/react':
         optional: true
 
+  '@radix-ui/react-visually-hidden@1.2.3':
+    resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/rect@1.1.1':
     resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
 
@@ -2058,6 +2121,11 @@ packages:
   tailwind-merge@3.5.0:
     resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
 
+  tailwindcss-animate@1.0.7:
+    resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
+    peerDependencies:
+      tailwindcss: '>=3.0.0 || insiders'
+
   tailwindcss@3.4.19:
     resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
     engines: {node: '>=14.0.0'}
@@ -2814,6 +2882,8 @@ snapshots:
     dependencies:
       '@prisma/debug': 5.22.0
 
+  '@radix-ui/number@1.1.1': {}
+
   '@radix-ui/primitive@1.1.3': {}
 
   '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
@@ -3035,6 +3105,35 @@ snapshots:
       '@types/react': 18.3.28
       '@types/react-dom': 18.3.7(@types/react@18.3.28)
 
+  '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+    dependencies:
+      '@radix-ui/number': 1.1.1
+      '@radix-ui/primitive': 1.1.3
+      '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      aria-hidden: 1.2.6
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+      react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1)
+    optionalDependencies:
+      '@types/react': 18.3.28
+      '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
   '@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)':
     dependencies:
       '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
@@ -3042,6 +3141,28 @@ snapshots:
     optionalDependencies:
       '@types/react': 18.3.28
 
+  '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@18.3.1)':
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+      react: 18.3.1
+    optionalDependencies:
+      '@types/react': 18.3.28
+
+  '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.3
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+      '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+    optionalDependencies:
+      '@types/react': 18.3.28
+      '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
   '@radix-ui/react-toggle@1.1.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
     dependencies:
       '@radix-ui/primitive': 1.1.3
@@ -3107,6 +3228,15 @@ snapshots:
     optionalDependencies:
       '@types/react': 18.3.28
 
+  '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+    optionalDependencies:
+      '@types/react': 18.3.28
+      '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
   '@radix-ui/rect@1.1.1': {}
 
   '@rollup/rollup-android-arm-eabi@4.59.0':
@@ -4204,6 +4334,10 @@ snapshots:
 
   tailwind-merge@3.5.0: {}
 
+  tailwindcss-animate@1.0.7(tailwindcss@3.4.19):
+    dependencies:
+      tailwindcss: 3.4.19
+
   tailwindcss@3.4.19:
     dependencies:
       '@alloc/quick-lru': 5.2.0

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác