Kaynağa Gözat

fix: CLOB best-ask pricing for all strategies, Docker fixes, city clocks UI

- Refine LATENCY_ARB signals with CLOB best ask (was using stale Gamma price)
  Drop signal if market already repriced (lag < 8%)
- Extract getCLOBBestAsk helper for reuse across TAIL_NO and LATENCY_ARB
- Fix Docker: add BOT_HEALTH_URL env for dashboard→bot connectivity,
  OpenSSL compatibility, prisma db push via pnpm filter
- Add CityClocks component on Signals page with local times
- Enlarge city clock fonts for better readability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Horace Zhang 7 ay önce
ebeveyn
işleme
e2ea1e3393

+ 12 - 5
Dockerfile

@@ -1,5 +1,7 @@
 FROM node:20-alpine AS base
-RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
+RUN apk add --no-cache openssl openssl-dev && \
+    corepack enable && corepack prepare pnpm@9.15.4 --activate
+ENV PRISMA_QUERY_ENGINE_BINARY_TARGETS="linux-musl-arm64-openssl-3.0.x,linux-musl-openssl-3.0.x"
 WORKDIR /app
 
 # --- Install dependencies ---
@@ -30,10 +32,15 @@ RUN pnpm --filter @poly-weather/core build && \
 FROM bot-build AS bot
 ENV NODE_ENV=production
 EXPOSE 3001
-CMD ["node", "packages/trading-engine/dist/main.js"]
+CMD ["sh", "-c", "pnpm --filter @poly-weather/core exec prisma db push --schema=prisma/schema.prisma --accept-data-loss && node packages/trading-engine/dist/main.js"]
 
-# --- Dashboard runtime (builds at startup since it needs DB access) ---
-FROM source AS dashboard
+# --- Dashboard: build Next.js (all pages are force-dynamic, no DB needed at build) ---
+FROM source AS dashboard-build
+RUN pnpm --filter @poly-weather/core build && \
+    pnpm --filter @poly-weather/dashboard build
+
+# --- Dashboard runtime ---
+FROM dashboard-build AS dashboard
 ENV NODE_ENV=production
 EXPOSE 3000
-CMD ["sh", "-c", "pnpm --filter @poly-weather/core build && pnpm --filter @poly-weather/dashboard build && pnpm --filter @poly-weather/dashboard start"]
+CMD ["pnpm", "--filter", "@poly-weather/dashboard", "start"]

+ 3 - 2
docker-compose.yml

@@ -1,5 +1,3 @@
-version: '3.8'
-
 services:
   postgres:
     image: postgres:16-alpine
@@ -54,11 +52,14 @@ services:
     environment:
       DATABASE_URL: postgresql://polybot:${DB_PASSWORD:-botpass}@postgres:5432/polybot
       REDIS_URL: redis://redis:6379
+      BOT_HEALTH_URL: http://bot:3001
     ports:
       - '${DASHBOARD_PORT:-3000}:3000'
     depends_on:
       postgres:
         condition: service_healthy
+      bot:
+        condition: service_started
     restart: unless-stopped
 
 volumes:

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

@@ -1,5 +1,6 @@
 generator client {
-  provider = "prisma-client-js"
+  provider      = "prisma-client-js"
+  binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x", "linux-musl-openssl-3.0.x"]
 }
 
 datasource db {

+ 5 - 1
packages/dashboard/src/app/signals/SignalsClient.tsx

@@ -1,8 +1,9 @@
 'use client';
 
-import { useState, useMemo } from 'react';
+import { useState, useMemo, useEffect } from 'react';
 import { SignalTable } from '@/components/SignalTable';
 import { OrderPanel } from '@/components/OrderPanel';
+import { CityClocks } from '@/components/CityClocks';
 import { Button } from '@/components/ui/button';
 import {
   Select,
@@ -125,6 +126,9 @@ export function SignalsClient({ signals }: { signals: Signal[] }) {
 
   return (
     <>
+      {/* City Local Times */}
+      <CityClocks />
+
       {/* Strategy Summary Cards */}
       <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
         {strategyTypes.map((type) => {

+ 139 - 0
packages/dashboard/src/components/CityClocks.tsx

@@ -0,0 +1,139 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+interface CityInfo {
+  name: string;
+  slug: string;
+  timezone: string;
+  flag: string;
+  /** Weather history URL — for checking actual settlement temperatures */
+  weatherUrl: string;
+}
+
+/**
+ * City config with weather history links.
+ * - Most cities use Weather Underground (wunderground.com)
+ * - Hong Kong uses HKO official daily extract (Polymarket settlement source)
+ */
+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: '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: '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: '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: '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' },
+];
+
+/** Build the actual URL, replacing {year}/{month} placeholders for HKO. */
+function buildWeatherUrl(city: CityInfo): string {
+  const now = new Date();
+  return city.weatherUrl
+    .replace('{year}', now.getFullYear().toString())
+    .replace('{month}', (now.getMonth() + 1).toString().padStart(2, '0'));
+}
+
+function getTimezoneOffset(tz: string, date: Date): number {
+  const utcStr = date.toLocaleString('en-US', { timeZone: 'UTC' });
+  const tzStr = date.toLocaleString('en-US', { timeZone: tz });
+  return (new Date(tzStr).getTime() - new Date(utcStr).getTime()) / 3600000;
+}
+
+function formatTime(date: Date, timezone: string): string {
+  return date.toLocaleTimeString('en-US', {
+    timeZone: timezone,
+    hour: '2-digit',
+    minute: '2-digit',
+    hour12: false,
+  });
+}
+
+function formatDate(date: Date, timezone: string): string {
+  return date.toLocaleDateString('en-US', {
+    timeZone: timezone,
+    month: 'short',
+    day: 'numeric',
+  });
+}
+
+function isDaytime(date: Date, timezone: string): boolean {
+  const hour = parseInt(
+    date.toLocaleTimeString('en-US', {
+      timeZone: timezone,
+      hour: 'numeric',
+      hour12: false,
+    }),
+    10,
+  );
+  return hour >= 6 && hour < 20;
+}
+
+export function CityClocks() {
+  const [now, setNow] = useState(new Date());
+
+  useEffect(() => {
+    const interval = setInterval(() => setNow(new Date()), 30_000);
+    return () => clearInterval(interval);
+  }, []);
+
+  // Sort cities by UTC offset (west → east)
+  const sortedCities = [...CITIES].sort(
+    (a, b) => getTimezoneOffset(a.timezone, now) - getTimezoneOffset(b.timezone, now),
+  );
+
+  return (
+    <div className="card">
+      <div className="flex items-center justify-between mb-3">
+        <h3 className="text-sm font-semibold text-heading">City Local Times</h3>
+        <span className="text-xs text-dim font-mono">
+          UTC {formatTime(now, 'UTC')}
+        </span>
+      </div>
+      <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-9 xl:grid-cols-9 gap-2">
+        {sortedCities.map((city) => {
+          const daytime = isDaytime(now, city.timezone);
+          const time = formatTime(now, city.timezone);
+          const date = formatDate(now, city.timezone);
+          const url = buildWeatherUrl(city);
+
+          return (
+            <a
+              key={city.slug}
+              href={url}
+              target="_blank"
+              rel="noopener noreferrer"
+              className={`flex flex-col items-center px-2 py-2 rounded-lg border transition-all hover:scale-105 hover:shadow-md cursor-pointer ${
+                daytime
+                  ? 'bg-amber-50/50 border-amber-200/50 dark:bg-amber-500/5 dark:border-amber-500/20 hover:border-amber-300 dark:hover:border-amber-500/40'
+                  : 'bg-indigo-50/50 border-indigo-200/50 dark:bg-indigo-500/5 dark:border-indigo-500/20 hover:border-indigo-300 dark:hover:border-indigo-500/40'
+              }`}
+              title={`${city.name} — click to view temperature history`}
+            >
+              <span className="text-sm">
+                {daytime ? '\u2600\uFE0F' : '\uD83C\uDF19'} {city.flag}
+              </span>
+              <span className="text-2xl font-mono font-bold text-heading leading-tight">
+                {time}
+              </span>
+              <span className="text-xs text-dim leading-tight">{date}</span>
+              <span className="text-xs text-sub leading-tight truncate max-w-full">
+                {city.name}
+              </span>
+            </a>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 169 - 2
packages/trading-engine/src/pipeline.ts

@@ -20,6 +20,7 @@ import {
 import {
   scanWeatherMarkets,
   groupMarketsByEvent,
+  CLOBReadClient,
 } from '@poly-weather/market-engine';
 import type { OrderManager } from '@poly-weather/market-engine';
 import {
@@ -47,6 +48,7 @@ export class TradingPipeline {
   private riskManager: RiskManager;
   private portfolio: PortfolioTracker;
   private orderManager: OrderManager;
+  private clobReader: CLOBReadClient;
   private redis: Redis | null;
   private cities: CityConfig[];
   private dryRun: boolean;
@@ -60,6 +62,7 @@ export class TradingPipeline {
     autoExecute: boolean = true,
   ) {
     this.orderManager = orderManager;
+    this.clobReader = new CLOBReadClient();
     this.cities = cities;
     this.dryRun = dryRun;
     this._autoExecute = autoExecute;
@@ -219,10 +222,24 @@ export class TradingPipeline {
       }
     }
 
-    result.signalsGenerated += allSignals.length;
+    // Refine signals with live CLOB best-ask prices
+    const refinedSignals: Signal[] = [];
+    for (const signal of allSignals) {
+      try {
+        const refined = await this.refineSignalWithCLOB(signal, market);
+        if (refined) {
+          refinedSignals.push(refined);
+        }
+      } catch (err) {
+        logger.warn({ type: signal.type, city: signal.city, err }, 'CLOB price refinement failed, using Gamma price');
+        refinedSignals.push(signal);
+      }
+    }
+
+    result.signalsGenerated += refinedSignals.length;
 
     // Persist and optionally execute signals
-    for (const signal of allSignals) {
+    for (const signal of refinedSignals) {
       // Persist signal to DB first
       const signalId = await persistSignal(signal);
 
@@ -253,6 +270,156 @@ 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.
+   */
+  private async getCLOBBestAsk(tokenId: string): Promise<number | null> {
+    const book = await this.clobReader.getOrderBook(tokenId);
+    const asks = (book.asks || [])
+      .map((a) => parseFloat(a.price))
+      .sort((a, b) => a - b);
+    return asks.length > 0 ? asks[0] : null;
+  }
+
+  /**
+   * 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).
+   */
+  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;
+    }
+
+    if (signal.type === 'TAIL_NO') {
+      if (!bucket.noTokenId) {
+        logger.debug({ bucket: signal.bucketLabel }, 'No NO token ID — skipping CLOB refinement');
+        return signal;
+      }
+
+      const noBestAsk = await this.getCLOBBestAsk(bucket.noTokenId);
+      if (noBestAsk === null) {
+        logger.debug({ bucket: signal.bucketLabel }, 'NO orderbook empty — using Gamma price');
+        return signal;
+      }
+
+      const impliedYesPrice = 1 - noBestAsk;
+      const edge = impliedYesPrice - signal.modelProbability;
+      const expectedReturn = edge / noBestAsk;
+
+      logger.info(
+        {
+          city: signal.city,
+          bucket: signal.bucketLabel,
+          gammaNO: signal.noPrice.toFixed(3),
+          clobNO: noBestAsk.toFixed(3),
+          gammaEdge: signal.edge.toFixed(3),
+          clobEdge: edge.toFixed(3),
+        },
+        'CLOB price refinement (TAIL_NO)',
+      );
+
+      if (edge < 0.02) {
+        logger.info(
+          { bucket: signal.bucketLabel, edge: edge.toFixed(4) },
+          'Signal dropped: edge too small after CLOB refinement',
+        );
+        return null;
+      }
+
+      return {
+        ...signal,
+        marketYesPrice: impliedYesPrice,
+        noPrice: noBestAsk,
+        edge,
+        expectedReturn,
+      };
+    }
+
+    // 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;
+        }
+      }
+
+      if (clobYesPrice === null) {
+        logger.debug({ bucket: signal.bucketLabel }, 'CLOB price unavailable for latency arb');
+        return signal;
+      }
+
+      const marketLag = Math.abs(clobYesPrice - signal.newModelProbability);
+
+      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),
+        },
+        'CLOB price refinement (LATENCY_ARB)',
+      );
+
+      // If market already repriced (lag < 8%), signal is stale
+      if (marketLag < 0.08) {
+        logger.info(
+          { bucket: signal.bucketLabel, marketLag: marketLag.toFixed(4) },
+          'Signal dropped: market already repriced after CLOB check',
+        );
+        return null;
+      }
+
+      return {
+        ...signal,
+        currentPrice: clobYesPrice,
+      };
+    }
+
+    return signal;
+  }
+
   /**
    * Get portfolio summary.
    */