ソースを参照

feat: compact market cards with per-city timezone Today filter, live temps, and forecast highs

- Redesign Live Weather Markets to compact card grid (4-6 per row) with click-to-expand modal
- Add date tabs defaulting to Today, determined per city local timezone (not browser time)
- Add /api/current-temps route fetching real-time temperature + forecast high via wttr.in
- Display current temp (large amber font) and forecast high (H: red) on today cards
- Show city-local time on today cards, auto-refreshing every 30s
- Highlight the bucket matching current temperature with amber ring
- Add new Polymarket cities (Wellington, Milan, Lucknow, Singapore, Ankara, Warsaw, Madrid, Taipei)
- Add OBSERVED_YES/OBSERVED_NO strategies replacing LATENCY_ARB/PARITY_ARB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
lushdog@outlook.com 7 ヶ月 前
親
コミット
4039fd9aeb

+ 1 - 4
.env.example

@@ -1,6 +1,6 @@
 # === Database ===
 DB_PASSWORD=botpass
-DATABASE_URL=postgresql://bot:${DB_PASSWORD}@localhost:5432/weather_bot
+DATABASE_URL=postgresql://polybot:${DB_PASSWORD}@localhost:5432/weather_bot
 REDIS_URL=redis://localhost:6379
 
 # === Polymarket Wallet ===
@@ -22,9 +22,6 @@ 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

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

@@ -28,6 +28,15 @@ export const TARGET_CITIES: CityConfig[] = [
   { 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' },
+  // P3: Newly added Polymarket cities
+  { name: 'Wellington', slug: 'wellington', lat: -41.2866, lon: 174.7756, tempUnit: '°C', timezone: 'Pacific/Auckland', priority: 'P2' },
+  { name: 'Milan', slug: 'milan', lat: 45.4642, lon: 9.1900, tempUnit: '°C', timezone: 'Europe/Rome', priority: 'P2' },
+  { name: 'Lucknow', slug: 'lucknow', lat: 26.8467, lon: 80.9462, tempUnit: '°C', timezone: 'Asia/Kolkata', priority: 'P2' },
+  { name: 'Singapore', slug: 'singapore', lat: 1.3521, lon: 103.8198, tempUnit: '°C', timezone: 'Asia/Singapore', priority: 'P2' },
+  { name: 'Ankara', slug: 'ankara', lat: 39.9334, lon: 32.8597, tempUnit: '°C', timezone: 'Europe/Istanbul', priority: 'P2' },
+  { name: 'Warsaw', slug: 'warsaw', lat: 52.2297, lon: 21.0122, tempUnit: '°C', timezone: 'Europe/Warsaw', priority: 'P2' },
+  { name: 'Madrid', slug: 'madrid', lat: 40.4168, lon: -3.7038, tempUnit: '°C', timezone: 'Europe/Madrid', priority: 'P2' },
+  { name: 'Taipei', slug: 'taipei', lat: 25.0330, lon: 121.5654, tempUnit: '°C', timezone: 'Asia/Taipei', priority: 'P2' },
 ];
 
 // ─── City Name Aliases (for matching Polymarket questions) ───────────────────

+ 22 - 16
packages/core/src/types/strategy.ts

@@ -4,7 +4,7 @@
 
 import type { OrderSide } from './market.js';
 
-export type StrategyType = 'TAIL_NO' | 'LATENCY_ARB' | 'PARITY_ARB';
+export type StrategyType = 'TAIL_NO' | 'OBSERVED_YES' | 'OBSERVED_NO';
 export type Confidence = 'HIGH' | 'MEDIUM' | 'LOW';
 
 export interface TailNOSignal {
@@ -22,30 +22,36 @@ export interface TailNOSignal {
   recommendedSize: number;       // USDC
 }
 
-export interface LatencySignal {
-  type: 'LATENCY_ARB';
+export interface ObservedYesSignal {
+  type: 'OBSERVED_YES';
   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
+  observedHigh: number;          // Actual observed temperature
+  currentYesPrice: number;       // Market YES price for this bucket
+  edge: number;                  // 1.0 - currentYesPrice
+  expectedReturn: number;        // edge / currentYesPrice
+  observationSource: string;     // 'open-meteo'
+  confidence: Confidence;
+  recommendedSize: number;       // USDC
 }
 
-export interface ParitySignal {
-  type: 'PARITY_ARB';
+export interface ObservedNoSignal {
+  type: 'OBSERVED_NO';
   city: string;
   date: Date;
-  totalYesPrice: number;
-  deviation: number;             // |totalYesPrice - 1.0|
-  profitPerUnit: number;
-  direction: 'BUY_ALL_NO' | 'BUY_ALL_YES';
+  bucketLabel: string;
+  observedHigh: number;          // Actual observed temperature (already above this bucket)
+  currentNoPrice: number;        // Price to buy NO
+  currentYesPrice: number;       // YES price (should be near 0 for confirmed NO)
+  edge: number;                  // 1.0 - currentNoPrice
+  expectedReturn: number;        // edge / currentNoPrice
+  observationSource: string;     // 'open-meteo'
+  confidence: Confidence;
+  recommendedSize: number;       // USDC
 }
 
-export type Signal = TailNOSignal | LatencySignal | ParitySignal;
+export type Signal = TailNOSignal | ObservedYesSignal | ObservedNoSignal;
 
 export interface RiskConfig {
   maxPositionPerMarket: number;  // Max USDC per single bucket

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

@@ -57,3 +57,12 @@ export interface ForecastDistribution {
  * Values are in °C — multiply by 1.8 for °F.
  */
 export type ModelStdErrTable = Record<WeatherModel, Record<string, number>>;
+
+/** Observed (actual) weather conditions for a city. */
+export interface ObservedConditions {
+  currentTemp: number;           // Current temperature reading
+  dailyHigh: number;             // Max temperature so far today
+  tempUnit: '°F' | '°C';
+  observedAt: Date;
+  source: 'open-meteo';
+}

+ 86 - 0
packages/dashboard/src/app/api/current-temps/route.ts

@@ -0,0 +1,86 @@
+/**
+ * API route to fetch current + forecast high temperatures for cities.
+ * Uses wttr.in JSON API (no API key, no rate limit).
+ * POST body: { cities: Array<{ name: string; tempUnit: 'F' | 'C' }> }
+ * Returns: { temps: Record<string, { current: number; high: number }> }
+ */
+
+import { NextResponse } from 'next/server';
+
+interface CityRequest {
+  name: string;
+  tempUnit: 'F' | 'C';
+}
+
+interface TempData {
+  current: number;
+  high: number;
+}
+
+// Simple in-memory cache (5 min TTL)
+const cache = new Map<string, { data: TempData; expiresAt: number }>();
+const CACHE_TTL = 5 * 60 * 1000;
+
+async function fetchTemp(cityName: string, tempUnit: 'F' | 'C'): Promise<TempData | null> {
+  const cacheKey = `${cityName}:${tempUnit}`;
+  const cached = cache.get(cacheKey);
+  if (cached && cached.expiresAt > Date.now()) return cached.data;
+
+  try {
+    const unit = tempUnit === 'F' ? 'u' : 'm';
+    const res = await fetch(
+      `https://wttr.in/${encodeURIComponent(cityName)}?format=j1&${unit}`,
+      {
+        headers: { 'User-Agent': 'curl/7.0' },
+        signal: AbortSignal.timeout(10000),
+      },
+    );
+
+    if (!res.ok) return null;
+    const json = await res.json();
+    // wttr.in wraps everything under "data" when using format=j1
+    const root = json?.data ?? json;
+
+    // Current temperature
+    const currentField = tempUnit === 'F' ? 'temp_F' : 'temp_C';
+    const current = parseInt(root?.current_condition?.[0]?.[currentField]);
+
+    // Today's forecast high
+    const highField = tempUnit === 'F' ? 'maxtempF' : 'maxtempC';
+    const high = parseInt(root?.weather?.[0]?.[highField]);
+
+    if (isNaN(current)) return null;
+
+    const data: TempData = {
+      current,
+      high: isNaN(high) ? current : high,
+    };
+
+    cache.set(cacheKey, { data, expiresAt: Date.now() + CACHE_TTL });
+    return data;
+  } catch {
+    return null;
+  }
+}
+
+export async function POST(req: Request) {
+  try {
+    const body = await req.json();
+    const cities: CityRequest[] = body.cities || [];
+
+    const results: Record<string, TempData> = {};
+
+    await Promise.allSettled(
+      cities.map(async (city) => {
+        const data = await fetchTemp(city.name, city.tempUnit);
+        if (data) {
+          results[city.name] = data;
+        }
+      }),
+    );
+
+    return NextResponse.json({ temps: results });
+  } catch {
+    return NextResponse.json({ temps: {} }, { status: 500 });
+  }
+}

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

@@ -97,7 +97,7 @@ export async function POST(request: Request) {
 
     // 1. Try DB (with date filter)
     for (const signal of signals) {
-      if (signal.type === 'PARITY_ARB') continue;
+      // All signal types use per-bucket pricing
       const dateStr = signal.targetDate?.slice(0, 10) || '';
       const key = `${signal.city}|${dateStr}|${signal.bucket}`;
       if (tokenMap.has(key)) continue;
@@ -127,7 +127,7 @@ export async function POST(request: Request) {
 
     // 2. Gamma fallback for missing (now keyed by city|date|bucket)
     const hasMissing = signals.some((s) => {
-      if (s.type === 'PARITY_ARB') return false;
+      // All signal types need token lookup
       const dateStr = s.targetDate?.slice(0, 10) || '';
       return !tokenMap.has(`${s.city}|${dateStr}|${s.bucket}`);
     });
@@ -146,9 +146,7 @@ export async function POST(request: Request) {
     // 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');
-        }
+        // All signal types use per-bucket pricing
 
         const dateStr = signal.targetDate?.slice(0, 10) || '';
         const tokenInfo = tokenMap.get(`${signal.city}|${dateStr}|${signal.bucket}`);

+ 500 - 90
packages/dashboard/src/app/markets/LiveMarketsClient.tsx

@@ -1,17 +1,46 @@
 'use client';
 
-import { useState } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
 import type { WeatherMarket, MarketBucket } from '@/lib/types';
 import { Orderbook } from '@/components/Orderbook';
 
+// ─── City Timezones ─────────────────────────────────────────────────────────
+
+const CITY_TIMEZONES: Record<string, string> = {
+  'New York City': 'America/New_York', 'NYC': 'America/New_York', 'New York': 'America/New_York',
+  'London': 'Europe/London', 'Chicago': 'America/Chicago', 'Paris': 'Europe/Paris',
+  'Seoul': 'Asia/Seoul', 'Tokyo': 'Asia/Tokyo', 'Miami': 'America/New_York',
+  'Buenos Aires': 'America/Argentina/Buenos_Aires', 'Hong Kong': 'Asia/Hong_Kong',
+  'Toronto': 'America/Toronto', 'Seattle': 'America/Los_Angeles', 'Dallas': 'America/Chicago',
+  'Atlanta': 'America/New_York', 'Munich': 'Europe/Berlin', 'Tel Aviv': 'Asia/Jerusalem',
+  'Sao Paulo': 'America/Sao_Paulo', 'São Paulo': 'America/Sao_Paulo', 'Shanghai': 'Asia/Shanghai',
+  'Wellington': 'Pacific/Auckland', 'Milan': 'Europe/Rome', 'Lucknow': 'Asia/Kolkata',
+  'Singapore': 'Asia/Singapore', 'Ankara': 'Europe/Istanbul', 'Warsaw': 'Europe/Warsaw',
+  'Madrid': 'Europe/Madrid', 'Taipei': 'Asia/Taipei', 'Houston': 'America/Chicago',
+  'Denver': 'America/Denver', 'Los Angeles': 'America/Los_Angeles', 'LA': 'America/Los_Angeles',
+  'Phoenix': 'America/Phoenix', 'San Francisco': 'America/Los_Angeles',
+};
+
+function getCityLocalTime(city: string): string {
+  const tz = CITY_TIMEZONES[city];
+  if (!tz) return '';
+  try {
+    return new Date().toLocaleTimeString('en-US', {
+      timeZone: tz,
+      hour: 'numeric',
+      minute: '2-digit',
+      hour12: true,
+    });
+  } catch {
+    return '';
+  }
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
 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}`}
-    >
+    <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" />
@@ -20,12 +49,230 @@ function ExternalLink({ href, children, className = '' }: { href: string; childr
   );
 }
 
+function parseBucketBounds(label: string): { lower: number; upper: number } | null {
+  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;
+}
+
+const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+
+/**
+ * Get the current local date for a city as "March 19" style string.
+ * Falls back to browser local date if timezone is unknown.
+ */
+function getCityTodayStr(city: string): string {
+  const tz = CITY_TIMEZONES[city];
+  if (tz) {
+    try {
+      // Format in the city's timezone
+      const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, month: 'long', day: 'numeric' }).formatToParts(new Date());
+      const month = parts.find((p) => p.type === 'month')?.value ?? '';
+      const day = parts.find((p) => p.type === 'day')?.value ?? '';
+      return `${month} ${day}`;
+    } catch { /* fall through */ }
+  }
+  const now = new Date();
+  return `${MONTHS[now.getMonth()]} ${now.getDate()}`;
+}
+
+/** Check if a market's date matches the city's current local date */
+function isTodayForCity(dateStr: string, city: string): boolean {
+  return dateStr.trim() === getCityTodayStr(city);
+}
+
+/**
+ * Collect all market dates that are "today" for at least one city.
+ * Used to label the "Today" tab and determine which markets to show.
+ */
+function getTodayDates(markets: WeatherMarket[]): Set<string> {
+  const todayDates = new Set<string>();
+  for (const m of markets) {
+    if (isTodayForCity(m.date, m.city)) {
+      todayDates.add(m.date.trim());
+    }
+  }
+  return todayDates;
+}
+
+function tempInBucket(temp: number, label: string): boolean {
+  const bounds = parseBucketBounds(label);
+  if (!bounds) return false;
+  return temp >= bounds.lower && temp <= bounds.upper;
+}
+
+/** Extract unique date strings from markets, sorted chronologically */
+function getUniqueDates(markets: WeatherMarket[]): string[] {
+  const dateSet = new Set(markets.map((m) => m.date.trim()));
+  const dates = Array.from(dateSet);
+  const now = new Date();
+  dates.sort((a, b) => {
+    const da = new Date(`${a}, ${now.getFullYear()}`);
+    const db = new Date(`${b}, ${now.getFullYear()}`);
+    return da.getTime() - db.getTime();
+  });
+  return dates;
+}
+
+// ─── Compact Price Bar ──────────────────────────────────────────────────────
+
+function CompactPriceBar({
+  bucket,
+  maxPrice,
+  isHighlighted,
+}: {
+  bucket: MarketBucket;
+  maxPrice: number;
+  isHighlighted: boolean;
+}) {
+  const pct = maxPrice > 0 ? (bucket.marketPrice / maxPrice) * 100 : 0;
+  const cents = (bucket.marketPrice * 100).toFixed(0);
+
+  return (
+    <div
+      className={`flex items-center gap-1.5 text-xs px-1.5 py-0.5 rounded transition-colors ${
+        isHighlighted
+          ? 'bg-amber-100 dark:bg-amber-500/20 ring-1 ring-amber-400 dark:ring-amber-500/50'
+          : ''
+      }`}
+    >
+      <span className={`font-mono w-[72px] truncate ${isHighlighted ? 'text-amber-700 dark:text-amber-300 font-semibold' : 'text-sub'}`}>
+        {bucket.label}
+      </span>
+      <div className="flex-1 h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden min-w-[40px]">
+        <div
+          className={`h-full rounded-full transition-all ${
+            isHighlighted ? 'bg-amber-500 dark:bg-amber-400' : 'bg-blue-500'
+          }`}
+          style={{ width: `${Math.min(pct, 100)}%` }}
+        />
+      </div>
+      <span className={`font-mono w-7 text-right ${isHighlighted ? 'text-amber-700 dark:text-amber-300 font-semibold' : 'text-dim'}`}>
+        {cents}¢
+      </span>
+    </div>
+  );
+}
+
+// ─── Compact Market Card ────────────────────────────────────────────────────
+
+function CompactMarketCard({
+  market,
+  tempData,
+  onExpand,
+}: {
+  market: WeatherMarket;
+  tempData?: { current: number; high: number };
+  onExpand: () => void;
+}) {
+  const maxPrice = Math.max(...market.buckets.map((b) => b.marketPrice), 0.01);
+  const today = isTodayForCity(market.date, market.city);
+
+  // Live clock for today's cards
+  const [localTime, setLocalTime] = useState(() => today ? getCityLocalTime(market.city) : '');
+  useEffect(() => {
+    if (!today) return;
+    setLocalTime(getCityLocalTime(market.city));
+    const timer = setInterval(() => setLocalTime(getCityLocalTime(market.city)), 30_000);
+    return () => clearInterval(timer);
+  }, [today, market.city]);
+
+  const topBucket = market.buckets.reduce((best, b) =>
+    b.marketPrice > best.marketPrice ? b : best, market.buckets[0]);
+
+  return (
+    <div
+      className={`card-hover overflow-hidden cursor-pointer transition-all ${
+        today ? 'ring-1 ring-purple-300 dark:ring-purple-500/40' : ''
+      }`}
+      onClick={onExpand}
+    >
+      {/* Header */}
+      <div className="flex items-start justify-between px-3 pt-3 pb-1.5">
+        <div className="flex-1 min-w-0">
+          <div className="flex items-center gap-1.5">
+            <h3 className="text-sm font-semibold text-heading truncate">{market.city}</h3>
+          </div>
+          {today && localTime ? (
+            <p className="text-xs text-purple-600 dark:text-purple-400 font-medium mt-0.5">
+              {localTime}
+            </p>
+          ) : (
+            <p className="text-[11px] text-sub mt-0.5 truncate">
+              {market.date}
+            </p>
+          )}
+        </div>
+        <div className="text-right shrink-0 ml-2">
+          {today && tempData ? (
+            <div className="leading-tight">
+              <div className="text-xl font-bold text-amber-600 dark:text-amber-400">
+                {tempData.current}°{market.tempUnit}
+              </div>
+              <div className="text-[10px] text-red-500 dark:text-red-400 font-medium">
+                H: {tempData.high}°{market.tempUnit}
+              </div>
+            </div>
+          ) : (
+            <div className="text-[11px] text-sub font-mono">
+              ${market.totalVolume.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Compact bucket bars */}
+      <div className="px-2 pb-2 space-y-0.5">
+        {market.buckets.map((bucket) => (
+          <CompactPriceBar
+            key={bucket.label}
+            bucket={bucket}
+            maxPrice={maxPrice}
+            isHighlighted={today && tempData != null && tempInBucket(tempData.current, bucket.label)}
+          />
+        ))}
+      </div>
+
+      {/* Footer */}
+      <div className="flex items-center justify-between px-3 py-1.5 border-t border-gray-100 dark:border-slate-700/30 bg-gray-50/50 dark:bg-slate-800/20">
+        <span className="text-[10px] text-dim font-mono">
+          Top: {topBucket?.label} @ {(topBucket?.marketPrice * 100).toFixed(0)}¢
+        </span>
+        {market.polymarketUrl && (
+          <span onClick={(e) => e.stopPropagation()}>
+            <ExternalLink href={market.polymarketUrl} className="text-purple-600 dark:text-purple-400 text-[10px] font-medium">
+              PM
+            </ExternalLink>
+          </span>
+        )}
+      </div>
+    </div>
+  );
+}
+
+// ─── Expanded Detail Modal ──────────────────────────────────────────────────
+
 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 className="text-dim">¢</span>
     </span>
   );
 }
@@ -34,10 +281,7 @@ 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 className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${Math.min(pct, 100)}%` }} />
     </div>
   );
 }
@@ -61,22 +305,31 @@ function BucketRow({
   bucket,
   maxPrice,
   isSelected,
+  isHighlighted,
   onSelect,
 }: {
   bucket: MarketBucket;
   maxPrice: number;
   isSelected: boolean;
+  isHighlighted: 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'
+        isHighlighted
+          ? 'bg-amber-50 dark:bg-amber-500/10'
+          : 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>
+        <span className={`font-mono text-sm ${isHighlighted ? 'text-amber-700 dark:text-amber-300 font-bold' : 'text-heading'}`}>
+          {bucket.label}
+          {isHighlighted && <span className="ml-1.5 text-[10px] text-amber-600 dark:text-amber-400">● NOW</span>}
+        </span>
       </td>
       <td className="px-3 py-2">
         <div className="flex items-center gap-2">
@@ -105,100 +358,243 @@ function BucketRow({
   );
 }
 
-function MarketCard({ market }: { market: WeatherMarket }) {
+function ExpandedMarketCard({
+  market,
+  tempData,
+  onClose,
+}: {
+  market: WeatherMarket;
+  tempData?: { current: number; high: number };
+  onClose: () => void;
+}) {
   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);
+  const today = isTodayForCity(market.date, market.city);
 
   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 className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 dark:bg-black/60" onClick={onClose}>
+      <div
+        className="card-hover overflow-hidden w-full max-w-2xl max-h-[80vh] overflow-y-auto"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* 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>
+              {today && tempData && (
+                <span className="text-sm font-bold text-amber-600 dark:text-amber-400">
+                  Now: {tempData.current}°{market.tempUnit}
+                  <span className="ml-2 text-red-500 dark:text-red-400 text-xs font-medium">
+                    H: {tempData.high}°{market.tempUnit}
+                  </span>
+                </span>
+              )}
+              {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>
-          <p className="text-sm text-sub mt-0.5">
-            {market.date} &middot; {market.tempUnit === 'F' ? 'Fahrenheit' : 'Celsius'} &middot;{' '}
-            {market.buckets.length} buckets
-          </p>
+          <button onClick={onClose} className="ml-3 text-dim hover:text-heading transition-colors text-xl leading-none">
+            &times;
+          </button>
         </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>
-          )}
+
+        {/* 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}
+                  isHighlighted={today && tempData != null && tempInBucket(tempData.current, bucket.label)}
+                  onSelect={() =>
+                    setSelectedBucket(selectedBucket === bucket.label ? null : bucket.label)
+                  }
+                />
+              ))}
+            </tbody>
+          </table>
         </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>
+        {/* 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>
+    </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>
+// ─── Date Tabs ──────────────────────────────────────────────────────────────
+
+function DateTabs({
+  dates,
+  selected,
+  onSelect,
+  marketCounts,
+  todayCount,
+}: {
+  dates: string[];
+  selected: string;
+  onSelect: (date: string) => void;
+  marketCounts: Record<string, number>;
+  todayCount: number;
+}) {
+  const tabStyle = (active: boolean, highlight?: boolean) =>
+    `shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
+      active
+        ? 'bg-purple-600 text-white'
+        : highlight
+          ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300 hover:bg-purple-200 dark:hover:bg-purple-500/30'
+          : 'bg-gray-100 dark:bg-slate-800 text-sub hover:bg-gray-200 dark:hover:bg-slate-700'
+    }`;
+
+  return (
+    <div className="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none">
+      <button onClick={() => onSelect('ALL')} className={tabStyle(selected === 'ALL')}>
+        All ({Object.values(marketCounts).reduce((a, b) => a + b, 0)})
+      </button>
+      {todayCount > 0 && (
+        <button onClick={() => onSelect('TODAY')} className={tabStyle(selected === 'TODAY', true)}>
+          Today ({todayCount})
+        </button>
       )}
+      {dates.map((date) => (
+        <button key={date} onClick={() => onSelect(date)} className={tabStyle(selected === date)}>
+          {date} ({marketCounts[date] || 0})
+        </button>
+      ))}
     </div>
   );
 }
 
+// ─── Main Component ─────────────────────────────────────────────────────────
+
 export function LiveMarketsClient({ markets }: { markets: WeatherMarket[] }) {
+  const allDates = useMemo(() => getUniqueDates(markets), [markets]);
+
+  // Compute which markets are "today" per city timezone
+  const todayMarketIds = useMemo(() => {
+    const ids = new Set<string>();
+    for (const m of markets) {
+      if (isTodayForCity(m.date, m.city)) ids.add(m.id);
+    }
+    return ids;
+  }, [markets]);
+
+  const todayCount = todayMarketIds.size;
+
+  // Default to "TODAY" if there are today markets, otherwise show all
+  const [selectedDate, setSelectedDate] = useState<string>(todayCount > 0 ? 'TODAY' : 'ALL');
   const [search, setSearch] = useState('');
+  const [expandedId, setExpandedId] = useState<string | null>(null);
+  const [currentTemps, setCurrentTemps] = useState<Record<string, { current: number; high: number }>>({});
+
+  const marketCounts = useMemo(() => {
+    const counts: Record<string, number> = {};
+    for (const m of markets) {
+      const d = m.date.trim();
+      counts[d] = (counts[d] || 0) + 1;
+    }
+    return counts;
+  }, [markets]);
+
+  // Filter by date + search
+  const filtered = useMemo(() => {
+    let result = markets;
+    if (selectedDate === 'TODAY') {
+      result = result.filter((m) => todayMarketIds.has(m.id));
+    } else if (selectedDate !== 'ALL') {
+      result = result.filter((m) => m.date.trim() === selectedDate);
+    }
+    if (search) {
+      const q = search.toLowerCase();
+      result = result.filter(
+        (m) => m.city.toLowerCase().includes(q) || m.date.toLowerCase().includes(q),
+      );
+    }
+    return result;
+  }, [markets, selectedDate, search, todayMarketIds]);
+
+  // Fetch current temps for today's markets
+  const fetchTemps = useCallback(async () => {
+    const todayMarkets = markets.filter((m) => todayMarketIds.has(m.id));
+    if (todayMarkets.length === 0) return;
+
+    const cities = todayMarkets.map((m) => ({ name: m.city, tempUnit: m.tempUnit }));
+    const unique = cities.filter((c, i) => cities.findIndex((x) => x.name === c.name) === i);
+
+    try {
+      const res = await fetch('/api/current-temps', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ cities: unique }),
+      });
+      if (res.ok) {
+        const data = await res.json();
+        setCurrentTemps(data.temps || {});
+      }
+    } catch {
+      // Silently fail
+    }
+  }, [markets, todayMarketIds]);
+
+  useEffect(() => {
+    fetchTemps();
+    const interval = setInterval(fetchTemps, 5 * 60 * 1000);
+    return () => clearInterval(interval);
+  }, [fetchTemps]);
 
-  const filtered = search
-    ? markets.filter(
-        (m) =>
-          m.city.toLowerCase().includes(search.toLowerCase()) ||
-          m.date.toLowerCase().includes(search.toLowerCase()),
-      )
-    : markets;
+  const expandedMarket = expandedId ? markets.find((m) => m.id === expandedId) : null;
 
   return (
     <div className="space-y-4">
+      {/* Date Tabs */}
+      <DateTabs
+        dates={allDates}
+        selected={selectedDate}
+        onSelect={setSelectedDate}
+        marketCounts={marketCounts}
+        todayCount={todayCount}
+      />
+
       {/* 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">
@@ -206,25 +602,39 @@ export function LiveMarketsClient({ markets }: { markets: WeatherMarket[] }) {
         </svg>
         <input
           type="text"
-          placeholder="Filter by city or date..."
+          placeholder="Filter by city..."
           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">
+      {/* Compact Market Grid */}
+      <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
         {filtered.map((market) => (
-          <MarketCard key={market.id} market={market} />
+          <CompactMarketCard
+            key={market.id}
+            market={market}
+            tempData={currentTemps[market.city]}
+            onExpand={() => setExpandedId(market.id)}
+          />
         ))}
       </div>
 
       {filtered.length === 0 && (
         <p className="text-dim text-center py-8">
-          No markets match &quot;{search}&quot;
+          {search ? `No markets match "${search}"` : 'No markets for this date.'}
         </p>
       )}
+
+      {/* Expanded Detail Modal */}
+      {expandedMarket && (
+        <ExpandedMarketCard
+          market={expandedMarket}
+          tempData={currentTemps[expandedMarket.city]}
+          onClose={() => setExpandedId(null)}
+        />
+      )}
     </div>
   );
 }

+ 32 - 9
packages/dashboard/src/app/signals/SignalsClient.tsx

@@ -1,6 +1,7 @@
 'use client';
 
-import { useState, useMemo, useEffect } from 'react';
+import { useState, useMemo, useEffect, useTransition } from 'react';
+import { useRouter } from 'next/navigation';
 import { SignalTable } from '@/components/SignalTable';
 import { OrderPanel } from '@/components/OrderPanel';
 import { CityClocks } from '@/components/CityClocks';
@@ -14,13 +15,13 @@ import {
 } from '@/components/ui/select';
 import type { Signal } from '@/lib/types';
 
-const strategyTypes = ['ALL', 'TAIL_NO', 'LATENCY_ARB', 'PARITY_ARB'] as const;
+const strategyTypes = ['ALL', 'TAIL_NO', 'OBSERVED_YES', 'OBSERVED_NO'] 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' },
+  OBSERVED_YES: { icon: '\uD83C\uDF21\uFE0F', desc: 'Buy YES on observed temp bucket' },
+  OBSERVED_NO: { icon: '\u2744\uFE0F', desc: 'Buy NO on confirmed lower bucket' },
 };
 
 function formatCity(city: string): string {
@@ -31,6 +32,8 @@ function formatCity(city: string): string {
 }
 
 export function SignalsClient({ signals }: { signals: Signal[] }) {
+  const router = useRouter();
+  const [isRefreshing, startRefresh] = useTransition();
   const [filterType, setFilterType] = useState<Signal['type'] | 'ALL'>('ALL');
   const [filterCity, setFilterCity] = useState<string>('ALL');
   const [filterDate, setFilterDate] = useState<string>('ALL');
@@ -38,6 +41,12 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
   const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
   const [showOrderPanel, setShowOrderPanel] = useState(false);
 
+  const handleRefresh = () => {
+    startRefresh(() => {
+      router.refresh();
+    });
+  };
+
   // Extract unique cities and dates for filter options
   const uniqueCities = useMemo(() => {
     const cities = [...new Set(signals.map((s) => s.city))].sort();
@@ -58,15 +67,15 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
   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,
+    OBSERVED_YES: signals.filter((s) => s.type === 'OBSERVED_YES').length,
+    OBSERVED_NO: signals.filter((s) => s.type === 'OBSERVED_NO').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),
+    OBSERVED_YES: Math.max(...signals.filter((s) => s.type === 'OBSERVED_YES').map((s) => s.edge), 0),
+    OBSERVED_NO: Math.max(...signals.filter((s) => s.type === 'OBSERVED_NO').map((s) => s.edge), 0),
   };
 
   // Filtered signals for the current view
@@ -167,7 +176,21 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
       <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-2">
+              <h2 className="text-lg font-semibold text-heading">Signal History</h2>
+              <button
+                onClick={handleRefresh}
+                disabled={isRefreshing}
+                className={`p-1.5 rounded-md text-dim hover:text-heading hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors ${
+                  isRefreshing ? 'animate-spin' : ''
+                }`}
+                title="Refresh signals"
+              >
+                <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+                </svg>
+              </button>
+            </div>
             <div className="flex items-center gap-3">
               <span className="text-sm text-sub">
                 Showing {filteredSignals.length} signals

+ 14 - 6
packages/dashboard/src/components/CityClocks.tsx

@@ -20,20 +20,28 @@ const CITIES: CityInfo[] = [
   { name: 'New York', slug: 'new_york', timezone: 'America/New_York', flag: '\u{1F1FA}\u{1F1F8}', weatherUrl: 'https://www.wunderground.com/history/daily/us/ny/new-york-city/KLGA' },
   { name: 'Chicago', slug: 'chicago', timezone: 'America/Chicago', flag: '\u{1F1FA}\u{1F1F8}', weatherUrl: 'https://www.wunderground.com/history/daily/us/il/chicago/KORD' },
   { name: 'Miami', slug: 'miami', timezone: 'America/New_York', flag: '\u{1F1FA}\u{1F1F8}', weatherUrl: 'https://www.wunderground.com/history/daily/us/fl/miami/KMIA' },
-  { name: 'Seattle', slug: 'seattle', timezone: 'America/Los_Angeles', flag: '\u{1F1FA}\u{1F1F8}', weatherUrl: 'https://www.wunderground.com/history/daily/us/wa/seattle/KSEA' },
+  { name: 'Seattle', slug: 'seattle', timezone: 'America/Los_Angeles', flag: '\u{1F1FA}\u{1F1F8}', weatherUrl: 'https://www.wunderground.com/history/daily/us/wa/seatac/KSEA' },
   { name: 'Dallas', slug: 'dallas', timezone: 'America/Chicago', flag: '\u{1F1FA}\u{1F1F8}', weatherUrl: 'https://www.wunderground.com/history/daily/us/tx/dallas/KDAL' },
   { name: 'Atlanta', slug: 'atlanta', timezone: 'America/New_York', flag: '\u{1F1FA}\u{1F1F8}', weatherUrl: 'https://www.wunderground.com/history/daily/us/ga/atlanta/KATL' },
-  { name: 'Toronto', slug: 'toronto', timezone: 'America/Toronto', flag: '\u{1F1E8}\u{1F1E6}', weatherUrl: 'https://www.wunderground.com/history/daily/ca/on/toronto/CYTZ' },
+  { name: 'Toronto', slug: 'toronto', timezone: 'America/Toronto', flag: '\u{1F1E8}\u{1F1E6}', weatherUrl: 'https://www.wunderground.com/history/daily/ca/mississauga/CYYZ' },
   { name: 'London', slug: 'london', timezone: 'Europe/London', flag: '\u{1F1EC}\u{1F1E7}', weatherUrl: 'https://www.wunderground.com/history/daily/gb/london/EGLC' },
-  { name: 'Paris', slug: 'paris', timezone: 'Europe/Paris', flag: '\u{1F1EB}\u{1F1F7}', weatherUrl: 'https://www.wunderground.com/history/daily/fr/paris/LFPO' },
+  { name: 'Paris', slug: 'paris', timezone: 'Europe/Paris', flag: '\u{1F1EB}\u{1F1F7}', weatherUrl: 'https://www.wunderground.com/history/daily/fr/paris/LFPG' },
   { name: 'Munich', slug: 'munich', timezone: 'Europe/Berlin', flag: '\u{1F1E9}\u{1F1EA}', weatherUrl: 'https://www.wunderground.com/history/daily/de/munich/EDDM' },
   { name: 'Tel Aviv', slug: 'tel_aviv', timezone: 'Asia/Jerusalem', flag: '\u{1F1EE}\u{1F1F1}', weatherUrl: 'https://www.wunderground.com/history/daily/il/tel-aviv/LLBG' },
-  { name: 'Seoul', slug: 'seoul', timezone: 'Asia/Seoul', flag: '\u{1F1F0}\u{1F1F7}', weatherUrl: 'https://www.wunderground.com/history/daily/kr/seoul/RKSS' },
+  { name: 'Seoul', slug: 'seoul', timezone: 'Asia/Seoul', flag: '\u{1F1F0}\u{1F1F7}', weatherUrl: 'https://www.wunderground.com/history/daily/kr/incheon/RKSI' },
   { name: 'Tokyo', slug: 'tokyo', timezone: 'Asia/Tokyo', flag: '\u{1F1EF}\u{1F1F5}', weatherUrl: 'https://www.wunderground.com/history/daily/jp/tokyo/RJTT' },
   { name: 'Hong Kong', slug: 'hong_kong', timezone: 'Asia/Hong_Kong', flag: '\u{1F1ED}\u{1F1F0}', weatherUrl: 'https://www.weather.gov.hk/en/cis/dailyExtract.htm?y={year}&m={month}' },
   { name: 'Shanghai', slug: 'shanghai', timezone: 'Asia/Shanghai', flag: '\u{1F1E8}\u{1F1F3}', weatherUrl: 'https://www.wunderground.com/history/daily/cn/shanghai/ZSPD' },
-  { name: 'S\u00E3o Paulo', slug: 'sao_paulo', timezone: 'America/Sao_Paulo', flag: '\u{1F1E7}\u{1F1F7}', weatherUrl: 'https://www.wunderground.com/history/daily/br/sao-paulo/SBSP' },
-  { name: 'Buenos Aires', slug: 'buenos_aires', timezone: 'America/Argentina/Buenos_Aires', flag: '\u{1F1E6}\u{1F1F7}', weatherUrl: 'https://www.wunderground.com/history/daily/ar/buenos-aires/SABE' },
+  { name: 'S\u00E3o Paulo', slug: 'sao_paulo', timezone: 'America/Sao_Paulo', flag: '\u{1F1E7}\u{1F1F7}', weatherUrl: 'https://www.wunderground.com/history/daily/br/guarulhos/SBGR' },
+  { name: 'Buenos Aires', slug: 'buenos_aires', timezone: 'America/Argentina/Buenos_Aires', flag: '\u{1F1E6}\u{1F1F7}', weatherUrl: 'https://www.wunderground.com/history/daily/ar/ezeiza/SAEZ' },
+  { name: 'Wellington', slug: 'wellington', timezone: 'Pacific/Auckland', flag: '\u{1F1F3}\u{1F1FF}', weatherUrl: 'https://www.wunderground.com/history/daily/nz/wellington/NZWN' },
+  { name: 'Milan', slug: 'milan', timezone: 'Europe/Rome', flag: '\u{1F1EE}\u{1F1F9}', weatherUrl: 'https://www.wunderground.com/history/daily/it/milan/LIMC' },
+  { name: 'Lucknow', slug: 'lucknow', timezone: 'Asia/Kolkata', flag: '\u{1F1EE}\u{1F1F3}', weatherUrl: 'https://www.wunderground.com/history/daily/in/lucknow/VILK' },
+  { name: 'Singapore', slug: 'singapore', timezone: 'Asia/Singapore', flag: '\u{1F1F8}\u{1F1EC}', weatherUrl: 'https://www.wunderground.com/history/daily/sg/singapore/WSSS' },
+  { name: 'Ankara', slug: 'ankara', timezone: 'Europe/Istanbul', flag: '\u{1F1F9}\u{1F1F7}', weatherUrl: 'https://www.wunderground.com/history/daily/tr/%C3%A7ubuk/LTAC' },
+  { name: 'Warsaw', slug: 'warsaw', timezone: 'Europe/Warsaw', flag: '\u{1F1F5}\u{1F1F1}', weatherUrl: 'https://www.wunderground.com/history/daily/pl/warsaw/EPWA' },
+  { name: 'Madrid', slug: 'madrid', timezone: 'Europe/Madrid', flag: '\u{1F1EA}\u{1F1F8}', weatherUrl: 'https://www.wunderground.com/history/daily/es/madrid/LEMD' },
+  { name: 'Taipei', slug: 'taipei', timezone: 'Asia/Taipei', flag: '\u{1F1F9}\u{1F1FC}', weatherUrl: 'https://www.cwa.gov.tw/V8/C/W/OBS_Station.html?ID=46692' },
 ];
 
 /** Build the actual URL, replacing {year}/{month} placeholders for HKO. */

+ 1 - 1
packages/dashboard/src/components/OrderPanel.tsx

@@ -172,7 +172,7 @@ export function OrderPanel({
 
         return {
           signalId: signal.id,
-          side: signal.side === 'BUY_ALL_YES' ? 'BUY_YES' : signal.side,
+          side: signal.side,
           price: Math.round(price * 100) / 100,
           size: getSize(signal.id, signal.recommendedSize),
           orderType: orderMode,

+ 28 - 62
packages/dashboard/src/components/SignalTable.tsx

@@ -11,29 +11,28 @@ type SortDir = 'asc' | 'desc';
 
 const typeVariants: Record<Signal['type'], 'yellow' | 'blue' | 'green'> = {
   TAIL_NO: 'yellow',
-  LATENCY_ARB: 'blue',
-  PARITY_ARB: 'green',
+  OBSERVED_YES: 'green',
+  OBSERVED_NO: 'blue',
 };
 
 const typeLabels: Record<Signal['type'], string> = {
   TAIL_NO: 'TAIL',
-  LATENCY_ARB: 'LATENCY',
-  PARITY_ARB: 'PARITY',
+  OBSERVED_YES: 'OBS YES',
+  OBSERVED_NO: 'OBS NO',
 };
 
 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.',
+  OBSERVED_YES:
+    'Observed temperature falls in this bucket during peak hours (1-3 PM). Buy YES before market settles at $1.',
+  OBSERVED_NO:
+    'Observed temperature already exceeds this bucket. Buy NO — confirmed fact, temperature cannot go back down.',
 };
 
 export function SignalTable({
@@ -238,7 +237,7 @@ function SignalDetail({ signal }: { signal: Signal }) {
       <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'}
+            {signal.type === 'TAIL_NO' ? '\uD83C\uDFAF' : signal.type === 'OBSERVED_YES' ? '\uD83C\uDF21\uFE0F' : '\u2744\uFE0F'}
           </span>
           <div className="flex-1">
             <h4 className="text-heading font-semibold text-sm mb-1">
@@ -254,29 +253,18 @@ function SignalDetail({ signal }: { signal: Signal }) {
       {/* 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'}
+          label="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="Market NO Price"
+          value={`${(noPrice * 100).toFixed(1)}\u00a2`}
+        />
+        <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)}`}
@@ -332,7 +320,7 @@ function SignalDetail({ signal }: { signal: Signal }) {
               <li>Review and confirm order</li>
             </>
           )}
-          {signal.type === 'LATENCY_ARB' && (
+          {signal.type === 'OBSERVED_YES' && (
             <>
               <li>
                 Open{' '}
@@ -346,16 +334,12 @@ function SignalDetail({ signal }: { signal: Signal }) {
                 </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>
+              <li>Select <span className="text-green-600 dark:text-green-400 font-semibold">YES</span> tab</li>
+              <li>Set limit price to <span className="text-heading font-medium">{(signal.marketYesPrice * 100).toFixed(1)}{'\u00a2'}</span> or better</li>
+              <li>Observed temperature is in this bucket -- high confidence signal</li>
             </>
           )}
-          {signal.type === 'PARITY_ARB' && signal.marketYesPrice < 1 && (
+          {signal.type === 'OBSERVED_NO' && (
             <>
               <li>
                 Open{' '}
@@ -368,28 +352,10 @@ function SignalDetail({ signal }: { signal: Signal }) {
                   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>
+              <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>Temperature already exceeds this bucket -- confirmed NO</li>
             </>
           )}
         </ol>

+ 14 - 14
packages/dashboard/src/lib/mock-data.ts

@@ -13,7 +13,7 @@ export interface DashboardSummary {
 export interface Signal {
   id: string;
   timestamp: string;
-  type: 'TAIL_NO' | 'LATENCY_ARB' | 'PARITY_ARB';
+  type: 'TAIL_NO' | 'OBSERVED_YES' | 'OBSERVED_NO';
   city: string;
   bucket: string;
   edge: number;
@@ -66,25 +66,25 @@ export const mockDashboardSummary: DashboardSummary = {
 
 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: 's2', timestamp: '2026-03-17T14:28:00Z', type: 'OBSERVED_YES', 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: 'OBSERVED_NO', 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: 's5', timestamp: '2026-03-17T13:42:00Z', type: 'OBSERVED_YES', 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: 'OBSERVED_NO', 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: 's8', timestamp: '2026-03-17T12:55:00Z', type: 'OBSERVED_YES', 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: 'OBSERVED_NO', 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: 's11', timestamp: '2026-03-17T12:10:00Z', type: 'OBSERVED_YES', 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: 'OBSERVED_NO', 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: 's14', timestamp: '2026-03-17T11:30:00Z', type: 'OBSERVED_YES', 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: 'OBSERVED_NO', 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: 's17', timestamp: '2026-03-17T10:45:00Z', type: 'OBSERVED_YES', 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: 'OBSERVED_NO', 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 },
+  { id: 's20', timestamp: '2026-03-17T10:00:00Z', type: 'OBSERVED_YES', city: 'Philadelphia', bucket: '30-35F', edge: 0.07, expectedReturn: 0.046, confidence: 0.81, executed: true },
 ];
 
 export const mockMarkets: WeatherMarket[] = [

+ 7 - 24
packages/dashboard/src/lib/queries.ts

@@ -157,31 +157,14 @@ function mapSignal(row: {
     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') {
+  } else if (type === 'OBSERVED_YES') {
+    side = 'BUY_YES';
     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`;
-    }
+    action = `Buy YES "${row.bucketLabel}" @ ${(marketYesPrice * 100).toFixed(1)}\u00a2 — observed temp in this bucket, edge ${(row.edge * 100).toFixed(1)}\u00a2`;
+  } else if (type === 'OBSERVED_NO') {
+    side = 'BUY_NO';
+    suggestedPrice = noPrice;
+    action = `Buy NO "${row.bucketLabel}" @ ${(noPrice * 100).toFixed(1)}\u00a2 — observed temp already above this bucket`;
   }
 
   return {

+ 2 - 2
packages/dashboard/src/lib/types.ts

@@ -16,7 +16,7 @@ export interface DashboardSummary {
 export interface Signal {
   id: string;
   timestamp: string;
-  type: 'TAIL_NO' | 'LATENCY_ARB' | 'PARITY_ARB';
+  type: 'TAIL_NO' | 'OBSERVED_YES' | 'OBSERVED_NO';
   city: string;
   targetDate: string;
   bucket: string;
@@ -35,7 +35,7 @@ export interface Signal {
   /** Human-readable action description */
   action: string;
   /** Which side to trade: BUY_YES or BUY_NO */
-  side: 'BUY_YES' | 'BUY_NO' | 'BUY_ALL_YES';
+  side: 'BUY_YES' | 'BUY_NO';
   /** Suggested limit price (0-1) */
   suggestedPrice: number;
   /** Direct Polymarket event URL */

+ 11 - 12
packages/market-engine/src/order-manager.ts

@@ -3,7 +3,7 @@
  * Tracks orders from placement through fill/cancellation, persists to DB.
  */
 
-import type { Signal, TailNOSignal, LatencySignal, ParitySignal } from '@poly-weather/core';
+import type { Signal, TailNOSignal, ObservedYesSignal, ObservedNoSignal } from '@poly-weather/core';
 import { createChildLogger } from '@poly-weather/core';
 import type { CLOBTradingClient } from './clob-client.js';
 
@@ -48,19 +48,18 @@ export class OrderManager {
       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}`;
+    } else if (signal.type === 'OBSERVED_YES') {
+      const s = signal as ObservedYesSignal;
+      tokenId = `yes_${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}`;
+      price = s.currentYesPrice;
+      size = s.recommendedSize / s.currentYesPrice;
+    } else if (signal.type === 'OBSERVED_NO') {
+      const s = signal as ObservedNoSignal;
+      tokenId = `no_${s.city}_${s.bucketLabel}`;
       side = 'BUY';
-      price = 1 - s.deviation;
-      size = 10;
+      price = s.currentNoPrice;
+      size = s.recommendedSize / s.currentNoPrice;
     } else {
       logger.warn({ signal }, 'Unknown signal type');
       return null;

+ 86 - 63
packages/strategy/src/__tests__/strategies.test.ts

@@ -1,9 +1,9 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import { TailNOStrategy } from '../tail-no-strategy.js';
-import { LatencyArbStrategy } from '../latency-arb.js';
-import { ParityArbStrategy } from '../parity-arb.js';
+import { ObservedYesStrategy } from '../observed-yes.js';
+import { ObservedNoStrategy } from '../observed-no.js';
 import { RiskManager, calculatePositionSize } from '../risk-manager.js';
-import type { ParsedWeatherMarket, BucketProbability, MarketBucket } from '@poly-weather/core';
+import type { ParsedWeatherMarket, BucketProbability, MarketBucket, ObservedConditions } from '@poly-weather/core';
 
 function makeMarket(buckets: Partial<MarketBucket>[]): ParsedWeatherMarket {
   return {
@@ -93,116 +93,139 @@ describe('TailNOStrategy', () => {
   });
 });
 
-// ─── Latency Arb Strategy ────────────────────────────────────────────────────
+// ─── Observed YES Strategy ───────────────────────────────────────────────────
 
-describe('LatencyArbStrategy', () => {
-  const strategy = new LatencyArbStrategy();
+describe('ObservedYesStrategy', () => {
+  const strategy = new ObservedYesStrategy();
 
-  it('should detect latency signal when model shifts but market stays', () => {
+  // Mock Date.toLocaleString to simulate 2 PM local time
+  beforeEach(() => {
+    vi.spyOn(Date.prototype, 'toLocaleString').mockReturnValue('14');
+  });
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  const observed: ObservedConditions = {
+    currentTemp: 37,
+    dailyHigh: 38,
+    tempUnit: '°F',
+    observedAt: new Date(),
+    source: 'open-meteo',
+  };
+
+  it('should generate YES signal when observed temp matches a bucket and price < 0.93', () => {
     const market = makeMarket([
-      { label: 'bucket-0', yesPrice: 0.30, noPrice: 0.70 },
+      { label: '31°F or below', lowerBound: -Infinity, upperBound: 31, yesPrice: 0.05, noPrice: 0.95 },
+      { label: '32-39°F', lowerBound: 32, upperBound: 39, yesPrice: 0.85, noPrice: 0.15 },
+      { label: '40°F or higher', lowerBound: 40, upperBound: Infinity, yesPrice: 0.10, noPrice: 0.90 },
     ]);
 
-    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
+      ensembleProbs: makeEnsembleProbs([0.05, 0.60, 0.35]),
+      agreementScore: 0.9,
+      observed,
+      cityTimezone: 'America/New_York',
     });
 
     expect(signals.length).toBe(1);
-    expect(signals[0].type).toBe('LATENCY_ARB');
-    expect(signals[0].side).toBe('BUY_YES');
+    expect(signals[0].type).toBe('OBSERVED_YES');
+    expect(signals[0].bucketLabel).toBe('32-39°F');
+    expect(signals[0].edge).toBeCloseTo(0.15, 2);
   });
 
-  it('should return empty when no previous data', () => {
-    const market = makeMarket([{ yesPrice: 0.30 }]);
-    const probs = makeEnsembleProbs([0.40]);
+  it('should not generate signal when YES price is already high', () => {
+    const market = makeMarket([
+      { label: '32-39°F', lowerBound: 32, upperBound: 39, yesPrice: 0.97, noPrice: 0.03 },
+    ]);
 
     const signals = strategy.evaluate({
       market,
-      ensembleProbs: probs,
-      agreementScore: 0.8,
+      ensembleProbs: makeEnsembleProbs([0.60]),
+      agreementScore: 0.9,
+      observed,
+      cityTimezone: 'America/New_York',
     });
 
     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]);
-
+  it('should not generate signal without observed data', () => {
+    const market = makeMarket([{ label: '32-39°F', lowerBound: 32, upperBound: 39, yesPrice: 0.85 }]);
     const signals = strategy.evaluate({
       market,
-      ensembleProbs: currProbs,
-      agreementScore: 0.8,
-      previousEnsembleProbs: prevProbs,
-      modelUpdateTime: new Date(Date.now() - 3 * 3600_000), // 3h ago
+      ensembleProbs: makeEnsembleProbs([0.60]),
+      agreementScore: 0.9,
     });
-
     expect(signals.length).toBe(0);
   });
 });
 
-// ─── Parity Arb Strategy ─────────────────────────────────────────────────────
+// ─── Observed NO Strategy ────────────────────────────────────────────────────
+
+describe('ObservedNoStrategy', () => {
+  const strategy = new ObservedNoStrategy();
 
-describe('ParityArbStrategy', () => {
-  const strategy = new ParityArbStrategy();
+  const observed: ObservedConditions = {
+    currentTemp: 38,
+    dailyHigh: 38,
+    tempUnit: '°F',
+    observedAt: new Date(),
+    source: 'open-meteo',
+  };
 
-  it('should detect overpriced market (sum > 1.0)', () => {
+  it('should generate NO signal on bucket below observed temp', () => {
     const market = makeMarket([
-      { yesPrice: 0.30 },
-      { yesPrice: 0.40 },
-      { yesPrice: 0.35 }, // Sum = 1.05
+      { label: '31°F or below', lowerBound: -Infinity, upperBound: 31, yesPrice: 0.10, noPrice: 0.90 },
+      { label: '32-39°F', lowerBound: 32, upperBound: 39, yesPrice: 0.70, noPrice: 0.30 },
+      { label: '40°F or higher', lowerBound: 40, upperBound: Infinity, yesPrice: 0.20, noPrice: 0.80 },
     ]);
 
     const signals = strategy.evaluate({
       market,
-      ensembleProbs: [],
-      agreementScore: 1,
+      ensembleProbs: makeEnsembleProbs([0.05, 0.60, 0.35]),
+      agreementScore: 0.9,
+      observed,
     });
 
     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);
+    expect(signals[0].type).toBe('OBSERVED_NO');
+    expect(signals[0].bucketLabel).toBe('31°F or below');
+    expect(signals[0].confidence).toBe('HIGH');
   });
 
-  it('should detect underpriced market (sum < 1.0)', () => {
+  it('should not generate signal when observed temp is in first bucket', () => {
+    const observedLow: ObservedConditions = {
+      currentTemp: 28,
+      dailyHigh: 28,
+      tempUnit: '°F',
+      observedAt: new Date(),
+      source: 'open-meteo',
+    };
+
     const market = makeMarket([
-      { yesPrice: 0.25 },
-      { yesPrice: 0.35 },
-      { yesPrice: 0.35 }, // Sum = 0.95
+      { label: '31°F or below', lowerBound: -Infinity, upperBound: 31, yesPrice: 0.80, noPrice: 0.20 },
+      { label: '32-39°F', lowerBound: 32, upperBound: 39, yesPrice: 0.15, noPrice: 0.85 },
     ]);
 
     const signals = strategy.evaluate({
       market,
-      ensembleProbs: [],
-      agreementScore: 1,
+      ensembleProbs: makeEnsembleProbs([0.80, 0.20]),
+      agreementScore: 0.9,
+      observed: observedLow,
     });
 
-    expect(signals.length).toBe(1);
-    expect(signals[0].direction).toBe('BUY_ALL_YES');
+    expect(signals.length).toBe(0);
   });
 
-  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
-    ]);
-
+  it('should not generate signal without observed data', () => {
+    const market = makeMarket([{ label: 'bucket', yesPrice: 0.10 }]);
     const signals = strategy.evaluate({
       market,
-      ensembleProbs: [],
-      agreementScore: 1,
+      ensembleProbs: makeEnsembleProbs([0.10]),
+      agreementScore: 0.9,
     });
-
     expect(signals.length).toBe(0);
   });
 });

+ 2 - 2
packages/strategy/src/index.ts

@@ -1,7 +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 './observed-yes.js';
+export * from './observed-no.js';
 export * from './risk-manager.js';
 export * from './portfolio-tracker.js';

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

@@ -1,76 +0,0 @@
-/**
- * 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;
-  }
-}

+ 96 - 0
packages/strategy/src/observed-no.ts

@@ -0,0 +1,96 @@
+/**
+ * Strategy: OBSERVED_NO
+ * Buy NO on the bucket immediately below the observed temperature.
+ * Active all day — temperature can only go up, so any observed high already
+ * confirms that lower buckets cannot win.
+ *
+ * Example: If observed high is 59°F and buckets are [54°F or lower, 55-57°F, 58-60°F, 61°F+],
+ * we buy NO on the "55-57°F" bucket (the one right below the matching bucket).
+ */
+
+import type { ObservedNoSignal } from '@poly-weather/core';
+import { createChildLogger } from '@poly-weather/core';
+import type { StrategyEngine, StrategyInput } from './signal-generator.js';
+
+const logger = createChildLogger('observed-no');
+
+/** YES price on the target bucket must be above this to be worth buying NO. */
+const MIN_YES_PRICE = 0.03;
+/** Minimum edge (1 - noPrice) to justify the trade. */
+const MIN_EDGE = 0.02;
+/** Default position size for OBSERVED_NO (highest confidence — confirmed fact). */
+const DEFAULT_SIZE = 25;
+
+export class ObservedNoStrategy implements StrategyEngine {
+  readonly type = 'OBSERVED_NO' as const;
+
+  evaluate(input: StrategyInput): ObservedNoSignal[] {
+    const { market, observed, cityTimezone } = input;
+
+    if (!observed) return [];
+
+    // Only apply to today's market — observed data is not valid for future dates
+    if (!cityTimezone || !isToday(market.date, cityTimezone)) return [];
+
+    const observedHigh = observed.dailyHigh;
+
+    // Find which bucket the observed high falls into
+    const matchingIdx = market.buckets.findIndex(
+      (b) => observedHigh >= b.lowerBound && observedHigh <= b.upperBound,
+    );
+
+    if (matchingIdx <= 0) return []; // No bucket below if matching is first or not found
+
+    // Target the bucket immediately below the matching one
+    const targetIdx = matchingIdx - 1;
+    const targetBucket = market.buckets[targetIdx];
+
+    // Confirm: observed high is above this bucket's upper bound
+    if (observedHigh <= targetBucket.upperBound) return [];
+
+    const yesPrice = targetBucket.yesPrice;
+    const noPrice = 1 - yesPrice;
+    const edge = 1.0 - noPrice; // = yesPrice (what we gain when NO settles at $1)
+
+    if (yesPrice < MIN_YES_PRICE || edge < MIN_EDGE) return [];
+
+    const signal: ObservedNoSignal = {
+      type: 'OBSERVED_NO',
+      city: market.city,
+      date: market.date,
+      bucketLabel: targetBucket.label,
+      observedHigh,
+      currentNoPrice: noPrice,
+      currentYesPrice: yesPrice,
+      edge,
+      expectedReturn: edge / noPrice,
+      observationSource: observed.source,
+      confidence: 'HIGH', // Confirmed fact — temp already exceeds this bucket
+      recommendedSize: DEFAULT_SIZE,
+    };
+
+    logger.info(
+      {
+        city: market.city,
+        bucket: targetBucket.label,
+        observedHigh,
+        yesPrice: yesPrice.toFixed(3),
+        noPrice: noPrice.toFixed(3),
+        edge: edge.toFixed(3),
+      },
+      'OBSERVED_NO signal generated',
+    );
+
+    return [signal];
+  }
+}
+
+/**
+ * Check if a market date is today in the given timezone.
+ */
+function isToday(marketDate: Date, timezone: string): boolean {
+  const now = new Date();
+  const todayStr = now.toLocaleDateString('en-CA', { timeZone: timezone }); // YYYY-MM-DD
+  const marketStr = marketDate.toISOString().slice(0, 10);
+  return todayStr === marketStr;
+}

+ 101 - 0
packages/strategy/src/observed-yes.ts

@@ -0,0 +1,101 @@
+/**
+ * Strategy: OBSERVED_YES
+ * Buy YES on the bucket matching the observed temperature during peak hours (1-3 PM local).
+ * The observed daily high is already known — if the market hasn't priced it in yet,
+ * we buy YES at a discount before settlement.
+ *
+ * Time gate: Only active when local time is 13:00–15:00 in the city's timezone.
+ */
+
+import type { ObservedYesSignal } from '@poly-weather/core';
+import { createChildLogger } from '@poly-weather/core';
+import type { StrategyEngine, StrategyInput } from './signal-generator.js';
+
+const logger = createChildLogger('observed-yes');
+
+/** YES price must be below this to generate a signal (7% edge minimum). */
+const MAX_YES_PRICE = 0.93;
+/** Minimum edge to justify the trade. */
+const MIN_EDGE = 0.03;
+/** Default position size for OBSERVED_YES (high confidence). */
+const DEFAULT_SIZE = 20;
+
+export class ObservedYesStrategy implements StrategyEngine {
+  readonly type = 'OBSERVED_YES' as const;
+
+  evaluate(input: StrategyInput): ObservedYesSignal[] {
+    const { market, observed, cityTimezone } = input;
+
+    if (!observed || !cityTimezone) return [];
+
+    // Only apply to today's market — observed data is not valid for future dates
+    if (!isToday(market.date, cityTimezone)) return [];
+
+    // Time gate: only active 11:00 AM – 2:00 PM local time
+    if (!isWithinTimeWindow(cityTimezone, 11, 14)) return [];
+
+    const observedHigh = observed.dailyHigh;
+
+    // Find which bucket the observed high falls into
+    const matchingIdx = market.buckets.findIndex(
+      (b) => observedHigh >= b.lowerBound && observedHigh <= b.upperBound,
+    );
+
+    if (matchingIdx === -1) return [];
+
+    const bucket = market.buckets[matchingIdx];
+    const yesPrice = bucket.yesPrice;
+    const edge = 1.0 - yesPrice;
+
+    if (yesPrice >= MAX_YES_PRICE || edge < MIN_EDGE) return [];
+
+    const signal: ObservedYesSignal = {
+      type: 'OBSERVED_YES',
+      city: market.city,
+      date: market.date,
+      bucketLabel: bucket.label,
+      observedHigh,
+      currentYesPrice: yesPrice,
+      edge,
+      expectedReturn: edge / yesPrice,
+      observationSource: observed.source,
+      confidence: edge >= 0.10 ? 'HIGH' : edge >= 0.05 ? 'MEDIUM' : 'LOW',
+      recommendedSize: DEFAULT_SIZE,
+    };
+
+    logger.info(
+      {
+        city: market.city,
+        bucket: bucket.label,
+        observedHigh,
+        yesPrice: yesPrice.toFixed(3),
+        edge: edge.toFixed(3),
+      },
+      'OBSERVED_YES signal generated',
+    );
+
+    return [signal];
+  }
+}
+
+/**
+ * Check if the current local time in a timezone is within [startHour, endHour).
+ */
+function isWithinTimeWindow(timezone: string, startHour: number, endHour: number): boolean {
+  const now = new Date();
+  const localHour = parseInt(
+    now.toLocaleString('en-US', { timeZone: timezone, hour: 'numeric', hour12: false }),
+    10,
+  );
+  return localHour >= startHour && localHour < endHour;
+}
+
+/**
+ * Check if a market date is today in the given timezone.
+ */
+function isToday(marketDate: Date, timezone: string): boolean {
+  const now = new Date();
+  const todayStr = now.toLocaleDateString('en-CA', { timeZone: timezone }); // YYYY-MM-DD
+  const marketStr = marketDate.toISOString().slice(0, 10);
+  return todayStr === marketStr;
+}

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

@@ -1,53 +0,0 @@
-/**
- * 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];
-  }
-}

+ 14 - 19
packages/strategy/src/risk-manager.ts

@@ -84,31 +84,26 @@ export class RiskManager {
     }
 
     // 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)}`,
-        };
-      }
+    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,
-      );
+    } else if (signal.type === 'OBSERVED_YES') {
+      recommendedSize = signal.recommendedSize;
+    } else if (signal.type === 'OBSERVED_NO') {
+      recommendedSize = signal.recommendedSize;
     }
 
     // Reduce size if near limits

+ 5 - 2
packages/strategy/src/signal-generator.ts

@@ -4,6 +4,7 @@
 
 import type {
   BucketProbability,
+  ObservedConditions,
   ParsedWeatherMarket,
   Signal,
 } from '@poly-weather/core';
@@ -12,8 +13,10 @@ export interface StrategyInput {
   market: ParsedWeatherMarket;
   ensembleProbs: BucketProbability[];
   agreementScore: number;
-  previousEnsembleProbs?: BucketProbability[];
-  modelUpdateTime?: Date;
+  /** Observed actual temperature conditions (for OBSERVED_YES / OBSERVED_NO strategies). */
+  observed?: ObservedConditions;
+  /** City timezone (IANA), used for time-gated strategies. */
+  cityTimezone?: string;
 }
 
 export interface StrategyEngine {

+ 0 - 28
packages/trading-engine/src/bot.ts

@@ -14,8 +14,6 @@ import {
   groupMarketsByEvent,
   PositionSyncService,
 } from '@poly-weather/market-engine';
-import { Redis as IORedis } from 'ioredis';
-import type { Redis } from 'ioredis';
 import { TradingPipeline } from './pipeline.js';
 import { HealthCheckServer } from './health-check.js';
 import {
@@ -38,7 +36,6 @@ export class WeatherArbitrageBot {
   private pipeline!: TradingPipeline;
   private healthCheck!: HealthCheckServer;
   private positionSync!: PositionSyncService;
-  private redis: Redis | null = null;
   private wsFeed: WebSocketFeed | null = null;
   private stopModelPolling: (() => void) | null = null;
   private running = false;
@@ -82,30 +79,11 @@ export class WeatherArbitrageBot {
       this.config.dryRun,
     );
 
-    // Initialize Redis for latency arb ensemble caching
-    const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
-    try {
-      this.redis = new IORedis(redisUrl, {
-        maxRetriesPerRequest: 3,
-        retryStrategy(times: number) {
-          if (times > 5) return null; // stop retrying
-          return Math.min(times * 500, 3000);
-        },
-        lazyConnect: true,
-      });
-      await this.redis!.connect();
-      logger.info({ url: redisUrl }, 'Redis connected (latency arb enabled)');
-    } catch (err) {
-      logger.warn({ err }, 'Redis connection failed — latency arb will be disabled');
-      this.redis = null;
-    }
-
     // Initialize pipeline
     this.pipeline = new TradingPipeline(
       orderManager,
       this.config.cities,
       this.config.dryRun,
-      this.redis,
       this.config.autoExecute,
     );
 
@@ -251,12 +229,6 @@ export class WeatherArbitrageBot {
     // Stop health check
     this.healthCheck.stop();
 
-    // Disconnect Redis
-    if (this.redis) {
-      await this.redis.quit().catch(() => {});
-      this.redis = null;
-    }
-
     // Disconnect database
     await disconnectDb();
 

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

@@ -4,7 +4,7 @@
  */
 
 import { PrismaClient } from '@prisma/client';
-import type { Signal, TailNOSignal, LatencySignal, ParitySignal } from '@poly-weather/core';
+import type { Signal, TailNOSignal, ObservedYesSignal, ObservedNoSignal } from '@poly-weather/core';
 import { createChildLogger } from '@poly-weather/core';
 
 const logger = createChildLogger('db');
@@ -47,26 +47,26 @@ export async function persistSignal(signal: Signal): Promise<string | null> {
       agreementScore = s.agreementScore;
       confidence = s.confidence;
       recommendedSize = s.recommendedSize;
-    } else if (signal.type === 'LATENCY_ARB') {
-      const s = signal as LatencySignal;
+    } else if (signal.type === 'OBSERVED_YES') {
+      const s = signal as ObservedYesSignal;
       bucketLabel = s.bucketLabel;
-      marketYesPrice = s.currentPrice;
-      modelProbability = s.newModelProbability;
-      edge = Math.abs(s.currentPrice - s.newModelProbability);
-      expectedReturn = edge / s.currentPrice;
+      marketYesPrice = s.currentYesPrice;
+      modelProbability = 1.0; // Observed fact: this bucket is the winner
+      edge = s.edge;
+      expectedReturn = s.expectedReturn;
       agreementScore = 1;
-      confidence = 'HIGH';
-      recommendedSize = 10;
-    } else if (signal.type === 'PARITY_ARB') {
-      const s = signal as ParitySignal;
-      bucketLabel = `parity_${s.direction}`;
-      marketYesPrice = s.totalYesPrice;
-      modelProbability = 1.0;
-      edge = s.deviation;
-      expectedReturn = s.profitPerUnit;
+      confidence = s.confidence;
+      recommendedSize = s.recommendedSize;
+    } else if (signal.type === 'OBSERVED_NO') {
+      const s = signal as ObservedNoSignal;
+      bucketLabel = s.bucketLabel;
+      marketYesPrice = s.currentYesPrice;
+      modelProbability = 0; // Observed fact: this bucket is confirmed NO
+      edge = s.edge;
+      expectedReturn = s.expectedReturn;
       agreementScore = 1;
-      confidence = 'HIGH';
-      recommendedSize = 10;
+      confidence = s.confidence;
+      recommendedSize = s.recommendedSize;
     }
 
     const record = await db.signal.upsert({

+ 79 - 83
packages/trading-engine/src/pipeline.ts

@@ -6,13 +6,15 @@
 import type {
   BucketProbability,
   CityConfig,
+  ObservedConditions,
   ParsedWeatherMarket,
   Signal,
   TempBucket,
 } from '@poly-weather/core';
-import { createChildLogger, formatDate } from '@poly-weather/core';
+import { createChildLogger, formatDate, TARGET_CITIES } from '@poly-weather/core';
 import {
   fetchAllModelsAllCities,
+  fetchObservedAllCities,
   calculateEnsembleProbabilities,
   calculateAgreementScore,
   buildDistribution,
@@ -25,13 +27,12 @@ import {
 import type { OrderManager } from '@poly-weather/market-engine';
 import {
   TailNOStrategy,
-  LatencyArbStrategy,
-  ParityArbStrategy,
+  ObservedYesStrategy,
+  ObservedNoStrategy,
   RiskManager,
   PortfolioTracker,
 } from '@poly-weather/strategy';
 import type { StrategyEngine } from '@poly-weather/strategy';
-import type { Redis } from 'ioredis';
 import { persistSignal, persistTrade, persistMarketSnapshots } from './db.js';
 
 const logger = createChildLogger('pipeline');
@@ -49,7 +50,6 @@ export class TradingPipeline {
   private portfolio: PortfolioTracker;
   private orderManager: OrderManager;
   private clobReader: CLOBReadClient;
-  private redis: Redis | null;
   private cities: CityConfig[];
   private dryRun: boolean;
   private _autoExecute: boolean;
@@ -58,7 +58,6 @@ export class TradingPipeline {
     orderManager: OrderManager,
     cities: CityConfig[],
     dryRun: boolean = true,
-    redis: Redis | null = null,
     autoExecute: boolean = true,
   ) {
     this.orderManager = orderManager;
@@ -66,12 +65,11 @@ export class TradingPipeline {
     this.cities = cities;
     this.dryRun = dryRun;
     this._autoExecute = autoExecute;
-    this.redis = redis;
 
     this.strategies = [
       new TailNOStrategy(),
-      new LatencyArbStrategy(),
-      new ParityArbStrategy(),
+      new ObservedYesStrategy(),
+      new ObservedNoStrategy(),
     ];
 
     this.portfolio = new PortfolioTracker();
@@ -97,10 +95,16 @@ export class TradingPipeline {
     };
 
     try {
-      // 1. Fetch latest weather forecasts for all cities
+      // 1. Fetch latest weather forecasts + observed conditions for all cities
       logger.info('Fetching weather data for all cities...');
-      const forecasts = await fetchAllModelsAllCities(this.cities);
-      logger.info({ citiesWithData: forecasts.size }, 'Weather data fetched');
+      const [forecasts, observedMap] = await Promise.all([
+        fetchAllModelsAllCities(this.cities),
+        fetchObservedAllCities(this.cities),
+      ]);
+      logger.info(
+        { citiesWithData: forecasts.size, citiesWithObserved: observedMap.size },
+        'Weather data fetched',
+      );
 
       // 2. Discover active weather markets
       logger.info('Scanning Polymarket weather markets...');
@@ -120,7 +124,7 @@ export class TradingPipeline {
       // 3. Evaluate each market
       for (const [groupId, market] of markets) {
         try {
-          await this.evaluateMarket(market, forecasts, result);
+          await this.evaluateMarket(market, forecasts, observedMap, result);
           result.marketsEvaluated++;
         } catch (err) {
           const msg = `Error evaluating market ${groupId} (${market.city}): ${err}`;
@@ -150,6 +154,7 @@ export class TradingPipeline {
   private async evaluateMarket(
     market: ParsedWeatherMarket,
     forecasts: Map<string, any>,
+    observedMap: Map<string, ObservedConditions>,
     result: PipelineResult,
   ): Promise<void> {
     const cityForecast = forecasts.get(market.city);
@@ -191,19 +196,9 @@ export class TradingPipeline {
     const ensembleProbs = calculateEnsembleProbabilities(distributions);
     const agreementScore = calculateAgreementScore(distributions);
 
-    // Get previous ensemble from Redis cache (for latency arb)
-    let previousEnsembleProbs: BucketProbability[] | undefined;
-    let modelUpdateTime: Date | undefined;
-    if (this.redis) {
-      const cacheKey = `ensemble:${market.city}:${targetDate}`;
-      const cached = await this.redis.get(cacheKey);
-      if (cached) {
-        previousEnsembleProbs = JSON.parse(cached);
-        modelUpdateTime = cityForecast.fetchedAt;
-      }
-      // Cache current ensemble
-      await this.redis.set(cacheKey, JSON.stringify(ensembleProbs), 'EX', 86400);
-    }
+    // Get observed conditions and city timezone for observed strategies
+    const observed = observedMap.get(market.city);
+    const cityConfig = TARGET_CITIES.find((c) => c.slug === market.city);
 
     // Run all strategies
     const allSignals: Signal[] = [];
@@ -213,8 +208,8 @@ export class TradingPipeline {
           market,
           ensembleProbs,
           agreementScore,
-          previousEnsembleProbs,
-          modelUpdateTime,
+          observed,
+          cityTimezone: cityConfig?.timezone,
         });
         allSignals.push(...signals);
       } catch (err) {
@@ -270,18 +265,6 @@ export class TradingPipeline {
     }
   }
 
-  /**
-   * Refine a signal's edge using the live CLOB orderbook best-ask price
-   * instead of the Gamma API midpoint snapshot.
-   *
-   * For TAIL_NO: look up the NO token's best ask from CLOB.
-   *   edge = (1 - noBestAsk) - modelProbability
-   *   expectedReturn = edge / noBestAsk
-   *   If edge drops below threshold after refinement, discard the signal.
-   *
-   * For LATENCY_ARB / PARITY_ARB: pass through unchanged (they use
-   * different pricing logic).
-   */
   /**
    * Fetch CLOB best ask for a given token.
    * Returns the lowest ask price, or null if orderbook is empty.
@@ -298,24 +281,14 @@ export class TradingPipeline {
    * Refine a signal's price and edge using the live CLOB orderbook best-ask
    * instead of the Gamma API midpoint snapshot.
    *
-   * For TAIL_NO (BUY NO):
-   *   noBestAsk from CLOB → implied YES = 1 - noBestAsk
-   *   edge = impliedYes - modelProb
-   *
-   * For LATENCY_ARB:
-   *   Determine actual executable price from CLOB best ask on the relevant side.
-   *   Recalculate marketLag with real price. Drop if lag < threshold.
-   *
-   * For PARITY_ARB: pass through (uses aggregate pricing across all buckets).
+   * TAIL_NO: NO token best ask → recalculate edge.
+   * OBSERVED_YES: YES token best ask → recalculate edge.
+   * OBSERVED_NO: NO token best ask → recalculate edge.
    */
   private async refineSignalWithCLOB(
     signal: Signal,
     market: ParsedWeatherMarket,
   ): Promise<Signal | null> {
-    if (signal.type === 'PARITY_ARB') {
-      return signal;
-    }
-
     const bucket = market.buckets.find((b) => b.label === signal.bucketLabel);
     if (!bucket) {
       return signal;
@@ -366,54 +339,77 @@ export class TradingPipeline {
       };
     }
 
-    // LATENCY_ARB — refine currentPrice and marketLag with CLOB best ask
-    if (signal.type === 'LATENCY_ARB') {
-      // Determine which side's best ask matters:
-      // BUY_YES → need YES best ask (cost to buy YES)
-      // BUY_NO  → need NO best ask, implied YES = 1 - noBestAsk
-      let clobYesPrice: number | null = null;
-
-      if (signal.side === 'BUY_YES' && bucket.yesTokenId) {
-        clobYesPrice = await this.getCLOBBestAsk(bucket.yesTokenId);
-      } else if (signal.side === 'BUY_NO' && bucket.noTokenId) {
-        const noBestAsk = await this.getCLOBBestAsk(bucket.noTokenId);
-        if (noBestAsk !== null) {
-          clobYesPrice = 1 - noBestAsk;
-        }
-      }
+    // OBSERVED_YES — refine YES price from CLOB
+    if (signal.type === 'OBSERVED_YES') {
+      if (!bucket.yesTokenId) return signal;
 
-      if (clobYesPrice === null) {
-        logger.debug({ bucket: signal.bucketLabel }, 'CLOB price unavailable for latency arb');
-        return signal;
+      const yesBestAsk = await this.getCLOBBestAsk(bucket.yesTokenId);
+      if (yesBestAsk === null) return signal;
+
+      const edge = 1.0 - yesBestAsk;
+
+      logger.info(
+        {
+          city: signal.city,
+          bucket: signal.bucketLabel,
+          gammaYES: signal.currentYesPrice.toFixed(3),
+          clobYES: yesBestAsk.toFixed(3),
+          edge: edge.toFixed(3),
+        },
+        'CLOB price refinement (OBSERVED_YES)',
+      );
+
+      if (edge < 0.02) {
+        logger.info(
+          { bucket: signal.bucketLabel, edge: edge.toFixed(4) },
+          'Signal dropped: edge too small after CLOB refinement',
+        );
+        return null;
       }
 
-      const marketLag = Math.abs(clobYesPrice - signal.newModelProbability);
+      return {
+        ...signal,
+        currentYesPrice: yesBestAsk,
+        edge,
+        expectedReturn: edge / yesBestAsk,
+      };
+    }
+
+    // OBSERVED_NO — refine NO price from CLOB
+    if (signal.type === 'OBSERVED_NO') {
+      if (!bucket.noTokenId) return signal;
+
+      const noBestAsk = await this.getCLOBBestAsk(bucket.noTokenId);
+      if (noBestAsk === null) return signal;
+
+      const impliedYes = 1 - noBestAsk;
+      const edge = 1.0 - noBestAsk; // = impliedYes = yesPrice
 
       logger.info(
         {
           city: signal.city,
           bucket: signal.bucketLabel,
-          side: signal.side,
-          gammaPrice: signal.currentPrice.toFixed(3),
-          clobPrice: clobYesPrice.toFixed(3),
-          gammaLag: Math.abs(signal.currentPrice - signal.newModelProbability).toFixed(3),
-          clobLag: marketLag.toFixed(3),
+          gammaNO: signal.currentNoPrice.toFixed(3),
+          clobNO: noBestAsk.toFixed(3),
+          edge: edge.toFixed(3),
         },
-        'CLOB price refinement (LATENCY_ARB)',
+        'CLOB price refinement (OBSERVED_NO)',
       );
 
-      // If market already repriced (lag < 8%), signal is stale
-      if (marketLag < 0.08) {
+      if (edge < 0.02) {
         logger.info(
-          { bucket: signal.bucketLabel, marketLag: marketLag.toFixed(4) },
-          'Signal dropped: market already repriced after CLOB check',
+          { bucket: signal.bucketLabel, edge: edge.toFixed(4) },
+          'Signal dropped: edge too small after CLOB refinement',
         );
         return null;
       }
 
       return {
         ...signal,
-        currentPrice: clobYesPrice,
+        currentNoPrice: noBestAsk,
+        currentYesPrice: impliedYes,
+        edge,
+        expectedReturn: edge / noBestAsk,
       };
     }
 

+ 129 - 1
packages/weather-engine/src/providers/open-meteo.ts

@@ -3,7 +3,7 @@
  * Free API — no key required for <10K requests/day.
  */
 
-import type { CityConfig, HourlyForecast, ModelForecasts, WeatherModel } from '@poly-weather/core';
+import type { CityConfig, HourlyForecast, ModelForecasts, ObservedConditions, WeatherModel } from '@poly-weather/core';
 import { createChildLogger, OPEN_METEO_BASE } from '@poly-weather/core';
 import type { IWeatherProvider } from './provider-types.js';
 import { hkoProvider } from './hko-api.js';
@@ -178,3 +178,131 @@ export async function fetchAllModelsAllCities(
   }
   return map;
 }
+
+// ─── Observed Temperature Fetching ──────────────────────────────────────────
+
+/** In-memory cache for observed conditions (5-minute TTL per city). */
+const observedCache = new Map<string, { data: ObservedConditions; expiresAt: number }>();
+const OBSERVED_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
+
+interface OpenMeteoCurrentResponse {
+  latitude: number;
+  longitude: number;
+  current: {
+    time: string;
+    temperature_2m: number;
+  };
+  hourly: {
+    time: string[];
+    temperature_2m: number[];
+  };
+}
+
+/**
+ * Fetch current observed temperature for a city using Open-Meteo.
+ * Uses the `current` parameter + past hourly data to derive today's high so far.
+ */
+export async function fetchObservedConditions(
+  city: CityConfig,
+): Promise<ObservedConditions | null> {
+  // Check cache
+  const cached = observedCache.get(city.slug);
+  if (cached && cached.expiresAt > Date.now()) {
+    return cached.data;
+  }
+
+  try {
+    checkRateLimit();
+
+    const params = new URLSearchParams({
+      latitude: city.lat.toString(),
+      longitude: city.lon.toString(),
+      current: 'temperature_2m',
+      hourly: 'temperature_2m',
+      temperature_unit: city.tempUnit === '°F' ? 'fahrenheit' : 'celsius',
+      forecast_days: '1',
+      past_days: '0',
+      timezone: city.timezone,
+    });
+
+    const url = `https://api.open-meteo.com/v1/forecast?${params}`;
+    logger.debug({ city: city.slug, url }, 'Fetching observed conditions');
+
+    const res = await fetch(url, {
+      headers: { 'Accept': 'application/json' },
+      signal: AbortSignal.timeout(10000),
+    });
+
+    if (!res.ok) {
+      throw new Error(`Open-Meteo current request failed: ${res.status} ${res.statusText}`);
+    }
+
+    const data = (await res.json()) as OpenMeteoCurrentResponse;
+
+    const currentTemp = data.current?.temperature_2m;
+    if (currentTemp === undefined || currentTemp === null) {
+      logger.warn({ city: city.slug }, 'No current temperature in response');
+      return null;
+    }
+
+    // Calculate daily high so far from hourly data (hours up to the current observation time).
+    // Open-Meteo returns local timezone strings (e.g. "2026-03-18T14:00") without offset.
+    // Compare as raw strings to avoid UTC/local timezone mismatch — both hourly.time[]
+    // and current.time are in the same local timezone.
+    const currentTimeStr = data.current.time; // e.g. "2026-03-18T08:45"
+    let dailyHigh = currentTemp;
+    if (data.hourly?.time?.length) {
+      for (let i = 0; i < data.hourly.time.length; i++) {
+        if (data.hourly.time[i] <= currentTimeStr && data.hourly.temperature_2m[i] != null) {
+          dailyHigh = Math.max(dailyHigh, data.hourly.temperature_2m[i]);
+        }
+      }
+    }
+
+    const conditions: ObservedConditions = {
+      currentTemp,
+      dailyHigh,
+      tempUnit: city.tempUnit,
+      observedAt: new Date(data.current.time),
+      source: 'open-meteo',
+    };
+
+    // Cache
+    observedCache.set(city.slug, {
+      data: conditions,
+      expiresAt: Date.now() + OBSERVED_CACHE_TTL_MS,
+    });
+
+    logger.info(
+      { city: city.slug, currentTemp, dailyHigh, unit: city.tempUnit },
+      'Observed conditions fetched',
+    );
+
+    return conditions;
+  } catch (err) {
+    logger.error({ err, city: city.slug }, 'Failed to fetch observed conditions');
+    return null;
+  }
+}
+
+/**
+ * Fetch observed conditions for all cities in parallel.
+ */
+export async function fetchObservedAllCities(
+  cities: CityConfig[],
+): Promise<Map<string, ObservedConditions>> {
+  const results = await Promise.allSettled(
+    cities.map(async (city) => ({
+      slug: city.slug,
+      data: await fetchObservedConditions(city),
+    })),
+  );
+
+  const map = new Map<string, ObservedConditions>();
+  for (const r of results) {
+    if (r.status === 'fulfilled' && r.value.data) {
+      map.set(r.value.slug, r.value.data);
+    }
+  }
+  return map;
+}