lushdog@outlook.com 8 mēneši atpakaļ
revīzija
51b520f0a1
100 mainītis faili ar 8288 papildinājumiem un 0 dzēšanām
  1. 12 0
      .claude/launch.json
  2. 46 0
      .env.example
  3. 36 0
      .gitignore
  4. 132 0
      CLAUDE.md
  5. 33 0
      docker-compose.yml
  6. 29 0
      package.json
  7. 33 0
      packages/core/package.json
  8. 96 0
      packages/core/prisma/schema.prisma
  9. 146 0
      packages/core/src/__tests__/probability.test.ts
  10. 98 0
      packages/core/src/__tests__/temperature.test.ts
  11. 81 0
      packages/core/src/config.ts
  12. 156 0
      packages/core/src/constants.ts
  13. 16 0
      packages/core/src/db.ts
  14. 6 0
      packages/core/src/index.ts
  15. 77 0
      packages/core/src/types/config.ts
  16. 4 0
      packages/core/src/types/index.ts
  17. 62 0
      packages/core/src/types/market.ts
  18. 88 0
      packages/core/src/types/strategy.ts
  19. 58 0
      packages/core/src/types/weather.ts
  20. 3 0
      packages/core/src/utils/index.ts
  21. 28 0
      packages/core/src/utils/logger.ts
  22. 101 0
      packages/core/src/utils/probability.ts
  23. 142 0
      packages/core/src/utils/temperature.ts
  24. 8 0
      packages/core/tsconfig.json
  25. 0 0
      packages/core/tsconfig.tsbuildinfo
  26. 8 0
      packages/core/vitest.config.ts
  27. 5 0
      packages/dashboard/next-env.d.ts
  28. 15 0
      packages/dashboard/next.config.mjs
  29. 42 0
      packages/dashboard/package.json
  30. 6 0
      packages/dashboard/postcss.config.js
  31. BIN
      packages/dashboard/public/apple-touch-icon.png
  32. BIN
      packages/dashboard/public/favicon-16.png
  33. BIN
      packages/dashboard/public/favicon-32.png
  34. BIN
      packages/dashboard/public/favicon.ico
  35. BIN
      packages/dashboard/public/icon-192.png
  36. BIN
      packages/dashboard/public/icon-512.png
  37. BIN
      packages/dashboard/public/logo.png
  38. 8 0
      packages/dashboard/src/app/RecentSignals.tsx
  39. 71 0
      packages/dashboard/src/app/api/bot-control/route.ts
  40. 20 0
      packages/dashboard/src/app/api/dashboard/route.ts
  41. 17 0
      packages/dashboard/src/app/api/live-markets/route.ts
  42. 17 0
      packages/dashboard/src/app/api/markets/route.ts
  43. 28 0
      packages/dashboard/src/app/api/orderbook/route.ts
  44. 206 0
      packages/dashboard/src/app/api/orders/route.ts
  45. 17 0
      packages/dashboard/src/app/api/pnl/route.ts
  46. 211 0
      packages/dashboard/src/app/api/prices/route.ts
  47. 19 0
      packages/dashboard/src/app/api/signals/route.ts
  48. BIN
      packages/dashboard/src/app/apple-icon.png
  49. BIN
      packages/dashboard/src/app/favicon.ico
  50. 102 0
      packages/dashboard/src/app/globals.css
  51. BIN
      packages/dashboard/src/app/icon.png
  52. 36 0
      packages/dashboard/src/app/layout.tsx
  53. 230 0
      packages/dashboard/src/app/markets/LiveMarketsClient.tsx
  54. 148 0
      packages/dashboard/src/app/markets/MarketsClient.tsx
  55. 39 0
      packages/dashboard/src/app/markets/loading.tsx
  56. 56 0
      packages/dashboard/src/app/markets/page.tsx
  57. 228 0
      packages/dashboard/src/app/page.tsx
  58. 24 0
      packages/dashboard/src/app/pnl/PnLClient.tsx
  59. 83 0
      packages/dashboard/src/app/pnl/page.tsx
  60. 284 0
      packages/dashboard/src/app/signals/SignalsClient.tsx
  61. 28 0
      packages/dashboard/src/app/signals/page.tsx
  62. 66 0
      packages/dashboard/src/components/MarketCard.tsx
  63. 718 0
      packages/dashboard/src/components/OrderPanel.tsx
  64. 128 0
      packages/dashboard/src/components/Orderbook.tsx
  65. 107 0
      packages/dashboard/src/components/PnLChart.tsx
  66. 83 0
      packages/dashboard/src/components/ProbabilityChart.tsx
  67. 192 0
      packages/dashboard/src/components/Sidebar.tsx
  68. 453 0
      packages/dashboard/src/components/SignalTable.tsx
  69. 11 0
      packages/dashboard/src/components/ThemeProvider.tsx
  70. 32 0
      packages/dashboard/src/components/ThemeToggle.tsx
  71. 18 0
      packages/dashboard/src/lib/db.ts
  72. 130 0
      packages/dashboard/src/lib/gamma-tokens.ts
  73. 191 0
      packages/dashboard/src/lib/mock-data.ts
  74. 308 0
      packages/dashboard/src/lib/polymarket-api.ts
  75. 354 0
      packages/dashboard/src/lib/queries.ts
  76. 116 0
      packages/dashboard/src/lib/types.ts
  77. 27 0
      packages/dashboard/tailwind.config.ts
  78. 23 0
      packages/dashboard/tsconfig.json
  79. 0 0
      packages/dashboard/tsconfig.tsbuildinfo
  80. 33 0
      packages/market-engine/package.json
  81. 110 0
      packages/market-engine/src/__tests__/market-scanner.test.ts
  82. 271 0
      packages/market-engine/src/clob-client.ts
  83. 171 0
      packages/market-engine/src/gamma-client.ts
  84. 6 0
      packages/market-engine/src/index.ts
  85. 165 0
      packages/market-engine/src/market-scanner.ts
  86. 144 0
      packages/market-engine/src/order-manager.ts
  87. 149 0
      packages/market-engine/src/websocket-feed.ts
  88. 11 0
      packages/market-engine/tsconfig.json
  89. 0 0
      packages/market-engine/tsconfig.tsbuildinfo
  90. 8 0
      packages/market-engine/vitest.config.ts
  91. 29 0
      packages/strategy/package.json
  92. 273 0
      packages/strategy/src/__tests__/strategies.test.ts
  93. 7 0
      packages/strategy/src/index.ts
  94. 76 0
      packages/strategy/src/latency-arb.ts
  95. 53 0
      packages/strategy/src/parity-arb.ts
  96. 112 0
      packages/strategy/src/portfolio-tracker.ts
  97. 155 0
      packages/strategy/src/risk-manager.ts
  98. 22 0
      packages/strategy/src/signal-generator.ts
  99. 84 0
      packages/strategy/src/tail-no-strategy.ts
  100. 13 0
      packages/strategy/tsconfig.json

+ 12 - 0
.claude/launch.json

@@ -0,0 +1,12 @@
+{
+  "version": "0.0.1",
+  "configurations": [
+    {
+      "name": "dashboard",
+      "runtimeExecutable": "pnpm",
+      "runtimeArgs": ["--filter", "@poly-weather/dashboard", "dev"],
+      "port": 3000,
+      "autoPort": false
+    }
+  ]
+}

+ 46 - 0
.env.example

@@ -0,0 +1,46 @@
+# === Database ===
+DATABASE_URL=postgresql://bot:botpass@localhost:5432/weather_bot
+REDIS_URL=redis://localhost:6379
+
+# === Polymarket Wallet ===
+# Signer private key (exported from Polymarket wallet via Settings > Export)
+PRIVATE_KEY=0x...
+# Proxy wallet address (your Polymarket deposit address — the contract wallet)
+FUNDER_ADDRESS=0x...
+# Signature type: 0=EOA (signer==funder), 1=POLY_PROXY (signer!=funder, default for Polymarket)
+SIGNATURE_TYPE=1
+
+# === L2 API Keys (derived from wallet) ===
+# Run: pnpm derive-keys  to generate these from your PRIVATE_KEY
+L2_API_KEY=
+L2_API_SECRET=
+L2_API_PASSPHRASE=
+
+# === Builder API Keys (from polymarket.com Settings > Builder) ===
+POLYMARKET_API_KEY=
+POLYMARKET_API_SECRET=
+POLYMARKET_API_PASSPHRASE=
+
+# === Polygon RPC ===
+POLYGON_RPC_URL=https://polygon-rpc.com
+
+# === Risk Limits ===
+MAX_POSITION_PER_MARKET=50
+MAX_POSITION_PER_CITY=200
+MAX_TOTAL_EXPOSURE=1000
+MAX_DAILY_LOSS=100
+KELLY_FRACTION=0.25
+
+# === Strategy Thresholds ===
+TAIL_NO_MIN_EDGE=0.03
+TAIL_NO_MAX_MODEL_PROB=0.05
+TAIL_NO_MAX_YES_PRICE=0.15
+LATENCY_MIN_PRICE_DELTA=0.05
+PARITY_MIN_DEVIATION=0.02
+
+# === Mode ===
+DRY_RUN=true
+AUTO_EXECUTE=false    # When false, signals are detected but orders are NOT auto-placed
+LOG_LEVEL=info
+DASHBOARD_PORT=3000
+BOT_HEALTH_PORT=3001

+ 36 - 0
.gitignore

@@ -0,0 +1,36 @@
+# Dependencies
+node_modules/
+
+# Build outputs
+dist/
+.turbo/
+.next/
+out/
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Database
+prisma/migrations/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+logs/
+
+# Test
+coverage/
+
+# Generated
+packages/core/src/generated/

+ 132 - 0
CLAUDE.md

@@ -0,0 +1,132 @@
+# Poly Weather — Polymarket Weather Arbitrage Bot
+
+## Project Overview
+
+Automated weather prediction market trading bot that exploits pricing inefficiencies in Polymarket's daily temperature markets. Compares professional weather forecast model probabilities (GFS/ECMWF/ICON ensembles) against market-implied probabilities and trades the delta.
+
+### Three Strategies
+
+- **TAIL_NO** — Buy NO on overpriced tail buckets (e.g. "56°F or higher") where model prob < 5% but market prices YES > 3¢
+- **LATENCY_ARB** — Trade when new weather model data shifts probabilities >5% but market hasn't repriced yet
+- **PARITY_ARB** — Exploit when sum of all YES prices in an event deviates from 100¢ (pure arbitrage)
+
+## Architecture
+
+TypeScript monorepo using Turborepo + pnpm workspaces.
+
+```
+packages/
+  core/             — Shared types, utils, Prisma schema, constants
+  weather-engine/   — GFS/ECMWF/ICON forecast fetching via Open-Meteo, ensemble probability calculation
+  market-engine/    — Polymarket Gamma API scanning, CLOB orderbook reads, authenticated order placement
+  strategy/         — Signal generation (Tail NO, Latency Arb, Parity Arb), risk management, Kelly sizing
+  trading-engine/   — Main bot loop, pipeline orchestration, health-check server
+  dashboard/        — Next.js App Router dashboard with live market data, signals, manual order placement
+```
+
+### Dependency Graph
+
+```
+core (leaf — no internal deps)
+  ↑
+weather-engine, market-engine (depend on core)
+  ↑
+strategy (depends on core + weather-engine + market-engine)
+  ↑
+trading-engine (depends on all above)
+dashboard (depends on core, reads DB directly)
+```
+
+## Tech Stack
+
+- **Runtime**: Node.js ≥ 20, TypeScript (ES2022, NodeNext modules, strict)
+- **Monorepo**: pnpm workspaces + Turborepo
+- **Database**: PostgreSQL 16 (Prisma ORM) + Redis 7
+- **Dashboard**: Next.js 14 (App Router), Tailwind CSS, next-themes (light/dark), Radix UI primitives
+- **Market APIs**: Polymarket Gamma API (market discovery), CLOB API (orderbook + trading)
+- **Weather APIs**: Open-Meteo (GFS, ECMWF, ICON models)
+- **Trading**: @polymarket/clob-client, ethers (wallet signing)
+
+## Key Commands
+
+```bash
+pnpm install                  # Install all deps
+pnpm build                    # Build all packages (turbo)
+pnpm dev                      # Dev mode all packages
+pnpm docker:up                # Start PostgreSQL + Redis
+pnpm db:push                  # Push Prisma schema to DB
+pnpm db:studio                # Open Prisma Studio
+pnpm bot:start                # Start trading bot
+pnpm dashboard:dev            # Start dashboard on :3000
+pnpm derive-keys              # Derive L2 API keys from PRIVATE_KEY
+pnpm test                     # Run all tests
+```
+
+## Database Schema (Prisma)
+
+Located at `packages/core/prisma/schema.prisma`.
+
+- **WeatherForecast** — Stored model forecasts (city, date, model, predictedHigh, stdDev)
+- **MarketSnapshot** — Point-in-time market prices (yesPrice, noPrice, volume, tokenIds)
+- **Signal** — Detected trading signals (type, city, bucket, edge, modelProb, marketPrice, executed)
+- **Trade** — Order records (side, tokenId, price, size, costBasis, status, dryRun, pnl)
+- **DailyMetrics** — Aggregated daily performance (trades, winRate, netPnl, maxDrawdown)
+
+## Dashboard Structure
+
+```
+src/app/
+  layout.tsx          — Root layout with Sidebar + ThemeProvider
+  page.tsx            — Dashboard home (summary cards, recent signals)
+  signals/            — Signal history with strategy filters + manual order placement
+  markets/            — Live market data from Polymarket Gamma API + orderbook viewer
+  pnl/                — PnL charts and performance metrics
+  api/
+    dashboard/        — GET summary stats
+    signals/          — GET signal history
+    markets/          — GET DB market snapshots
+    live-markets/     — GET live Polymarket data via Gamma API
+    orderbook/        — GET merged YES/NO orderbook from CLOB
+    prices/           — POST fetch latest prices for selected signals
+    orders/           — POST place orders (dry run or live)
+```
+
+### Dashboard Features
+
+- **Light/Dark theme** via next-themes with `class` attribute strategy
+- **Neg-risk orderbook merging**: YES bids = direct YES bids ∪ (1 − NO asks)
+- **Manual order placement**: Multi-select signals → fetch live prices → choose limit/market → confirm → execute (works in DRY_RUN)
+- **Polymarket deep links**: Direct links to specific market events using city slug mapping
+
+## Important Conventions
+
+- **DRY_RUN=true by default** — All signal detection and DB recording works, but no real orders are placed
+- **Temperature units** — NYC/Chicago/Miami/Houston/Denver/LA/Phoenix/Seattle = °F, others = °C
+- **Maker orders only** — Limit orders (GTC) on Polymarket to pay zero fees
+- **All timestamps UTC** — Local timezone only in dashboard display
+- **Error isolation** — One city/market failing must not block others
+- **City slug overrides** — `new_york` → `nyc` in Polymarket URLs (see `citySlugOverrides` in queries.ts)
+
+## CSS Theme System
+
+Uses Tailwind `darkMode: 'class'` with custom component classes in `globals.css`:
+
+- `.card`, `.badge-*`, `.table-header`, `.table-cell`, `.btn-*` — All have light + `.dark` variants
+- `.text-heading`, `.text-body`, `.text-sub`, `.text-dim` — Theme-aware text colors
+- Always use `dark:` prefix for dark-mode-specific colors inline
+
+## Environment Variables
+
+See `.env.example` for all required variables. Key ones:
+
+- `DATABASE_URL` — PostgreSQL connection
+- `PRIVATE_KEY` + `FUNDER_ADDRESS` — Polymarket wallet (for live trading)
+- `DRY_RUN` — `true` (default) to simulate, `false` for real orders
+- `SIGNATURE_TYPE` — `1` for POLY_PROXY (standard Polymarket exported wallets)
+
+## Polymarket Integration Notes
+
+- Gamma API (`gamma-api.polymarket.com`) — Market discovery, no auth needed
+- CLOB API (`clob.polymarket.com`) — Orderbook reads (no auth), order placement (requires wallet auth)
+- Neg-risk markets have separate YES and NO token IDs; orderbooks must be merged for accurate pricing
+- Market URL pattern: `https://polymarket.com/event/highest-temperature-in-{city-slug}-on-{month}-{day}-{year}`

+ 33 - 0
docker-compose.yml

@@ -0,0 +1,33 @@
+version: '3.8'
+services:
+  postgres:
+    image: postgres:16-alpine
+    environment:
+      POSTGRES_DB: weather_bot
+      POSTGRES_USER: bot
+      POSTGRES_PASSWORD: botpass
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+    ports:
+      - '5432:5432'
+    healthcheck:
+      test: ['CMD-SHELL', 'pg_isready -U bot -d weather_bot']
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+  redis:
+    image: redis:7-alpine
+    ports:
+      - '6379:6379'
+    volumes:
+      - redisdata:/data
+    healthcheck:
+      test: ['CMD', 'redis-cli', 'ping']
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+volumes:
+  pgdata:
+  redisdata:

+ 29 - 0
package.json

@@ -0,0 +1,29 @@
+{
+  "name": "polymarket-weather-bot",
+  "version": "1.0.0",
+  "private": true,
+  "packageManager": "pnpm@9.15.4",
+  "engines": {
+    "node": ">=20"
+  },
+  "scripts": {
+    "build": "turbo build",
+    "dev": "turbo dev",
+    "test": "turbo test",
+    "lint": "turbo lint",
+    "db:generate": "pnpm --filter @poly-weather/core db:generate",
+    "db:push": "pnpm --filter @poly-weather/core db:push",
+    "db:studio": "pnpm --filter @poly-weather/core db:studio",
+    "docker:up": "docker compose up -d",
+    "docker:down": "docker compose down",
+    "bot:start": "pnpm --filter @poly-weather/trading-engine start",
+    "dashboard:dev": "pnpm --filter @poly-weather/dashboard dev",
+    "derive-keys": "npx tsx scripts/derive-keys.ts"
+  },
+  "devDependencies": {
+    "turbo": "^2.3.0",
+    "typescript": "^5.7.0",
+    "@types/node": "^22.0.0",
+    "vitest": "^2.1.0"
+  }
+}

+ 33 - 0
packages/core/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "@poly-weather/core",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/index.js",
+      "types": "./dist/index.d.ts"
+    }
+  },
+  "scripts": {
+    "build": "tsc -b",
+    "dev": "tsc -b --watch",
+    "test": "vitest run",
+    "db:generate": "prisma generate",
+    "db:push": "dotenv -e ../../.env -- prisma db push",
+    "db:studio": "dotenv -e ../../.env -- prisma studio"
+  },
+  "dependencies": {
+    "@prisma/client": "^5.22.0",
+    "pino": "^9.5.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0",
+    "dotenv-cli": "^11.0.0",
+    "prisma": "^5.22.0",
+    "typescript": "^5.7.0",
+    "vitest": "^2.1.0"
+  }
+}

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

@@ -0,0 +1,96 @@
+generator client {
+  provider = "prisma-client-js"
+}
+
+datasource db {
+  provider = "postgresql"
+  url      = env("DATABASE_URL")
+}
+
+model WeatherForecast {
+  id             String   @id @default(cuid())
+  city           String
+  targetDate     DateTime
+  model          String   // 'gfs', 'ecmwf', 'icon'
+  modelRunTime   DateTime
+  predictedHigh  Float
+  stdDev         Float
+  rawData        Json
+  createdAt      DateTime @default(now())
+
+  @@index([city, targetDate, model])
+}
+
+model MarketSnapshot {
+  id              String   @id @default(cuid())
+  polymarketId    Int
+  marketGroupId   Int
+  city            String
+  targetDate      DateTime
+  bucketLabel     String
+  yesPrice        Float
+  noPrice         Float
+  volume24h       Float
+  liquidity       Float
+  yesTokenId      String
+  noTokenId       String
+  createdAt       DateTime @default(now())
+
+  @@index([city, targetDate, createdAt])
+}
+
+model Signal {
+  id                  String    @id @default(cuid())
+  type                String    // 'TAIL_NO', 'LATENCY_ARB', 'PARITY_ARB'
+  city                String
+  targetDate          DateTime
+  bucketLabel         String
+  marketYesPrice      Float
+  modelProbability    Float
+  edge                Float
+  expectedReturn      Float
+  agreementScore      Float
+  confidence          String
+  recommendedSize     Float
+  executed            Boolean   @default(false)
+  executedAt          DateTime?
+  tradeId             String?
+  trade               Trade?    @relation(fields: [tradeId], references: [id])
+  createdAt           DateTime  @default(now())
+
+  @@index([type, city, targetDate])
+}
+
+model Trade {
+  id              String    @id @default(cuid())
+  signalId        String?
+  signals         Signal[]
+  side            String    // 'BUY_YES', 'BUY_NO'
+  tokenId         String
+  price           Float
+  size            Float
+  costBasis       Float     // price * size
+  orderId         String?   // Polymarket order ID
+  status          String    // 'PENDING', 'FILLED', 'CANCELLED'
+  dryRun          Boolean   @default(true)
+  filledPrice     Float?
+  pnl             Float?
+  resolvedAt      DateTime?
+  createdAt       DateTime  @default(now())
+
+  @@index([status, createdAt])
+}
+
+model DailyMetrics {
+  id              String   @id @default(cuid())
+  date            DateTime @unique
+  totalTrades     Int
+  winningTrades   Int
+  losingTrades    Int
+  grossPnl        Float
+  netPnl          Float
+  winRate         Float
+  avgEdge         Float
+  maxDrawdown     Float
+  createdAt       DateTime @default(now())
+}

+ 146 - 0
packages/core/src/__tests__/probability.test.ts

@@ -0,0 +1,146 @@
+import { describe, it, expect } from 'vitest';
+import {
+  normalCDF,
+  normalPDF,
+  bucketProbability,
+  temperatureToBuckets,
+  kellyFraction,
+} from '../utils/probability.js';
+import type { TempBucket } from '../types/weather.js';
+
+describe('normalCDF', () => {
+  it('should return 0.5 for z=0 (mean)', () => {
+    expect(normalCDF(0, 0, 1)).toBeCloseTo(0.5, 5);
+  });
+
+  it('should return ~0.8413 for z=1', () => {
+    expect(normalCDF(1, 0, 1)).toBeCloseTo(0.8413, 3);
+  });
+
+  it('should return ~0.9772 for z=2', () => {
+    expect(normalCDF(2, 0, 1)).toBeCloseTo(0.9772, 3);
+  });
+
+  it('should return ~0.975 for z=1.96', () => {
+    expect(normalCDF(1.96, 0, 1)).toBeCloseTo(0.975, 2);
+  });
+
+  it('should return ~0.0228 for z=-2', () => {
+    expect(normalCDF(-2, 0, 1)).toBeCloseTo(0.0228, 3);
+  });
+
+  it('should handle non-standard mean and stddev', () => {
+    // N(50, 3²): P(X ≤ 50) = 0.5
+    expect(normalCDF(50, 50, 3)).toBeCloseTo(0.5, 5);
+    // P(X ≤ 53) = P(Z ≤ 1) ≈ 0.8413
+    expect(normalCDF(53, 50, 3)).toBeCloseTo(0.8413, 3);
+  });
+
+  it('should return 0 for extreme negative z', () => {
+    expect(normalCDF(-10, 0, 1)).toBe(0);
+  });
+
+  it('should return 1 for extreme positive z', () => {
+    expect(normalCDF(10, 0, 1)).toBe(1);
+  });
+});
+
+describe('normalPDF', () => {
+  it('should return ~0.3989 for z=0 (standard normal)', () => {
+    expect(normalPDF(0, 0, 1)).toBeCloseTo(0.3989, 3);
+  });
+
+  it('should be symmetric', () => {
+    expect(normalPDF(1, 0, 1)).toBeCloseTo(normalPDF(-1, 0, 1), 5);
+  });
+});
+
+describe('bucketProbability', () => {
+  it('should sum to approximately 1.0 across all buckets', () => {
+    // Create buckets from -inf to +inf
+    const total =
+      bucketProbability(-Infinity, 40, 50, 3) +
+      bucketProbability(40, 45, 50, 3) +
+      bucketProbability(45, 50, 50, 3) +
+      bucketProbability(50, 55, 50, 3) +
+      bucketProbability(55, 60, 50, 3) +
+      bucketProbability(60, Infinity, 50, 3);
+
+    expect(total).toBeCloseTo(1.0, 5);
+  });
+
+  it('should handle tail buckets correctly', () => {
+    // Left tail: P(X < 40) for N(50, 3²) should be very small
+    const leftTail = bucketProbability(-Infinity, 40, 50, 3);
+    expect(leftTail).toBeLessThan(0.001);
+
+    // Right tail: P(X > 60) for N(50, 3²) should be very small
+    const rightTail = bucketProbability(60, Infinity, 50, 3);
+    expect(rightTail).toBeLessThan(0.001);
+  });
+
+  it('should have highest probability around the mean', () => {
+    const nearMean = bucketProbability(48, 52, 50, 3);
+    const awayFromMean = bucketProbability(40, 44, 50, 3);
+    expect(nearMean).toBeGreaterThan(awayFromMean);
+  });
+});
+
+describe('temperatureToBuckets', () => {
+  const buckets: TempBucket[] = [
+    { label: '31°F or below', lowerBound: -Infinity, upperBound: 32 },
+    { label: '32-35°F', lowerBound: 32, upperBound: 36 },
+    { label: '36-39°F', lowerBound: 36, upperBound: 40 },
+    { label: '40-43°F', lowerBound: 40, upperBound: 44 },
+    { label: '44-47°F', lowerBound: 44, upperBound: 48 },
+    { label: '48-51°F', lowerBound: 48, upperBound: 52 },
+    { label: '52-55°F', lowerBound: 52, upperBound: 56 },
+    { label: '56°F or higher', lowerBound: 56, upperBound: Infinity },
+  ];
+
+  it('should return correct number of bucket probabilities', () => {
+    const result = temperatureToBuckets(46, 4, buckets);
+    expect(result.length).toBe(buckets.length);
+  });
+
+  it('should sum to approximately 1.0', () => {
+    const result = temperatureToBuckets(46, 4, buckets);
+    const total = result.reduce((sum, b) => sum + b.probability, 0);
+    expect(total).toBeCloseTo(1.0, 5);
+  });
+
+  it('should have highest probability near the forecast', () => {
+    const result = temperatureToBuckets(46, 3, buckets);
+    // '44-47°F' bucket should have the highest probability
+    const bucket4447 = result.find((b) => b.bucketLabel === '44-47°F');
+    expect(bucket4447).toBeDefined();
+
+    for (const b of result) {
+      if (b.bucketLabel !== '44-47°F') {
+        expect(bucket4447!.probability).toBeGreaterThanOrEqual(b.probability);
+      }
+    }
+  });
+});
+
+describe('kellyFraction', () => {
+  it('should return 0 for no edge', () => {
+    expect(kellyFraction(0.5, 0.5)).toBeCloseTo(0, 5);
+  });
+
+  it('should return positive for positive edge', () => {
+    // 60% win prob at 50c cost → positive edge
+    expect(kellyFraction(0.6, 0.5)).toBeGreaterThan(0);
+  });
+
+  it('should return 0 for negative edge', () => {
+    // 30% win prob at 50c cost → negative edge
+    expect(kellyFraction(0.3, 0.5)).toBe(0);
+  });
+
+  it('should return 0 for invalid inputs', () => {
+    expect(kellyFraction(0, 0.5)).toBe(0);
+    expect(kellyFraction(0.5, 0)).toBe(0);
+    expect(kellyFraction(0.5, 1)).toBe(0);
+  });
+});

+ 98 - 0
packages/core/src/__tests__/temperature.test.ts

@@ -0,0 +1,98 @@
+import { describe, it, expect } from 'vitest';
+import {
+  celsiusToFahrenheit,
+  fahrenheitToCelsius,
+  parseBucketLabel,
+  extractDailyHigh,
+  parseMarketDate,
+} from '../utils/temperature.js';
+
+describe('temperature conversions', () => {
+  it('should convert 0°C to 32°F', () => {
+    expect(celsiusToFahrenheit(0)).toBe(32);
+  });
+
+  it('should convert 100°C to 212°F', () => {
+    expect(celsiusToFahrenheit(100)).toBe(212);
+  });
+
+  it('should convert 32°F to 0°C', () => {
+    expect(fahrenheitToCelsius(32)).toBe(0);
+  });
+
+  it('should round-trip conversions', () => {
+    const original = 25;
+    expect(fahrenheitToCelsius(celsiusToFahrenheit(original))).toBeCloseTo(original, 5);
+  });
+});
+
+describe('parseBucketLabel', () => {
+  it('should parse range buckets', () => {
+    const result = parseBucketLabel('46-47°F');
+    expect(result.lowerBound).toBe(46);
+    expect(result.upperBound).toBe(48);
+    expect(result.label).toBe('46-47°F');
+  });
+
+  it('should parse "or below" buckets', () => {
+    const result = parseBucketLabel('31°F or below');
+    expect(result.lowerBound).toBe(-Infinity);
+    expect(result.upperBound).toBe(32);
+  });
+
+  it('should parse "or higher" buckets', () => {
+    const result = parseBucketLabel('56°F or higher');
+    expect(result.lowerBound).toBe(56);
+    expect(result.upperBound).toBe(Infinity);
+  });
+
+  it('should parse "or less" variant', () => {
+    const result = parseBucketLabel('10°C or less');
+    expect(result.lowerBound).toBe(-Infinity);
+    expect(result.upperBound).toBe(11);
+  });
+
+  it('should parse "+" variant', () => {
+    const result = parseBucketLabel('30°C+');
+    expect(result.lowerBound).toBe(30);
+    expect(result.upperBound).toBe(Infinity);
+  });
+
+  it('should throw for unparseable labels', () => {
+    expect(() => parseBucketLabel('unknown format')).toThrow();
+  });
+});
+
+describe('extractDailyHigh', () => {
+  it('should extract max daytime temperature for a given date', () => {
+    const times = [
+      '2026-03-17T06:00', '2026-03-17T09:00', '2026-03-17T12:00',
+      '2026-03-17T15:00', '2026-03-17T18:00', '2026-03-17T21:00',
+    ];
+    const temps = [40, 45, 52, 55, 50, 42];
+
+    expect(extractDailyHigh(times, temps, '2026-03-17')).toBe(55);
+  });
+
+  it('should throw for missing date', () => {
+    expect(() => extractDailyHigh(['2026-03-17T12:00'], [50], '2026-03-18')).toThrow();
+  });
+});
+
+describe('parseMarketDate', () => {
+  it('should parse "March 17"', () => {
+    const date = parseMarketDate('March 17');
+    expect(date.getMonth()).toBe(2); // March = 2
+    expect(date.getDate()).toBe(17);
+  });
+
+  it('should parse "January 1"', () => {
+    const date = parseMarketDate('January 1');
+    expect(date.getMonth()).toBe(0);
+    expect(date.getDate()).toBe(1);
+  });
+
+  it('should throw for invalid format', () => {
+    expect(() => parseMarketDate('invalid')).toThrow();
+  });
+});

+ 81 - 0
packages/core/src/config.ts

@@ -0,0 +1,81 @@
+/**
+ * Configuration loader from environment variables.
+ */
+
+import type { BotConfig } from './types/config.js';
+import {
+  TARGET_CITIES,
+  DEFAULT_ENSEMBLE_WEIGHTS,
+  DEFAULT_RISK_CONFIG,
+  DEFAULT_TAIL_NO_THRESHOLDS,
+  POLYGON_CHAIN_ID,
+} from './constants.js';
+
+function envOrDefault(key: string, defaultValue: string): string {
+  return process.env[key] || defaultValue;
+}
+
+function envFloat(key: string, defaultValue: number): number {
+  const val = process.env[key];
+  return val ? parseFloat(val) : defaultValue;
+}
+
+function envInt(key: string, defaultValue: number): number {
+  const val = process.env[key];
+  return val ? parseInt(val, 10) : defaultValue;
+}
+
+/**
+ * Load bot configuration from environment variables.
+ * Defaults to safe values (DRY_RUN=true, conservative limits).
+ */
+export function loadConfig(): BotConfig {
+  return {
+    dryRun: envOrDefault('DRY_RUN', 'true') === 'true',
+    autoExecute: envOrDefault('AUTO_EXECUTE', 'true') === 'true',
+    logLevel: envOrDefault('LOG_LEVEL', 'info'),
+
+    databaseUrl: envOrDefault('DATABASE_URL', 'postgresql://bot:botpass@localhost:5432/weather_bot'),
+    redisUrl: envOrDefault('REDIS_URL', 'redis://localhost:6379'),
+
+    polymarket: {
+      privateKey: envOrDefault('PRIVATE_KEY', ''),
+      funderAddress: envOrDefault('FUNDER_ADDRESS', ''),
+      signatureType: envInt('SIGNATURE_TYPE', 1),
+      chainId: POLYGON_CHAIN_ID,
+      rpcUrl: envOrDefault('POLYGON_RPC_URL', 'https://polygon-rpc.com'),
+      l2Creds: {
+        key: envOrDefault('L2_API_KEY', ''),
+        secret: envOrDefault('L2_API_SECRET', ''),
+        passphrase: envOrDefault('L2_API_PASSPHRASE', ''),
+      },
+      builderCreds: {
+        key: envOrDefault('POLYMARKET_API_KEY', ''),
+        secret: envOrDefault('POLYMARKET_API_SECRET', ''),
+        passphrase: envOrDefault('POLYMARKET_API_PASSPHRASE', ''),
+      },
+    },
+
+    thresholds: {
+      minEdge: envFloat('TAIL_NO_MIN_EDGE', DEFAULT_TAIL_NO_THRESHOLDS.minEdge),
+      maxModelProbability: envFloat('TAIL_NO_MAX_MODEL_PROB', DEFAULT_TAIL_NO_THRESHOLDS.maxModelProbability),
+      maxYesPrice: envFloat('TAIL_NO_MAX_YES_PRICE', DEFAULT_TAIL_NO_THRESHOLDS.maxYesPrice),
+      minAgreementScore: DEFAULT_TAIL_NO_THRESHOLDS.minAgreementScore,
+      minLeadTimeHours: DEFAULT_TAIL_NO_THRESHOLDS.minLeadTimeHours,
+    },
+
+    risk: {
+      maxPositionPerMarket: envFloat('MAX_POSITION_PER_MARKET', DEFAULT_RISK_CONFIG.maxPositionPerMarket),
+      maxPositionPerCity: envFloat('MAX_POSITION_PER_CITY', DEFAULT_RISK_CONFIG.maxPositionPerCity),
+      maxTotalExposure: envFloat('MAX_TOTAL_EXPOSURE', DEFAULT_RISK_CONFIG.maxTotalExposure),
+      maxDailyLoss: envFloat('MAX_DAILY_LOSS', DEFAULT_RISK_CONFIG.maxDailyLoss),
+      kellyFraction: envFloat('KELLY_FRACTION', DEFAULT_RISK_CONFIG.kellyFraction),
+      minLiquidity: DEFAULT_RISK_CONFIG.minLiquidity,
+    },
+
+    cities: TARGET_CITIES,
+    ensembleWeights: DEFAULT_ENSEMBLE_WEIGHTS,
+    dashboardPort: parseInt(envOrDefault('DASHBOARD_PORT', '3000'), 10),
+    healthPort: parseInt(envOrDefault('BOT_HEALTH_PORT', '3001'), 10),
+  };
+}

+ 156 - 0
packages/core/src/constants.ts

@@ -0,0 +1,156 @@
+/**
+ * Application constants: cities, API URLs, model config, default risk settings.
+ */
+
+import type { CityConfig, ModelStdErrTable, WeatherModel } from './types/weather.js';
+import type { RiskConfig, TailNOThresholds } from './types/index.js';
+
+// ─── Target Cities ───────────────────────────────────────────────────────────
+
+export const TARGET_CITIES: CityConfig[] = [
+  // P0: High volume markets
+  { name: 'New York City', slug: 'new_york', lat: 40.7128, lon: -74.0060, tempUnit: '°F', timezone: 'America/New_York', priority: 'P0' },
+  { name: 'London', slug: 'london', lat: 51.5074, lon: -0.1278, tempUnit: '°C', timezone: 'Europe/London', priority: 'P0' },
+  { name: 'Chicago', slug: 'chicago', lat: 41.8781, lon: -87.6298, tempUnit: '°F', timezone: 'America/Chicago', priority: 'P0' },
+  // P1: Medium volume
+  { name: 'Paris', slug: 'paris', lat: 48.8566, lon: 2.3522, tempUnit: '°C', timezone: 'Europe/Paris', priority: 'P1' },
+  { name: 'Seoul', slug: 'seoul', lat: 37.5665, lon: 126.9780, tempUnit: '°C', timezone: 'Asia/Seoul', priority: 'P1' },
+  { name: 'Tokyo', slug: 'tokyo', lat: 35.6762, lon: 139.6503, tempUnit: '°C', timezone: 'Asia/Tokyo', priority: 'P1' },
+  { name: 'Miami', slug: 'miami', lat: 25.7617, lon: -80.1918, tempUnit: '°F', timezone: 'America/New_York', priority: 'P1' },
+  // P2: Additional Polymarket cities
+  { name: 'Buenos Aires', slug: 'buenos_aires', lat: -34.6037, lon: -58.3816, tempUnit: '°C', timezone: 'America/Argentina/Buenos_Aires', priority: 'P2' },
+  { name: 'Hong Kong', slug: 'hong_kong', lat: 22.3193, lon: 114.1694, tempUnit: '°C', timezone: 'Asia/Hong_Kong', priority: 'P2' },
+  { name: 'Toronto', slug: 'toronto', lat: 43.6532, lon: -79.3832, tempUnit: '°C', timezone: 'America/Toronto', priority: 'P2' },
+  { name: 'Seattle', slug: 'seattle', lat: 47.6062, lon: -122.3321, tempUnit: '°F', timezone: 'America/Los_Angeles', priority: 'P2' },
+  { name: 'Dallas', slug: 'dallas', lat: 32.7767, lon: -96.7970, tempUnit: '°F', timezone: 'America/Chicago', priority: 'P2' },
+  { name: 'Atlanta', slug: 'atlanta', lat: 33.7490, lon: -84.3880, tempUnit: '°F', timezone: 'America/New_York', priority: 'P2' },
+  { name: 'Munich', slug: 'munich', lat: 48.1351, lon: 11.5820, tempUnit: '°C', timezone: 'Europe/Berlin', priority: 'P2' },
+  { name: 'Tel Aviv', slug: 'tel_aviv', lat: 32.0853, lon: 34.7818, tempUnit: '°C', timezone: 'Asia/Jerusalem', priority: 'P2' },
+  { name: 'Sao Paulo', slug: 'sao_paulo', lat: -23.5505, lon: -46.6333, tempUnit: '°C', timezone: 'America/Sao_Paulo', priority: 'P2' },
+  { name: 'Shanghai', slug: 'shanghai', lat: 31.2304, lon: 121.4737, tempUnit: '°C', timezone: 'Asia/Shanghai', priority: 'P2' },
+];
+
+// ─── City Name Aliases (for matching Polymarket questions) ───────────────────
+
+export const CITY_ALIASES: Record<string, string> = {
+  'NYC': 'new_york',
+  'New York': 'new_york',
+  'New York City': 'new_york',
+  'London': 'london',
+  'Paris': 'paris',
+  'Seoul': 'seoul',
+  'Tokyo': 'tokyo',
+  'Chicago': 'chicago',
+  'Miami': 'miami',
+  'Buenos Aires': 'buenos_aires',
+  'Hong Kong': 'hong_kong',
+  'Toronto': 'toronto',
+  'Seattle': 'seattle',
+  'Dallas': 'dallas',
+  'Atlanta': 'atlanta',
+  'Munich': 'munich',
+  'Tel Aviv': 'tel_aviv',
+  'Sao Paulo': 'sao_paulo',
+  'São Paulo': 'sao_paulo',
+  'Shanghai': 'shanghai',
+};
+
+// ─── API URLs ────────────────────────────────────────────────────────────────
+
+export const OPEN_METEO_BASE = 'https://api.open-meteo.com/v1';
+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 GAMMA_BASE = 'https://gamma-api.polymarket.com';
+export const CLOB_HOST = 'https://clob.polymarket.com';
+export const DATA_API_BASE = 'https://data-api.polymarket.com';
+export const WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
+
+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,
+};
+
+// ─── Model Forecast Standard Error ───────────────────────────────────────────
+// Lead time (hours) → std dev in °C. Multiply by 1.8 for °F.
+
+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 },
+};
+
+/**
+ * Get standard error for a model at a given lead time.
+ * Interpolates between known lead times.
+ */
+export function getModelStdErr(
+  model: WeatherModel,
+  leadTimeHours: number,
+  tempUnit: '°F' | '°C' = '°C',
+): number {
+  const table = MODEL_STDERR[model];
+  const knownHours = Object.keys(table).map(Number).sort((a, b) => a - b);
+
+  let stdErr: number;
+
+  if (leadTimeHours <= knownHours[0]) {
+    stdErr = table[knownHours[0].toString()];
+  } else if (leadTimeHours >= knownHours[knownHours.length - 1]) {
+    stdErr = table[knownHours[knownHours.length - 1].toString()];
+  } else {
+    // Linear interpolation
+    let lowerIdx = 0;
+    for (let i = 0; i < knownHours.length - 1; i++) {
+      if (leadTimeHours >= knownHours[i] && leadTimeHours < knownHours[i + 1]) {
+        lowerIdx = i;
+        break;
+      }
+    }
+    const lh = knownHours[lowerIdx];
+    const uh = knownHours[lowerIdx + 1];
+    const lv = table[lh.toString()];
+    const uv = table[uh.toString()];
+    const ratio = (leadTimeHours - lh) / (uh - lh);
+    stdErr = lv + ratio * (uv - lv);
+  }
+
+  return tempUnit === '°F' ? stdErr * 1.8 : stdErr;
+}
+
+// ─── Model Update Schedule ───────────────────────────────────────────────────
+
+export const MODEL_SCHEDULES = {
+  gfs:   ['25 4,10,16,22 * * *'],    // Check 4h25 after each run
+  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
+};
+
+// ─── Default Risk Config ─────────────────────────────────────────────────────
+
+export const DEFAULT_RISK_CONFIG: RiskConfig = {
+  maxPositionPerMarket: 50,
+  maxPositionPerCity: 200,
+  maxTotalExposure: 1000,
+  maxDailyLoss: 100,
+  kellyFraction: 0.25,
+  minLiquidity: 500,
+};
+
+// ─── Default Strategy Thresholds ─────────────────────────────────────────────
+
+export const DEFAULT_TAIL_NO_THRESHOLDS: TailNOThresholds = {
+  minEdge: 0.03,
+  maxModelProbability: 0.05,
+  maxYesPrice: 0.15,
+  minAgreementScore: 0.6,
+  minLeadTimeHours: 6,
+};

+ 16 - 0
packages/core/src/db.ts

@@ -0,0 +1,16 @@
+/**
+ * Singleton Prisma client instance.
+ */
+
+import { PrismaClient } from '@prisma/client';
+
+// Global singleton to prevent multiple instances in development
+const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
+
+export const prisma = globalForPrisma.prisma ?? new PrismaClient({
+  log: process.env['LOG_LEVEL'] === 'debug' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
+});
+
+if (process.env['NODE_ENV'] !== 'production') {
+  globalForPrisma.prisma = prisma;
+}

+ 6 - 0
packages/core/src/index.ts

@@ -0,0 +1,6 @@
+// @poly-weather/core - Shared types, utils, config, and constants
+export * from './types/index.js';
+export * from './utils/index.js';
+export * from './constants.js';
+export * from './config.js';
+export * from './db.js';

+ 77 - 0
packages/core/src/types/config.ts

@@ -0,0 +1,77 @@
+/**
+ * Bot configuration types.
+ */
+
+import type { RiskConfig } from './strategy.js';
+import type { CityConfig, WeatherModel } from './weather.js';
+
+export interface BotConfig {
+  // Mode
+  dryRun: boolean;
+  autoExecute: boolean;  // When false, signals are generated but orders are NOT placed
+  logLevel: string;
+
+  // Database
+  databaseUrl: string;
+  redisUrl: string;
+
+  // Polymarket Wallet
+  polymarket: PolymarketConfig;
+
+  // Strategy thresholds
+  thresholds: TailNOThresholds;
+
+  // Risk
+  risk: RiskConfig;
+
+  // Cities
+  cities: CityConfig[];
+
+  // Ensemble weights
+  ensembleWeights: Record<WeatherModel, number>;
+
+  // Ports
+  dashboardPort: number;
+  healthPort: number;
+}
+
+export interface PolymarketConfig {
+  /** Signer private key (exported from Polymarket wallet) */
+  privateKey: string;
+  /** Proxy wallet address (Polymarket deposit/contract wallet) */
+  funderAddress: string;
+  /** 0=EOA (signer==funder), 1=POLY_PROXY (signer!=funder) */
+  signatureType: number;
+  /** Polygon chain ID (137 for mainnet) */
+  chainId: number;
+  /** Polygon RPC URL */
+  rpcUrl: string;
+  /** L2 API credentials (derived from wallet via CLOB /derive-api-key) */
+  l2Creds: ApiCreds;
+  /** Builder API credentials (from polymarket.com Settings > Builder) */
+  builderCreds: ApiCreds;
+}
+
+export interface ApiCreds {
+  key: string;
+  secret: string;
+  passphrase: string;
+}
+
+export interface TailNOThresholds {
+  minEdge: number;               // 3% minimum edge
+  maxModelProbability: number;   // Only trade buckets with <5% true prob
+  maxYesPrice: number;           // Don't buy NO if YES > 15¢
+  minAgreementScore: number;     // Models must somewhat agree
+  minLeadTimeHours: number;      // At least 6h before resolution
+}
+
+export interface LatencyThresholds {
+  minPriceDelta: number;         // 5% minimum model shift
+  maxMarketLag: number;          // Market >8% from new fair value
+  maxAgeSeconds: number;         // Valid within 2h of model update
+}
+
+export interface ParityThresholds {
+  minDeviation: number;          // 2% minimum deviation from 1.0
+}

+ 4 - 0
packages/core/src/types/index.ts

@@ -0,0 +1,4 @@
+export * from './weather.js';
+export * from './market.js';
+export * from './strategy.js';
+export * from './config.js';

+ 62 - 0
packages/core/src/types/market.ts

@@ -0,0 +1,62 @@
+/**
+ * Polymarket market types for Gamma API, CLOB API, and trading.
+ */
+
+export interface GammaMarket {
+  id: number;
+  question: string;
+  conditionId: string;
+  slug: string;
+  outcomes: string;              // JSON string: '["Yes","No"]'
+  outcomePrices: string;         // JSON string: '["0.45","0.55"]'
+  clobTokenIds: string;          // JSON string: '["token_yes","token_no"]'
+  volume: number;
+  volume24hr: number;
+  liquidityNum: number;
+  endDateIso: string;
+  closed: boolean;
+  groupItemTitle: string;        // '46-47°F' (the temp bucket)
+  groupItemThreshold: string;
+  marketGroup: number;           // Groups all buckets for same day/city
+  enableOrderBook: boolean;
+  tags: Array<{ label: string }>;
+}
+
+export interface MarketBucket {
+  label: string;                 // '46-47°F'
+  lowerBound: number;
+  upperBound: number;
+  yesTokenId: string;
+  noTokenId: string;
+  yesPrice: number;              // Current YES price (0-1)
+  noPrice: number;               // Current NO price (0-1)
+  marketId: number;
+}
+
+export interface ParsedWeatherMarket {
+  city: string;
+  date: Date;
+  tempUnit: '°F' | '°C';
+  buckets: MarketBucket[];
+  marketGroupId: number;
+  totalVolume: number;
+}
+
+export interface OrderbookLevel {
+  price: string;
+  size: string;
+}
+
+export interface Orderbook {
+  bids: OrderbookLevel[];
+  asks: OrderbookLevel[];
+}
+
+export interface PriceUpdate {
+  tokenId: string;
+  price: number;
+  timestamp: number;
+}
+
+export type OrderSide = 'BUY' | 'SELL';
+export type OrderStatus = 'PENDING' | 'FILLED' | 'PARTIAL' | 'CANCELLED' | 'REJECTED';

+ 88 - 0
packages/core/src/types/strategy.ts

@@ -0,0 +1,88 @@
+/**
+ * Strategy types for signal generation, risk management, and positions.
+ */
+
+import type { OrderSide } from './market.js';
+
+export type StrategyType = 'TAIL_NO' | 'LATENCY_ARB' | 'PARITY_ARB';
+export type Confidence = 'HIGH' | 'MEDIUM' | 'LOW';
+
+export interface TailNOSignal {
+  type: 'TAIL_NO';
+  city: string;
+  date: Date;
+  bucketLabel: string;
+  marketYesPrice: number;
+  modelProbability: number;
+  edge: number;                  // marketYesPrice - modelProbability
+  noPrice: number;               // Price to buy NO (1 - yesPrice)
+  expectedReturn: number;        // edge / noPrice
+  agreementScore: number;
+  confidence: Confidence;
+  recommendedSize: number;       // USDC
+}
+
+export interface LatencySignal {
+  type: 'LATENCY_ARB';
+  city: string;
+  date: Date;
+  bucketLabel: string;
+  side: 'BUY_YES' | 'BUY_NO';
+  currentPrice: number;
+  newModelProbability: number;
+  previousModelProbability: number;
+  priceDelta: number;
+  modelUpdateAge: number;        // Seconds since model updated
+}
+
+export interface ParitySignal {
+  type: 'PARITY_ARB';
+  city: string;
+  date: Date;
+  totalYesPrice: number;
+  deviation: number;             // |totalYesPrice - 1.0|
+  profitPerUnit: number;
+  direction: 'BUY_ALL_NO' | 'BUY_ALL_YES';
+}
+
+export type Signal = TailNOSignal | LatencySignal | ParitySignal;
+
+export interface RiskConfig {
+  maxPositionPerMarket: number;  // Max USDC per single bucket
+  maxPositionPerCity: number;    // Max USDC exposure per city
+  maxTotalExposure: number;      // Max total portfolio exposure
+  maxDailyLoss: number;          // Stop-loss for the day
+  kellyFraction: number;         // Fraction of Kelly (0.25 = quarter Kelly)
+  minLiquidity: number;          // Min orderbook liquidity to enter
+}
+
+export interface Position {
+  id: string;
+  signalType: StrategyType;
+  city: string;
+  date: Date;
+  bucketLabel: string;
+  side: OrderSide;
+  tokenId: string;
+  price: number;
+  size: number;
+  costBasis: number;             // price * size
+  orderId: string | null;
+  status: 'OPEN' | 'CLOSED' | 'PENDING';
+  filledPrice: number | null;
+  pnl: number | null;
+  createdAt: Date;
+  resolvedAt: Date | null;
+}
+
+export interface DailyMetrics {
+  date: Date;
+  totalTrades: number;
+  winningTrades: number;
+  losingTrades: number;
+  grossPnl: number;
+  netPnl: number;
+  winRate: number;
+  avgEdge: number;
+  maxDrawdown: number;
+}

+ 58 - 0
packages/core/src/types/weather.ts

@@ -0,0 +1,58 @@
+/**
+ * Weather data types for forecast models and temperature processing.
+ */
+
+export type WeatherModel = 'gfs' | 'ecmwf' | 'icon';
+
+export interface CityConfig {
+  name: string;
+  slug: string;
+  lat: number;
+  lon: number;
+  tempUnit: '°F' | '°C';
+  timezone: string;
+  priority: 'P0' | 'P1' | 'P2';
+}
+
+export interface HourlyForecast {
+  model: WeatherModel;
+  city: string;
+  times: string[];           // ISO 8601 timestamps
+  temperatures: number[];    // Hourly temperature values
+  modelRunTime: Date;        // When the model run started
+  fetchedAt: Date;
+  tempUnit: '°F' | '°C';
+}
+
+export interface ModelForecasts {
+  gfs: HourlyForecast | null;
+  ecmwf: HourlyForecast | null;
+  icon: HourlyForecast | null;
+  fetchedAt: Date;
+}
+
+export interface TempBucket {
+  label: string;             // '46-47°F', '31°F or below', '56°F or higher'
+  lowerBound: number;        // -Infinity for open lower
+  upperBound: number;        // Infinity for open upper
+}
+
+export interface BucketProbability {
+  bucketLabel: string;
+  lowerBound: number;
+  upperBound: number;
+  probability: number;       // 0.0 to 1.0
+}
+
+export interface ForecastDistribution {
+  model: WeatherModel;
+  predictedHigh: number;     // Point forecast for daily high
+  stdDev: number;            // Forecast uncertainty
+  buckets: BucketProbability[];
+}
+
+/**
+ * Forecast standard deviation by model and lead time (in hours).
+ * Values are in °C — multiply by 1.8 for °F.
+ */
+export type ModelStdErrTable = Record<WeatherModel, Record<string, number>>;

+ 3 - 0
packages/core/src/utils/index.ts

@@ -0,0 +1,3 @@
+export * from './probability.js';
+export * from './temperature.js';
+export * from './logger.js';

+ 28 - 0
packages/core/src/utils/logger.ts

@@ -0,0 +1,28 @@
+/**
+ * Structured logging via pino.
+ */
+
+import pino from 'pino';
+
+const level = process.env['LOG_LEVEL'] || 'info';
+
+export const logger = pino({
+  level,
+  transport:
+    process.env['NODE_ENV'] !== 'production'
+      ? { target: 'pino/file', options: { destination: 1 } }
+      : undefined,
+  formatters: {
+    level(label) {
+      return { level: label };
+    },
+  },
+  timestamp: pino.stdTimeFunctions.isoTime,
+});
+
+/**
+ * Create a child logger with a specific component name.
+ */
+export function createChildLogger(component: string): pino.Logger {
+  return logger.child({ component });
+}

+ 101 - 0
packages/core/src/utils/probability.ts

@@ -0,0 +1,101 @@
+/**
+ * Probability utility functions for temperature bucket calculations.
+ * Uses Abramowitz & Stegun approximation for normal CDF — accurate to 1e-7.
+ */
+
+import type { BucketProbability, TempBucket } from '../types/weather.js';
+
+/**
+ * Normal cumulative distribution function.
+ * P(X ≤ x) for X ~ N(mean, stdDev²)
+ */
+export function normalCDF(x: number, mean: number, stdDev: number): number {
+  const z = (x - mean) / stdDev;
+  if (z < -8) return 0;
+  if (z > 8) return 1;
+
+  const t = 1 / (1 + 0.2316419 * Math.abs(z));
+  const d = 0.3989422804014327; // 1/sqrt(2*PI)
+  const p =
+    d *
+    Math.exp((-z * z) / 2) *
+    (t *
+      (0.31938153 +
+        t *
+          (-0.356563782 +
+            t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))));
+
+  return z > 0 ? 1 - p : p;
+}
+
+/**
+ * Normal probability density function.
+ */
+export function normalPDF(x: number, mean: number, stdDev: number): number {
+  const z = (x - mean) / stdDev;
+  return (1 / (stdDev * Math.sqrt(2 * Math.PI))) * Math.exp((-z * z) / 2);
+}
+
+/**
+ * Calculate probability that a value falls within [lower, upper).
+ */
+export function bucketProbability(
+  lower: number,
+  upper: number,
+  mean: number,
+  stdDev: number,
+): number {
+  if (lower === -Infinity) {
+    return normalCDF(upper, mean, stdDev);
+  }
+  if (upper === Infinity) {
+    return 1 - normalCDF(lower, mean, stdDev);
+  }
+  return normalCDF(upper, mean, stdDev) - normalCDF(lower, mean, stdDev);
+}
+
+/**
+ * Map a temperature forecast to Polymarket-style bucket probabilities.
+ * Each bucket probability is P(lower ≤ T < upper) under N(mean, stdDev²).
+ */
+export function temperatureToBuckets(
+  mean: number,
+  stdDev: number,
+  buckets: TempBucket[],
+): BucketProbability[] {
+  return buckets.map((bucket) => ({
+    bucketLabel: bucket.label,
+    lowerBound: bucket.lowerBound,
+    upperBound: bucket.upperBound,
+    probability: bucketProbability(
+      bucket.lowerBound,
+      bucket.upperBound,
+      mean,
+      stdDev,
+    ),
+  }));
+}
+
+/**
+ * Kelly Criterion position sizing.
+ * f* = (bp - q) / b
+ * b = net odds = (1/cost - 1)
+ * p = win probability
+ * q = 1 - p
+ *
+ * Returns optimal fraction of bankroll to wager.
+ */
+export function kellyFraction(
+  winProbability: number,
+  cost: number,
+): number {
+  if (cost <= 0 || cost >= 1) return 0;
+  if (winProbability <= 0 || winProbability >= 1) return 0;
+
+  const b = (1 / cost) - 1; // net odds
+  const p = winProbability;
+  const q = 1 - p;
+  const kelly = (b * p - q) / b;
+
+  return Math.max(0, kelly);
+}

+ 142 - 0
packages/core/src/utils/temperature.ts

@@ -0,0 +1,142 @@
+/**
+ * Temperature conversion and bucket parsing utilities.
+ */
+
+import type { TempBucket } from '../types/weather.js';
+
+export function celsiusToFahrenheit(c: number): number {
+  return (c * 9) / 5 + 32;
+}
+
+export function fahrenheitToCelsius(f: number): number {
+  return ((f - 32) * 5) / 9;
+}
+
+/**
+ * Parse a bucket label like '46-47°F', '31°F or below', '56°F or higher'
+ * into a TempBucket with numeric bounds.
+ */
+export function parseBucketLabel(label: string): TempBucket {
+  // Range pattern: '46-47°F' or '46-47°C'
+  const rangeMatch = label.match(/(-?\d+)\s*-\s*(-?\d+)/);
+  if (rangeMatch) {
+    return {
+      label,
+      lowerBound: +rangeMatch[1],
+      upperBound: +rangeMatch[2] + 1, // exclusive upper bound
+    };
+  }
+
+  // Below pattern: '31°F or below', '31°F or less'
+  const belowMatch = label.match(/(-?\d+)\s*°?\s*[FC]?\s*(or below|or less)/i);
+  if (belowMatch) {
+    return {
+      label,
+      lowerBound: -Infinity,
+      upperBound: +belowMatch[1] + 1,
+    };
+  }
+
+  // Above pattern: '56°F or higher', '56°F or more', '56°F+'
+  const aboveMatch = label.match(
+    /(-?\d+)\s*°?\s*[FC]?\s*(or higher|or more|or above|\+)/i,
+  );
+  if (aboveMatch) {
+    return {
+      label,
+      lowerBound: +aboveMatch[1],
+      upperBound: Infinity,
+    };
+  }
+
+  // Single degree pattern: '12°C', '45°F' (exact temperature, no range/tail qualifier)
+  const singleMatch = label.match(/^(-?\d+)\s*°\s*[FC]$/);
+  if (singleMatch) {
+    const temp = +singleMatch[1];
+    return {
+      label,
+      lowerBound: temp,
+      upperBound: temp + 1, // exclusive upper bound: "12°C" means [12, 13)
+    };
+  }
+
+  throw new Error(`Cannot parse bucket label: ${label}`);
+}
+
+/**
+ * Extract daily high temperature from hourly forecast data for a target date.
+ * Considers hours between 6 AM and 9 PM local time as "daytime" for high temp.
+ */
+export function extractDailyHigh(
+  times: string[],
+  temperatures: number[],
+  targetDate: string, // 'YYYY-MM-DD'
+): number {
+  let maxTemp = -Infinity;
+  let found = false;
+
+  for (let i = 0; i < times.length; i++) {
+    const time = times[i];
+    if (!time.startsWith(targetDate)) continue;
+
+    const hour = parseInt(time.slice(11, 13), 10);
+    // Consider daytime hours (6 AM to 9 PM) for high temperature
+    if (hour >= 6 && hour <= 21) {
+      maxTemp = Math.max(maxTemp, temperatures[i]);
+      found = true;
+    }
+  }
+
+  if (!found) {
+    // Fallback: use max of all hours on that date
+    for (let i = 0; i < times.length; i++) {
+      if (times[i].startsWith(targetDate)) {
+        maxTemp = Math.max(maxTemp, temperatures[i]);
+        found = true;
+      }
+    }
+  }
+
+  if (!found) {
+    throw new Error(`No forecast data for date: ${targetDate}`);
+  }
+
+  return maxTemp;
+}
+
+/**
+ * Get the lead time in hours between now and a target date (noon local).
+ */
+export function getLeadTimeHours(targetDate: Date): number {
+  const now = new Date();
+  return Math.max(0, (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60));
+}
+
+/**
+ * Format a date as 'YYYY-MM-DD'.
+ */
+export function formatDate(date: Date): string {
+  return date.toISOString().slice(0, 10);
+}
+
+/**
+ * Parse market date strings like 'March 17' into a Date object (current year).
+ */
+export function parseMarketDate(dateStr: string): Date {
+  const months: Record<string, number> = {
+    January: 0, February: 1, March: 2, April: 3,
+    May: 4, June: 5, July: 6, August: 7,
+    September: 8, October: 9, November: 10, December: 11,
+  };
+
+  const match = dateStr.match(/(\w+)\s+(\d+)/);
+  if (!match) throw new Error(`Cannot parse date: ${dateStr}`);
+
+  const month = months[match[1]];
+  if (month === undefined) throw new Error(`Unknown month: ${match[1]}`);
+
+  const day = parseInt(match[2], 10);
+  const year = new Date().getFullYear();
+
+  return new Date(year, month, day);
+}

+ 8 - 0
packages/core/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "./dist",
+    "rootDir": "./src"
+  },
+  "include": ["src"]
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
packages/core/tsconfig.tsbuildinfo


+ 8 - 0
packages/core/vitest.config.ts

@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    root: '.',
+    include: ['src/__tests__/**/*.test.ts'],
+  },
+});

+ 5 - 0
packages/dashboard/next-env.d.ts

@@ -0,0 +1,5 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

+ 15 - 0
packages/dashboard/next.config.mjs

@@ -0,0 +1,15 @@
+import { config } from 'dotenv';
+import { resolve, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// Load root .env so all packages share the same env vars (override any existing)
+config({ path: resolve(__dirname, '../../.env'), override: true });
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+  transpilePackages: ['@poly-weather/core', '@poly-weather/market-engine'],
+};
+
+export default nextConfig;

+ 42 - 0
packages/dashboard/package.json

@@ -0,0 +1,42 @@
+{
+  "name": "@poly-weather/dashboard",
+  "version": "1.0.0",
+  "private": true,
+  "scripts": {
+    "build": "next build",
+    "dev": "next dev -p 3000",
+    "start": "next start",
+    "test": "vitest run --passWithNoTests",
+    "lint": "next lint"
+  },
+  "dependencies": {
+    "@poly-weather/core": "workspace:*",
+    "@poly-weather/market-engine": "workspace:^",
+    "@prisma/client": "^5.22.0",
+    "@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-toggle": "^1.1.10",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "dotenv": "^17.3.1",
+    "lucide-react": "^0.577.0",
+    "next": "^14.2.0",
+    "next-themes": "^0.4.6",
+    "react": "^18.3.0",
+    "react-dom": "^18.3.0",
+    "recharts": "^2.13.0",
+    "socket.io-client": "^4.8.0",
+    "tailwind-merge": "^3.5.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0",
+    "@types/react": "^18.3.0",
+    "@types/react-dom": "^18.3.0",
+    "autoprefixer": "^10.4.0",
+    "postcss": "^8.4.0",
+    "tailwindcss": "^3.4.0",
+    "typescript": "^5.7.0",
+    "vitest": "^2.1.0"
+  }
+}

+ 6 - 0
packages/dashboard/postcss.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+};

BIN
packages/dashboard/public/apple-touch-icon.png


BIN
packages/dashboard/public/favicon-16.png


BIN
packages/dashboard/public/favicon-32.png


BIN
packages/dashboard/public/favicon.ico


BIN
packages/dashboard/public/icon-192.png


BIN
packages/dashboard/public/icon-512.png


BIN
packages/dashboard/public/logo.png


+ 8 - 0
packages/dashboard/src/app/RecentSignals.tsx

@@ -0,0 +1,8 @@
+'use client';
+
+import { SignalTable } from '@/components/SignalTable';
+import type { Signal } from '@/lib/types';
+
+export function RecentSignalsSection({ signals }: { signals: Signal[] }) {
+  return <SignalTable signals={signals} />;
+}

+ 71 - 0
packages/dashboard/src/app/api/bot-control/route.ts

@@ -0,0 +1,71 @@
+import { NextResponse } from 'next/server';
+
+export const dynamic = 'force-dynamic';
+
+const BOT_HEALTH_URL = process.env.BOT_HEALTH_URL || 'http://localhost:3001';
+
+/**
+ * GET /api/bot-control — get current bot status (autoExecute, dryRun, etc.)
+ * When bot is offline, falls back to reading DRY_RUN from dashboard's env.
+ */
+export async function GET() {
+  const envDryRun = process.env['DRY_RUN'] !== 'false';
+
+  try {
+    const res = await fetch(`${BOT_HEALTH_URL}/metrics`, {
+      signal: AbortSignal.timeout(3000),
+    });
+    if (!res.ok) {
+      return NextResponse.json({
+        connected: false,
+        autoExecute: false,
+        dryRun: envDryRun,
+      }, { status: 200 });
+    }
+    const data = await res.json();
+    return NextResponse.json({
+      connected: true,
+      autoExecute: data.autoExecute ?? false,
+      dryRun: data.dryRun ?? envDryRun,
+      isRunning: data.isRunning ?? false,
+      uptime: data.uptime ?? 0,
+      lastCycleResult: data.lastCycleResult ?? null,
+    });
+  } catch {
+    // Bot not reachable — use dashboard env
+    return NextResponse.json({
+      connected: false,
+      autoExecute: false,
+      dryRun: envDryRun,
+    }, { status: 200 });
+  }
+}
+
+/**
+ * POST /api/bot-control — toggle auto-execute
+ * Body: { autoExecute: boolean }
+ */
+export async function POST(request: Request) {
+  try {
+    const { autoExecute } = await request.json();
+    if (typeof autoExecute !== 'boolean') {
+      return NextResponse.json({ error: 'autoExecute must be a boolean' }, { status: 400 });
+    }
+
+    const res = await fetch(`${BOT_HEALTH_URL}/auto-execute`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ enabled: autoExecute }),
+      signal: AbortSignal.timeout(3000),
+    });
+
+    if (!res.ok) {
+      return NextResponse.json({ error: 'Bot returned error' }, { status: 502 });
+    }
+
+    const data = await res.json();
+    return NextResponse.json(data);
+  } catch {
+    return NextResponse.json({ error: 'Bot not reachable' }, { status: 502 });
+  }
+}

+ 20 - 0
packages/dashboard/src/app/api/dashboard/route.ts

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

+ 17 - 0
packages/dashboard/src/app/api/live-markets/route.ts

@@ -0,0 +1,17 @@
+import { NextResponse } from 'next/server';
+import { fetchLiveMarkets } from '@/lib/polymarket-api';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+  try {
+    const markets = await fetchLiveMarkets();
+    return NextResponse.json({ markets });
+  } catch (err) {
+    console.error('Live markets API error:', err);
+    return NextResponse.json(
+      { error: 'Failed to fetch live markets from Polymarket' },
+      { status: 500 },
+    );
+  }
+}

+ 17 - 0
packages/dashboard/src/app/api/markets/route.ts

@@ -0,0 +1,17 @@
+import { NextResponse } from 'next/server';
+import { getMarkets } from '@/lib/queries';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+  try {
+    const markets = await getMarkets();
+    return NextResponse.json({ markets });
+  } catch (err) {
+    console.error('Markets API error:', err);
+    return NextResponse.json(
+      { error: 'Failed to fetch markets' },
+      { status: 500 },
+    );
+  }
+}

+ 28 - 0
packages/dashboard/src/app/api/orderbook/route.ts

@@ -0,0 +1,28 @@
+import { NextResponse } from 'next/server';
+import { fetchOrderbook } from '@/lib/polymarket-api';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(request: Request) {
+  const { searchParams } = new URL(request.url);
+  const tokenId = searchParams.get('tokenId');
+  const noTokenId = searchParams.get('noTokenId') || undefined;
+
+  if (!tokenId) {
+    return NextResponse.json({ error: 'tokenId is required' }, { status: 400 });
+  }
+
+  try {
+    const orderbook = await fetchOrderbook(tokenId, noTokenId);
+    if (!orderbook) {
+      return NextResponse.json({ error: 'Orderbook not found' }, { status: 404 });
+    }
+    return NextResponse.json(orderbook);
+  } catch (err) {
+    console.error('Orderbook API error:', err);
+    return NextResponse.json(
+      { error: 'Failed to fetch orderbook' },
+      { status: 500 },
+    );
+  }
+}

+ 206 - 0
packages/dashboard/src/app/api/orders/route.ts

@@ -0,0 +1,206 @@
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/db';
+import { lookupTokenPair } from '@/lib/gamma-tokens';
+
+export const dynamic = 'force-dynamic';
+
+interface OrderRequest {
+  signalId: string;
+  side: 'BUY_YES' | 'BUY_NO';
+  price: number;
+  size: number;
+  orderType: 'MARKET' | 'LIMIT';
+  tokenId?: string;
+}
+
+interface OrderResult {
+  signalId: string;
+  orderId: string;
+  status: 'DRY_RUN' | 'SUBMITTED' | 'FAILED';
+  side: string;
+  price: number;
+  size: number;
+  costBasis: number;
+  error?: string;
+}
+
+// Lazy-initialized CLOB trading client singleton
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let clobClientPromise: Promise<any> | null = null;
+
+async function getClobClient() {
+  if (clobClientPromise) return clobClientPromise;
+
+  clobClientPromise = (async () => {
+    const { CLOBTradingClient } = await import('@poly-weather/market-engine');
+    const { loadConfig } = await import('@poly-weather/core');
+    const config = loadConfig();
+
+    const client = new CLOBTradingClient(config.polymarket, false /* not dry run */);
+    await client.initialize();
+    return client;
+  })();
+
+  return clobClientPromise;
+}
+
+/**
+ * Place orders for selected signals.
+ * In DRY_RUN mode, simulates order placement and records in DB.
+ * In live mode, connects to Polymarket CLOB for real execution.
+ */
+export async function POST(request: Request) {
+  try {
+    const { orders } = (await request.json()) as { orders: OrderRequest[] };
+
+    if (!orders?.length) {
+      return NextResponse.json({ error: 'No orders provided' }, { status: 400 });
+    }
+
+    const isDryRun = process.env['DRY_RUN'] !== 'false';
+    const results: OrderResult[] = [];
+
+    for (const order of orders) {
+      const costBasis = order.price * order.size;
+
+      try {
+        if (isDryRun) {
+          // DRY RUN: simulate the order, persist to DB
+          const mockOrderId = `dry_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+
+          const trade = await prisma.trade.create({
+            data: {
+              signalId: order.signalId,
+              side: order.side,
+              tokenId: order.tokenId || `sim_${order.signalId}`,
+              price: order.price,
+              size: order.size,
+              costBasis,
+              orderId: mockOrderId,
+              status: 'FILLED',
+              dryRun: true,
+            },
+          });
+
+          await prisma.signal.update({
+            where: { id: order.signalId },
+            data: { executed: true, executedAt: new Date(), tradeId: trade.id },
+          });
+
+          results.push({
+            signalId: order.signalId,
+            orderId: mockOrderId,
+            status: 'DRY_RUN',
+            side: order.side,
+            price: order.price,
+            size: order.size,
+            costBasis,
+          });
+        } else {
+          // LIVE MODE: place real order via CLOB
+          let tokenId = order.tokenId || '';
+
+          // If no tokenId provided, look it up from DB or Gamma API
+          if (!tokenId) {
+            const signal = await prisma.signal.findUnique({
+              where: { id: order.signalId },
+              select: { city: true, bucketLabel: true },
+            });
+
+            if (signal) {
+              const tokens = await lookupTokenPair(signal.city, signal.bucketLabel, prisma);
+              if (tokens) {
+                tokenId = order.side === 'BUY_YES'
+                  ? tokens.yesTokenId
+                  : tokens.noTokenId;
+              }
+            }
+          }
+
+          if (!tokenId) {
+            results.push({
+              signalId: order.signalId,
+              orderId: '',
+              status: 'FAILED',
+              side: order.side,
+              price: order.price,
+              size: order.size,
+              costBasis,
+              error: 'Token ID not found. Run a bot cycle to populate market data.',
+            });
+            continue;
+          }
+
+          try {
+            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);
+
+            // Persist trade to DB
+            const trade = await prisma.trade.create({
+              data: {
+                signalId: order.signalId,
+                side: order.side,
+                tokenId,
+                price: order.price,
+                size: order.size,
+                costBasis,
+                orderId: clobResult.orderId,
+                status: clobResult.status === 'MATCHED' || clobResult.status === 'LIVE' ? 'FILLED' : 'PENDING',
+                dryRun: false,
+              },
+            });
+
+            await prisma.signal.update({
+              where: { id: order.signalId },
+              data: { executed: true, executedAt: new Date(), tradeId: trade.id },
+            });
+
+            results.push({
+              signalId: order.signalId,
+              orderId: clobResult.orderId,
+              status: 'SUBMITTED',
+              side: order.side,
+              price: order.price,
+              size: order.size,
+              costBasis,
+            });
+          } catch (err) {
+            console.error('CLOB order failed:', err);
+            results.push({
+              signalId: order.signalId,
+              orderId: '',
+              status: 'FAILED',
+              side: order.side,
+              price: order.price,
+              size: order.size,
+              costBasis,
+              error: err instanceof Error ? err.message : 'CLOB order placement failed',
+            });
+          }
+        }
+      } catch (err) {
+        results.push({
+          signalId: order.signalId,
+          orderId: '',
+          status: 'FAILED',
+          side: order.side,
+          price: order.price,
+          size: order.size,
+          costBasis,
+          error: err instanceof Error ? err.message : 'Unknown error',
+        });
+      }
+    }
+
+    return NextResponse.json({
+      results,
+      dryRun: isDryRun,
+      placedAt: new Date().toISOString(),
+    });
+  } catch (err) {
+    console.error('Order API error:', err);
+    return NextResponse.json({ error: 'Failed to place orders' }, { status: 500 });
+  }
+}

+ 17 - 0
packages/dashboard/src/app/api/pnl/route.ts

@@ -0,0 +1,17 @@
+import { NextResponse } from 'next/server';
+import { getPnLData } from '@/lib/queries';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+  try {
+    const { data, metrics } = await getPnLData();
+    return NextResponse.json({ data, metrics });
+  } catch (err) {
+    console.error('PnL API error:', err);
+    return NextResponse.json(
+      { error: 'Failed to fetch PnL data' },
+      { status: 500 },
+    );
+  }
+}

+ 211 - 0
packages/dashboard/src/app/api/prices/route.ts

@@ -0,0 +1,211 @@
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/db';
+import { fetchAllGammaTokens, type TokenPair } from '@/lib/gamma-tokens';
+
+export const dynamic = 'force-dynamic';
+
+const CLOB_BASE = 'https://clob.polymarket.com';
+
+interface PriceRequest {
+  signalId: string;
+  city: string;
+  targetDate: string;
+  bucket: string;
+  type: string;
+  side: string;
+}
+
+interface BookLevel {
+  price: number;
+  size: number;
+}
+
+interface PriceResult {
+  signalId: string;
+  // YES side (direct from YES token orderbook)
+  yesPrice: number;
+  yesBestBid: number;
+  yesBestAsk: number;
+  yesSpread: number;
+  // NO side (direct from NO token orderbook)
+  noPrice: number;
+  noBestBid: number;
+  noBestAsk: number;
+  noSpread: 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)
+  // Legacy fields
+  bestBid: number;
+  bestAsk: number;
+  spread: number;
+  midpoint: number;
+  lastUpdated: string;
+  error?: string;
+}
+
+function formatCityName(city: string): string {
+  return city.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
+}
+
+interface RawBookResponse {
+  bids: Array<{ price: string; size: string }>;
+  asks: Array<{ price: string; size: string }>;
+}
+
+async function fetchBook(tokenId: string): Promise<RawBookResponse | null> {
+  try {
+    const res = await fetch(`${CLOB_BASE}/book?token_id=${tokenId}`, {
+      headers: { Accept: 'application/json' },
+      signal: AbortSignal.timeout(8000),
+    });
+    if (!res.ok) return null;
+    return (await res.json()) as RawBookResponse;
+  } catch {
+    return 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,
+    noAsks: [], yesAsks: [],
+    bestBid: 0, bestAsk: 0, spread: 0, midpoint: 0,
+    lastUpdated: new Date().toISOString(),
+    error,
+  };
+}
+
+/**
+ * Fetch latest CLOB prices for a set of signals.
+ * Queries BOTH YES and NO orderbooks directly — no synthetic conversion.
+ */
+export async function POST(request: Request) {
+  try {
+    const { signals } = (await request.json()) as { signals: PriceRequest[] };
+
+    if (!signals?.length) {
+      return NextResponse.json({ error: 'No signals provided' }, { status: 400 });
+    }
+
+    // Build token map: try DB first, then Gamma fallback for any missing
+    const tokenMap = new Map<string, TokenPair>();
+
+    // 1. Try DB
+    for (const signal of signals) {
+      if (signal.type === 'PARITY_ARB') continue;
+      const key = `${signal.city}|${signal.bucket}`;
+      if (tokenMap.has(key)) continue;
+
+      const snapshot = await prisma.marketSnapshot.findFirst({
+        where: { city: signal.city, bucketLabel: signal.bucket },
+        orderBy: { createdAt: 'desc' },
+        select: { yesTokenId: true, noTokenId: true },
+      });
+
+      if (snapshot?.yesTokenId) {
+        tokenMap.set(key, { yesTokenId: snapshot.yesTokenId, noTokenId: snapshot.noTokenId || '' });
+      }
+    }
+
+    // 2. Gamma fallback for missing
+    const hasMissing = signals.some(
+      (s) => s.type !== 'PARITY_ARB' && !tokenMap.has(`${s.city}|${s.bucket}`),
+    );
+
+    if (hasMissing) {
+      const gammaTokens = await fetchAllGammaTokens();
+      for (const signal of signals) {
+        const key = `${signal.city}|${signal.bucket}`;
+        if (!tokenMap.has(key) && gammaTokens.has(key)) {
+          tokenMap.set(key, gammaTokens.get(key)!);
+        }
+      }
+    }
+
+    // Fetch prices for each signal
+    const results: PriceResult[] = await Promise.all(
+      signals.map(async (signal): Promise<PriceResult> => {
+        if (signal.type === 'PARITY_ARB') {
+          return emptyResult(signal.signalId, 'Parity signals use aggregate pricing');
+        }
+
+        const tokenInfo = tokenMap.get(`${signal.city}|${signal.bucket}`);
+        if (!tokenInfo?.yesTokenId) {
+          return emptyResult(
+            signal.signalId,
+            `Token not found for ${formatCityName(signal.city)} "${signal.bucket}"`,
+          );
+        }
+
+        try {
+          // Fetch YES and NO orderbooks in parallel
+          const [yesBook, noBook] = await Promise.all([
+            fetchBook(tokenInfo.yesTokenId),
+            tokenInfo.noTokenId ? fetchBook(tokenInfo.noTokenId) : Promise.resolve(null),
+          ]);
+
+          if (!yesBook && !noBook) {
+            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;
+          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 noSpread = noBestAsk - noBestBid;
+
+          // Midpoint from YES book for legacy compatibility
+          const midpoint = (yesBestBid + yesBestAsk) / 2;
+
+          return {
+            signalId: signal.signalId,
+            yesPrice: midpoint,
+            yesBestBid,
+            yesBestAsk,
+            yesSpread,
+            noPrice: noBook ? (noBestBid + noBestAsk) / 2 : 1 - midpoint,
+            noBestBid,
+            noBestAsk,
+            noSpread,
+            noAsks: noAsks.slice(0, 5),
+            yesAsks: yesAsks.slice(0, 5),
+            // Legacy
+            bestBid: yesBestBid,
+            bestAsk: yesBestAsk,
+            spread: yesSpread,
+            midpoint,
+            lastUpdated: new Date().toISOString(),
+          };
+        } catch {
+          return emptyResult(signal.signalId, 'Price fetch failed');
+        }
+      }),
+    );
+
+    return NextResponse.json({ prices: results, fetchedAt: new Date().toISOString() });
+  } catch (err) {
+    console.error('Price API error:', err);
+    return NextResponse.json({ error: 'Failed to fetch prices' }, { status: 500 });
+  }
+}

+ 19 - 0
packages/dashboard/src/app/api/signals/route.ts

@@ -0,0 +1,19 @@
+import { NextResponse } from 'next/server';
+import { getSignals } from '@/lib/queries';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(request: Request) {
+  try {
+    const { searchParams } = new URL(request.url);
+    const type = searchParams.get('type') ?? undefined;
+    const signals = await getSignals(type);
+    return NextResponse.json({ signals });
+  } catch (err) {
+    console.error('Signals API error:', err);
+    return NextResponse.json(
+      { error: 'Failed to fetch signals' },
+      { status: 500 },
+    );
+  }
+}

BIN
packages/dashboard/src/app/apple-icon.png


BIN
packages/dashboard/src/app/favicon.ico


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

@@ -0,0 +1,102 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+  /* Light mode */
+  body {
+    @apply bg-gray-50 text-gray-900 antialiased;
+  }
+  /* Dark mode */
+  .dark body {
+    @apply bg-slate-900 text-slate-100;
+  }
+
+  .dark ::-webkit-scrollbar { @apply w-2; }
+  .dark ::-webkit-scrollbar-track { @apply bg-slate-900; }
+  .dark ::-webkit-scrollbar-thumb { @apply bg-slate-700 rounded-full; }
+  .dark ::-webkit-scrollbar-thumb:hover { @apply bg-slate-600; }
+}
+
+@layer components {
+  /* ─── Card ─── */
+  .card {
+    @apply bg-white border border-gray-200 rounded-lg p-6 shadow-sm;
+  }
+  .dark .card {
+    @apply bg-slate-800 border-slate-700 shadow-none;
+  }
+  .card-hover {
+    @apply card transition-colors hover:border-gray-300;
+  }
+  .dark .card-hover {
+    @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; }
+  .text-body { @apply text-gray-700; }
+  .dark .text-body { @apply text-slate-300; }
+  .text-sub { @apply text-gray-500; }
+  .dark .text-sub { @apply text-slate-400; }
+  .text-dim { @apply text-gray-400; }
+  .dark .text-dim { @apply text-slate-500; }
+
+  /* ─── Theme-aware divider ─── */
+  .divider { @apply bg-gray-200; }
+  .dark .divider { @apply bg-slate-700; }
+}

BIN
packages/dashboard/src/app/icon.png


+ 36 - 0
packages/dashboard/src/app/layout.tsx

@@ -0,0 +1,36 @@
+import type { Metadata } from 'next';
+import './globals.css';
+import { Sidebar } from '@/components/Sidebar';
+import { ThemeProvider } from '@/components/ThemeProvider';
+
+export const metadata: Metadata = {
+  title: 'Poly Weather - Arbitrage Dashboard',
+  description: 'Polymarket Weather Arbitrage Bot Dashboard',
+  icons: {
+    icon: [
+      { url: '/favicon.ico', sizes: '32x32' },
+      { url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
+      { url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
+    ],
+    apple: '/apple-touch-icon.png',
+  },
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en" suppressHydrationWarning>
+      <body className="flex h-screen overflow-hidden">
+        <ThemeProvider>
+          <Sidebar />
+          <main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-900 p-8">
+            {children}
+          </main>
+        </ThemeProvider>
+      </body>
+    </html>
+  );
+}

+ 230 - 0
packages/dashboard/src/app/markets/LiveMarketsClient.tsx

@@ -0,0 +1,230 @@
+'use client';
+
+import { useState } from 'react';
+import type { WeatherMarket, MarketBucket } from '@/lib/types';
+import { Orderbook } from '@/components/Orderbook';
+
+function ExternalLink({ href, children, className = '' }: { href: string; children: React.ReactNode; className?: string }) {
+  return (
+    <a
+      href={href}
+      target="_blank"
+      rel="noopener noreferrer"
+      className={`inline-flex items-center gap-1 hover:underline ${className}`}
+    >
+      {children}
+      <svg className="w-3 h-3 opacity-50" 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>
+  );
+}
+
+function PriceBadge({ price }: { price: number }) {
+  const cents = (price * 100).toFixed(1);
+  return (
+    <span className="font-mono text-sm">
+      <span className="text-heading">{cents}</span>
+      <span className="text-dim">{'\u00a2'}</span>
+    </span>
+  );
+}
+
+function PriceBar({ price, maxPrice }: { price: number; maxPrice: number }) {
+  const pct = maxPrice > 0 ? (price / maxPrice) * 100 : 0;
+  return (
+    <div className="w-24 h-2 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
+      <div
+        className="h-full bg-blue-500 rounded-full transition-all"
+        style={{ width: `${Math.min(pct, 100)}%` }}
+      />
+    </div>
+  );
+}
+
+function ParitySummary({ priceSum }: { priceSum: number }) {
+  const deviation = Math.abs(priceSum - 1);
+  const color = deviation > 0.03 ? 'text-yellow-600 dark:text-yellow-400' : deviation > 0.05 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400';
+  return (
+    <span className={`text-xs font-mono ${color}`}>
+      &Sigma; = {(priceSum * 100).toFixed(1)}%
+      {deviation > 0.02 && (
+        <span className="ml-1 text-yellow-600 dark:text-yellow-500">
+          ({deviation > 0 ? '+' : ''}{((priceSum - 1) * 100).toFixed(1)}% parity gap)
+        </span>
+      )}
+    </span>
+  );
+}
+
+function BucketRow({
+  bucket,
+  maxPrice,
+  isSelected,
+  onSelect,
+}: {
+  bucket: MarketBucket;
+  maxPrice: number;
+  isSelected: boolean;
+  onSelect: () => void;
+}) {
+  return (
+    <tr
+      className={`cursor-pointer transition-colors ${
+        isSelected ? 'bg-blue-50 dark:bg-slate-700/50' : 'hover:bg-gray-50 dark:hover:bg-slate-800/50'
+      }`}
+      onClick={onSelect}
+    >
+      <td className="px-3 py-2">
+        <span className="font-mono text-sm text-heading">{bucket.label}</span>
+      </td>
+      <td className="px-3 py-2">
+        <div className="flex items-center gap-2">
+          <PriceBadge price={bucket.marketPrice} />
+          <PriceBar price={bucket.marketPrice} maxPrice={maxPrice} />
+        </div>
+      </td>
+      <td className="px-3 py-2 text-right">
+        <span className="text-xs text-sub font-mono">
+          ${bucket.volume.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+        </span>
+      </td>
+      <td className="px-3 py-2 text-center">
+        {bucket.polymarketUrl ? (
+          <ExternalLink href={bucket.polymarketUrl} className="text-purple-600 dark:text-purple-400 text-xs">
+            Trade
+          </ExternalLink>
+        ) : null}
+      </td>
+      <td className="px-3 py-2 text-center w-6">
+        <span className={`text-dim transition-transform inline-block ${isSelected ? 'rotate-90' : ''}`}>
+          &rsaquo;
+        </span>
+      </td>
+    </tr>
+  );
+}
+
+function MarketCard({ market }: { market: WeatherMarket }) {
+  const [selectedBucket, setSelectedBucket] = useState<string | null>(null);
+  const maxPrice = Math.max(...market.buckets.map((b) => b.marketPrice), 0.01);
+
+  const selectedBucketData = market.buckets.find((b) => b.label === selectedBucket);
+
+  return (
+    <div className="card-hover overflow-hidden">
+      {/* Header */}
+      <div className="flex items-start justify-between p-5 pb-3">
+        <div className="flex-1">
+          <div className="flex items-center gap-3">
+            <h3 className="text-lg font-semibold text-heading">{market.city}</h3>
+            {market.polymarketUrl && (
+              <ExternalLink href={market.polymarketUrl} className="text-purple-600 dark:text-purple-400 text-xs font-medium">
+                Polymarket
+              </ExternalLink>
+            )}
+          </div>
+          <p className="text-sm text-sub mt-0.5">
+            {market.date} &middot; {market.tempUnit === 'F' ? 'Fahrenheit' : 'Celsius'} &middot;{' '}
+            {market.buckets.length} buckets
+          </p>
+        </div>
+        <div className="text-right">
+          <p className="text-sm text-body font-mono">
+            ${market.totalVolume.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+          </p>
+          <p className="text-xs text-dim">24h volume</p>
+          {market.priceSum !== undefined && (
+            <div className="mt-1">
+              <ParitySummary priceSum={market.priceSum} />
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Price Table */}
+      <div className="overflow-x-auto">
+        <table className="w-full">
+          <thead>
+            <tr className="border-t border-b border-gray-200 dark:border-slate-700/50">
+              <th className="px-3 py-2 text-left text-xs font-medium text-sub">Bucket</th>
+              <th className="px-3 py-2 text-left text-xs font-medium text-sub">YES Price</th>
+              <th className="px-3 py-2 text-right text-xs font-medium text-sub">24h Vol</th>
+              <th className="px-3 py-2 text-center text-xs font-medium text-sub">Link</th>
+              <th className="px-3 py-2 w-6" />
+            </tr>
+          </thead>
+          <tbody className="divide-y divide-gray-100 dark:divide-slate-700/30">
+            {market.buckets.map((bucket) => (
+              <BucketRow
+                key={bucket.label}
+                bucket={bucket}
+                maxPrice={maxPrice}
+                isSelected={selectedBucket === bucket.label}
+                onSelect={() =>
+                  setSelectedBucket(
+                    selectedBucket === bucket.label ? null : bucket.label,
+                  )
+                }
+              />
+            ))}
+          </tbody>
+        </table>
+      </div>
+
+      {/* Orderbook Panel */}
+      {selectedBucketData?.yesTokenId && (
+        <div className="border-t border-gray-200 dark:border-slate-700/50 p-5 bg-gray-50 dark:bg-slate-800/30">
+          <Orderbook
+            tokenId={selectedBucketData.yesTokenId}
+            noTokenId={selectedBucketData.noTokenId}
+            label={selectedBucketData.label}
+          />
+        </div>
+      )}
+    </div>
+  );
+}
+
+export function LiveMarketsClient({ markets }: { markets: WeatherMarket[] }) {
+  const [search, setSearch] = useState('');
+
+  const filtered = search
+    ? markets.filter(
+        (m) =>
+          m.city.toLowerCase().includes(search.toLowerCase()) ||
+          m.date.toLowerCase().includes(search.toLowerCase()),
+      )
+    : markets;
+
+  return (
+    <div className="space-y-4">
+      {/* Search */}
+      <div className="relative">
+        <svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
+        </svg>
+        <input
+          type="text"
+          placeholder="Filter by city or date..."
+          value={search}
+          onChange={(e) => setSearch(e.target.value)}
+          className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-sm text-heading placeholder-gray-400 dark:placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500"
+        />
+      </div>
+
+      {/* Market Grid */}
+      <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
+        {filtered.map((market) => (
+          <MarketCard key={market.id} market={market} />
+        ))}
+      </div>
+
+      {filtered.length === 0 && (
+        <p className="text-dim text-center py-8">
+          No markets match &quot;{search}&quot;
+        </p>
+      )}
+    </div>
+  );
+}

+ 148 - 0
packages/dashboard/src/app/markets/MarketsClient.tsx

@@ -0,0 +1,148 @@
+'use client';
+
+import { useState } from 'react';
+import type { WeatherMarket } from '@/lib/types';
+import { ProbabilityChart } from '@/components/ProbabilityChart';
+
+export function MarketsClient({ markets }: { markets: WeatherMarket[] }) {
+  const [expandedId, setExpandedId] = useState<string | null>(null);
+
+  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">
+          {markets.map((market) => {
+            const maxEdge = Math.max(
+              ...market.buckets.map((b) =>
+                Math.abs(b.modelProb - b.marketPrice)
+              )
+            );
+            const isExpanded = expandedId === market.id;
+
+            return (
+              <Fragment key={market.id}>
+                <tr
+                  className="hover:bg-slate-800/50 cursor-pointer transition-colors"
+                  onClick={() =>
+                    setExpandedId(isExpanded ? null : market.id)
+                  }
+                >
+                  <td className="table-cell font-medium text-white">
+                    {market.city}
+                  </td>
+                  <td className="table-cell font-mono text-xs">
+                    {market.date}
+                  </td>
+                  <td className="table-cell text-slate-300">
+                    {market.question}
+                  </td>
+                  <td className="table-cell text-center">
+                    {market.buckets.length}
+                  </td>
+                  <td className="table-cell text-right font-mono">
+                    ${market.totalVolume.toLocaleString()}
+                  </td>
+                  <td className="table-cell text-center">
+                    <span
+                      className={
+                        maxEdge > 0.03
+                          ? 'badge-green'
+                          : 'badge-blue'
+                      }
+                    >
+                      {(maxEdge * 100).toFixed(1)}%
+                    </span>
+                  </td>
+                  <td className="table-cell text-center">
+                    <span
+                      className={`inline-block transition-transform ${
+                        isExpanded ? 'rotate-180' : ''
+                      }`}
+                    >
+                      <svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+                      </svg>
+                    </span>
+                  </td>
+                </tr>
+                {isExpanded && (
+                  <tr>
+                    <td colSpan={7} className="bg-slate-800/30 p-6">
+                      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+                        <div>
+                          <h4 className="text-sm font-medium text-slate-300 mb-4">
+                            Model vs Market Probabilities
+                          </h4>
+                          <ProbabilityChart buckets={market.buckets} />
+                        </div>
+                        <div>
+                          <h4 className="text-sm font-medium text-slate-300 mb-4">
+                            Bucket Details
+                          </h4>
+                          <div className="space-y-2">
+                            {market.buckets.map((bucket) => {
+                              const edge =
+                                bucket.modelProb - bucket.marketPrice;
+                              return (
+                                <div
+                                  key={bucket.label}
+                                  className="flex items-center justify-between bg-slate-800 rounded-lg px-4 py-2"
+                                >
+                                  <span className="font-mono text-sm text-slate-300 w-16">
+                                    {bucket.label}
+                                  </span>
+                                  <div className="flex items-center gap-6 text-xs">
+                                    <span className="text-blue-400">
+                                      Model: {(bucket.modelProb * 100).toFixed(1)}%
+                                    </span>
+                                    <span className="text-red-400">
+                                      Market: {(bucket.marketPrice * 100).toFixed(1)}%
+                                    </span>
+                                    <span
+                                      className={
+                                        edge > 0.02
+                                          ? 'text-green-400 font-medium'
+                                          : edge < -0.02
+                                          ? 'text-red-400'
+                                          : 'text-slate-500'
+                                      }
+                                    >
+                                      Edge: {edge > 0 ? '+' : ''}
+                                      {(edge * 100).toFixed(1)}%
+                                    </span>
+                                    <span className="text-slate-500">
+                                      Vol: ${bucket.volume.toLocaleString()}
+                                    </span>
+                                  </div>
+                                </div>
+                              );
+                            })}
+                          </div>
+                        </div>
+                      </div>
+                    </td>
+                  </tr>
+                )}
+              </Fragment>
+            );
+          })}
+        </tbody>
+      </table>
+    </div>
+  );
+}
+
+function Fragment({ children }: { children: React.ReactNode }) {
+  return <>{children}</>;
+}

+ 39 - 0
packages/dashboard/src/app/markets/loading.tsx

@@ -0,0 +1,39 @@
+export default function MarketsLoading() {
+  return (
+    <div className="space-y-6">
+      <div className="flex items-center justify-between">
+        <div>
+          <div className="h-8 w-64 bg-gray-200 dark:bg-slate-700 rounded animate-pulse" />
+          <div className="h-4 w-80 bg-gray-200 dark:bg-slate-700 rounded animate-pulse mt-2" />
+        </div>
+        <div className="h-10 w-40 bg-gray-200 dark:bg-slate-700 rounded-lg animate-pulse" />
+      </div>
+
+      {/* Search skeleton */}
+      <div className="h-11 bg-gray-200 dark:bg-slate-700 rounded-lg animate-pulse" />
+
+      {/* Market card skeletons */}
+      <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
+        {[1, 2, 3, 4].map((i) => (
+          <div key={i} className="card animate-pulse">
+            <div className="flex items-start justify-between p-5 pb-3">
+              <div className="flex-1">
+                <div className="h-6 w-32 bg-gray-200 dark:bg-slate-700 rounded" />
+                <div className="h-4 w-48 bg-gray-200 dark:bg-slate-700 rounded mt-2" />
+              </div>
+              <div className="text-right">
+                <div className="h-5 w-20 bg-gray-200 dark:bg-slate-700 rounded" />
+                <div className="h-3 w-16 bg-gray-200 dark:bg-slate-700 rounded mt-1" />
+              </div>
+            </div>
+            <div className="space-y-2 px-5 pb-4">
+              {[1, 2, 3, 4, 5].map((j) => (
+                <div key={j} className="h-8 bg-gray-200 dark:bg-slate-700 rounded" />
+              ))}
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 56 - 0
packages/dashboard/src/app/markets/page.tsx

@@ -0,0 +1,56 @@
+import { fetchLiveMarkets } from '@/lib/polymarket-api';
+import { LiveMarketsClient } from './LiveMarketsClient';
+
+export const dynamic = 'force-dynamic';
+
+export default async function MarketsPage() {
+  let markets: Awaited<ReturnType<typeof fetchLiveMarkets>> = [];
+  let fetchError: string | null = null;
+
+  try {
+    markets = await fetchLiveMarkets();
+  } catch (err) {
+    fetchError = String(err);
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-heading">Live Weather Markets</h1>
+          <p className="text-sm text-sub mt-1">
+            Real-time prices from Polymarket CLOB &middot; {markets.length} active markets
+          </p>
+        </div>
+        <a
+          href="https://polymarket.com/markets?tag=weather"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition-colors"
+        >
+          <svg className="w-4 h-4" 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>
+          Open Polymarket
+        </a>
+      </div>
+
+      {fetchError && (
+        <div className="card border border-red-300 dark:border-red-500/30 bg-red-50 dark:bg-red-500/5">
+          <p className="text-red-600 dark:text-red-400 text-sm">Failed to fetch live markets: {fetchError}</p>
+          <p className="text-sub text-xs mt-1">Check your network connection. Polymarket API may be temporarily unavailable.</p>
+        </div>
+      )}
+
+      {markets.length > 0 ? (
+        <LiveMarketsClient markets={markets} />
+      ) : !fetchError ? (
+        <div className="card">
+          <p className="text-dim text-center py-8">
+            No active weather markets found on Polymarket.
+          </p>
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 228 - 0
packages/dashboard/src/app/page.tsx

@@ -0,0 +1,228 @@
+import { getDashboardSummary, getRecentSignals, getRecentTrades } from '@/lib/queries';
+import { RecentSignalsSection } from './RecentSignals';
+import type { TradeRecord } from '@/lib/types';
+
+function StatusIndicator({ running }: { running: boolean }) {
+  return (
+    <span className="flex items-center gap-2">
+      <span
+        className={`w-2.5 h-2.5 rounded-full ${
+          running ? 'bg-green-500 animate-pulse' : 'bg-red-500'
+        }`}
+      />
+      <span className={running ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
+        {running ? 'Running' : 'Stopped'}
+      </span>
+    </span>
+  );
+}
+
+function SummaryCard({
+  label,
+  value,
+  subtext,
+  color = 'text-heading',
+}: {
+  label: string;
+  value: string;
+  subtext?: string;
+  color?: string;
+}) {
+  return (
+    <div className="card">
+      <p className="text-sub text-sm mb-1">{label}</p>
+      <p className={`text-2xl font-bold ${color}`}>{value}</p>
+      {subtext && <p className="text-dim text-xs mt-1">{subtext}</p>}
+    </div>
+  );
+}
+
+export const dynamic = 'force-dynamic';
+
+export default async function DashboardPage() {
+  const [summary, recentSignals, recentTrades] = await Promise.all([
+    getDashboardSummary(),
+    getRecentSignals(20),
+    getRecentTrades(20),
+  ]);
+
+  return (
+    <div className="space-y-8">
+      <div className="flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-heading">Dashboard</h1>
+          <p className="text-sm text-sub mt-1">Weather arbitrage bot overview</p>
+        </div>
+        <div className="flex items-center gap-4">
+          {summary.dryRun && (
+            <span className="badge-yellow">DRY RUN</span>
+          )}
+          <StatusIndicator running={summary.botStatus === 'running'} />
+        </div>
+      </div>
+
+      {/* Bot Status Card */}
+      <div className="card flex items-center justify-between">
+        <div className="flex items-center gap-6">
+          <div>
+            <p className="text-sm text-sub">Bot Status</p>
+            <StatusIndicator running={summary.botStatus === 'running'} />
+          </div>
+          <div className="h-10 w-px divider" />
+          <div>
+            <p className="text-sm text-sub">Uptime</p>
+            <p className="text-lg font-mono text-heading">{summary.uptime}</p>
+          </div>
+          <div className="h-10 w-px divider" />
+          <div>
+            <p className="text-sm text-sub">Mode</p>
+            <p className="text-lg font-medium text-yellow-600 dark:text-yellow-400">
+              {summary.dryRun ? 'Dry Run' : 'Live'}
+            </p>
+          </div>
+        </div>
+      </div>
+
+      {/* Summary Cards */}
+      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+        <SummaryCard
+          label="Active Markets"
+          value={String(summary.activeMarkets)}
+          subtext="Weather prediction markets"
+        />
+        <SummaryCard
+          label="Open Positions"
+          value={String(summary.openPositions)}
+          subtext="Across all markets"
+        />
+        <SummaryCard
+          label="Today's PnL"
+          value={`$${summary.todayPnl.toFixed(2)}`}
+          color={summary.todayPnl >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}
+          subtext="Unrealized + realized"
+        />
+        <SummaryCard
+          label="Win Rate"
+          value={`${(summary.winRate * 100).toFixed(1)}%`}
+          color="text-blue-600 dark:text-blue-400"
+          subtext="Last 30 days"
+        />
+      </div>
+
+      {/* 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} />
+        </div>
+      )}
+
+      {/* Recent Signals */}
+      <div className="card">
+        <div className="flex items-center justify-between mb-4">
+          <h2 className="text-lg font-semibold text-heading">Recent Signals</h2>
+          <span className="text-sm text-sub">
+            {recentSignals.length > 0 ? `Last ${recentSignals.length} signals` : 'No signals yet'}
+          </span>
+        </div>
+        {recentSignals.length > 0 ? (
+          <RecentSignalsSection signals={recentSignals} />
+        ) : (
+          <p className="text-dim text-center py-8">
+            No signals yet. Start the bot to begin scanning markets.
+          </p>
+        )}
+      </div>
+    </div>
+  );
+}
+
+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>
+  );
+}

+ 24 - 0
packages/dashboard/src/app/pnl/PnLClient.tsx

@@ -0,0 +1,24 @@
+'use client';
+
+import { CumulativePnLChart, DailyPnLChart } from '@/components/PnLChart';
+import type { PnLDataPoint } from '@/lib/types';
+
+export function PnLClient({ data }: { data: PnLDataPoint[] }) {
+  return (
+    <>
+      <div className="card">
+        <h2 className="text-lg font-semibold text-white mb-4">
+          Cumulative PnL
+        </h2>
+        <CumulativePnLChart data={data} />
+      </div>
+
+      <div className="card">
+        <h2 className="text-lg font-semibold text-white mb-4">
+          Daily PnL
+        </h2>
+        <DailyPnLChart data={data} />
+      </div>
+    </>
+  );
+}

+ 83 - 0
packages/dashboard/src/app/pnl/page.tsx

@@ -0,0 +1,83 @@
+import { getPnLData } from '@/lib/queries';
+import { PnLClient } from './PnLClient';
+
+function MetricCard({
+  label,
+  value,
+  color = 'text-white',
+}: {
+  label: string;
+  value: string;
+  color?: string;
+}) {
+  return (
+    <div className="card">
+      <p className="text-sm text-slate-400 mb-1">{label}</p>
+      <p className={`text-2xl font-bold ${color}`}>{value}</p>
+    </div>
+  );
+}
+
+export const dynamic = 'force-dynamic';
+
+export default async function PnLPage() {
+  const { data, metrics } = await getPnLData();
+
+  return (
+    <div className="space-y-8">
+      <div>
+        <h1 className="text-2xl font-bold text-white">Profit &amp; Loss</h1>
+        <p className="text-sm text-slate-400 mt-1">
+          Performance metrics and PnL tracking
+        </p>
+      </div>
+
+      {/* Metrics Row */}
+      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+        <MetricCard
+          label="Total PnL"
+          value={`$${metrics.totalPnl.toFixed(2)}`}
+          color={metrics.totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}
+        />
+        <MetricCard
+          label="Win Rate"
+          value={metrics.totalTrades > 0 ? `${(metrics.winRate * 100).toFixed(1)}%` : '—'}
+          color="text-blue-400"
+        />
+        <MetricCard
+          label="Sharpe Ratio"
+          value={metrics.totalTrades > 0 ? metrics.sharpe.toFixed(2) : '—'}
+          color={metrics.sharpe > 1 ? 'text-green-400' : 'text-yellow-400'}
+        />
+        <MetricCard
+          label="Max Drawdown"
+          value={metrics.totalTrades > 0 ? `$${metrics.maxDrawdown.toFixed(2)}` : '—'}
+          color="text-red-400"
+        />
+      </div>
+
+      {/* Trade Stats */}
+      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+        <div className="card">
+          <p className="text-sm text-slate-400 mb-1">Total Trades</p>
+          <p className="text-2xl font-bold text-white">{metrics.totalTrades}</p>
+        </div>
+        <div className="card">
+          <p className="text-sm text-slate-400 mb-1">Winning Trades</p>
+          <p className="text-2xl font-bold text-green-400">{metrics.winningTrades}</p>
+        </div>
+      </div>
+
+      {/* Charts */}
+      {data.length > 0 ? (
+        <PnLClient data={data} />
+      ) : (
+        <div className="card">
+          <p className="text-slate-500 text-center py-8">
+            No PnL data yet. Metrics will appear after the bot completes trades.
+          </p>
+        </div>
+      )}
+    </div>
+  );
+}

+ 284 - 0
packages/dashboard/src/app/signals/SignalsClient.tsx

@@ -0,0 +1,284 @@
+'use client';
+
+import { useState, useMemo } from 'react';
+import { SignalTable } from '@/components/SignalTable';
+import { OrderPanel } from '@/components/OrderPanel';
+import type { Signal } from '@/lib/types';
+
+const strategyTypes = ['ALL', 'TAIL_NO', 'LATENCY_ARB', 'PARITY_ARB'] as const;
+
+const strategyInfo: Record<string, { icon: string; desc: string }> = {
+  ALL: { icon: '\uD83D\uDCCA', desc: 'All detected signals' },
+  TAIL_NO: { icon: '\uD83C\uDFAF', desc: 'Buy NO on overpriced tails' },
+  LATENCY_ARB: { icon: '\u26A1', desc: 'Trade before market reprices' },
+  PARITY_ARB: { icon: '\uD83D\uDD04', desc: 'Exploit \u03A3 YES \u2260 100\u00A2' },
+};
+
+function formatCity(city: string): string {
+  return city
+    .split('_')
+    .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+    .join(' ');
+}
+
+export function SignalsClient({ signals }: { signals: Signal[] }) {
+  const [filterType, setFilterType] = useState<Signal['type'] | 'ALL'>('ALL');
+  const [filterCity, setFilterCity] = useState<string>('ALL');
+  const [filterDate, setFilterDate] = useState<string>('ALL');
+  const [selectionMode, setSelectionMode] = useState(false);
+  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
+  const [showOrderPanel, setShowOrderPanel] = useState(false);
+
+  // Extract unique cities and dates for filter options
+  const uniqueCities = useMemo(() => {
+    const cities = [...new Set(signals.map((s) => s.city))].sort();
+    return cities;
+  }, [signals]);
+
+  const uniqueDates = useMemo(() => {
+    const dates = [...new Set(signals.map((s) => s.targetDate))].sort().reverse();
+    return dates;
+  }, [signals]);
+
+  const counts = {
+    ALL: signals.length,
+    TAIL_NO: signals.filter((s) => s.type === 'TAIL_NO').length,
+    LATENCY_ARB: signals.filter((s) => s.type === 'LATENCY_ARB').length,
+    PARITY_ARB: signals.filter((s) => s.type === 'PARITY_ARB').length,
+  };
+
+  const bestEdge = {
+    ALL: Math.max(...signals.map((s) => s.edge), 0),
+    TAIL_NO: Math.max(...signals.filter((s) => s.type === 'TAIL_NO').map((s) => s.edge), 0),
+    LATENCY_ARB: Math.max(...signals.filter((s) => s.type === 'LATENCY_ARB').map((s) => s.edge), 0),
+    PARITY_ARB: Math.max(...signals.filter((s) => s.type === 'PARITY_ARB').map((s) => s.edge), 0),
+  };
+
+  // Filtered signals for the current view
+  const filteredSignals = useMemo(() => {
+    let result = signals;
+    if (filterType !== 'ALL') result = result.filter((s) => s.type === filterType);
+    if (filterCity !== 'ALL') result = result.filter((s) => s.city === filterCity);
+    if (filterDate !== 'ALL') result = result.filter((s) => s.targetDate === filterDate);
+    return result;
+  }, [signals, filterType, filterCity, filterDate]);
+
+  const selectedSignals = useMemo(
+    () => signals.filter((s) => selectedIds.has(s.id)),
+    [signals, selectedIds],
+  );
+
+  const toggleSelect = (id: string) => {
+    setSelectedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  };
+
+  const toggleAll = () => {
+    const allIds = filteredSignals.map((s) => s.id);
+    const allSelected = allIds.every((id) => selectedIds.has(id));
+    if (allSelected) {
+      setSelectedIds((prev) => {
+        const next = new Set(prev);
+        allIds.forEach((id) => next.delete(id));
+        return next;
+      });
+    } else {
+      setSelectedIds((prev) => {
+        const next = new Set(prev);
+        allIds.forEach((id) => next.add(id));
+        return next;
+      });
+    }
+  };
+
+  const enterSelectionMode = () => {
+    setSelectionMode(true);
+    setSelectedIds(new Set());
+  };
+
+  const exitSelectionMode = () => {
+    setSelectionMode(false);
+    setSelectedIds(new Set());
+  };
+
+  const openOrderPanel = () => {
+    if (selectedIds.size > 0) {
+      setShowOrderPanel(true);
+    }
+  };
+
+  const handleOrderComplete = () => {
+    setShowOrderPanel(false);
+    setSelectionMode(false);
+    setSelectedIds(new Set());
+    // Could trigger a page refresh here
+  };
+
+  return (
+    <>
+      {/* Strategy Summary Cards */}
+      <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+        {strategyTypes.map((type) => {
+          const info = strategyInfo[type];
+          return (
+            <button
+              key={type}
+              onClick={() => setFilterType(type)}
+              className={`card text-left transition-colors ${
+                filterType === type
+                  ? 'border-blue-500/50 bg-blue-500/5'
+                  : 'hover:border-gray-300 dark:hover:border-slate-600'
+              }`}
+            >
+              <div className="flex items-center gap-2">
+                <span className="text-lg">{info.icon}</span>
+                <p className="text-sm text-sub">
+                  {type === 'ALL' ? 'All Signals' : type.replace(/_/g, ' ')}
+                </p>
+              </div>
+              <p className="text-2xl font-bold text-heading mt-1">{counts[type]}</p>
+              <div className="flex items-center justify-between mt-1">
+                <p className="text-xs text-dim">{info.desc}</p>
+                {bestEdge[type] > 0 && (
+                  <span className="text-xs text-green-600 dark:text-green-400">
+                    best +{(bestEdge[type] * 100).toFixed(1)}%
+                  </span>
+                )}
+              </div>
+            </button>
+          );
+        })}
+      </div>
+
+      {/* Signal Table */}
+      <div className="card">
+        <div className="flex flex-col gap-3 mb-4">
+          <div className="flex items-center justify-between">
+            <h2 className="text-lg font-semibold text-heading">Signal History</h2>
+            <div className="flex items-center gap-3">
+              <span className="text-sm text-sub">
+                Showing {filteredSignals.length} signals
+              </span>
+            {!selectionMode ? (
+              <button
+                onClick={enterSelectionMode}
+                className="btn-secondary btn-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
+                onClick={exitSelectionMode}
+                className="btn-ghost btn-sm text-red-600 dark:text-red-400"
+              >
+                Cancel Selection
+              </button>
+            )}
+          </div>
+          </div>
+
+          {/* City & Date Filters */}
+          <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>
+            </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>
+            </div>
+
+            {(filterCity !== 'ALL' || filterDate !== 'ALL') && (
+              <button
+                onClick={() => { setFilterCity('ALL'); setFilterDate('ALL'); }}
+                className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
+              >
+                Clear filters
+              </button>
+            )}
+          </div>
+        </div>
+        <SignalTable
+          signals={filteredSignals}
+          selectedIds={selectionMode ? selectedIds : undefined}
+          onToggleSelect={selectionMode ? toggleSelect : undefined}
+          onToggleAll={selectionMode ? toggleAll : undefined}
+          selectionMode={selectionMode}
+          filterType={filterType}
+        />
+      </div>
+
+      {/* Floating Action Bar */}
+      {selectionMode && selectedIds.size > 0 && (
+        <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
+          <div className="flex items-center gap-4 px-6 py-3 rounded-xl shadow-2xl border bg-white dark:bg-slate-800 border-gray-200 dark:border-slate-700">
+            <div className="flex items-center gap-2">
+              <span className="flex items-center justify-center w-7 h-7 rounded-full bg-blue-600 text-white text-sm font-bold">
+                {selectedIds.size}
+              </span>
+              <span className="text-sm font-medium text-heading whitespace-nowrap">
+                signal{selectedIds.size > 1 ? 's' : ''} selected
+              </span>
+            </div>
+            <div className="w-px h-6 bg-gray-200 dark:bg-slate-700" />
+            <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"
+            >
+              Place Orders
+            </button>
+            <button
+              onClick={() => setSelectedIds(new Set())}
+              className="btn-ghost btn-sm text-xs"
+            >
+              Clear
+            </button>
+          </div>
+        </div>
+      )}
+
+      {/* Order Panel Modal */}
+      {showOrderPanel && selectedSignals.length > 0 && (
+        <OrderPanel
+          signals={selectedSignals}
+          onClose={() => setShowOrderPanel(false)}
+          onComplete={handleOrderComplete}
+        />
+      )}
+    </>
+  );
+}

+ 28 - 0
packages/dashboard/src/app/signals/page.tsx

@@ -0,0 +1,28 @@
+import { getSignals } from '@/lib/queries';
+import { SignalsClient } from './SignalsClient';
+
+export const dynamic = 'force-dynamic';
+
+export default async function SignalsPage() {
+  const signals = await getSignals();
+
+  return (
+    <div className="space-y-8">
+      <div>
+        <h1 className="text-2xl font-bold text-heading">Signals</h1>
+        <p className="text-sm text-sub mt-1">
+          Signal history and strategy performance
+        </p>
+      </div>
+      {signals.length > 0 ? (
+        <SignalsClient signals={signals} />
+      ) : (
+        <div className="card">
+          <p className="text-slate-500 text-center py-8">
+            No signals yet. Start the bot to begin generating trading signals.
+          </p>
+        </div>
+      )}
+    </div>
+  );
+}

+ 66 - 0
packages/dashboard/src/components/MarketCard.tsx

@@ -0,0 +1,66 @@
+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>;
+  } else if (edge < -0.02) {
+    return <span className="badge-red">{pct}%</span>;
+  }
+  return <span className="badge-blue">{pct}%</span>;
+}
+
+export function MarketCard({ market }: { market: WeatherMarket }) {
+  const maxEdge = Math.max(
+    ...market.buckets.map((b) => Math.abs(b.modelProb - b.marketPrice))
+  );
+
+  return (
+    <div className="card-hover">
+      <div className="flex items-start justify-between mb-4">
+        <div>
+          <h3 className="text-lg font-semibold text-white">{market.city}</h3>
+          <p className="text-sm text-slate-400">{market.date} &middot; {market.tempUnit === 'F' ? 'Fahrenheit' : 'Celsius'}</p>
+        </div>
+        <EdgeBadge edge={maxEdge} />
+      </div>
+      <p className="text-sm text-slate-300 mb-4">{market.question}</p>
+
+      <div className="space-y-2">
+        {market.buckets.map((bucket) => {
+          const edge = bucket.modelProb - bucket.marketPrice;
+          const maxProb = Math.max(bucket.modelProb, bucket.marketPrice, 0.01);
+          return (
+            <div key={bucket.label} className="space-y-1">
+              <div className="flex items-center justify-between text-xs">
+                <span className="text-slate-400 w-16 font-mono">{bucket.label}</span>
+                <div className="flex items-center gap-4">
+                  <span className="text-blue-400">M: {(bucket.modelProb * 100).toFixed(1)}%</span>
+                  <span className="text-red-400">P: {(bucket.marketPrice * 100).toFixed(1)}%</span>
+                  <span className={edge > 0.02 ? 'text-green-400' : edge < -0.02 ? 'text-red-400' : 'text-slate-500'}>
+                    {edge > 0 ? '+' : ''}{(edge * 100).toFixed(1)}%
+                  </span>
+                </div>
+              </div>
+              <div className="relative h-2 bg-slate-700 rounded-full overflow-hidden">
+                <div
+                  className="absolute top-0 left-0 h-full bg-blue-500/60 rounded-full"
+                  style={{ width: `${(bucket.modelProb / maxProb) * 100}%` }}
+                />
+                <div
+                  className="absolute top-0 left-0 h-full bg-red-500/40 rounded-full"
+                  style={{ width: `${(bucket.marketPrice / maxProb) * 100}%` }}
+                />
+              </div>
+            </div>
+          );
+        })}
+      </div>
+
+      <div className="mt-4 pt-4 border-t border-slate-700 flex items-center justify-between text-xs text-slate-500">
+        <span>{market.buckets.length} buckets</span>
+        <span>${market.totalVolume.toLocaleString()} volume</span>
+      </div>
+    </div>
+  );
+}

+ 718 - 0
packages/dashboard/src/components/OrderPanel.tsx

@@ -0,0 +1,718 @@
+'use client';
+
+import { useState, useCallback, useEffect } from 'react';
+import type { Signal } from '@/lib/types';
+
+type OrderStep = 'review' | 'pricing' | 'confirm' | 'result';
+type OrderMode = 'LIMIT' | 'MARKET';
+
+interface BookLevel {
+  price: number;
+  size: number;
+}
+
+interface PriceData {
+  signalId: string;
+  yesPrice: number;
+  noPrice: number;
+  // YES side
+  yesBestBid: number;
+  yesBestAsk: number;
+  yesSpread: number;
+  // NO side (direct from NO token orderbook)
+  noBestBid: number;
+  noBestAsk: number;
+  noSpread: number;
+  noAsks: BookLevel[];
+  yesAsks: BookLevel[];
+  // Legacy
+  bestBid: number;
+  bestAsk: number;
+  spread: number;
+  midpoint: number;
+  error?: string;
+}
+
+interface OrderResult {
+  signalId: string;
+  orderId: string;
+  status: 'DRY_RUN' | 'SUBMITTED' | 'FAILED';
+  side: string;
+  price: number;
+  size: number;
+  costBasis: number;
+  error?: string;
+}
+
+const sideLabels: Record<string, { text: string; color: string; bg: string }> = {
+  BUY_YES: { text: 'BUY YES', color: 'text-green-600 dark:text-green-400', bg: 'bg-green-100 dark:bg-green-500/10' },
+  BUY_NO: { text: 'BUY NO', color: 'text-red-600 dark:text-red-400', bg: 'bg-red-100 dark:bg-red-500/10' },
+  BUY_ALL_YES: { text: 'BUY ALL YES', color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-100 dark:bg-blue-500/10' },
+};
+
+function formatCity(city: string): string {
+  return city.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
+}
+
+export function OrderPanel({
+  signals,
+  onClose,
+  onComplete,
+}: {
+  signals: Signal[];
+  onClose: () => void;
+  onComplete: () => void;
+}) {
+  const [step, setStep] = useState<OrderStep>('review');
+  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set(signals.map((s) => s.id)));
+  const [prices, setPrices] = useState<Map<string, PriceData>>(new Map());
+  const [orderMode, setOrderMode] = useState<OrderMode>('LIMIT');
+  const [customSizes, setCustomSizes] = useState<Map<string, number>>(
+    () => new Map(signals.map((s) => [s.id, s.recommendedSize])),
+  );
+  const [loading, setLoading] = useState(false);
+  const [results, setResults] = useState<OrderResult[]>([]);
+  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())
+      .then((data) => setIsDryRun(data.dryRun ?? false))
+      .catch(() => setIsDryRun(false));
+  }, []);
+
+  const selectedSignals = signals.filter((s) => selectedIds.has(s.id));
+
+  const getSize = (id: string, fallback: number) => customSizes.get(id) ?? fallback;
+
+  const updateSize = (id: string, value: string) => {
+    const num = parseFloat(value);
+    setCustomSizes((prev) => {
+      const next = new Map(prev);
+      if (!isNaN(num) && num >= 0) {
+        next.set(id, num);
+      }
+      return next;
+    });
+  };
+
+  const toggleSignal = (id: string) => {
+    setSelectedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  };
+
+  const toggleAll = () => {
+    if (selectedIds.size === signals.length) {
+      setSelectedIds(new Set());
+    } else {
+      setSelectedIds(new Set(signals.map((s) => s.id)));
+    }
+  };
+
+  // Step 1 → Step 2: Fetch latest prices
+  const fetchPrices = useCallback(async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const res = await fetch('/api/prices', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          signals: selectedSignals.map((s) => ({
+            signalId: s.id,
+            city: s.city,
+            targetDate: s.targetDate,
+            bucket: s.bucket,
+            type: s.type,
+            side: s.side,
+          })),
+        }),
+      });
+
+      if (!res.ok) throw new Error('Failed to fetch prices');
+      const data = await res.json();
+      const priceMap = new Map<string, PriceData>();
+      for (const p of data.prices) {
+        priceMap.set(p.signalId, p);
+      }
+      setPrices(priceMap);
+      setStep('pricing');
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Price fetch failed');
+    } finally {
+      setLoading(false);
+    }
+  }, [selectedSignals]);
+
+  // Step 3: Place orders
+  const placeOrders = useCallback(async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const orders = selectedSignals.map((signal) => {
+        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;
+        }
+
+        return {
+          signalId: signal.id,
+          side: signal.side === 'BUY_ALL_YES' ? 'BUY_YES' : signal.side,
+          price: Math.round(price * 100) / 100,
+          size: getSize(signal.id, signal.recommendedSize),
+          orderType: orderMode,
+        };
+      });
+
+      const res = await fetch('/api/orders', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ orders }),
+      });
+
+      if (!res.ok) throw new Error('Failed to place orders');
+      const data = await res.json();
+      setResults(data.results);
+      setStep('result');
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Order placement failed');
+    } finally {
+      setLoading(false);
+    }
+  }, [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">
+        {/* 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>
+
+        {/* Step indicator */}
+        <div className="px-6 py-3 border-b border-gray-100 dark:border-slate-700/50">
+          {(() => {
+            const steps: OrderStep[] = ['review', 'pricing', 'confirm', 'result'];
+            const currentIdx = steps.indexOf(step);
+            return (
+              <div className="flex items-center text-xs">
+                {steps.map((s, i) => {
+                  const isCurrent = step === s;
+                  const isPast = currentIdx > i;
+                  const circleClass = isCurrent
+                    ? 'bg-blue-600 text-white'
+                    : isPast
+                    ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
+                    : 'bg-gray-200 text-gray-400 dark:bg-slate-700 dark:text-slate-500';
+                  return (
+                    <div key={s} className="flex items-center">
+                      {i > 0 && (
+                        <div className={`w-12 h-px mx-1 ${currentIdx >= i ? 'bg-blue-400' : 'bg-gray-300 dark:bg-slate-600'}`} />
+                      )}
+                      <div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-medium shrink-0 ${circleClass}`}>
+                        {i + 1}
+                      </div>
+                    </div>
+                  );
+                })}
+              </div>
+            );
+          })()}
+        </div>
+
+        {/* Content */}
+        <div className="flex-1 overflow-y-auto px-6 py-4">
+          {error && (
+            <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-sm text-red-700 dark:text-red-400">
+              {error}
+            </div>
+          )}
+
+          {/* Step 1: Review & Select */}
+          {step === 'review' && (
+            <div className="space-y-3">
+              <div className="flex items-center justify-between">
+                <button
+                  onClick={toggleAll}
+                  className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
+                >
+                  {selectedIds.size === signals.length ? 'Deselect All' : 'Select All'}
+                </button>
+                <span className="text-xs text-sub">{selectedIds.size} of {signals.length} selected</span>
+              </div>
+              <div className="space-y-2">
+                {signals.map((signal) => {
+                  const side = sideLabels[signal.side];
+                  const checked = selectedIds.has(signal.id);
+                  return (
+                    <label
+                      key={signal.id}
+                      className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
+                        checked
+                          ? 'border-blue-300 bg-blue-50 dark:border-blue-500/30 dark:bg-blue-500/5'
+                          : 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
+                      }`}
+                    >
+                      <input
+                        type="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"
+                      />
+                      <div className="flex-1 min-w-0">
+                        <div className="flex items-center gap-2">
+                          <span className={`text-xs font-semibold ${side.color}`}>{side.text}</span>
+                          <span className="text-xs text-dim">|</span>
+                          <span className="text-sm font-medium text-heading">{formatCity(signal.city)}</span>
+                          <span className="text-xs text-dim">{signal.targetDate.slice(5)}</span>
+                        </div>
+                        <div className="flex items-center gap-3 mt-0.5">
+                          <span className="text-xs text-sub font-mono">{signal.bucket}</span>
+                          <span className="text-xs text-green-600 dark:text-green-400 font-medium">
+                            +{(signal.edge * 100).toFixed(1)}% edge
+                          </span>
+                        </div>
+                      </div>
+                      <div className="flex items-center gap-1.5" onClick={(e) => e.preventDefault()}>
+                        <span className="text-xs text-dim">Shares:</span>
+                        <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"
+                        />
+                      </div>
+                    </label>
+                  );
+                })}
+              </div>
+            </div>
+          )}
+
+          {/* 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 ? (
+                              <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>
+
+              {/* 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">
+                <h4 className="text-sm font-medium text-heading mb-3">Order Type</h4>
+                <div className="grid grid-cols-2 gap-3">
+                  <button
+                    onClick={() => setOrderMode('LIMIT')}
+                    className={`p-3 rounded-lg border text-left transition-colors ${
+                      orderMode === 'LIMIT'
+                        ? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10'
+                        : 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
+                    }`}
+                  >
+                    <p className={`text-sm font-medium ${orderMode === 'LIMIT' ? 'text-blue-700 dark:text-blue-400' : 'text-heading'}`}>
+                      Limit Order (Recommended)
+                    </p>
+                    <p className="text-xs text-sub mt-0.5">
+                      Place at suggested price. Zero fees as maker. May take time to fill.
+                    </p>
+                  </button>
+                  <button
+                    onClick={() => setOrderMode('MARKET')}
+                    className={`p-3 rounded-lg border text-left transition-colors ${
+                      orderMode === 'MARKET'
+                        ? 'border-orange-500 bg-orange-50 dark:bg-orange-500/10'
+                        : 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
+                    }`}
+                  >
+                    <p className={`text-sm font-medium ${orderMode === 'MARKET' ? 'text-orange-700 dark:text-orange-400' : 'text-heading'}`}>
+                      Market Order
+                    </p>
+                    <p className="text-xs text-sub mt-0.5">
+                      Fill at best available price. Immediate execution. Pays taker fees.
+                    </p>
+                  </button>
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* Step 3: Confirm */}
+          {step === 'confirm' && (
+            <div className="space-y-4">
+              {isDryRun === null ? (
+                <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-700 rounded-lg p-4">
+                  <p className="text-sm text-sub">Checking mode...</p>
+                </div>
+              ) : isDryRun ? (
+                <div className="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 rounded-lg p-4">
+                  <p className="text-sm font-medium text-yellow-800 dark:text-yellow-400">
+                    🧪 DRY RUN mode — orders will be simulated, no real trades
+                  </p>
+                </div>
+              ) : (
+                <div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-lg p-4">
+                  <p className="text-sm font-medium text-red-800 dark:text-red-400">
+                    ⚠️ LIVE mode — real orders will be placed on Polymarket!
+                  </p>
+                </div>
+              )}
+
+              <div className="space-y-2">
+                {selectedSignals.map((signal) => {
+                  const priceData = prices.get(signal.id);
+                  const side = sideLabels[signal.side];
+                  let orderPrice: number;
+                  if (orderMode === 'MARKET') {
+                    orderPrice = signal.side === 'BUY_NO'
+                      ? (priceData?.noBestAsk ?? (1 - signal.marketYesPrice))
+                      : (priceData?.yesBestAsk ?? signal.marketYesPrice);
+                  } else {
+                    orderPrice = signal.suggestedPrice;
+                  }
+                  const size = getSize(signal.id, signal.recommendedSize);
+                  const cost = orderPrice * size;
+
+                  return (
+                    <div key={signal.id} className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-slate-700">
+                      <div>
+                        <div className="flex items-center gap-2">
+                          <span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${side.bg} ${side.color}`}>
+                            {side.text}
+                          </span>
+                          <span className="text-sm text-heading font-medium">
+                            {formatCity(signal.city)} · {signal.bucket}
+                          </span>
+                        </div>
+                        <p className="text-xs text-sub mt-0.5">
+                          {orderMode === 'LIMIT' ? 'Limit' : 'Market'} @ {(orderPrice * 100).toFixed(1)}c
+                          {' · '}{size.toFixed(1)} shares
+                        </p>
+                      </div>
+                      <div className="text-right">
+                        <p className="text-sm font-semibold text-heading">${cost.toFixed(2)}</p>
+                        <p className="text-xs text-dim">cost</p>
+                      </div>
+                    </div>
+                  );
+                })}
+              </div>
+
+              {/* Total summary */}
+              <div className="flex items-center justify-between p-4 rounded-lg bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-700">
+                <div>
+                  <p className="text-sm text-sub">Total Orders</p>
+                  <p className="text-2xl font-bold text-heading">{selectedSignals.length}</p>
+                </div>
+                <div className="text-right">
+                  <p className="text-sm text-sub">Total Cost</p>
+                  <p className="text-2xl font-bold text-heading">
+                    ${selectedSignals.reduce((sum, s) => {
+                      const pd = prices.get(s.id);
+                      let p: number;
+                      if (orderMode === 'MARKET') {
+                        p = s.side === 'BUY_NO' ? (pd?.noBestAsk ?? (1 - s.marketYesPrice)) : (pd?.yesBestAsk ?? s.marketYesPrice);
+                      } else {
+                        p = s.suggestedPrice;
+                      }
+                      return sum + p * getSize(s.id, s.recommendedSize);
+                    }, 0).toFixed(2)}
+                  </p>
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* Step 4: Results */}
+          {step === 'result' && (
+            <div className="space-y-3">
+              {results.map((result) => {
+                const signal = signals.find((s) => s.id === result.signalId);
+                const isSuccess = result.status === 'DRY_RUN' || result.status === 'SUBMITTED';
+                return (
+                  <div
+                    key={result.signalId}
+                    className={`p-3 rounded-lg border ${
+                      isSuccess
+                        ? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/5'
+                        : 'border-red-200 bg-red-50 dark:border-red-500/20 dark:bg-red-500/5'
+                    }`}
+                  >
+                    <div className="flex items-center justify-between">
+                      <div className="flex items-center gap-2">
+                        <span className={isSuccess ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
+                          {isSuccess ? '\u2713' : '\u2717'}
+                        </span>
+                        <span className="text-sm font-medium text-heading">
+                          {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'
+                      }`}>
+                        {result.status}
+                      </span>
+                    </div>
+                    <div className="mt-1 text-xs text-sub">
+                      {result.side} @ {(result.price * 100).toFixed(1)}c
+                      {' · '}{result.size.toFixed(1)} shares
+                      {' · '}${result.costBasis.toFixed(2)}
+                      {result.status === 'DRY_RUN' && (
+                        <span className="ml-2 text-dim">ID: {result.orderId}</span>
+                      )}
+                    </div>
+                    {result.error && (
+                      <p className="mt-1 text-xs text-red-600 dark:text-red-400">{result.error}</p>
+                    )}
+                  </div>
+                );
+              })}
+            </div>
+          )}
+        </div>
+
+        {/* Footer actions */}
+        <div className="px-6 py-4 border-t border-gray-200 dark:border-slate-700 flex items-center justify-between">
+          <div>
+            {step !== 'review' && step !== 'result' && (
+              <button
+                onClick={() => {
+                  if (step === 'pricing') setStep('review');
+                  if (step === 'confirm') setStep('pricing');
+                }}
+                className="btn-ghost btn-sm"
+                disabled={loading}
+              >
+                Back
+              </button>
+            )}
+          </div>
+          <div className="flex items-center gap-3">
+            {step === 'result' ? (
+              <button onClick={onComplete} className="btn-primary btn-sm">
+                Done
+              </button>
+            ) : (
+              <>
+                <button onClick={onClose} className="btn-ghost btn-sm">
+                  Cancel
+                </button>
+                {step === 'review' && (
+                  <button
+                    onClick={fetchPrices}
+                    disabled={selectedIds.size === 0 || loading}
+                    className="btn-primary btn-sm"
+                  >
+                    {loading ? (
+                      <span className="flex items-center gap-2">
+                        <Spinner /> Fetching Prices...
+                      </span>
+                    ) : (
+                      `Fetch Prices (${selectedIds.size})`
+                    )}
+                  </button>
+                )}
+                {step === 'pricing' && (
+                  <button
+                    onClick={() => setStep('confirm')}
+                    disabled={selectedIds.size === 0}
+                    className="btn-primary btn-sm"
+                  >
+                    Review Orders ({selectedIds.size})
+                  </button>
+                )}
+                {step === 'confirm' && (
+                  <button
+                    onClick={placeOrders}
+                    disabled={loading}
+                    className={`btn-sm ${orderMode === 'MARKET' ? 'btn-danger' : 'btn-primary'}`}
+                  >
+                    {loading ? (
+                      <span className="flex items-center gap-2">
+                        <Spinner /> Placing Orders...
+                      </span>
+                    ) : (
+                      `Place ${selectedIds.size} ${orderMode === 'LIMIT' ? 'Limit' : 'Market'} Order${selectedIds.size > 1 ? 's' : ''}`
+                    )}
+                  </button>
+                )}
+              </>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function Spinner() {
+  return (
+    <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" 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>
+  );
+}

+ 128 - 0
packages/dashboard/src/components/Orderbook.tsx

@@ -0,0 +1,128 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import type { OrderbookLevel } from '@/lib/types';
+
+interface OrderbookData {
+  bids: OrderbookLevel[];
+  asks: OrderbookLevel[];
+  spread: number;
+  midpoint: number;
+}
+
+export function Orderbook({ tokenId, noTokenId, label }: { tokenId: string; noTokenId?: string; label: string }) {
+  const [data, setData] = useState<OrderbookData | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    if (!tokenId) {
+      setLoading(false);
+      return;
+    }
+
+    let cancelled = false;
+
+    async function load() {
+      try {
+        setLoading(true);
+        let url = `/api/orderbook?tokenId=${encodeURIComponent(tokenId)}`;
+        if (noTokenId) url += `&noTokenId=${encodeURIComponent(noTokenId)}`;
+        const res = await fetch(url);
+        if (!res.ok) throw new Error('Failed to fetch');
+        const ob = await res.json();
+        if (!cancelled) setData(ob);
+      } catch {
+        if (!cancelled) setError('Failed to load orderbook');
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    }
+
+    load();
+    // Refresh every 15s
+    const interval = setInterval(load, 15000);
+    return () => {
+      cancelled = true;
+      clearInterval(interval);
+    };
+  }, [tokenId, noTokenId]);
+
+  if (!tokenId) {
+    return <p className="text-dim text-xs">No token ID</p>;
+  }
+
+  if (loading && !data) {
+    return (
+      <div className="animate-pulse space-y-1">
+        {[...Array(5)].map((_, i) => (
+          <div key={i} className="h-4 bg-gray-200 dark:bg-slate-700 rounded w-full" />
+        ))}
+      </div>
+    );
+  }
+
+  if (error || !data) {
+    return <p className="text-red-600 dark:text-red-400 text-xs">{error || 'No data'}</p>;
+  }
+
+  const maxBidSize = Math.max(...data.bids.map((b) => b.size), 1);
+  const maxAskSize = Math.max(...data.asks.map((a) => a.size), 1);
+
+  return (
+    <div className="space-y-2">
+      <div className="flex items-center justify-between">
+        <span className="text-xs font-medium text-body">{label} — YES Orderbook</span>
+        <span className="text-xs text-dim">
+          Spread: {(data.spread * 100).toFixed(1)}% | Mid: {(data.midpoint * 100).toFixed(1)}%
+        </span>
+      </div>
+
+      <div className="grid grid-cols-2 gap-2 text-xs font-mono">
+        {/* Bids (left) */}
+        <div>
+          <div className="flex justify-between text-dim mb-1 px-1">
+            <span>Size</span>
+            <span>Bid</span>
+          </div>
+          {data.bids.length > 0 ? (
+            data.bids.map((bid, i) => (
+              <div key={i} className="relative flex justify-between px-1 py-0.5">
+                <div
+                  className="absolute inset-y-0 right-0 bg-green-500/10 rounded-sm"
+                  style={{ width: `${(bid.size / maxBidSize) * 100}%` }}
+                />
+                <span className="relative text-body">{bid.size.toFixed(1)}</span>
+                <span className="relative text-green-600 dark:text-green-400">{(bid.price * 100).toFixed(1)}{'\u00a2'}</span>
+              </div>
+            ))
+          ) : (
+            <p className="text-dim text-center py-2">No bids</p>
+          )}
+        </div>
+
+        {/* Asks (right) */}
+        <div>
+          <div className="flex justify-between text-dim mb-1 px-1">
+            <span>Ask</span>
+            <span>Size</span>
+          </div>
+          {data.asks.length > 0 ? (
+            data.asks.map((ask, i) => (
+              <div key={i} className="relative flex justify-between px-1 py-0.5">
+                <div
+                  className="absolute inset-y-0 left-0 bg-red-500/10 rounded-sm"
+                  style={{ width: `${(ask.size / maxAskSize) * 100}%` }}
+                />
+                <span className="relative text-red-600 dark:text-red-400">{(ask.price * 100).toFixed(1)}{'\u00a2'}</span>
+                <span className="relative text-body">{ask.size.toFixed(1)}</span>
+              </div>
+            ))
+          ) : (
+            <p className="text-dim text-center py-2">No asks</p>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 107 - 0
packages/dashboard/src/components/PnLChart.tsx

@@ -0,0 +1,107 @@
+'use client';
+
+import {
+  LineChart,
+  Line,
+  BarChart,
+  Bar,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+  ReferenceLine,
+  Cell,
+} from 'recharts';
+import type { PnLDataPoint } from '@/lib/types';
+
+interface PnLChartProps {
+  data: PnLDataPoint[];
+}
+
+export function CumulativePnLChart({ data }: PnLChartProps) {
+  return (
+    <ResponsiveContainer width="100%" height={350}>
+      <LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
+        <defs>
+          <linearGradient id="pnlGradient" x1="0" y1="0" x2="0" y2="1">
+            <stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
+            <stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
+          </linearGradient>
+        </defs>
+        <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
+        <XAxis
+          dataKey="date"
+          tick={{ fill: '#94a3b8', fontSize: 11 }}
+          axisLine={{ stroke: '#475569' }}
+          tickFormatter={(v: string) => v.slice(5)}
+        />
+        <YAxis
+          tick={{ fill: '#94a3b8', fontSize: 12 }}
+          axisLine={{ stroke: '#475569' }}
+          tickFormatter={(v: number) => `$${v}`}
+        />
+        <Tooltip
+          contentStyle={{
+            backgroundColor: '#1e293b',
+            border: '1px solid #334155',
+            borderRadius: '8px',
+            color: '#f1f5f9',
+          }}
+          formatter={(value: number) => [`$${value.toFixed(2)}`, 'Cumulative PnL']}
+          labelFormatter={(label: string) => `Date: ${label}`}
+        />
+        <ReferenceLine y={0} stroke="#475569" strokeDasharray="3 3" />
+        <Line
+          type="monotone"
+          dataKey="cumulative"
+          stroke="#22c55e"
+          strokeWidth={2.5}
+          dot={{ fill: '#22c55e', r: 3, strokeWidth: 0 }}
+          activeDot={{ r: 5, fill: '#22c55e' }}
+          fill="url(#pnlGradient)"
+        />
+      </LineChart>
+    </ResponsiveContainer>
+  );
+}
+
+export function DailyPnLChart({ data }: PnLChartProps) {
+  return (
+    <ResponsiveContainer width="100%" height={250}>
+      <BarChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
+        <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
+        <XAxis
+          dataKey="date"
+          tick={{ fill: '#94a3b8', fontSize: 11 }}
+          axisLine={{ stroke: '#475569' }}
+          tickFormatter={(v: string) => v.slice(5)}
+        />
+        <YAxis
+          tick={{ fill: '#94a3b8', fontSize: 12 }}
+          axisLine={{ stroke: '#475569' }}
+          tickFormatter={(v: number) => `$${v}`}
+        />
+        <Tooltip
+          contentStyle={{
+            backgroundColor: '#1e293b',
+            border: '1px solid #334155',
+            borderRadius: '8px',
+            color: '#f1f5f9',
+          }}
+          formatter={(value: number) => [`$${value.toFixed(2)}`, 'Daily PnL']}
+          labelFormatter={(label: string) => `Date: ${label}`}
+        />
+        <ReferenceLine y={0} stroke="#475569" strokeDasharray="3 3" />
+        <Bar dataKey="daily" radius={[3, 3, 0, 0]}>
+          {data.map((entry, index) => (
+            <Cell
+              key={`bar-${index}`}
+              fill={entry.daily >= 0 ? '#22c55e' : '#ef4444'}
+            />
+          ))}
+        </Bar>
+      </BarChart>
+    </ResponsiveContainer>
+  );
+}

+ 83 - 0
packages/dashboard/src/components/ProbabilityChart.tsx

@@ -0,0 +1,83 @@
+'use client';
+
+import {
+  BarChart,
+  Bar,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  Legend,
+  ResponsiveContainer,
+  Cell,
+} from 'recharts';
+import type { MarketBucket } from '@/lib/types';
+
+interface ProbabilityChartProps {
+  buckets: MarketBucket[];
+  edgeThreshold?: number;
+}
+
+export function ProbabilityChart({
+  buckets,
+  edgeThreshold = 0.03,
+}: ProbabilityChartProps) {
+  const data = buckets.map((b) => ({
+    label: b.label,
+    model: +(b.modelProb * 100).toFixed(1),
+    market: +(b.marketPrice * 100).toFixed(1),
+    edge: +((b.modelProb - b.marketPrice) * 100).toFixed(1),
+    hasEdge: Math.abs(b.modelProb - b.marketPrice) > edgeThreshold,
+  }));
+
+  return (
+    <ResponsiveContainer width="100%" height={300}>
+      <BarChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
+        <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
+        <XAxis
+          dataKey="label"
+          tick={{ fill: '#94a3b8', fontSize: 12 }}
+          axisLine={{ stroke: '#475569' }}
+        />
+        <YAxis
+          tick={{ fill: '#94a3b8', fontSize: 12 }}
+          axisLine={{ stroke: '#475569' }}
+          tickFormatter={(v: number) => `${v}%`}
+        />
+        <Tooltip
+          contentStyle={{
+            backgroundColor: '#1e293b',
+            border: '1px solid #334155',
+            borderRadius: '8px',
+            color: '#f1f5f9',
+          }}
+          formatter={(value: number, name: string) => [
+            `${value}%`,
+            name === 'model' ? 'Model Prob' : 'Market Price',
+          ]}
+        />
+        <Legend
+          formatter={(value: string) =>
+            value === 'model' ? 'Model Probability' : 'Market Price'
+          }
+        />
+        <Bar dataKey="model" fill="#3b82f6" radius={[2, 2, 0, 0]}>
+          {data.map((entry, index) => (
+            <Cell
+              key={`model-${index}`}
+              fill={entry.hasEdge ? '#3b82f6' : '#3b82f680'}
+            />
+          ))}
+        </Bar>
+        <Bar dataKey="market" fill="#ef4444" radius={[2, 2, 0, 0]}>
+          {data.map((entry, index) => (
+            <Cell
+              key={`market-${index}`}
+              fill={entry.hasEdge ? '#ef4444' : '#ef444480'}
+            />
+          ))}
+        </Bar>
+      </BarChart>
+    </ResponsiveContainer>
+  );
+}

+ 192 - 0
packages/dashboard/src/components/Sidebar.tsx

@@ -0,0 +1,192 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { ThemeToggle } from './ThemeToggle';
+
+const navItems = [
+  {
+    label: 'Dashboard',
+    href: '/',
+    icon: (
+      <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
+      </svg>
+    ),
+  },
+  {
+    label: 'Markets',
+    href: '/markets',
+    icon: (
+      <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
+      </svg>
+    ),
+  },
+  {
+    label: 'Signals',
+    href: '/signals',
+    icon: (
+      <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
+      </svg>
+    ),
+  },
+  {
+    label: 'PnL',
+    href: '/pnl',
+    icon: (
+      <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
+      </svg>
+    ),
+  },
+];
+
+interface BotStatus {
+  connected: boolean;
+  autoExecute: boolean;
+  dryRun: boolean;
+  isRunning: boolean;
+}
+
+export function Sidebar() {
+  const pathname = usePathname();
+  const [botStatus, setBotStatus] = useState<BotStatus>({
+    connected: false,
+    autoExecute: false,
+    dryRun: true,
+    isRunning: false,
+  });
+  const [toggling, setToggling] = useState(false);
+
+  const fetchStatus = useCallback(async () => {
+    try {
+      const res = await fetch('/api/bot-control');
+      if (res.ok) {
+        const data = await res.json();
+        setBotStatus(data);
+      }
+    } catch {
+      setBotStatus((prev) => ({ ...prev, connected: false }));
+    }
+  }, []);
+
+  useEffect(() => {
+    fetchStatus();
+    const interval = setInterval(fetchStatus, 10000); // poll every 10s
+    return () => clearInterval(interval);
+  }, [fetchStatus]);
+
+  const toggleAutoExecute = async () => {
+    setToggling(true);
+    try {
+      const res = await fetch('/api/bot-control', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ autoExecute: !botStatus.autoExecute }),
+      });
+      if (res.ok) {
+        setBotStatus((prev) => ({ ...prev, autoExecute: !prev.autoExecute }));
+      }
+    } catch {
+      // ignore
+    } finally {
+      setToggling(false);
+    }
+  };
+
+  return (
+    <aside className="w-64 bg-white dark:bg-slate-950 border-r border-gray-200 dark:border-slate-800 flex flex-col">
+      <div className="p-6 border-b border-gray-200 dark:border-slate-800">
+        <div className="flex items-center gap-3">
+          <Image
+            src="/logo.png"
+            alt="Poly Weather"
+            width={36}
+            height={36}
+            className="rounded-lg"
+          />
+          <div className="flex-1">
+            <h1 className="text-lg font-bold text-gray-900 dark:text-white">Poly Weather</h1>
+            <p className="text-xs text-gray-500 dark:text-slate-500">Arbitrage Bot</p>
+          </div>
+          <ThemeToggle />
+        </div>
+      </div>
+
+      <nav className="flex-1 p-4 space-y-1">
+        {navItems.map((item) => {
+          const isActive =
+            item.href === '/'
+              ? pathname === '/'
+              : pathname.startsWith(item.href);
+          return (
+            <Link
+              key={item.href}
+              href={item.href}
+              className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
+                isActive
+                  ? 'bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20'
+                  : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/50'
+              }`}
+            >
+              {item.icon}
+              {item.label}
+            </Link>
+          );
+        })}
+      </nav>
+
+      <div className="p-4 border-t border-gray-200 dark:border-slate-800 space-y-3">
+        {/* Bot Connection Status */}
+        <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-slate-500">
+          <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">
+              LIVE
+            </span>
+          )}
+          {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">
+              DRY RUN
+            </span>
+          )}
+        </div>
+
+        {/* Auto-Execute Toggle */}
+        {botStatus.connected && (
+          <div className="flex items-center justify-between">
+            <span className="text-xs text-gray-600 dark:text-slate-400 font-medium">
+              Auto-Execute
+            </span>
+            <button
+              onClick={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>
+        )}
+
+        {botStatus.connected && !botStatus.autoExecute && (
+          <p className="text-[10px] text-yellow-600 dark:text-yellow-400 leading-tight">
+            Signals detected but orders NOT auto-placed. Use Manual Order on Signals page.
+          </p>
+        )}
+      </div>
+    </aside>
+  );
+}

+ 453 - 0
packages/dashboard/src/components/SignalTable.tsx

@@ -0,0 +1,453 @@
+'use client';
+
+import { useState, useMemo, Fragment } from 'react';
+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 sideLabels: Record<Signal['side'], { text: string; color: string }> = {
+  BUY_YES: { text: 'BUY YES', color: 'text-green-600 dark:text-green-400' },
+  BUY_NO: { text: 'BUY NO', color: 'text-red-600 dark:text-red-400' },
+  BUY_ALL_YES: { text: 'BUY ALL', color: 'text-blue-600 dark:text-blue-400' },
+};
+
+const strategyDescriptions: Record<Signal['type'], string> = {
+  TAIL_NO:
+    'Tail buckets (e.g. "56\u00b0F or higher") have near-zero real probability but market prices them > 0. Buy NO to collect near-free premium.',
+  LATENCY_ARB:
+    'Weather model updated but market hasn\'t repriced yet. Buy the mispriced side before others catch up.',
+  PARITY_ARB:
+    'Sum of all YES prices should = 100\u00a2. When it deviates, buy the cheap portfolio for guaranteed profit.',
+};
+
+export function SignalTable({
+  signals,
+  filterType,
+  selectedIds,
+  onToggleSelect,
+  onToggleAll,
+  selectionMode,
+}: {
+  signals: Signal[];
+  filterType?: Signal['type'] | 'ALL';
+  selectedIds?: Set<string>;
+  onToggleSelect?: (id: string) => void;
+  onToggleAll?: () => void;
+  selectionMode?: boolean;
+}) {
+  const [sortKey, setSortKey] = useState<SortKey>('timestamp');
+  const [sortDir, setSortDir] = useState<SortDir>('desc');
+  const [expandedId, setExpandedId] = useState<string | null>(null);
+
+  const filtered = useMemo(() => {
+    let result = signals;
+    if (filterType && filterType !== 'ALL') {
+      result = result.filter((s) => s.type === filterType);
+    }
+    return result;
+  }, [signals, filterType]);
+
+  const sorted = useMemo(() => {
+    return [...filtered].sort((a, b) => {
+      const av = a[sortKey];
+      const bv = b[sortKey];
+      if (typeof av === 'number' && typeof bv === 'number') {
+        return sortDir === 'asc' ? av - bv : bv - av;
+      }
+      const as = String(av);
+      const bs = String(bv);
+      return sortDir === 'asc' ? as.localeCompare(bs) : bs.localeCompare(as);
+    });
+  }, [filtered, sortKey, sortDir]);
+
+  function handleSort(key: SortKey) {
+    if (sortKey === key) {
+      setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
+    } else {
+      setSortKey(key);
+      setSortDir('desc');
+    }
+  }
+
+  const allFilteredSelected = selectedIds && filtered.length > 0 && filtered.every((s) => selectedIds.has(s.id));
+
+  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"
+        onClick={() => handleSort(field)}
+      >
+        <span className="flex items-center gap-1">
+          {label}
+          {active && (
+            <span className="text-blue-500 dark:text-blue-400">
+              {sortDir === 'asc' ? '\u2191' : '\u2193'}
+            </span>
+          )}
+        </span>
+      </th>
+    );
+  }
+
+  return (
+    <div className="space-y-3">
+      {/* Strategy legend at top */}
+      {filterType && filterType !== 'ALL' && (
+        <div className="bg-gray-100 dark:bg-slate-800/40 border border-gray-200 dark:border-slate-700/50 rounded-lg px-4 py-3 text-sm text-body">
+          <span className="font-semibold text-heading">Strategy: </span>
+          {strategyDescriptions[filterType]}
+        </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);
+
+              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'}
+                          </span>
+                          <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>
+                    </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>
+                    </td>
+                  </tr>
+
+                  {/* Expanded detail panel */}
+                  {isExpanded && (
+                    <tr>
+                      <td colSpan={selectionMode ? 5 : 4} className="p-0">
+                        <SignalDetail signal={signal} />
+                      </td>
+                    </tr>
+                  )}
+                </Fragment>
+              );
+            })}
+          </tbody>
+        </table>
+      </div>
+    </div>
+  );
+}
+
+function SignalDetail({ signal }: { signal: Signal }) {
+  const noPrice = 1 - signal.marketYesPrice;
+
+  return (
+    <div className="bg-gray-50 dark:bg-slate-800/60 border-t border-b border-gray-200 dark:border-slate-700/30 px-6 py-4 space-y-4">
+      {/* Action description */}
+      <div className="bg-white dark:bg-slate-900/50 rounded-lg p-4 border border-gray-200 dark:border-slate-700/50 shadow-sm dark:shadow-none">
+        <div className="flex items-start gap-3">
+          <span className="text-xl mt-0.5">
+            {signal.type === 'TAIL_NO' ? '\uD83C\uDFAF' : signal.type === 'LATENCY_ARB' ? '\u26A1' : '\uD83D\uDD04'}
+          </span>
+          <div className="flex-1">
+            <h4 className="text-heading font-semibold text-sm mb-1">
+              How to Trade This Signal
+            </h4>
+            <p className="text-body text-sm leading-relaxed">
+              {signal.action}
+            </p>
+          </div>
+        </div>
+      </div>
+
+      {/* Details grid */}
+      <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+        <DetailItem
+          label={signal.type === 'PARITY_ARB' ? '\u03A3 YES Price' : 'Market YES Price'}
+          value={`${(signal.marketYesPrice * 100).toFixed(1)}\u00a2`}
+          highlight={signal.type === 'PARITY_ARB'}
+        />
+        {signal.type !== 'PARITY_ARB' ? (
+          <DetailItem
+            label="Market NO Price"
+            value={`${(noPrice * 100).toFixed(1)}\u00a2`}
+          />
+        ) : (
+          <DetailItem
+            label="Gap from 100\u00a2"
+            value={`${signal.marketYesPrice >= 1 ? '+' : ''}${((signal.marketYesPrice - 1) * 100).toFixed(1)}\u00a2`}
+            positive={signal.marketYesPrice < 1}
+          />
+        )}
+        {signal.type !== 'PARITY_ARB' && (
+          <DetailItem
+            label="Model Probability"
+            value={`${(signal.modelProbability * 100).toFixed(1)}%`}
+            highlight={Math.abs(signal.modelProbability - signal.marketYesPrice) > 0.05}
+          />
+        )}
+        <DetailItem
+          label="Suggested Size"
+          value={`$${signal.recommendedSize.toFixed(2)}`}
+        />
+        <DetailItem
+          label="Edge (model vs market)"
+          value={`${(signal.edge * 100).toFixed(1)}%`}
+          positive={signal.edge > 0}
+        />
+        {(() => {
+          const buyPrice = signal.suggestedPrice;
+          const roi = buyPrice > 0 && buyPrice < 1 ? (1 - buyPrice) / buyPrice : 0;
+          return (
+            <DetailItem
+              label={`ROI (buy @ ${(buyPrice * 100).toFixed(1)}c, win $1)`}
+              value={`+${(roi * 100).toFixed(0)}%`}
+              positive={roi > 0}
+            />
+          );
+        })()}
+        <DetailItem
+          label="Confidence"
+          value={signal.confidenceLevel}
+          badge
+        />
+        <DetailItem
+          label="Model Agreement"
+          value={`${(signal.confidence * 100).toFixed(0)}%`}
+        />
+      </div>
+
+      {/* Step-by-step instructions */}
+      <div className="border-t border-gray-200 dark:border-slate-700/50 pt-3">
+        <h5 className="text-xs text-dim uppercase tracking-wider mb-2">Steps to Execute</h5>
+        <ol className="text-sm text-body space-y-1.5 list-decimal list-inside">
+          {signal.type === 'TAIL_NO' && (
+            <>
+              <li>
+                Open{' '}
+                <a
+                  href={signal.polymarketUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="text-blue-600 dark:text-blue-400 hover:underline"
+                >
+                  Polymarket: {formatCity(signal.city)} {signal.targetDate}
+                </a>
+              </li>
+              <li>Click on bucket <span className="text-heading font-mono font-medium">{`"${signal.bucket}"`}</span></li>
+              <li>Select <span className="text-red-600 dark:text-red-400 font-semibold">NO</span> tab</li>
+              <li>Set limit price to <span className="text-heading font-medium">{(noPrice * 100).toFixed(1)}{'\u00a2'}</span> or better</li>
+              <li>Enter amount: <span className="text-heading font-medium">${signal.recommendedSize.toFixed(2)}</span></li>
+              <li>Review and confirm order</li>
+            </>
+          )}
+          {signal.type === 'LATENCY_ARB' && (
+            <>
+              <li>
+                Open{' '}
+                <a
+                  href={signal.polymarketUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="text-blue-600 dark:text-blue-400 hover:underline"
+                >
+                  Polymarket: {formatCity(signal.city)} {signal.targetDate}
+                </a>
+              </li>
+              <li>Click on bucket <span className="text-heading font-mono font-medium">{`"${signal.bucket}"`}</span></li>
+              <li>
+                Select <span className={`font-semibold ${signal.side === 'BUY_YES' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
+                  {signal.side === 'BUY_YES' ? 'YES' : 'NO'}
+                </span> tab
+              </li>
+              <li>Set limit price to <span className="text-heading font-medium">{(signal.suggestedPrice * 100).toFixed(1)}{'\u00a2'}</span></li>
+              <li>Act fast -- latency edge decays in minutes</li>
+            </>
+          )}
+          {signal.type === 'PARITY_ARB' && signal.marketYesPrice < 1 && (
+            <>
+              <li>
+                Open{' '}
+                <a
+                  href={signal.polymarketUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="text-blue-600 dark:text-blue-400 hover:underline"
+                >
+                  Polymarket: {formatCity(signal.city)} {signal.targetDate}
+                </a>
+              </li>
+              <li>Buy YES on <span className="text-heading font-semibold">every</span> bucket in this market</li>
+              <li>Total cost: <span className="text-heading font-medium">{(signal.marketYesPrice * 100).toFixed(1)}{'\u00a2'}</span> per set, payout: <span className="text-green-600 dark:text-green-400 font-medium">100{'\u00a2'}</span></li>
+              <li>Guaranteed profit: <span className="text-green-600 dark:text-green-400 font-medium">{((1 - signal.marketYesPrice) * 100).toFixed(1)}{'\u00a2'}</span> per set at resolution</li>
+            </>
+          )}
+          {signal.type === 'PARITY_ARB' && signal.marketYesPrice >= 1 && (
+            <>
+              <li>
+                Open{' '}
+                <a
+                  href={signal.polymarketUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="text-blue-600 dark:text-blue-400 hover:underline"
+                >
+                  Polymarket: {formatCity(signal.city)} {signal.targetDate}
+                </a>
+              </li>
+              <li>{'\u03A3'} YES = <span className="text-yellow-600 dark:text-yellow-400 font-medium">{(signal.marketYesPrice * 100).toFixed(1)}{'\u00a2'}</span> &gt; 100{'\u00a2'} -- market is overpriced by <span className="text-green-600 dark:text-green-400 font-medium">{((signal.marketYesPrice - 1) * 100).toFixed(1)}{'\u00a2'}</span></li>
+              <li>Find the bucket with <span className="text-heading font-semibold">lowest NO price</span> (cheapest to buy NO)</li>
+              <li>Buy <span className="text-red-600 dark:text-red-400 font-semibold">NO</span> on that bucket -- you profit if temperature doesn{`'`}t land in it</li>
+              <li>Check the Markets page for current NO prices and orderbook depth</li>
+            </>
+          )}
+        </ol>
+      </div>
+    </div>
+  );
+}
+
+function DetailItem({
+  label,
+  value,
+  positive,
+  highlight,
+  badge,
+}: {
+  label: string;
+  value: string;
+  positive?: boolean;
+  highlight?: boolean;
+  badge?: boolean;
+}) {
+  let valueClass = 'text-heading font-semibold text-sm';
+  if (positive !== undefined) {
+    valueClass = positive
+      ? 'text-green-600 dark:text-green-400 font-semibold text-sm'
+      : 'text-red-600 dark:text-red-400 font-semibold text-sm';
+  }
+  if (highlight) {
+    valueClass += ' bg-yellow-100 dark:bg-yellow-500/10 px-1.5 py-0.5 rounded';
+  }
+
+  return (
+    <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 ${
+            value === 'HIGH'
+              ? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
+              : 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'
+          }`}
+        >
+          {value}
+        </span>
+      ) : (
+        <p className={valueClass}>{value}</p>
+      )}
+    </div>
+  );
+}
+
+function formatCity(city: string): string {
+  return city
+    .split('_')
+    .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+    .join(' ');
+}

+ 11 - 0
packages/dashboard/src/components/ThemeProvider.tsx

@@ -0,0 +1,11 @@
+'use client';
+
+import { ThemeProvider as NextThemesProvider } from 'next-themes';
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+  return (
+    <NextThemesProvider attribute="class" defaultTheme="dark" enableSystem={false}>
+      {children}
+    </NextThemesProvider>
+  );
+}

+ 32 - 0
packages/dashboard/src/components/ThemeToggle.tsx

@@ -0,0 +1,32 @@
+'use client';
+
+import { useTheme } from 'next-themes';
+import { useEffect, useState } from 'react';
+
+export function ThemeToggle() {
+  const { theme, setTheme } = useTheme();
+  const [mounted, setMounted] = useState(false);
+
+  useEffect(() => setMounted(true), []);
+  if (!mounted) return <div className="w-8 h-8" />;
+
+  const isDark = theme === 'dark';
+
+  return (
+    <button
+      onClick={() => setTheme(isDark ? 'light' : 'dark')}
+      className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-gray-200 dark:hover:bg-slate-700"
+      title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
+    >
+      {isDark ? (
+        <svg className="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
+          <path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
+        </svg>
+      ) : (
+        <svg className="w-4 h-4 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
+          <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
+        </svg>
+      )}
+    </button>
+  );
+}

+ 18 - 0
packages/dashboard/src/lib/db.ts

@@ -0,0 +1,18 @@
+/**
+ * Prisma client singleton for the dashboard.
+ * Reads DATABASE_URL from environment.
+ */
+
+import { PrismaClient } from '@prisma/client';
+
+const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
+
+export const prisma =
+  globalForPrisma.prisma ??
+  new PrismaClient({
+    log: process.env['LOG_LEVEL'] === 'debug' ? ['query', 'warn', 'error'] : ['warn', 'error'],
+  });
+
+if (process.env['NODE_ENV'] !== 'production') {
+  globalForPrisma.prisma = prisma;
+}

+ 130 - 0
packages/dashboard/src/lib/gamma-tokens.ts

@@ -0,0 +1,130 @@
+/**
+ * Fetch token IDs from Gamma events API (temperature markets).
+ * Used as fallback when MarketSnapshot table is empty.
+ */
+
+import { CITY_ALIASES } from '@poly-weather/core';
+
+const GAMMA_BASE = 'https://gamma-api.polymarket.com';
+
+interface GammaEventMarket {
+  id: string;
+  groupItemTitle: string;
+  clobTokenIds: string;
+  closed: boolean;
+}
+
+interface GammaEvent {
+  id: string;
+  title: string;
+  markets: GammaEventMarket[];
+}
+
+export interface TokenPair {
+  yesTokenId: string;
+  noTokenId: string;
+}
+
+// In-memory cache, refreshed every minute
+let cache: Map<string, TokenPair> | null = null;
+let cacheTime = 0;
+const CACHE_TTL = 60_000;
+
+/**
+ * Fetch ALL temperature event markets from Gamma.
+ * Returns a map of "city_slug|bucketLabel" -> { yesTokenId, noTokenId }
+ */
+export async function fetchAllGammaTokens(): Promise<Map<string, TokenPair>> {
+  if (cache && Date.now() - cacheTime < CACHE_TTL) {
+    return cache;
+  }
+
+  const tokenMap = new Map<string, TokenPair>();
+  let offset = 0;
+
+  try {
+    while (offset <= 500) {
+      const params = new URLSearchParams({
+        tag_slug: 'temperature',
+        active: 'true',
+        closed: 'false',
+        limit: '50',
+        offset: offset.toString(),
+      });
+
+      const res = await fetch(`${GAMMA_BASE}/events?${params}`, {
+        headers: { Accept: 'application/json' },
+        signal: AbortSignal.timeout(15000),
+      });
+
+      if (!res.ok) break;
+      const events = (await res.json()) as GammaEvent[];
+      if (events.length === 0) break;
+
+      for (const event of events) {
+        // Parse city from event title: "Highest temperature in Atlanta on March 18?"
+        const titleMatch = event.title.match(/temperature in (.+?) on /i);
+        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, '_');
+
+        for (const m of event.markets || []) {
+          if (m.closed) continue;
+          const bucketLabel = m.groupItemTitle;
+          if (!bucketLabel) continue;
+
+          try {
+            const tokenIds = JSON.parse(m.clobTokenIds || '[]');
+            if (tokenIds.length >= 1) {
+              tokenMap.set(`${citySlug}|${bucketLabel}`, {
+                yesTokenId: tokenIds[0] || '',
+                noTokenId: tokenIds[1] || '',
+              });
+            }
+          } catch {
+            // skip
+          }
+        }
+      }
+
+      offset += 50;
+    }
+  } catch (err) {
+    console.error('Gamma events fetch failed:', err);
+  }
+
+  console.log(`[gamma-tokens] Cache loaded: ${tokenMap.size} buckets`);
+  cache = tokenMap;
+  cacheTime = Date.now();
+  return tokenMap;
+}
+
+/**
+ * Look up token pair for a specific city + bucket.
+ * Tries MarketSnapshot DB first, then Gamma API fallback.
+ */
+export async function lookupTokenPair(
+  city: string,
+  bucket: string,
+  prismaClient: { marketSnapshot: { findFirst: (...args: any[]) => Promise<any> } },
+): Promise<TokenPair | null> {
+  // Try DB first
+  const snapshot = await prismaClient.marketSnapshot.findFirst({
+    where: { city, bucketLabel: bucket },
+    orderBy: { createdAt: 'desc' },
+    select: { yesTokenId: true, noTokenId: true },
+  });
+
+  if (snapshot?.yesTokenId) {
+    return {
+      yesTokenId: snapshot.yesTokenId,
+      noTokenId: snapshot.noTokenId || '',
+    };
+  }
+
+  // Fallback to Gamma
+  const gammaTokens = await fetchAllGammaTokens();
+  return gammaTokens.get(`${city}|${bucket}`) || null;
+}

+ 191 - 0
packages/dashboard/src/lib/mock-data.ts

@@ -0,0 +1,191 @@
+// ─── Mock Data for the Poly Weather Dashboard ───
+
+export interface DashboardSummary {
+  botStatus: 'running' | 'stopped';
+  dryRun: boolean;
+  uptime: string;
+  activeMarkets: number;
+  openPositions: number;
+  todayPnl: number;
+  winRate: number;
+}
+
+export interface Signal {
+  id: string;
+  timestamp: string;
+  type: 'TAIL_NO' | 'LATENCY_ARB' | 'PARITY_ARB';
+  city: string;
+  bucket: string;
+  edge: number;
+  expectedReturn: number;
+  confidence: number;
+  executed: boolean;
+}
+
+export interface MarketBucket {
+  label: string;
+  modelProb: number;
+  marketPrice: number;
+  volume: number;
+}
+
+export interface WeatherMarket {
+  id: string;
+  city: string;
+  date: string;
+  question: string;
+  tempUnit: 'F' | 'C';
+  totalVolume: number;
+  buckets: MarketBucket[];
+}
+
+export interface PnLDataPoint {
+  date: string;
+  daily: number;
+  cumulative: number;
+}
+
+export interface PnLMetrics {
+  totalPnl: number;
+  winRate: number;
+  sharpe: number;
+  maxDrawdown: number;
+  totalTrades: number;
+  winningTrades: number;
+}
+
+export const mockDashboardSummary: DashboardSummary = {
+  botStatus: 'running',
+  dryRun: true,
+  uptime: '4d 12h 37m',
+  activeMarkets: 18,
+  openPositions: 7,
+  todayPnl: 42.87,
+  winRate: 0.634,
+};
+
+export const mockSignals: Signal[] = [
+  { id: 's1', timestamp: '2026-03-17T14:32:00Z', type: 'TAIL_NO', city: 'New York', bucket: '<20F', edge: 0.12, expectedReturn: 0.087, confidence: 0.92, executed: true },
+  { id: 's2', timestamp: '2026-03-17T14:28:00Z', type: 'LATENCY_ARB', city: 'Chicago', bucket: '30-35F', edge: 0.08, expectedReturn: 0.054, confidence: 0.85, executed: true },
+  { id: 's3', timestamp: '2026-03-17T14:15:00Z', type: 'PARITY_ARB', city: 'Miami', bucket: '75-80F', edge: -0.03, expectedReturn: -0.012, confidence: 0.71, executed: false },
+  { id: 's4', timestamp: '2026-03-17T13:55:00Z', type: 'TAIL_NO', city: 'Denver', bucket: '<10F', edge: 0.15, expectedReturn: 0.11, confidence: 0.94, executed: true },
+  { id: 's5', timestamp: '2026-03-17T13:42:00Z', type: 'LATENCY_ARB', city: 'Los Angeles', bucket: '65-70F', edge: 0.06, expectedReturn: 0.038, confidence: 0.78, executed: false },
+  { id: 's6', timestamp: '2026-03-17T13:30:00Z', type: 'PARITY_ARB', city: 'Seattle', bucket: '45-50F', edge: 0.09, expectedReturn: 0.062, confidence: 0.88, executed: true },
+  { id: 's7', timestamp: '2026-03-17T13:18:00Z', type: 'TAIL_NO', city: 'Phoenix', bucket: '>95F', edge: 0.18, expectedReturn: 0.14, confidence: 0.96, executed: true },
+  { id: 's8', timestamp: '2026-03-17T12:55:00Z', type: 'LATENCY_ARB', city: 'New York', bucket: '25-30F', edge: 0.05, expectedReturn: 0.031, confidence: 0.74, executed: false },
+  { id: 's9', timestamp: '2026-03-17T12:40:00Z', type: 'PARITY_ARB', city: 'Chicago', bucket: '35-40F', edge: 0.11, expectedReturn: 0.078, confidence: 0.91, executed: true },
+  { id: 's10', timestamp: '2026-03-17T12:22:00Z', type: 'TAIL_NO', city: 'Houston', bucket: '>85F', edge: 0.14, expectedReturn: 0.098, confidence: 0.93, executed: true },
+  { id: 's11', timestamp: '2026-03-17T12:10:00Z', type: 'LATENCY_ARB', city: 'Boston', bucket: '20-25F', edge: 0.07, expectedReturn: 0.048, confidence: 0.82, executed: true },
+  { id: 's12', timestamp: '2026-03-17T11:58:00Z', type: 'PARITY_ARB', city: 'Miami', bucket: '70-75F', edge: 0.04, expectedReturn: 0.024, confidence: 0.69, executed: false },
+  { id: 's13', timestamp: '2026-03-17T11:45:00Z', type: 'TAIL_NO', city: 'Denver', bucket: '>70F', edge: 0.21, expectedReturn: 0.168, confidence: 0.97, executed: true },
+  { id: 's14', timestamp: '2026-03-17T11:30:00Z', type: 'LATENCY_ARB', city: 'Atlanta', bucket: '55-60F', edge: 0.06, expectedReturn: 0.039, confidence: 0.77, executed: false },
+  { id: 's15', timestamp: '2026-03-17T11:15:00Z', type: 'PARITY_ARB', city: 'San Francisco', bucket: '50-55F', edge: 0.10, expectedReturn: 0.072, confidence: 0.89, executed: true },
+  { id: 's16', timestamp: '2026-03-17T11:00:00Z', type: 'TAIL_NO', city: 'Minneapolis', bucket: '<5F', edge: 0.16, expectedReturn: 0.124, confidence: 0.95, executed: true },
+  { id: 's17', timestamp: '2026-03-17T10:45:00Z', type: 'LATENCY_ARB', city: 'Dallas', bucket: '60-65F', edge: 0.08, expectedReturn: 0.055, confidence: 0.84, executed: true },
+  { id: 's18', timestamp: '2026-03-17T10:30:00Z', type: 'PARITY_ARB', city: 'Portland', bucket: '40-45F', edge: -0.02, expectedReturn: -0.008, confidence: 0.65, executed: false },
+  { id: 's19', timestamp: '2026-03-17T10:15:00Z', type: 'TAIL_NO', city: 'Las Vegas', bucket: '>90F', edge: 0.19, expectedReturn: 0.152, confidence: 0.96, executed: true },
+  { id: 's20', timestamp: '2026-03-17T10:00:00Z', type: 'LATENCY_ARB', city: 'Philadelphia', bucket: '30-35F', edge: 0.07, expectedReturn: 0.046, confidence: 0.81, executed: true },
+];
+
+export const mockMarkets: WeatherMarket[] = [
+  {
+    id: 'm1', city: 'New York', date: '2026-03-18', question: 'NYC High Temperature March 18?', tempUnit: 'F', totalVolume: 124500,
+    buckets: [
+      { label: '<20F', modelProb: 0.02, marketPrice: 0.05, volume: 4200 },
+      { label: '20-25F', modelProb: 0.05, marketPrice: 0.07, volume: 8100 },
+      { label: '25-30F', modelProb: 0.12, marketPrice: 0.14, volume: 15300 },
+      { label: '30-35F', modelProb: 0.25, marketPrice: 0.22, volume: 28700 },
+      { label: '35-40F', modelProb: 0.30, marketPrice: 0.28, volume: 32100 },
+      { label: '40-45F', modelProb: 0.18, marketPrice: 0.16, volume: 22400 },
+      { label: '45-50F', modelProb: 0.06, marketPrice: 0.06, volume: 9200 },
+      { label: '>50F', modelProb: 0.02, marketPrice: 0.02, volume: 4500 },
+    ],
+  },
+  {
+    id: 'm2', city: 'Chicago', date: '2026-03-18', question: 'Chicago High Temperature March 18?', tempUnit: 'F', totalVolume: 89200,
+    buckets: [
+      { label: '<15F', modelProb: 0.03, marketPrice: 0.06, volume: 3100 },
+      { label: '15-20F', modelProb: 0.06, marketPrice: 0.08, volume: 5800 },
+      { label: '20-25F', modelProb: 0.14, marketPrice: 0.12, volume: 11200 },
+      { label: '25-30F', modelProb: 0.22, marketPrice: 0.20, volume: 18400 },
+      { label: '30-35F', modelProb: 0.28, marketPrice: 0.26, volume: 22600 },
+      { label: '35-40F', modelProb: 0.18, marketPrice: 0.19, volume: 16200 },
+      { label: '40-45F', modelProb: 0.07, marketPrice: 0.07, volume: 7900 },
+      { label: '>45F', modelProb: 0.02, marketPrice: 0.02, volume: 4000 },
+    ],
+  },
+  {
+    id: 'm3', city: 'Miami', date: '2026-03-18', question: 'Miami High Temperature March 18?', tempUnit: 'F', totalVolume: 67800,
+    buckets: [
+      { label: '<70F', modelProb: 0.04, marketPrice: 0.05, volume: 2800 },
+      { label: '70-75F', modelProb: 0.15, marketPrice: 0.18, volume: 9400 },
+      { label: '75-80F', modelProb: 0.35, marketPrice: 0.32, volume: 21200 },
+      { label: '80-85F', modelProb: 0.30, marketPrice: 0.28, volume: 19800 },
+      { label: '85-90F', modelProb: 0.12, marketPrice: 0.13, volume: 9600 },
+      { label: '>90F', modelProb: 0.04, marketPrice: 0.04, volume: 5000 },
+    ],
+  },
+  {
+    id: 'm4', city: 'Denver', date: '2026-03-18', question: 'Denver High Temperature March 18?', tempUnit: 'F', totalVolume: 55400,
+    buckets: [
+      { label: '<25F', modelProb: 0.05, marketPrice: 0.08, volume: 3200 },
+      { label: '25-35F', modelProb: 0.15, marketPrice: 0.14, volume: 8100 },
+      { label: '35-45F', modelProb: 0.30, marketPrice: 0.27, volume: 16800 },
+      { label: '45-55F', modelProb: 0.28, marketPrice: 0.30, volume: 15200 },
+      { label: '55-65F', modelProb: 0.16, marketPrice: 0.15, volume: 8400 },
+      { label: '>65F', modelProb: 0.06, marketPrice: 0.06, volume: 3700 },
+    ],
+  },
+  {
+    id: 'm5', city: 'Los Angeles', date: '2026-03-18', question: 'LA High Temperature March 18?', tempUnit: 'F', totalVolume: 72100,
+    buckets: [
+      { label: '<55F', modelProb: 0.03, marketPrice: 0.04, volume: 2200 },
+      { label: '55-60F', modelProb: 0.08, marketPrice: 0.09, volume: 5600 },
+      { label: '60-65F', modelProb: 0.22, marketPrice: 0.20, volume: 14800 },
+      { label: '65-70F', modelProb: 0.32, marketPrice: 0.30, volume: 22400 },
+      { label: '70-75F', modelProb: 0.24, marketPrice: 0.25, volume: 17200 },
+      { label: '75-80F', modelProb: 0.08, marketPrice: 0.09, volume: 6400 },
+      { label: '>80F', modelProb: 0.03, marketPrice: 0.03, volume: 3500 },
+    ],
+  },
+  {
+    id: 'm6', city: 'Seattle', date: '2026-03-18', question: 'Seattle High Temperature March 18?', tempUnit: 'F', totalVolume: 48300,
+    buckets: [
+      { label: '<35F', modelProb: 0.04, marketPrice: 0.06, volume: 2100 },
+      { label: '35-40F', modelProb: 0.10, marketPrice: 0.11, volume: 4800 },
+      { label: '40-45F', modelProb: 0.22, marketPrice: 0.20, volume: 10200 },
+      { label: '45-50F', modelProb: 0.32, marketPrice: 0.30, volume: 15400 },
+      { label: '50-55F', modelProb: 0.22, marketPrice: 0.23, volume: 10100 },
+      { label: '>55F', modelProb: 0.10, marketPrice: 0.10, volume: 5700 },
+    ],
+  },
+];
+
+export const mockPnLData: PnLDataPoint[] = [
+  { date: '2026-03-01', daily: 12.5, cumulative: 12.5 },
+  { date: '2026-03-02', daily: -8.3, cumulative: 4.2 },
+  { date: '2026-03-03', daily: 22.1, cumulative: 26.3 },
+  { date: '2026-03-04', daily: 15.7, cumulative: 42.0 },
+  { date: '2026-03-05', daily: -5.2, cumulative: 36.8 },
+  { date: '2026-03-06', daily: 18.9, cumulative: 55.7 },
+  { date: '2026-03-07', daily: -12.4, cumulative: 43.3 },
+  { date: '2026-03-08', daily: 8.6, cumulative: 51.9 },
+  { date: '2026-03-09', daily: 25.3, cumulative: 77.2 },
+  { date: '2026-03-10', daily: -3.1, cumulative: 74.1 },
+  { date: '2026-03-11', daily: 14.8, cumulative: 88.9 },
+  { date: '2026-03-12', daily: -18.6, cumulative: 70.3 },
+  { date: '2026-03-13', daily: 32.4, cumulative: 102.7 },
+  { date: '2026-03-14', daily: 11.2, cumulative: 113.9 },
+  { date: '2026-03-15', daily: -7.8, cumulative: 106.1 },
+  { date: '2026-03-16', daily: 19.6, cumulative: 125.7 },
+  { date: '2026-03-17', daily: 42.87, cumulative: 168.57 },
+];
+
+export const mockPnLMetrics: PnLMetrics = {
+  totalPnl: 168.57,
+  winRate: 0.634,
+  sharpe: 1.82,
+  maxDrawdown: -18.6,
+  totalTrades: 142,
+  winningTrades: 90,
+};

+ 308 - 0
packages/dashboard/src/lib/polymarket-api.ts

@@ -0,0 +1,308 @@
+/**
+ * Direct Polymarket API client for the dashboard.
+ * Fetches live market data from Gamma + CLOB APIs — no auth required for reads.
+ */
+
+import type { WeatherMarket, MarketBucket, OrderbookLevel } from './types';
+
+const GAMMA_BASE = 'https://gamma-api.polymarket.com';
+const CLOB_BASE = 'https://clob.polymarket.com';
+
+// ─── Gamma API Types ────────────────────────────────────────────────────────
+
+interface GammaEvent {
+  id: string;
+  title: string;
+  slug: string;
+  description: string;
+  markets: GammaMarket[];
+  tags: Array<{ id: string; label: string; slug: string }>;
+}
+
+interface GammaMarket {
+  id: string;
+  question: string;
+  conditionId: string;
+  slug: string;
+  outcomes: string;
+  outcomePrices: string;
+  clobTokenIds: string;
+  volume: number;
+  volume24hr: number;
+  liquidityNum: number;
+  endDateIso: string;
+  closed: boolean;
+  groupItemTitle: string;
+  groupItemThreshold: string;
+  enableOrderBook: boolean;
+  negRisk: boolean;
+}
+
+// ─── Bucket Label Parsing ───────────────────────────────────────────────────
+
+function parseBucketBounds(label: string): { lower: number; upper: number } | null {
+  // "12°C", "46-47°F", "31°F or below", "56°F or higher", "3°C or below"
+  const singleMatch = label.match(/^(-?\d+)\s*°[FC]$/i);
+  if (singleMatch) {
+    const v = parseInt(singleMatch[1]);
+    return { lower: v, upper: v };
+  }
+  const rangeMatch = label.match(/(-?\d+)\s*[-–]\s*(-?\d+)/);
+  if (rangeMatch) {
+    return { lower: parseInt(rangeMatch[1]), upper: parseInt(rangeMatch[2]) };
+  }
+  const belowMatch = label.match(/(-?\d+)\s*°[FC]\s*or\s*below/i);
+  if (belowMatch) {
+    return { lower: -Infinity, upper: parseInt(belowMatch[1]) };
+  }
+  const higherMatch = label.match(/(-?\d+)\s*°[FC]\s*or\s*higher/i);
+  if (higherMatch) {
+    return { lower: parseInt(higherMatch[1]), upper: Infinity };
+  }
+  return null;
+}
+
+// ─── Question Parsing ───────────────────────────────────────────────────────
+
+function parseEventTitle(title: string): { city: string; dateStr: string } | null {
+  // "Highest temperature in Seoul on March 17?"
+  const match = title.match(/[Hh]ighest\s+temperature\s+in\s+(.+?)\s+on\s+(.+?)\??$/);
+  if (match) {
+    return { city: match[1].trim(), dateStr: match[2].trim() };
+  }
+  return null;
+}
+
+// ─── Fetch Live Markets ─────────────────────────────────────────────────────
+
+/**
+ * Fetch all active temperature events from Polymarket via the Gamma events API.
+ * Uses tag_slug=temperature to find daily temperature markets.
+ */
+export async function fetchLiveMarkets(): Promise<WeatherMarket[]> {
+  const allEvents: GammaEvent[] = [];
+  let offset = 0;
+
+  while (true) {
+    const params = new URLSearchParams({
+      tag_slug: 'temperature',
+      active: 'true',
+      closed: 'false',
+      limit: '50',
+      offset: offset.toString(),
+    });
+
+    const res = await fetch(`${GAMMA_BASE}/events?${params}`, {
+      headers: { Accept: 'application/json' },
+      signal: AbortSignal.timeout(15000),
+      cache: 'no-store',
+    });
+
+    if (!res.ok) break;
+    const batch = (await res.json()) as GammaEvent[];
+    if (batch.length === 0) break;
+    allEvents.push(...batch);
+    offset += 50;
+    if (offset > 500) break;
+  }
+
+  // Build structured markets from events
+  const markets: WeatherMarket[] = [];
+
+  for (const event of allEvents) {
+    const parsed = parseEventTitle(event.title);
+    if (!parsed) continue;
+
+    const eventMarkets = event.markets || [];
+    if (eventMarkets.length === 0) continue;
+
+    // Determine temp unit from bucket labels
+    const firstLabel = eventMarkets[0]?.groupItemTitle || '';
+    const tempUnit = firstLabel.includes('°F') ? 'F' : 'C';
+
+    const buckets: MarketBucket[] = [];
+    for (const m of eventMarkets) {
+      if (!m.groupItemTitle) continue;
+
+      let tokenIds: string[] = [];
+      let prices: number[] = [];
+      try {
+        tokenIds = JSON.parse(m.clobTokenIds || '[]');
+        prices = JSON.parse(m.outcomePrices || '[]').map(Number);
+      } catch {
+        continue;
+      }
+
+      const yesPrice = prices[0] || 0;
+
+      buckets.push({
+        label: m.groupItemTitle,
+        modelProb: 0,
+        marketPrice: yesPrice,
+        volume: m.volume24hr || 0,
+        yesTokenId: tokenIds[0] || '',
+        noTokenId: tokenIds[1] || '',
+        polymarketUrl: `https://polymarket.com/event/${event.slug}/${m.slug}`,
+      });
+    }
+
+    if (buckets.length === 0) continue;
+
+    // Sort by temperature bound
+    buckets.sort((a, b) => {
+      const ab = parseBucketBounds(a.label);
+      const bb = parseBucketBounds(b.label);
+      if (ab && bb) {
+        const al = ab.lower === -Infinity ? -999 : ab.lower;
+        const bl = bb.lower === -Infinity ? -999 : bb.lower;
+        return al - bl;
+      }
+      return a.label.localeCompare(b.label);
+    });
+
+    const priceSum = buckets.reduce((s, b) => s + b.marketPrice, 0);
+    const totalVolume = eventMarkets.reduce((s, m) => s + (m.volume24hr || 0), 0);
+
+    // Skip resolved markets — any single bucket ≥ 0.95 means effectively settled
+    const maxPrice = Math.max(...buckets.map((b) => b.marketPrice));
+    const isResolved =
+      maxPrice >= 0.95 ||
+      buckets.every(
+        (b) => b.marketPrice === 0 || b.marketPrice === 1 || b.marketPrice >= 0.999 || b.marketPrice <= 0.001,
+      );
+    if (isResolved) continue;
+
+    markets.push({
+      id: `event-${event.id}`,
+      city: parsed.city,
+      date: parsed.dateStr,
+      question: event.title,
+      tempUnit: tempUnit as 'F' | 'C',
+      totalVolume,
+      buckets,
+      polymarketUrl: `https://polymarket.com/event/${event.slug}`,
+      priceSum,
+    });
+  }
+
+  // Sort: today's and future markets first, then by volume
+  const today = new Date();
+  const todayStr = today.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
+
+  function parseDatePriority(dateStr: string): number {
+    // Try to parse "March 17" style dates
+    const parsed = new Date(`${dateStr}, ${today.getFullYear()}`);
+    if (isNaN(parsed.getTime())) return 0;
+    const diff = parsed.getTime() - today.getTime();
+    // Future/today = high priority (negative sort value), past = low priority
+    if (diff >= -86400000) return -1; // today or future
+    return 1; // past
+  }
+
+  markets.sort((a, b) => {
+    const aPri = parseDatePriority(a.date);
+    const bPri = parseDatePriority(b.date);
+    if (aPri !== bPri) return aPri - bPri;
+    return b.totalVolume - a.totalVolume;
+  });
+
+  return markets;
+}
+
+// ─── Fetch Orderbook ────────────────────────────────────────────────────────
+
+interface CLOBOrderbookResponse {
+  bids: Array<{ price: string; size: string }>;
+  asks: Array<{ price: string; size: string }>;
+}
+
+async function fetchRawOrderbook(tokenId: string): Promise<CLOBOrderbookResponse | null> {
+  try {
+    const res = await fetch(`${CLOB_BASE}/book?token_id=${tokenId}`, {
+      headers: { Accept: 'application/json' },
+      signal: AbortSignal.timeout(10000),
+    });
+    if (!res.ok) return null;
+    return (await res.json()) as CLOBOrderbookResponse;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Fetch merged orderbook for a YES/NO token pair in neg-risk markets.
+ *
+ * In Polymarket neg-risk markets, the effective YES orderbook is:
+ *   YES bids = direct YES bids ∪ (1 − NO asks)   ← people buying YES or selling NO
+ *   YES asks = direct YES asks ∪ (1 − NO bids)   ← people selling YES or buying NO
+ *
+ * This gives the true tradeable spread instead of the misleading single-token book.
+ */
+export async function fetchOrderbook(
+  yesTokenId: string,
+  noTokenId?: string,
+): Promise<{
+  bids: OrderbookLevel[];
+  asks: OrderbookLevel[];
+  spread: number;
+  midpoint: number;
+} | null> {
+  try {
+    // Fetch both books in parallel
+    const [yesBook, noBook] = await Promise.all([
+      fetchRawOrderbook(yesTokenId),
+      noTokenId ? fetchRawOrderbook(noTokenId) : Promise.resolve(null),
+    ]);
+
+    if (!yesBook) return null;
+
+    // Direct YES bids
+    const yesBids: OrderbookLevel[] = (yesBook.bids || []).map((b) => ({
+      price: parseFloat(b.price),
+      size: parseFloat(b.size),
+    }));
+
+    // Direct YES asks
+    const yesAsks: OrderbookLevel[] = (yesBook.asks || []).map((a) => ({
+      price: parseFloat(a.price),
+      size: parseFloat(a.size),
+    }));
+
+    if (noBook) {
+      // Synthetic YES bids from NO asks: buying YES ≈ selling NO
+      // If someone asks 74¢ for NO, that's a synthetic YES bid at (1 - 0.74) = 26¢
+      for (const a of noBook.asks || []) {
+        const syntheticPrice = 1 - parseFloat(a.price);
+        if (syntheticPrice > 0 && syntheticPrice < 1) {
+          yesBids.push({ price: syntheticPrice, size: parseFloat(a.size) });
+        }
+      }
+
+      // Synthetic YES asks from NO bids: selling YES ≈ buying NO
+      // If someone bids 74¢ for NO, that's a synthetic YES ask at (1 - 0.74) = 26¢
+      for (const b of noBook.bids || []) {
+        const syntheticPrice = 1 - parseFloat(b.price);
+        if (syntheticPrice > 0 && syntheticPrice < 1) {
+          yesAsks.push({ price: syntheticPrice, size: parseFloat(b.size) });
+        }
+      }
+    }
+
+    // Sort and deduplicate: bids descending, asks ascending
+    yesBids.sort((a, b) => b.price - a.price);
+    yesAsks.sort((a, b) => a.price - b.price);
+
+    // Take top 8
+    const bids = yesBids.slice(0, 8);
+    const asks = yesAsks.slice(0, 8);
+
+    const bestBid = bids[0]?.price ?? 0;
+    const bestAsk = asks[0]?.price ?? 1;
+    const spread = bestAsk - bestBid;
+    const midpoint = (bestBid + bestAsk) / 2;
+
+    return { bids, asks, spread, midpoint };
+  } catch {
+    return null;
+  }
+}

+ 354 - 0
packages/dashboard/src/lib/queries.ts

@@ -0,0 +1,354 @@
+/**
+ * Database queries for the dashboard.
+ * Maps Prisma models to the shapes the frontend components expect.
+ */
+
+import { prisma } from './db';
+import type {
+  DashboardSummary,
+  Signal,
+  TradeRecord,
+  WeatherMarket,
+  MarketBucket,
+  PnLDataPoint,
+  PnLMetrics,
+} from './types';
+
+// ─── Dashboard Summary ──────────────────────────────────────────────────────
+
+export async function getDashboardSummary(): Promise<DashboardSummary> {
+  const today = new Date();
+  today.setHours(0, 0, 0, 0);
+
+  const [activeMarketCount, openPositionCount, todayTrades, last30Trades] = await Promise.all([
+    // Count distinct city+targetDate combos in recent market snapshots
+    prisma.marketSnapshot.findMany({
+      where: { createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
+      select: { city: true, targetDate: true },
+      distinct: ['city', 'targetDate'],
+    }),
+    // Open positions = trades with FILLED status and no pnl yet
+    prisma.trade.count({
+      where: { status: 'FILLED', pnl: null },
+    }),
+    // Today's trades for PnL
+    prisma.trade.findMany({
+      where: { createdAt: { gte: today } },
+      select: { pnl: true },
+    }),
+    // Last 30 days trades for win rate
+    prisma.trade.findMany({
+      where: {
+        createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
+        pnl: { not: null },
+      },
+      select: { pnl: true },
+    }),
+  ]);
+
+  const todayPnl = todayTrades.reduce((sum, t) => sum + (t.pnl ?? 0), 0);
+  const wins = last30Trades.filter((t) => (t.pnl ?? 0) > 0).length;
+  const winRate = last30Trades.length > 0 ? wins / last30Trades.length : 0;
+
+  return {
+    botStatus: 'running',
+    dryRun: process.env['DRY_RUN'] !== 'false',
+    uptime: await getBotUptime(),
+    activeMarkets: activeMarketCount.length,
+    openPositions: openPositionCount,
+    todayPnl,
+    winRate,
+  };
+}
+
+async function getBotUptime(): Promise<string> {
+  // Estimate from earliest signal today, or show '—' if no data
+  const earliest = await prisma.signal.findFirst({
+    orderBy: { createdAt: 'asc' },
+    select: { createdAt: true },
+  });
+  if (!earliest) return '—';
+  const ms = Date.now() - earliest.createdAt.getTime();
+  const days = Math.floor(ms / 86_400_000);
+  const hours = Math.floor((ms % 86_400_000) / 3_600_000);
+  const mins = Math.floor((ms % 3_600_000) / 60_000);
+  if (days > 0) return `${days}d ${hours}h ${mins}m`;
+  if (hours > 0) return `${hours}h ${mins}m`;
+  return `${mins}m`;
+}
+
+// ─── Signals ────────────────────────────────────────────────────────────────
+
+export async function getRecentSignals(limit = 20): Promise<Signal[]> {
+  const rows = await prisma.signal.findMany({
+    orderBy: { createdAt: 'desc' },
+    take: limit,
+  });
+
+  return rows.map(mapSignal);
+}
+
+export async function getSignals(type?: string): Promise<Signal[]> {
+  const where = type && type !== 'ALL' ? { type } : {};
+
+  const rows = await prisma.signal.findMany({
+    where,
+    orderBy: { createdAt: 'desc' },
+    take: 200,
+  });
+
+  return rows.map(mapSignal);
+}
+
+// Current max position size from env (used to cap stale recommendedSize in DB)
+const MAX_POSITION = parseFloat(process.env['MAX_POSITION_PER_MARKET'] || '50');
+
+const monthNames = [
+  'january', 'february', 'march', 'april', 'may', 'june',
+  'july', 'august', 'september', 'october', 'november', 'december',
+];
+
+/**
+ * Map bot city names to Polymarket event slug city parts.
+ * Most cities just convert underscores to hyphens, but some have custom slugs.
+ */
+const citySlugOverrides: Record<string, string> = {
+  new_york: 'nyc',
+};
+
+/**
+ * Build Polymarket event URL from city + targetDate.
+ * e.g. https://polymarket.com/event/highest-temperature-in-nyc-on-march-20-2026
+ */
+function buildPolymarketUrl(city: string, targetDate: Date): string {
+  const slug = citySlugOverrides[city] ?? city.replace(/_/g, '-').toLowerCase();
+  const month = monthNames[targetDate.getMonth()];
+  const day = targetDate.getDate();
+  const year = targetDate.getFullYear();
+  return `https://polymarket.com/event/highest-temperature-in-${slug}-on-${month}-${day}-${year}`;
+}
+
+function mapSignal(row: {
+  id: string;
+  createdAt: Date;
+  type: string;
+  city: string;
+  targetDate: Date;
+  bucketLabel: string;
+  edge: number;
+  expectedReturn: number;
+  agreementScore: number;
+  executed: boolean;
+  marketYesPrice: number;
+  modelProbability: number;
+  recommendedSize: number;
+  confidence: string;
+}): Signal {
+  const type = row.type as Signal['type'];
+  const marketYesPrice = row.marketYesPrice;
+  const noPrice = 1 - marketYesPrice;
+
+  // Determine side and action based on signal type
+  let side: Signal['side'] = 'BUY_NO';
+  let suggestedPrice = noPrice;
+  let action = '';
+
+  if (type === 'TAIL_NO') {
+    side = 'BUY_NO';
+    suggestedPrice = noPrice;
+    action = `Buy NO "${row.bucketLabel}" @ ${(noPrice * 100).toFixed(1)}\u00a2 — model says ${(row.modelProbability * 100).toFixed(1)}% likely, market priced ${(marketYesPrice * 100).toFixed(1)}%`;
+  } else if (type === 'LATENCY_ARB') {
+    // If model prob > market price, buy YES; otherwise buy NO
+    if (row.modelProbability > marketYesPrice) {
+      side = 'BUY_YES';
+      suggestedPrice = marketYesPrice;
+      action = `Buy YES "${row.bucketLabel}" @ ${(marketYesPrice * 100).toFixed(1)}\u00a2 — new forecast ${(row.modelProbability * 100).toFixed(1)}% vs market ${(marketYesPrice * 100).toFixed(1)}%`;
+    } else {
+      side = 'BUY_NO';
+      suggestedPrice = noPrice;
+      action = `Buy NO "${row.bucketLabel}" @ ${(noPrice * 100).toFixed(1)}\u00a2 — new forecast ${(row.modelProbability * 100).toFixed(1)}% vs market ${(marketYesPrice * 100).toFixed(1)}%`;
+    }
+  } else if (type === 'PARITY_ARB') {
+    suggestedPrice = marketYesPrice;
+    // marketYesPrice here is totalYesPrice (sum of all YES prices in the event)
+    if (marketYesPrice < 1) {
+      // Sum < 100¢: buy all YES tokens cheaply, guaranteed 100¢ payout
+      side = 'BUY_ALL_YES';
+      const profit = (1 - marketYesPrice);
+      action = `Buy YES on every bucket — total cost ${(marketYesPrice * 100).toFixed(1)}\u00a2, payout 100\u00a2, profit ${(profit * 100).toFixed(1)}\u00a2 per set`;
+    } else {
+      // Sum > 100¢: market overpriced, buy NO on cheapest buckets
+      side = 'BUY_NO';
+      const overprice = (marketYesPrice - 1);
+      action = `\u03A3 YES = ${(marketYesPrice * 100).toFixed(1)}\u00a2 > 100\u00a2 — buy NO on the cheapest bucket(s) to capture ${(overprice * 100).toFixed(1)}\u00a2 mispricing`;
+    }
+  }
+
+  return {
+    id: row.id,
+    timestamp: row.createdAt.toISOString(),
+    type,
+    city: row.city,
+    targetDate: row.targetDate.toISOString().slice(0, 10),
+    bucket: row.bucketLabel,
+    edge: row.edge,
+    expectedReturn: row.expectedReturn,
+    confidence: row.agreementScore,
+    executed: row.executed,
+    marketYesPrice,
+    modelProbability: row.modelProbability,
+    recommendedSize: Math.min(row.recommendedSize, MAX_POSITION),
+    confidenceLevel: row.confidence,
+    action,
+    side,
+    suggestedPrice,
+    polymarketUrl: buildPolymarketUrl(row.city, row.targetDate),
+  };
+}
+
+// ─── Trades ─────────────────────────────────────────────────────────────────
+
+export async function getRecentTrades(limit = 50): Promise<TradeRecord[]> {
+  const rows = await prisma.trade.findMany({
+    orderBy: { createdAt: 'desc' },
+    take: limit,
+    include: {
+      signals: {
+        select: { type: true, city: true, targetDate: true, bucketLabel: true },
+        take: 1,
+      },
+    },
+  });
+
+  return rows.map((t) => {
+    const sig = t.signals[0];
+    return {
+      id: t.id,
+      signalId: t.signalId,
+      side: t.side,
+      tokenId: t.tokenId,
+      price: t.price,
+      size: t.size,
+      costBasis: t.costBasis,
+      orderId: t.orderId,
+      status: t.status,
+      dryRun: t.dryRun,
+      createdAt: t.createdAt.toISOString(),
+      city: sig?.city,
+      bucket: sig?.bucketLabel,
+      targetDate: sig?.targetDate?.toISOString().slice(0, 10),
+      signalType: sig?.type,
+    };
+  });
+}
+
+// ─── Markets ────────────────────────────────────────────────────────────────
+
+export async function getMarkets(): Promise<WeatherMarket[]> {
+  // Get the latest snapshot per city+targetDate+bucket
+  const snapshots = await prisma.marketSnapshot.findMany({
+    orderBy: { createdAt: 'desc' },
+    // Get recent snapshots (last 24h)
+    where: { createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
+  });
+
+  // Group by city+date
+  const grouped = new Map<string, typeof snapshots>();
+  for (const snap of snapshots) {
+    const key = `${snap.city}|${snap.targetDate.toISOString().slice(0, 10)}`;
+    if (!grouped.has(key)) {
+      grouped.set(key, []);
+    }
+    // Only keep latest per bucket
+    const existing = grouped.get(key)!;
+    const existingBucket = existing.find((s) => s.bucketLabel === snap.bucketLabel);
+    if (!existingBucket) {
+      existing.push(snap);
+    }
+  }
+
+  // Also fetch matching weather forecasts for model probabilities
+  // For now, use market data only (modelProb will be 0 until forecasts are available)
+  const markets: WeatherMarket[] = [];
+
+  for (const [key, snaps] of grouped) {
+    const [city, date] = key.split('|');
+    const tempUnit = ['New York', 'Chicago', 'Miami', 'Houston', 'Denver', 'Los Angeles', 'Phoenix', 'Seattle'].includes(city) ? 'F' : 'C';
+    const totalVolume = snaps.reduce((s, b) => s + b.volume24h, 0);
+
+    const buckets: MarketBucket[] = snaps.map((s) => ({
+      label: s.bucketLabel,
+      modelProb: 0, // Will be filled when forecasts are available
+      marketPrice: s.yesPrice,
+      volume: s.volume24h,
+    }));
+
+    markets.push({
+      id: `${city}-${date}`,
+      city,
+      date,
+      question: `${city} High Temperature ${date}?`,
+      tempUnit: tempUnit as 'F' | 'C',
+      totalVolume,
+      buckets,
+    });
+  }
+
+  return markets;
+}
+
+// ─── PnL ────────────────────────────────────────────────────────────────────
+
+export async function getPnLData(): Promise<{ data: PnLDataPoint[]; metrics: PnLMetrics }> {
+  const dailyMetrics = await prisma.dailyMetrics.findMany({
+    orderBy: { date: 'asc' },
+  });
+
+  // Build cumulative PnL series
+  let cumulative = 0;
+  const data: PnLDataPoint[] = dailyMetrics.map((dm) => {
+    cumulative += dm.netPnl;
+    return {
+      date: dm.date.toISOString().slice(0, 10),
+      daily: dm.netPnl,
+      cumulative,
+    };
+  });
+
+  // Aggregate metrics
+  const totalTrades = dailyMetrics.reduce((s, d) => s + d.totalTrades, 0);
+  const winningTrades = dailyMetrics.reduce((s, d) => s + d.winningTrades, 0);
+  const totalPnl = cumulative;
+  const winRate = totalTrades > 0 ? winningTrades / totalTrades : 0;
+
+  // Sharpe ratio (annualized from daily)
+  const dailyReturns = dailyMetrics.map((d) => d.netPnl);
+  const avgReturn = dailyReturns.length > 0 ? dailyReturns.reduce((s, r) => s + r, 0) / dailyReturns.length : 0;
+  const variance = dailyReturns.length > 1
+    ? dailyReturns.reduce((s, r) => s + (r - avgReturn) ** 2, 0) / (dailyReturns.length - 1)
+    : 0;
+  const stdDev = Math.sqrt(variance);
+  const sharpe = stdDev > 0 ? (avgReturn / stdDev) * Math.sqrt(252) : 0;
+
+  // Max drawdown
+  let peak = 0;
+  let maxDrawdown = 0;
+  let runningPnl = 0;
+  for (const dm of dailyMetrics) {
+    runningPnl += dm.netPnl;
+    if (runningPnl > peak) peak = runningPnl;
+    const dd = runningPnl - peak;
+    if (dd < maxDrawdown) maxDrawdown = dd;
+  }
+
+  const metrics: PnLMetrics = {
+    totalPnl,
+    winRate,
+    sharpe: +sharpe.toFixed(2),
+    maxDrawdown: +maxDrawdown.toFixed(2),
+    totalTrades,
+    winningTrades,
+  };
+
+  return { data, metrics };
+}

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

@@ -0,0 +1,116 @@
+/**
+ * Dashboard data types.
+ * Shared between API routes, queries, and frontend components.
+ */
+
+export interface DashboardSummary {
+  botStatus: 'running' | 'stopped';
+  dryRun: boolean;
+  uptime: string;
+  activeMarkets: number;
+  openPositions: number;
+  todayPnl: number;
+  winRate: number;
+}
+
+export interface Signal {
+  id: string;
+  timestamp: string;
+  type: 'TAIL_NO' | 'LATENCY_ARB' | 'PARITY_ARB';
+  city: string;
+  targetDate: string;
+  bucket: string;
+  edge: number;
+  expectedReturn: number;
+  confidence: number;
+  executed: boolean;
+  /** Current market YES price (0-1) */
+  marketYesPrice: number;
+  /** Model-predicted probability (0-1) */
+  modelProbability: number;
+  /** Recommended position size in USDC */
+  recommendedSize: number;
+  /** Confidence level string */
+  confidenceLevel: string;
+  /** Human-readable action description */
+  action: string;
+  /** Which side to trade: BUY_YES or BUY_NO */
+  side: 'BUY_YES' | 'BUY_NO' | 'BUY_ALL_YES';
+  /** Suggested limit price (0-1) */
+  suggestedPrice: number;
+  /** Direct Polymarket event URL */
+  polymarketUrl: string;
+}
+
+export interface OrderbookLevel {
+  price: number;
+  size: number;
+}
+
+export interface MarketBucket {
+  label: string;
+  modelProb: number;
+  marketPrice: number;
+  volume: number;
+  /** YES token ID for CLOB orderbook queries */
+  yesTokenId?: string;
+  /** NO token ID — needed to merge orderbooks for neg-risk markets */
+  noTokenId?: string;
+  /** Polymarket URL for this specific bucket market */
+  polymarketUrl?: string;
+  /** Orderbook snapshot (top 5 bids/asks) */
+  orderbook?: {
+    bids: OrderbookLevel[];
+    asks: OrderbookLevel[];
+    spread: number;
+    midpoint: number;
+  };
+}
+
+export interface WeatherMarket {
+  id: string;
+  city: string;
+  date: string;
+  question: string;
+  tempUnit: 'F' | 'C';
+  totalVolume: number;
+  buckets: MarketBucket[];
+  /** Polymarket event page URL */
+  polymarketUrl?: string;
+  /** Sum of all YES prices — should be ~1.00 */
+  priceSum?: number;
+}
+
+export interface TradeRecord {
+  id: string;
+  signalId: string | null;
+  side: string;
+  tokenId: string;
+  price: number;
+  size: number;
+  costBasis: number;
+  orderId: string | null;
+  status: string;
+  dryRun: boolean;
+  createdAt: string;
+  /** Joined signal fields */
+  city?: string;
+  bucket?: string;
+  targetDate?: string;
+  signalType?: string;
+}
+
+export interface PnLDataPoint {
+  date: string;
+  daily: number;
+  cumulative: number;
+}
+
+export interface PnLMetrics {
+  totalPnl: number;
+  winRate: number;
+  sharpe: number;
+  maxDrawdown: number;
+  totalTrades: number;
+  winningTrades: number;
+}

+ 27 - 0
packages/dashboard/tailwind.config.ts

@@ -0,0 +1,27 @@
+import type { Config } from 'tailwindcss';
+
+const config: Config = {
+  content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
+  darkMode: 'class',
+  theme: {
+    extend: {
+      colors: {
+        brand: {
+          50: '#eff6ff',
+          100: '#dbeafe',
+          200: '#bfdbfe',
+          300: '#93c5fd',
+          400: '#60a5fa',
+          500: '#3b82f6',
+          600: '#2563eb',
+          700: '#1d4ed8',
+          800: '#1e40af',
+          900: '#1e3a8a',
+        },
+      },
+    },
+  },
+  plugins: [],
+};
+
+export default config;

+ 23 - 0
packages/dashboard/tsconfig.json

@@ -0,0 +1,23 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["dom", "dom.iterable", "ES2022"],
+    "allowJs": true,
+    "skipLibCheck": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "preserve",
+    "incremental": true,
+    "plugins": [{ "name": "next" }],
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "include": ["src", "next-env.d.ts", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
packages/dashboard/tsconfig.tsbuildinfo


+ 33 - 0
packages/market-engine/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "@poly-weather/market-engine",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/index.js",
+      "types": "./dist/index.d.ts"
+    }
+  },
+  "scripts": {
+    "build": "tsc -b",
+    "dev": "tsc -b --watch",
+    "test": "vitest run"
+  },
+  "dependencies": {
+    "@ethersproject/providers": "^5.8.0",
+    "@ethersproject/wallet": "^5.8.0",
+    "@poly-weather/core": "workspace:*",
+    "@polymarket/clob-client": "^4.0.0",
+    "ethers": "^6.13.0",
+    "ws": "^8.18.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0",
+    "@types/ws": "^8.5.0",
+    "typescript": "^5.7.0",
+    "vitest": "^2.1.0"
+  }
+}

+ 110 - 0
packages/market-engine/src/__tests__/market-scanner.test.ts

@@ -0,0 +1,110 @@
+import { describe, it, expect } from 'vitest';
+import { parseWeatherQuestion, groupMarketsByEvent } from '../market-scanner.js';
+import type { GammaMarket } from '@poly-weather/core';
+
+describe('parseWeatherQuestion', () => {
+  it('should parse "Highest temperature in NYC on March 17"', () => {
+    const result = parseWeatherQuestion('Highest temperature in NYC on March 17?');
+    expect(result).not.toBeNull();
+    expect(result!.city).toBe('new_york');
+    expect(result!.dateStr).toBe('March 17');
+  });
+
+  it('should parse "Highest temperature in London on March 20"', () => {
+    const result = parseWeatherQuestion('Highest temperature in London on March 20?');
+    expect(result).not.toBeNull();
+    expect(result!.city).toBe('london');
+    expect(result!.dateStr).toBe('March 20');
+  });
+
+  it('should parse "High temperature in Chicago on April 5"', () => {
+    const result = parseWeatherQuestion('High temperature in Chicago on April 5?');
+    expect(result).not.toBeNull();
+    expect(result!.city).toBe('chicago');
+    expect(result!.dateStr).toBe('April 5');
+  });
+
+  it('should return null for non-weather questions', () => {
+    const result = parseWeatherQuestion('Will Bitcoin reach $100K?');
+    expect(result).toBeNull();
+  });
+
+  it('should return null for unknown cities', () => {
+    const result = parseWeatherQuestion('Highest temperature in Atlantis on March 17?');
+    expect(result).toBeNull();
+  });
+
+  it('should be case-insensitive for city matching', () => {
+    const result = parseWeatherQuestion('Highest temperature in london on March 17?');
+    expect(result).not.toBeNull();
+    expect(result!.city).toBe('london');
+  });
+});
+
+describe('groupMarketsByEvent', () => {
+  const makeGammaMarket = (
+    id: number,
+    question: string,
+    groupItemTitle: string,
+    marketGroup: number,
+  ): GammaMarket => ({
+    id,
+    question,
+    conditionId: `cond_${id}`,
+    slug: `market-${id}`,
+    outcomes: '["Yes","No"]',
+    outcomePrices: '["0.05","0.95"]',
+    clobTokenIds: `["token_yes_${id}","token_no_${id}"]`,
+    volume: 1000,
+    volume24hr: 100,
+    liquidityNum: 500,
+    endDateIso: '2026-03-18T00:00:00Z',
+    closed: false,
+    groupItemTitle,
+    groupItemThreshold: '',
+    marketGroup,
+    enableOrderBook: true,
+    tags: [{ label: 'weather' }],
+  });
+
+  it('should group markets by marketGroup ID', () => {
+    const markets: GammaMarket[] = [
+      makeGammaMarket(1, 'Highest temperature in NYC on March 17?', '31°F or below', 100),
+      makeGammaMarket(2, 'Highest temperature in NYC on March 17?', '32-35°F', 100),
+      makeGammaMarket(3, 'Highest temperature in NYC on March 17?', '36-39°F', 100),
+      makeGammaMarket(4, 'Highest temperature in London on March 17?', '10°C or below', 200),
+    ];
+
+    const grouped = groupMarketsByEvent(markets);
+
+    expect(grouped.size).toBe(2);
+    expect(grouped.get(100)?.city).toBe('new_york');
+    expect(grouped.get(100)?.buckets.length).toBe(3);
+    expect(grouped.get(200)?.city).toBe('london');
+    expect(grouped.get(200)?.buckets.length).toBe(1);
+  });
+
+  it('should sort buckets by lower bound', () => {
+    const markets: GammaMarket[] = [
+      makeGammaMarket(3, 'Highest temperature in NYC on March 17?', '36-39°F', 100),
+      makeGammaMarket(1, 'Highest temperature in NYC on March 17?', '31°F or below', 100),
+      makeGammaMarket(2, 'Highest temperature in NYC on March 17?', '32-35°F', 100),
+    ];
+
+    const grouped = groupMarketsByEvent(markets);
+    const buckets = grouped.get(100)!.buckets;
+
+    expect(buckets[0].lowerBound).toBe(-Infinity);
+    expect(buckets[1].lowerBound).toBe(32);
+    expect(buckets[2].lowerBound).toBe(36);
+  });
+
+  it('should skip markets without marketGroup', () => {
+    const markets: GammaMarket[] = [
+      makeGammaMarket(1, 'Highest temperature in NYC on March 17?', '31°F or below', 0),
+    ];
+
+    const grouped = groupMarketsByEvent(markets);
+    expect(grouped.size).toBe(0);
+  });
+});

+ 271 - 0
packages/market-engine/src/clob-client.ts

@@ -0,0 +1,271 @@
+/**
+ * Polymarket CLOB API client wrapper.
+ * Handles orderbook queries and authenticated order placement.
+ *
+ * Auth model for Polymarket-exported wallets:
+ *  - PRIVATE_KEY = signer key (exported from Polymarket wallet)
+ *  - FUNDER_ADDRESS = proxy/contract wallet (Polymarket deposit address)
+ *  - SIGNATURE_TYPE = 1 (POLY_PROXY) when signer != funder
+ *  - L2 API creds = derived from wallet for CLOB order endpoints
+ *  - Builder API creds = from polymarket.com Settings > Builder (optional)
+ */
+
+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 { Orderbook, OrderSide } from '@poly-weather/core';
+import type { PolymarketConfig } from '@poly-weather/core';
+import { createChildLogger, CLOB_HOST } from '@poly-weather/core';
+
+const logger = createChildLogger('clob');
+
+interface CLOBOrderbookResponse {
+  bids: Array<{ price: string; size: string }>;
+  asks: Array<{ price: string; size: string }>;
+}
+
+// ─── Read-Only Client ────────────────────────────────────────────────────────
+
+/**
+ * Read-only CLOB client — no authentication needed for orderbook data.
+ */
+export class CLOBReadClient {
+  protected baseUrl: string;
+
+  constructor(baseUrl: string = CLOB_HOST) {
+    this.baseUrl = baseUrl;
+  }
+
+  async getOrderBook(tokenId: string): Promise<Orderbook> {
+    const url = `${this.baseUrl}/book?token_id=${tokenId}`;
+    const res = await fetch(url, {
+      headers: { 'Accept': 'application/json' },
+      signal: AbortSignal.timeout(10000),
+    });
+    if (!res.ok) {
+      throw new Error(`CLOB orderbook failed: ${res.status}`);
+    }
+    const data = (await res.json()) as CLOBOrderbookResponse;
+    return { bids: data.bids || [], asks: data.asks || [] };
+  }
+
+  async getMidpoint(tokenId: string): Promise<number> {
+    const url = `${this.baseUrl}/midpoint?token_id=${tokenId}`;
+    const res = await fetch(url, {
+      headers: { 'Accept': 'application/json' },
+      signal: AbortSignal.timeout(10000),
+    });
+    if (!res.ok) {
+      throw new Error(`CLOB midpoint failed: ${res.status}`);
+    }
+    const data = (await res.json()) as { mid?: string };
+    return parseFloat(data.mid || '0');
+  }
+
+  async getPrice(tokenId: string): Promise<number> {
+    const url = `${this.baseUrl}/price?token_id=${tokenId}`;
+    const res = await fetch(url, {
+      headers: { 'Accept': 'application/json' },
+      signal: AbortSignal.timeout(10000),
+    });
+    if (!res.ok) {
+      throw new Error(`CLOB price failed: ${res.status}`);
+    }
+    const data = (await res.json()) as { price?: string };
+    return parseFloat(data.price || '0');
+  }
+}
+
+// ─── Trading Client ──────────────────────────────────────────────────────────
+
+/**
+ * Authenticated trading CLOB client.
+ * Uses @polymarket/clob-client with proper wallet signing.
+ */
+export class CLOBTradingClient extends CLOBReadClient {
+  private polyConfig: PolymarketConfig;
+  private dryRun: boolean;
+  private clobClient: ClobClient | null = null;
+  private initialized = false;
+
+  constructor(polyConfig: PolymarketConfig, dryRun: boolean = true) {
+    super(CLOB_HOST);
+    this.polyConfig = polyConfig;
+    this.dryRun = dryRun;
+  }
+
+  /**
+   * Initialize the authenticated CLOB client.
+   * Creates a Wallet from the private key, then sets up API credentials.
+   *
+   * If L2 API creds are provided in config, uses them directly.
+   * Otherwise, derives them from the private key via /auth/derive-api-key.
+   */
+  async initialize(): Promise<void> {
+    if (this.initialized) return;
+
+    if (!this.polyConfig.privateKey) {
+      logger.warn('No PRIVATE_KEY configured — trading disabled');
+      return;
+    }
+
+    try {
+      // Create ethers Wallet from private key
+      const wallet = new Wallet(this.polyConfig.privateKey);
+
+      // Prepare L2 API creds if available
+      let creds: ApiKeyCreds | undefined;
+      if (this.polyConfig.l2Creds.key) {
+        creds = {
+          key: this.polyConfig.l2Creds.key,
+          secret: this.polyConfig.l2Creds.secret,
+          passphrase: this.polyConfig.l2Creds.passphrase,
+        };
+      }
+
+      // Create the ClobClient with wallet + signature type
+      this.clobClient = new ClobClient(
+        CLOB_HOST,
+        this.polyConfig.chainId,
+        wallet,                                         // Wallet signer
+        creds,                                          // API creds (or undefined)
+        this.polyConfig.signatureType,                  // 1 = POLY_PROXY
+        this.polyConfig.funderAddress,                  // proxy wallet address
+      );
+
+      // If no creds were provided, derive them
+      if (!creds) {
+        logger.info('Deriving L2 API keys from wallet...');
+        const derivedCreds = await this.clobClient.createOrDeriveApiKey();
+        logger.info(
+          { apiKey: derivedCreds.key },
+          'L2 API keys derived. Save these to .env as L2_API_KEY/SECRET/PASSPHRASE',
+        );
+        // Re-create client with derived creds
+        this.clobClient = new ClobClient(
+          CLOB_HOST,
+          this.polyConfig.chainId,
+          wallet,
+          derivedCreds,
+          this.polyConfig.signatureType,
+          this.polyConfig.funderAddress,
+        );
+      }
+
+      this.initialized = true;
+      logger.info(
+        {
+          funder: this.polyConfig.funderAddress,
+          signer: wallet.address,
+          signatureType: this.polyConfig.signatureType,
+          dryRun: this.dryRun,
+        },
+        'CLOB trading client ready',
+      );
+    } catch (err) {
+      logger.error({ err }, 'Failed to initialize CLOB trading client');
+      throw err;
+    }
+  }
+
+  /**
+   * Place a GTC limit order (maker, zero fees).
+   * In DRY_RUN mode, logs the order but does not submit.
+   */
+  async placeLimitOrder(
+    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) };
+
+    // DRY RUN — log only
+    if (this.dryRun) {
+      const mockId = `dry_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+      logger.info({ orderId: mockId, ...orderParams }, '[DRY RUN] Would place limit order');
+      return { orderId: mockId, status: 'DRY_RUN' };
+    }
+
+    // Ensure client is initialized
+    await this.initialize();
+    if (!this.clobClient) {
+      throw new Error('CLOB client not initialized — check PRIVATE_KEY');
+    }
+
+    logger.info(orderParams, 'Placing limit order');
+
+    // Create and sign the order
+    const clobSide = side === 'BUY' ? ClobSide.BUY : ClobSide.SELL;
+    const signedOrder = await this.clobClient.createOrder({
+      tokenID: tokenId,
+      price,
+      size,
+      side: clobSide,
+    });
+
+    // Post the signed order as GTC (Good Till Cancelled)
+    const result = await this.clobClient.postOrder(signedOrder, OrderType.GTC);
+
+    logger.info(
+      { orderId: result?.orderID, status: result?.status },
+      'Order placed successfully',
+    );
+
+    return { orderId: result?.orderID || '', status: result?.status || 'UNKNOWN' };
+  }
+
+  /**
+   * Cancel an open order by order ID.
+   */
+  async cancelOrder(orderId: string): Promise<void> {
+    if (this.dryRun) {
+      logger.info({ orderId }, '[DRY RUN] Would cancel order');
+      return;
+    }
+
+    await this.initialize();
+    if (!this.clobClient) throw new Error('CLOB client not initialized');
+
+    logger.info({ orderId }, 'Cancelling order');
+    await this.clobClient.cancelOrder({ orderID: orderId });
+    logger.info({ orderId }, 'Order cancelled');
+  }
+
+  /**
+   * Cancel all open orders.
+   */
+  async cancelAllOrders(): Promise<void> {
+    if (this.dryRun) {
+      logger.info('[DRY RUN] Would cancel all orders');
+      return;
+    }
+
+    await this.initialize();
+    if (!this.clobClient) throw new Error('CLOB client not initialized');
+
+    logger.info('Cancelling all open orders');
+    await this.clobClient.cancelAll();
+    logger.info('All orders cancelled');
+  }
+
+  /**
+   * Get all open orders.
+   */
+  async getOpenOrders(): Promise<unknown[]> {
+    await this.initialize();
+    if (!this.clobClient) return [];
+    return this.clobClient.getOpenOrders();
+  }
+
+  /**
+   * Check if client is ready for trading.
+   */
+  isReady(): boolean {
+    return this.initialized && this.clobClient !== null;
+  }
+}
+
+// Default read-only client instance
+export const readClient = new CLOBReadClient();

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

@@ -0,0 +1,171 @@
+/**
+ * Gamma API client for Polymarket market discovery.
+ * Public API — no authentication required.
+ *
+ * Weather temperature markets use:
+ *  - Events endpoint (not markets) with tag_slug=temperature
+ *  - negRisk=true (grouped outcomes)
+ *  - Slug pattern: highest-temperature-in-{city}-on-{month}-{day}-{year}
+ */
+
+import type { GammaMarket } from '@poly-weather/core';
+import { createChildLogger, GAMMA_BASE } from '@poly-weather/core';
+
+const logger = createChildLogger('gamma');
+
+interface GammaEventMarket {
+  id: string;
+  question: string;
+  conditionId: string;
+  slug: string;
+  outcomes: string;
+  outcomePrices: string;
+  clobTokenIds: string;
+  volume: number;
+  volume24hr: number;
+  liquidityNum: number;
+  endDateIso: string;
+  closed: boolean;
+  groupItemTitle: string;
+  groupItemThreshold: string;
+  enableOrderBook: boolean;
+}
+
+interface GammaEvent {
+  id: string;
+  title: string;
+  slug: string;
+  description: string;
+  markets: GammaEventMarket[];
+  tags: Array<{ id: string; label: string; slug: string }>;
+}
+
+/**
+ * Fetch all active weather temperature markets from Polymarket.
+ * Uses the events endpoint with tag_slug=temperature.
+ */
+export async function scanWeatherMarkets(): Promise<GammaMarket[]> {
+  const allMarkets: GammaMarket[] = [];
+  let offset = 0;
+
+  while (true) {
+    const params = new URLSearchParams({
+      tag_slug: 'temperature',
+      active: 'true',
+      closed: 'false',
+      limit: '50',
+      offset: offset.toString(),
+    });
+
+    const url = `${GAMMA_BASE}/events?${params}`;
+    logger.debug({ url, offset }, 'Scanning weather events');
+
+    const res = await fetch(url, {
+      headers: { Accept: 'application/json' },
+      signal: AbortSignal.timeout(15000),
+    });
+
+    if (!res.ok) {
+      throw new Error(`Gamma API request failed: ${res.status} ${res.statusText}`);
+    }
+
+    const events = (await res.json()) as GammaEvent[];
+    if (events.length === 0) break;
+
+    // Extract individual markets from each event
+    for (const event of events) {
+      const markets = event.markets || [];
+      for (const m of markets) {
+        // Augment market with event-level group info
+        // The event ID serves as the marketGroup for grouping buckets
+        const market: GammaMarket = {
+          id: parseInt(m.id) || 0,
+          question: m.question,
+          conditionId: m.conditionId,
+          slug: m.slug,
+          outcomes: m.outcomes,
+          outcomePrices: m.outcomePrices,
+          clobTokenIds: m.clobTokenIds,
+          volume: m.volume || 0,
+          volume24hr: m.volume24hr || 0,
+          liquidityNum: m.liquidityNum || 0,
+          endDateIso: m.endDateIso,
+          closed: m.closed,
+          groupItemTitle: m.groupItemTitle,
+          groupItemThreshold: m.groupItemThreshold,
+          enableOrderBook: m.enableOrderBook,
+          marketGroup: parseInt(event.id) || 0,
+          tags: event.tags?.map((t) => ({ label: t.label })) || [],
+        };
+        allMarkets.push(market);
+      }
+    }
+
+    offset += 50;
+    if (offset > 500) break;
+  }
+
+  logger.info({ count: allMarkets.length }, 'Weather markets scanned');
+  return allMarkets;
+}
+
+/**
+ * Fetch a specific event by slug.
+ */
+export async function getEventBySlug(slug: string): Promise<GammaEvent | null> {
+  const url = `${GAMMA_BASE}/events?slug=${encodeURIComponent(slug)}`;
+
+  const res = await fetch(url, {
+    headers: { Accept: 'application/json' },
+    signal: AbortSignal.timeout(15000),
+  });
+
+  if (!res.ok) return null;
+
+  const events = (await res.json()) as GammaEvent[];
+  return events[0] || null;
+}
+
+/**
+ * Fetch a specific market by condition ID.
+ */
+export async function getMarketByConditionId(
+  conditionId: string,
+): Promise<GammaMarket | null> {
+  const url = `${GAMMA_BASE}/markets?condition_id=${conditionId}`;
+
+  const res = await fetch(url, {
+    headers: { Accept: 'application/json' },
+    signal: AbortSignal.timeout(15000),
+  });
+
+  if (!res.ok) return null;
+
+  const markets = (await res.json()) as GammaMarket[];
+  return markets[0] || null;
+}
+
+/**
+ * Search markets by text query.
+ */
+export async function searchMarkets(query: string): Promise<GammaMarket[]> {
+  const params = new URLSearchParams({
+    _q: query,
+    active: 'true',
+    closed: 'false',
+    limit: '50',
+  });
+
+  const url = `${GAMMA_BASE}/markets?${params}`;
+
+  const res = await fetch(url, {
+    headers: { Accept: 'application/json' },
+    signal: AbortSignal.timeout(15000),
+  });
+
+  if (!res.ok) {
+    throw new Error(`Gamma search failed: ${res.status}`);
+  }
+
+  return (await res.json()) as GammaMarket[];
+}

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

@@ -0,0 +1,6 @@
+// @poly-weather/market-engine
+export * from './gamma-client.js';
+export * from './clob-client.js';
+export * from './market-scanner.js';
+export * from './order-manager.js';
+export * from './websocket-feed.js';

+ 165 - 0
packages/market-engine/src/market-scanner.ts

@@ -0,0 +1,165 @@
+/**
+ * Market scanner: parses Polymarket questions, matches cities,
+ * and groups markets by weather event.
+ */
+
+import type { GammaMarket, ParsedWeatherMarket, MarketBucket } from '@poly-weather/core';
+import {
+  CITY_ALIASES,
+  createChildLogger,
+  parseBucketLabel,
+  parseMarketDate,
+} from '@poly-weather/core';
+
+const logger = createChildLogger('market-scanner');
+
+// ─── Question Parsing ────────────────────────────────────────────────────────
+
+const QUESTION_PATTERNS = [
+  // "Will the highest temperature in Seoul be 12°C on March 17?"
+  // "Will the highest temperature in New York City be 45°F or below on March 16?"
+  /[Hh]ighest\s+temperature\s+in\s+([\w][\w\s]*?)\s+be\s+.*?\s+(?:on\s+)?(\w+\s+\d+)/,
+  // "highest temperature in CITY on DATE" (without "be X°")
+  /[Hh]ighest\s+temperature\s+in\s+([\w][\w\s]*?)\s+on\s+(\w+\s+\d+)/,
+  /[Ww]hat\s+will\s+.*\s+high\s+.*\s+([\w][\w\s]*?)\s+.*\s+(\w+\s+\d+)/,
+  /[Hh]igh\s+temp(?:erature)?\s+(?:in|for)\s+([\w][\w\s]*?)\s+(?:on|for)\s+(\w+\s+\d+)/,
+];
+
+interface ParsedQuestion {
+  city: string;    // Slug from CITY_ALIASES
+  dateStr: string; // Raw date string like 'March 17'
+}
+
+/**
+ * Try to parse a Polymarket question to extract city and date.
+ */
+export function parseWeatherQuestion(question: string): ParsedQuestion | null {
+  for (const pattern of QUESTION_PATTERNS) {
+    const match = question.match(pattern);
+    if (!match) continue;
+
+    const rawCity = match[1].trim();
+    const dateStr = match[2].trim();
+
+    // Try to match city name to our known cities
+    const citySlug = CITY_ALIASES[rawCity];
+    if (!citySlug) {
+      // Try partial match
+      const key = Object.keys(CITY_ALIASES).find(
+        (k) => k.toLowerCase() === rawCity.toLowerCase(),
+      );
+      if (!key) {
+        logger.debug({ rawCity, question }, 'Unknown city in question');
+        continue;
+      }
+      return { city: CITY_ALIASES[key], dateStr };
+    }
+
+    return { city: citySlug, dateStr };
+  }
+
+  return null;
+}
+
+/**
+ * Parse a bucket label to extract numeric bounds.
+ * Wraps the core utility with error handling.
+ */
+function safeParseBucketLabel(label: string): { lower: number; upper: number } | null {
+  try {
+    const bucket = parseBucketLabel(label);
+    return { lower: bucket.lowerBound, upper: bucket.upperBound };
+  } catch {
+    logger.warn({ label }, 'Failed to parse bucket label');
+    return null;
+  }
+}
+
+/**
+ * Group Gamma markets by event (same day/city) into ParsedWeatherMarket.
+ */
+export function groupMarketsByEvent(
+  markets: GammaMarket[],
+): Map<number, ParsedWeatherMarket> {
+  // Group by marketGroup ID
+  const groups = new Map<number, GammaMarket[]>();
+  for (const m of markets) {
+    if (!m.marketGroup) continue;
+    const existing = groups.get(m.marketGroup) || [];
+    existing.push(m);
+    groups.set(m.marketGroup, existing);
+  }
+
+  const parsed = new Map<number, ParsedWeatherMarket>();
+
+  for (const [groupId, groupMarkets] of groups) {
+    // Parse question from first market in group
+    const firstQuestion = groupMarkets[0].question;
+    const parsedQ = parseWeatherQuestion(firstQuestion);
+    if (!parsedQ) {
+      logger.debug({ question: firstQuestion }, 'Could not parse question');
+      continue;
+    }
+
+    let date: Date;
+    try {
+      date = parseMarketDate(parsedQ.dateStr);
+    } catch {
+      logger.warn({ dateStr: parsedQ.dateStr }, 'Failed to parse date');
+      continue;
+    }
+
+    // Build buckets from group
+    const buckets: MarketBucket[] = [];
+    for (const m of groupMarkets) {
+      const bounds = safeParseBucketLabel(m.groupItemTitle);
+      if (!bounds) continue;
+
+      let tokenIds: string[] = [];
+      let prices: string[] = [];
+
+      try {
+        tokenIds = JSON.parse(m.clobTokenIds || '[]');
+        prices = JSON.parse(m.outcomePrices || '[]');
+      } catch {
+        logger.warn({ marketId: m.id }, 'Failed to parse token/price JSON');
+        continue;
+      }
+
+      buckets.push({
+        label: m.groupItemTitle,
+        lowerBound: bounds.lower,
+        upperBound: bounds.upper,
+        yesTokenId: tokenIds[0] || '',
+        noTokenId: tokenIds[1] || '',
+        yesPrice: parseFloat(prices[0]) || 0,
+        noPrice: parseFloat(prices[1]) || 0,
+        marketId: m.id,
+      });
+    }
+
+    if (buckets.length === 0) continue;
+
+    // Sort buckets by lower bound
+    buckets.sort((a, b) => a.lowerBound - b.lowerBound);
+
+    // Determine temp unit from first bucket label
+    const tempUnit = buckets[0].label.includes('°F') ? '°F' as const : '°C' as const;
+
+    parsed.set(groupId, {
+      city: parsedQ.city,
+      date,
+      tempUnit,
+      buckets,
+      marketGroupId: groupId,
+      totalVolume: groupMarkets.reduce((s, m) => s + m.volume, 0),
+    });
+  }
+
+  logger.info(
+    { totalGroups: groups.size, parsedMarkets: parsed.size },
+    'Markets grouped by event',
+  );
+
+  return parsed;
+}

+ 144 - 0
packages/market-engine/src/order-manager.ts

@@ -0,0 +1,144 @@
+/**
+ * Order lifecycle management.
+ * Tracks orders from placement through fill/cancellation, persists to DB.
+ */
+
+import type { Signal, TailNOSignal, LatencySignal, ParitySignal } from '@poly-weather/core';
+import { createChildLogger } from '@poly-weather/core';
+import type { CLOBTradingClient } from './clob-client.js';
+
+const logger = createChildLogger('order-manager');
+
+interface ManagedOrder {
+  orderId: string;
+  signalType: string;
+  tokenId: string;
+  side: 'BUY' | 'SELL';
+  price: number;
+  size: number;
+  status: 'PENDING' | 'FILLED' | 'CANCELLED';
+  dryRun: boolean;
+  createdAt: Date;
+}
+
+export class OrderManager {
+  private openOrders = new Map<string, ManagedOrder>();
+  private clobClient: CLOBTradingClient;
+  private dryRun: boolean;
+
+  constructor(clobClient: CLOBTradingClient, dryRun: boolean = true) {
+    this.clobClient = clobClient;
+    this.dryRun = dryRun;
+  }
+
+  /**
+   * Execute a signal by placing an order.
+   */
+  async executeSignal(signal: Signal): Promise<ManagedOrder | null> {
+    let tokenId: string;
+    let side: 'BUY' | 'SELL';
+    let price: number;
+    let size: number;
+
+    if (signal.type === 'TAIL_NO') {
+      const s = signal as TailNOSignal;
+      // Buy NO shares — we need the noTokenId from market data
+      // For now, use the label as a reference
+      tokenId = `no_${s.city}_${s.bucketLabel}`;
+      side = 'BUY';
+      price = s.noPrice;
+      size = s.recommendedSize / s.noPrice; // Convert USDC to shares
+    } else if (signal.type === 'LATENCY_ARB') {
+      const s = signal as LatencySignal;
+      tokenId = `${s.side === 'BUY_YES' ? 'yes' : 'no'}_${s.city}_${s.bucketLabel}`;
+      side = 'BUY';
+      price = s.currentPrice;
+      size = 10; // Default size for latency arb
+    } else if (signal.type === 'PARITY_ARB') {
+      // Parity arb involves multiple orders — simplified here
+      const s = signal as ParitySignal;
+      tokenId = `parity_${s.city}`;
+      side = 'BUY';
+      price = 1 - s.deviation;
+      size = 10;
+    } else {
+      logger.warn({ signal }, 'Unknown signal type');
+      return null;
+    }
+
+    try {
+      const result = await this.clobClient.placeLimitOrder(
+        tokenId,
+        side,
+        price,
+        size,
+      );
+
+      const order: ManagedOrder = {
+        orderId: result.orderId,
+        signalType: signal.type,
+        tokenId,
+        side,
+        price,
+        size,
+        status: 'PENDING',
+        dryRun: this.dryRun,
+        createdAt: new Date(),
+      };
+
+      this.openOrders.set(result.orderId, order);
+
+      logger.info(
+        { orderId: result.orderId, type: signal.type, dryRun: this.dryRun },
+        'Order placed for signal',
+      );
+
+      return order;
+    } catch (error) {
+      logger.error({ error, signal }, 'Failed to execute signal');
+      return null;
+    }
+  }
+
+  /**
+   * Cancel all open orders.
+   */
+  async cancelAll(): Promise<void> {
+    const orders = [...this.openOrders.values()];
+    logger.info({ count: orders.length }, 'Cancelling all open orders');
+
+    for (const order of orders) {
+      try {
+        await this.clobClient.cancelOrder(order.orderId);
+        order.status = 'CANCELLED';
+      } catch (error) {
+        logger.error({ orderId: order.orderId, error }, 'Cancel failed');
+      }
+    }
+  }
+
+  /**
+   * Get all open orders.
+   */
+  getOpenOrders(): ManagedOrder[] {
+    return [...this.openOrders.values()].filter((o) => o.status === 'PENDING');
+  }
+
+  /**
+   * Get count of open orders.
+   */
+  getOpenOrderCount(): number {
+    return this.getOpenOrders().length;
+  }
+
+  /**
+   * Mark an order as filled.
+   */
+  markFilled(orderId: string): void {
+    const order = this.openOrders.get(orderId);
+    if (order) {
+      order.status = 'FILLED';
+      logger.info({ orderId }, 'Order marked as filled');
+    }
+  }
+}

+ 149 - 0
packages/market-engine/src/websocket-feed.ts

@@ -0,0 +1,149 @@
+/**
+ * WebSocket feed for real-time Polymarket price updates.
+ * Auto-reconnects on disconnect with exponential backoff.
+ */
+
+import WebSocket from 'ws';
+import type { PriceUpdate } from '@poly-weather/core';
+import { createChildLogger, WS_URL } from '@poly-weather/core';
+
+const logger = createChildLogger('ws-feed');
+
+export type PriceCallback = (update: PriceUpdate) => void;
+
+export class WebSocketFeed {
+  private ws: WebSocket | null = null;
+  private tokenIds: string[];
+  private onUpdate: PriceCallback;
+  private reconnectAttempt = 0;
+  private maxReconnectDelay = 60_000; // 1 minute max
+  private running = false;
+
+  constructor(tokenIds: string[], onUpdate: PriceCallback) {
+    this.tokenIds = tokenIds;
+    this.onUpdate = onUpdate;
+  }
+
+  /**
+   * Connect to the WebSocket feed and subscribe to price updates.
+   */
+  connect(): void {
+    this.running = true;
+    this.doConnect();
+  }
+
+  private doConnect(): void {
+    if (!this.running) return;
+
+    logger.info(
+      { tokenCount: this.tokenIds.length, attempt: this.reconnectAttempt },
+      'Connecting to WebSocket',
+    );
+
+    this.ws = new WebSocket(WS_URL);
+
+    this.ws.on('open', () => {
+      logger.info('WebSocket connected');
+      this.reconnectAttempt = 0;
+
+      // Subscribe to each token's market channel
+      for (const tokenId of this.tokenIds) {
+        this.ws!.send(
+          JSON.stringify({
+            type: 'subscribe',
+            channel: 'market',
+            assets_id: tokenId,
+          }),
+        );
+      }
+    });
+
+    this.ws.on('message', (data: Buffer) => {
+      try {
+        const msg = JSON.parse(data.toString());
+        if (msg.event_type === 'price_change') {
+          this.onUpdate({
+            tokenId: msg.asset_id,
+            price: parseFloat(msg.price),
+            timestamp: Date.now(),
+          });
+        }
+      } catch (err) {
+        logger.warn({ err }, 'Failed to parse WS message');
+      }
+    });
+
+    this.ws.on('error', (err) => {
+      logger.error({ err }, 'WebSocket error');
+    });
+
+    this.ws.on('close', (code, reason) => {
+      logger.warn(
+        { code, reason: reason.toString() },
+        'WebSocket closed',
+      );
+      this.scheduleReconnect();
+    });
+  }
+
+  private scheduleReconnect(): void {
+    if (!this.running) return;
+
+    const delay = Math.min(
+      1000 * Math.pow(2, this.reconnectAttempt),
+      this.maxReconnectDelay,
+    );
+    this.reconnectAttempt++;
+
+    logger.info(
+      { delayMs: delay, attempt: this.reconnectAttempt },
+      'Scheduling reconnect',
+    );
+
+    setTimeout(() => this.doConnect(), delay);
+  }
+
+  /**
+   * Update the list of subscribed tokens.
+   */
+  updateSubscriptions(tokenIds: string[]): void {
+    // Unsubscribe from removed tokens
+    const removed = this.tokenIds.filter((id) => !tokenIds.includes(id));
+    const added = tokenIds.filter((id) => !this.tokenIds.includes(id));
+
+    this.tokenIds = tokenIds;
+
+    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+      for (const tokenId of removed) {
+        this.ws.send(
+          JSON.stringify({
+            type: 'unsubscribe',
+            channel: 'market',
+            assets_id: tokenId,
+          }),
+        );
+      }
+      for (const tokenId of added) {
+        this.ws.send(
+          JSON.stringify({
+            type: 'subscribe',
+            channel: 'market',
+            assets_id: tokenId,
+          }),
+        );
+      }
+    }
+  }
+
+  /**
+   * Disconnect and stop reconnecting.
+   */
+  disconnect(): void {
+    this.running = false;
+    if (this.ws) {
+      this.ws.close();
+      this.ws = null;
+    }
+    logger.info('WebSocket disconnected');
+  }
+}

+ 11 - 0
packages/market-engine/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "./dist",
+    "rootDir": "./src"
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../core" }
+  ]
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
packages/market-engine/tsconfig.tsbuildinfo


+ 8 - 0
packages/market-engine/vitest.config.ts

@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    root: '.',
+    include: ['src/__tests__/**/*.test.ts'],
+  },
+});

+ 29 - 0
packages/strategy/package.json

@@ -0,0 +1,29 @@
+{
+  "name": "@poly-weather/strategy",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/index.js",
+      "types": "./dist/index.d.ts"
+    }
+  },
+  "scripts": {
+    "build": "tsc -b",
+    "dev": "tsc -b --watch",
+    "test": "vitest run"
+  },
+  "dependencies": {
+    "@poly-weather/core": "workspace:*",
+    "@poly-weather/weather-engine": "workspace:*",
+    "@poly-weather/market-engine": "workspace:*"
+  },
+  "devDependencies": {
+    "vitest": "^2.1.0",
+    "typescript": "^5.7.0",
+    "@types/node": "^22.0.0"
+  }
+}

+ 273 - 0
packages/strategy/src/__tests__/strategies.test.ts

@@ -0,0 +1,273 @@
+import { describe, it, expect } from 'vitest';
+import { TailNOStrategy } from '../tail-no-strategy.js';
+import { LatencyArbStrategy } from '../latency-arb.js';
+import { ParityArbStrategy } from '../parity-arb.js';
+import { RiskManager, calculatePositionSize } from '../risk-manager.js';
+import type { ParsedWeatherMarket, BucketProbability, MarketBucket } from '@poly-weather/core';
+
+function makeMarket(buckets: Partial<MarketBucket>[]): ParsedWeatherMarket {
+  return {
+    city: 'new_york',
+    date: new Date('2026-03-18'),
+    tempUnit: '°F',
+    buckets: buckets.map((b, i) => ({
+      label: b.label || `bucket-${i}`,
+      lowerBound: b.lowerBound ?? i * 5,
+      upperBound: b.upperBound ?? (i + 1) * 5,
+      yesTokenId: `yes_${i}`,
+      noTokenId: `no_${i}`,
+      yesPrice: b.yesPrice ?? 0.1,
+      noPrice: b.noPrice ?? 0.9,
+      marketId: 1000 + i,
+    })),
+    marketGroupId: 1,
+    totalVolume: 10000,
+  };
+}
+
+function makeEnsembleProbs(probs: number[]): BucketProbability[] {
+  return probs.map((p, i) => ({
+    bucketLabel: `bucket-${i}`,
+    lowerBound: i * 5,
+    upperBound: (i + 1) * 5,
+    probability: p,
+  }));
+}
+
+// ─── Tail NO Strategy ────────────────────────────────────────────────────────
+
+describe('TailNOStrategy', () => {
+  const strategy = new TailNOStrategy();
+
+  it('should generate signal when tail bucket is overpriced', () => {
+    const market = makeMarket([
+      { label: '31°F or below', yesPrice: 0.05, noPrice: 0.95 },
+      { label: '32-35°F', yesPrice: 0.15, noPrice: 0.85 },
+      { label: '36-39°F', yesPrice: 0.50, noPrice: 0.50 },
+      { label: '40-43°F', yesPrice: 0.25, noPrice: 0.75 },
+      { label: '44°F or higher', yesPrice: 0.05, noPrice: 0.95 },
+    ]);
+
+    // Model says bucket 0 has 0.5% probability, market says 5%
+    const probs = makeEnsembleProbs([0.005, 0.02, 0.60, 0.35, 0.025]);
+
+    const signals = strategy.evaluate({
+      market,
+      ensembleProbs: probs,
+      agreementScore: 0.9,
+    });
+
+    expect(signals.length).toBeGreaterThan(0);
+    expect(signals[0].type).toBe('TAIL_NO');
+    expect(signals[0].edge).toBeGreaterThanOrEqual(0.03);
+  });
+
+  it('should not generate signal when edge is below threshold', () => {
+    const market = makeMarket([
+      { label: '31°F or below', yesPrice: 0.03, noPrice: 0.97 },
+    ]);
+    const probs = makeEnsembleProbs([0.02]); // Edge = 0.01, below threshold
+
+    const signals = strategy.evaluate({
+      market,
+      ensembleProbs: probs,
+      agreementScore: 0.9,
+    });
+
+    expect(signals.length).toBe(0);
+  });
+
+  it('should not generate signal when agreement is low', () => {
+    const market = makeMarket([
+      { label: '31°F or below', yesPrice: 0.10, noPrice: 0.90 },
+    ]);
+    const probs = makeEnsembleProbs([0.005]);
+
+    const signals = strategy.evaluate({
+      market,
+      ensembleProbs: probs,
+      agreementScore: 0.3, // Below threshold
+    });
+
+    expect(signals.length).toBe(0);
+  });
+});
+
+// ─── Latency Arb Strategy ────────────────────────────────────────────────────
+
+describe('LatencyArbStrategy', () => {
+  const strategy = new LatencyArbStrategy();
+
+  it('should detect latency signal when model shifts but market stays', () => {
+    const market = makeMarket([
+      { label: 'bucket-0', yesPrice: 0.30, noPrice: 0.70 },
+    ]);
+
+    const prevProbs = makeEnsembleProbs([0.30]);
+    const currProbs = makeEnsembleProbs([0.42]); // +12% shift
+
+    const signals = strategy.evaluate({
+      market,
+      ensembleProbs: currProbs,
+      agreementScore: 0.8,
+      previousEnsembleProbs: prevProbs,
+      modelUpdateTime: new Date(Date.now() - 300_000), // 5 min ago
+    });
+
+    expect(signals.length).toBe(1);
+    expect(signals[0].type).toBe('LATENCY_ARB');
+    expect(signals[0].side).toBe('BUY_YES');
+  });
+
+  it('should return empty when no previous data', () => {
+    const market = makeMarket([{ yesPrice: 0.30 }]);
+    const probs = makeEnsembleProbs([0.40]);
+
+    const signals = strategy.evaluate({
+      market,
+      ensembleProbs: probs,
+      agreementScore: 0.8,
+    });
+
+    expect(signals.length).toBe(0);
+  });
+
+  it('should return empty when model update is too old', () => {
+    const market = makeMarket([{ yesPrice: 0.30 }]);
+    const prevProbs = makeEnsembleProbs([0.30]);
+    const currProbs = makeEnsembleProbs([0.45]);
+
+    const signals = strategy.evaluate({
+      market,
+      ensembleProbs: currProbs,
+      agreementScore: 0.8,
+      previousEnsembleProbs: prevProbs,
+      modelUpdateTime: new Date(Date.now() - 3 * 3600_000), // 3h ago
+    });
+
+    expect(signals.length).toBe(0);
+  });
+});
+
+// ─── Parity Arb Strategy ─────────────────────────────────────────────────────
+
+describe('ParityArbStrategy', () => {
+  const strategy = new ParityArbStrategy();
+
+  it('should detect overpriced market (sum > 1.0)', () => {
+    const market = makeMarket([
+      { yesPrice: 0.30 },
+      { yesPrice: 0.40 },
+      { yesPrice: 0.35 }, // Sum = 1.05
+    ]);
+
+    const signals = strategy.evaluate({
+      market,
+      ensembleProbs: [],
+      agreementScore: 1,
+    });
+
+    expect(signals.length).toBe(1);
+    expect(signals[0].type).toBe('PARITY_ARB');
+    expect(signals[0].direction).toBe('BUY_ALL_NO');
+    expect(signals[0].deviation).toBeCloseTo(0.05, 2);
+  });
+
+  it('should detect underpriced market (sum < 1.0)', () => {
+    const market = makeMarket([
+      { yesPrice: 0.25 },
+      { yesPrice: 0.35 },
+      { yesPrice: 0.35 }, // Sum = 0.95
+    ]);
+
+    const signals = strategy.evaluate({
+      market,
+      ensembleProbs: [],
+      agreementScore: 1,
+    });
+
+    expect(signals.length).toBe(1);
+    expect(signals[0].direction).toBe('BUY_ALL_YES');
+  });
+
+  it('should not signal when sum is close to 1.0', () => {
+    const market = makeMarket([
+      { yesPrice: 0.33 },
+      { yesPrice: 0.34 },
+      { yesPrice: 0.33 }, // Sum = 1.00
+    ]);
+
+    const signals = strategy.evaluate({
+      market,
+      ensembleProbs: [],
+      agreementScore: 1,
+    });
+
+    expect(signals.length).toBe(0);
+  });
+});
+
+// ─── Risk Manager ────────────────────────────────────────────────────────────
+
+describe('calculatePositionSize', () => {
+  it('should return positive size for positive edge', () => {
+    const size = calculatePositionSize(0.05, 0.95, 0.9);
+    expect(size).toBeGreaterThan(0);
+  });
+
+  it('should return 0 for no edge', () => {
+    const size = calculatePositionSize(0, 0.95, 0.9);
+    expect(size).toBe(0);
+  });
+
+  it('should scale with agreement score', () => {
+    // Use small edge to avoid hitting position cap
+    const highAgreement = calculatePositionSize(0.03, 0.97, 0.9);
+    const lowAgreement = calculatePositionSize(0.03, 0.97, 0.3);
+    expect(highAgreement).toBeGreaterThanOrEqual(lowAgreement);
+  });
+});
+
+describe('RiskManager', () => {
+  it('should approve signal within limits', () => {
+    const rm = new RiskManager();
+    const result = rm.checkSignal({
+      type: 'TAIL_NO',
+      city: 'new_york',
+      date: new Date(),
+      bucketLabel: 'test',
+      marketYesPrice: 0.05,
+      modelProbability: 0.005,
+      edge: 0.045,
+      noPrice: 0.95,
+      expectedReturn: 0.047,
+      agreementScore: 0.9,
+      confidence: 'HIGH',
+      recommendedSize: 25,
+    });
+
+    expect(result.approved).toBe(true);
+    expect(result.adjustedSize).toBeGreaterThan(0);
+  });
+
+  it('should reject when daily loss limit reached', () => {
+    const rm = new RiskManager(undefined, [], -100);
+    const result = rm.checkSignal({
+      type: 'TAIL_NO',
+      city: 'new_york',
+      date: new Date(),
+      bucketLabel: 'test',
+      marketYesPrice: 0.05,
+      modelProbability: 0.005,
+      edge: 0.045,
+      noPrice: 0.95,
+      expectedReturn: 0.047,
+      agreementScore: 0.9,
+      confidence: 'HIGH',
+      recommendedSize: 25,
+    });
+
+    expect(result.approved).toBe(false);
+    expect(result.reason).toContain('Daily loss limit');
+  });
+});

+ 7 - 0
packages/strategy/src/index.ts

@@ -0,0 +1,7 @@
+// @poly-weather/strategy
+export * from './signal-generator.js';
+export * from './tail-no-strategy.js';
+export * from './latency-arb.js';
+export * from './parity-arb.js';
+export * from './risk-manager.js';
+export * from './portfolio-tracker.js';

+ 76 - 0
packages/strategy/src/latency-arb.ts

@@ -0,0 +1,76 @@
+/**
+ * Strategy B: Forecast Latency Arbitrage
+ * Detect when new model data shifts probabilities but market hasn't repriced.
+ */
+
+import type { LatencySignal, BucketProbability, ParsedWeatherMarket } from '@poly-weather/core';
+import { createChildLogger } from '@poly-weather/core';
+import type { StrategyEngine, StrategyInput } from './signal-generator.js';
+
+const logger = createChildLogger('latency-arb');
+
+const LATENCY_THRESHOLDS = {
+  minPriceDelta: 0.05,      // Model shift >5%
+  minMarketLag: 0.08,       // Market >8% from new fair value
+  maxAgeSeconds: 7200,      // Valid within 2 hours of model update
+};
+
+export class LatencyArbStrategy implements StrategyEngine {
+  readonly type = 'LATENCY_ARB' as const;
+
+  evaluate(input: StrategyInput): LatencySignal[] {
+    const { market, ensembleProbs, previousEnsembleProbs, modelUpdateTime } = input;
+
+    if (!previousEnsembleProbs || !modelUpdateTime) {
+      return [];
+    }
+
+    const ageSeconds = (Date.now() - modelUpdateTime.getTime()) / 1000;
+
+    // Only valid in the first 2 hours after model update
+    if (ageSeconds > LATENCY_THRESHOLDS.maxAgeSeconds) return [];
+
+    const signals: LatencySignal[] = [];
+
+    for (let i = 0; i < market.buckets.length; i++) {
+      const prev = previousEnsembleProbs[i]?.probability ?? 0;
+      const curr = ensembleProbs[i]?.probability ?? 0;
+      const priceDelta = curr - prev;
+      const currentPrice = market.buckets[i].yesPrice;
+
+      // If model shifted >5% but market price hasn't moved
+      if (Math.abs(priceDelta) > LATENCY_THRESHOLDS.minPriceDelta) {
+        const marketLag = Math.abs(currentPrice - curr);
+        if (marketLag > LATENCY_THRESHOLDS.minMarketLag) {
+          const signal: LatencySignal = {
+            type: 'LATENCY_ARB',
+            city: market.city,
+            date: market.date,
+            bucketLabel: market.buckets[i].label,
+            side: curr > currentPrice ? 'BUY_YES' : 'BUY_NO',
+            currentPrice,
+            newModelProbability: curr,
+            previousModelProbability: prev,
+            priceDelta,
+            modelUpdateAge: ageSeconds,
+          };
+
+          signals.push(signal);
+
+          logger.info(
+            {
+              city: market.city,
+              bucket: market.buckets[i].label,
+              priceDelta: priceDelta.toFixed(3),
+              marketLag: marketLag.toFixed(3),
+              ageSeconds: Math.round(ageSeconds),
+            },
+            'Latency arb signal detected',
+          );
+        }
+      }
+    }
+
+    return signals;
+  }
+}

+ 53 - 0
packages/strategy/src/parity-arb.ts

@@ -0,0 +1,53 @@
+/**
+ * Strategy C: Parity Arbitrage
+ * Detect when all YES outcome prices sum to >$1.00 or <$1.00.
+ * Pure arbitrage — does not require weather forecast data.
+ */
+
+import type { ParitySignal, ParsedWeatherMarket } from '@poly-weather/core';
+import { createChildLogger } from '@poly-weather/core';
+import type { StrategyEngine, StrategyInput } from './signal-generator.js';
+
+const logger = createChildLogger('parity-arb');
+
+const MIN_DEVIATION = 0.02; // 2% minimum to cover slippage
+
+export class ParityArbStrategy implements StrategyEngine {
+  readonly type = 'PARITY_ARB' as const;
+  private minDeviation: number;
+
+  constructor(minDeviation: number = MIN_DEVIATION) {
+    this.minDeviation = minDeviation;
+  }
+
+  evaluate(input: StrategyInput): ParitySignal[] {
+    const { market } = input;
+
+    const totalYes = market.buckets.reduce((sum, b) => sum + b.yesPrice, 0);
+    const deviation = Math.abs(totalYes - 1.0);
+
+    if (deviation < this.minDeviation) return [];
+
+    const signal: ParitySignal = {
+      type: 'PARITY_ARB',
+      city: market.city,
+      date: market.date,
+      totalYesPrice: totalYes,
+      deviation,
+      profitPerUnit: deviation,
+      direction: totalYes > 1.0 ? 'BUY_ALL_NO' : 'BUY_ALL_YES',
+    };
+
+    logger.info(
+      {
+        city: market.city,
+        totalYes: totalYes.toFixed(4),
+        deviation: deviation.toFixed(4),
+        direction: signal.direction,
+      },
+      'Parity arb detected',
+    );
+
+    return [signal];
+  }
+}

+ 112 - 0
packages/strategy/src/portfolio-tracker.ts

@@ -0,0 +1,112 @@
+/**
+ * Portfolio tracker for PnL, win rate, and position monitoring.
+ */
+
+import type { Position, DailyMetrics } from '@poly-weather/core';
+import { createChildLogger } from '@poly-weather/core';
+
+const logger = createChildLogger('portfolio');
+
+export class PortfolioTracker {
+  private positions: Map<string, Position> = new Map();
+  private trades: Array<{ pnl: number; date: Date }> = [];
+
+  /**
+   * Add or update a position.
+   */
+  updatePosition(position: Position): void {
+    this.positions.set(position.id, position);
+  }
+
+  /**
+   * Record a closed trade with PnL.
+   */
+  recordTrade(pnl: number): void {
+    this.trades.push({ pnl, date: new Date() });
+  }
+
+  /**
+   * Get all open positions.
+   */
+  getOpenPositions(): Position[] {
+    return [...this.positions.values()].filter((p) => p.status === 'OPEN');
+  }
+
+  /**
+   * Get total unrealized PnL.
+   */
+  getUnrealizedPnl(): number {
+    return this.getOpenPositions().reduce((sum, p) => sum + (p.pnl || 0), 0);
+  }
+
+  /**
+   * Get total realized PnL for today.
+   */
+  getDailyPnl(): number {
+    const today = new Date().toISOString().slice(0, 10);
+    return this.trades
+      .filter((t) => t.date.toISOString().slice(0, 10) === today)
+      .reduce((sum, t) => sum + t.pnl, 0);
+  }
+
+  /**
+   * Get total exposure (sum of all open position cost bases).
+   */
+  getTotalExposure(): number {
+    return this.getOpenPositions().reduce((sum, p) => sum + p.costBasis, 0);
+  }
+
+  /**
+   * Calculate daily metrics.
+   */
+  calculateDailyMetrics(): DailyMetrics {
+    const today = new Date().toISOString().slice(0, 10);
+    const todayTrades = this.trades.filter(
+      (t) => t.date.toISOString().slice(0, 10) === today,
+    );
+
+    const winning = todayTrades.filter((t) => t.pnl > 0).length;
+    const losing = todayTrades.filter((t) => t.pnl < 0).length;
+    const total = todayTrades.length;
+    const grossPnl = todayTrades.reduce((sum, t) => sum + t.pnl, 0);
+
+    // Calculate max drawdown
+    let peak = 0;
+    let maxDrawdown = 0;
+    let cumulative = 0;
+    for (const t of todayTrades) {
+      cumulative += t.pnl;
+      peak = Math.max(peak, cumulative);
+      maxDrawdown = Math.max(maxDrawdown, peak - cumulative);
+    }
+
+    const avgEdge =
+      total > 0
+        ? todayTrades.reduce((sum, t) => sum + Math.abs(t.pnl), 0) / total
+        : 0;
+
+    return {
+      date: new Date(today),
+      totalTrades: total,
+      winningTrades: winning,
+      losingTrades: losing,
+      grossPnl,
+      netPnl: grossPnl, // No fees for maker orders
+      winRate: total > 0 ? winning / total : 0,
+      avgEdge,
+      maxDrawdown,
+    };
+  }
+
+  /**
+   * Get a summary string for logging.
+   */
+  getSummary(): string {
+    const open = this.getOpenPositions().length;
+    const exposure = this.getTotalExposure();
+    const dailyPnl = this.getDailyPnl();
+    const metrics = this.calculateDailyMetrics();
+
+    return `Positions: ${open} | Exposure: $${exposure.toFixed(2)} | Daily PnL: $${dailyPnl.toFixed(2)} | Win Rate: ${(metrics.winRate * 100).toFixed(1)}%`;
+  }
+}

+ 155 - 0
packages/strategy/src/risk-manager.ts

@@ -0,0 +1,155 @@
+/**
+ * Risk manager with Kelly Criterion position sizing.
+ */
+
+import type { RiskConfig, Signal, Position } from '@poly-weather/core';
+import { DEFAULT_RISK_CONFIG, kellyFraction, createChildLogger } from '@poly-weather/core';
+
+const logger = createChildLogger('risk-manager');
+
+export interface RiskCheckResult {
+  approved: boolean;
+  adjustedSize: number;
+  reason?: string;
+}
+
+/**
+ * Calculate position size using Kelly Criterion.
+ * f* = (bp - q) / b, scaled by kellyFraction and agreementScore.
+ */
+export function calculatePositionSize(
+  edge: number,
+  noPrice: number,
+  agreementScore: number,
+  config: RiskConfig = DEFAULT_RISK_CONFIG,
+): number {
+  // For Tail NO: we buy NO at noPrice. We win $1 if outcome doesn't happen.
+  // Win probability = 1 - modelProbability = 1 - (yesPrice - edge) = 1 - ((1-noPrice) - edge) = noPrice + edge
+  // Simplified: edge = yesPrice - modelProb, noPrice = 1 - yesPrice
+  // So winProb = 1 - modelProb = 1 - (yesPrice - edge) = noPrice + edge
+  const winProb = Math.min(noPrice + edge, 0.999);
+  const kelly = kellyFraction(winProb, noPrice);
+
+  // Apply fraction and agreement scaling
+  const adjusted = kelly * config.kellyFraction * agreementScore;
+
+  // Clamp to position limits
+  const dollarSize = Math.min(
+    adjusted * config.maxTotalExposure,
+    config.maxPositionPerMarket,
+  );
+
+  return Math.max(0, dollarSize);
+}
+
+export class RiskManager {
+  private config: RiskConfig;
+  private positions: Position[];
+  private dailyPnl: number;
+
+  constructor(
+    config: RiskConfig = DEFAULT_RISK_CONFIG,
+    positions: Position[] = [],
+    dailyPnl: number = 0,
+  ) {
+    this.config = config;
+    this.positions = positions;
+    this.dailyPnl = dailyPnl;
+  }
+
+  /**
+   * Check if a signal should be executed based on risk limits.
+   */
+  checkSignal(signal: Signal): RiskCheckResult {
+    // Check daily loss limit
+    if (this.dailyPnl <= -this.config.maxDailyLoss) {
+      return {
+        approved: false,
+        adjustedSize: 0,
+        reason: `Daily loss limit reached: ${this.dailyPnl.toFixed(2)}`,
+      };
+    }
+
+    // Calculate current exposure
+    const totalExposure = this.positions
+      .filter((p) => p.status === 'OPEN')
+      .reduce((sum, p) => sum + p.costBasis, 0);
+
+    if (totalExposure >= this.config.maxTotalExposure) {
+      return {
+        approved: false,
+        adjustedSize: 0,
+        reason: `Total exposure limit reached: $${totalExposure.toFixed(2)}`,
+      };
+    }
+
+    // Check per-city exposure
+    if (signal.type === 'TAIL_NO' || signal.type === 'LATENCY_ARB') {
+      const cityExposure = this.positions
+        .filter((p) => p.status === 'OPEN' && p.city === signal.city)
+        .reduce((sum, p) => sum + p.costBasis, 0);
+
+      if (cityExposure >= this.config.maxPositionPerCity) {
+        return {
+          approved: false,
+          adjustedSize: 0,
+          reason: `City exposure limit for ${signal.city}: $${cityExposure.toFixed(2)}`,
+        };
+      }
+    }
+
+    // Determine recommended size based on signal type
+    let recommendedSize = 0;
+    if (signal.type === 'TAIL_NO') {
+      recommendedSize = signal.recommendedSize;
+    } else if (signal.type === 'LATENCY_ARB') {
+      recommendedSize = 10; // Conservative default
+    } else if (signal.type === 'PARITY_ARB') {
+      recommendedSize = Math.min(
+        signal.profitPerUnit * 100,
+        this.config.maxPositionPerMarket,
+      );
+    }
+
+    // Reduce size if near limits
+    const remainingExposure = this.config.maxTotalExposure - totalExposure;
+    const adjustedSize = Math.min(
+      recommendedSize,
+      remainingExposure,
+      this.config.maxPositionPerMarket,
+    );
+
+    if (adjustedSize <= 0) {
+      return {
+        approved: false,
+        adjustedSize: 0,
+        reason: 'Position size too small after limits',
+      };
+    }
+
+    logger.info(
+      {
+        signalType: signal.type,
+        recommendedSize: recommendedSize.toFixed(2),
+        adjustedSize: adjustedSize.toFixed(2),
+      },
+      'Risk check passed',
+    );
+
+    return { approved: true, adjustedSize };
+  }
+
+  /**
+   * Update daily PnL.
+   */
+  updateDailyPnl(pnl: number): void {
+    this.dailyPnl = pnl;
+  }
+
+  /**
+   * Update tracked positions.
+   */
+  updatePositions(positions: Position[]): void {
+    this.positions = positions;
+  }
+}

+ 22 - 0
packages/strategy/src/signal-generator.ts

@@ -0,0 +1,22 @@
+/**
+ * Base strategy interface and signal generation types.
+ */
+
+import type {
+  BucketProbability,
+  ParsedWeatherMarket,
+  Signal,
+} from '@poly-weather/core';
+
+export interface StrategyInput {
+  market: ParsedWeatherMarket;
+  ensembleProbs: BucketProbability[];
+  agreementScore: number;
+  previousEnsembleProbs?: BucketProbability[];
+  modelUpdateTime?: Date;
+}
+
+export interface StrategyEngine {
+  readonly type: string;
+  evaluate(input: StrategyInput): Signal[];
+}

+ 84 - 0
packages/strategy/src/tail-no-strategy.ts

@@ -0,0 +1,84 @@
+/**
+ * Strategy A: Tail NO
+ * Buy NO shares on extreme temperature buckets where market YES price
+ * significantly exceeds model-derived probability.
+ */
+
+import type {
+  TailNOSignal,
+  TailNOThresholds,
+  ParsedWeatherMarket,
+  BucketProbability,
+} from '@poly-weather/core';
+import { DEFAULT_TAIL_NO_THRESHOLDS, createChildLogger } from '@poly-weather/core';
+import type { StrategyEngine, StrategyInput } from './signal-generator.js';
+import { calculatePositionSize } from './risk-manager.js';
+
+const logger = createChildLogger('tail-no');
+
+export class TailNOStrategy implements StrategyEngine {
+  readonly type = 'TAIL_NO' as const;
+  private thresholds: TailNOThresholds;
+
+  constructor(thresholds: TailNOThresholds = DEFAULT_TAIL_NO_THRESHOLDS) {
+    this.thresholds = thresholds;
+  }
+
+  evaluate(input: StrategyInput): TailNOSignal[] {
+    const { market, ensembleProbs, agreementScore } = input;
+    const signals: TailNOSignal[] = [];
+
+    for (let i = 0; i < market.buckets.length; i++) {
+      const bucket = market.buckets[i];
+      const modelProb = ensembleProbs[i]?.probability ?? 0;
+      const yesPrice = bucket.yesPrice;
+      const edge = yesPrice - modelProb;
+
+      if (
+        edge >= this.thresholds.minEdge &&
+        modelProb <= this.thresholds.maxModelProbability &&
+        yesPrice <= this.thresholds.maxYesPrice &&
+        agreementScore >= this.thresholds.minAgreementScore
+      ) {
+        const noPrice = 1 - yesPrice;
+        const expectedReturn = edge / noPrice;
+
+        const signal: TailNOSignal = {
+          type: 'TAIL_NO',
+          city: market.city,
+          date: market.date,
+          bucketLabel: bucket.label,
+          marketYesPrice: yesPrice,
+          modelProbability: modelProb,
+          edge,
+          noPrice,
+          expectedReturn,
+          agreementScore,
+          confidence:
+            agreementScore > 0.8
+              ? 'HIGH'
+              : agreementScore > 0.6
+                ? 'MEDIUM'
+                : 'LOW',
+          recommendedSize: calculatePositionSize(edge, noPrice, agreementScore),
+        };
+
+        signals.push(signal);
+
+        logger.info(
+          {
+            city: market.city,
+            bucket: bucket.label,
+            yesPrice: yesPrice.toFixed(3),
+            modelProb: modelProb.toFixed(4),
+            edge: edge.toFixed(3),
+            expectedReturn: expectedReturn.toFixed(3),
+          },
+          'Tail NO signal generated',
+        );
+      }
+    }
+
+    return signals.sort((a, b) => b.expectedReturn - a.expectedReturn);
+  }
+}

+ 13 - 0
packages/strategy/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "./dist",
+    "rootDir": "./src"
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../core" },
+    { "path": "../weather-engine" },
+    { "path": "../market-engine" }
+  ]
+}

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels