소스 검색

feat: replace PnL page with live Positions page, fix neg-risk orderbook pricing

Remove broken PnL page and add Positions page that fetches live holdings
from Polymarket Data API. Shows summary cards (count, cost, market value,
unrealized PnL) and a compact position list with city/bucket, side, shares,
price range, and PnL. Also fix orderbook pricing to merge YES + NO books
for neg-risk markets so NO prices match the Polymarket website.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
lushdog@outlook.com 7 달 전
부모
커밋
00b80ba822

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

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

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

@@ -0,0 +1,122 @@
+import { NextResponse } from 'next/server';
+
+export const dynamic = 'force-dynamic';
+
+const DATA_API = 'https://data-api.polymarket.com';
+
+interface RawPosition {
+  proxyWallet: string;
+  asset: string;
+  conditionId: string;
+  size: number;
+  avgPrice: number;
+  initialValue: number;
+  currentValue: number;
+  cashPnl: number;
+  percentPnl: number;
+  curPrice: number;
+  realizedPnl: number;
+  title: string;
+  slug: string;
+  eventSlug: string;
+  outcome: string;
+  outcomeIndex: number;
+  endDate: string;
+  negativeRisk: boolean;
+  redeemable: boolean;
+}
+
+export interface Position {
+  asset: string;
+  conditionId: string;
+  title: string;
+  outcome: string; // "Yes" or "No"
+  size: number;
+  avgPrice: number;
+  curPrice: number;
+  initialValue: number;
+  currentValue: number;
+  cashPnl: number;
+  percentPnl: number;
+  realizedPnl: number;
+  endDate: string;
+  slug: string;
+  eventSlug: string;
+  redeemable: boolean;
+}
+
+/**
+ * Fetch live positions from Polymarket Data API.
+ * Uses FUNDER_ADDRESS (proxy wallet) to query all held positions.
+ */
+export async function GET() {
+  const funderAddress = process.env.FUNDER_ADDRESS;
+  if (!funderAddress) {
+    return NextResponse.json(
+      { error: 'FUNDER_ADDRESS not configured' },
+      { status: 500 },
+    );
+  }
+
+  try {
+    const url = `${DATA_API}/positions?user=${funderAddress}&sizeThreshold=0&limit=200`;
+    const res = await fetch(url, {
+      headers: { Accept: 'application/json' },
+      signal: AbortSignal.timeout(15000),
+    });
+
+    if (!res.ok) {
+      return NextResponse.json(
+        { error: `Data API returned ${res.status}` },
+        { status: 502 },
+      );
+    }
+
+    const raw = (await res.json()) as RawPosition[];
+
+    const positions: Position[] = raw.map((p) => ({
+      asset: p.asset,
+      conditionId: p.conditionId,
+      title: p.title,
+      outcome: p.outcome,
+      size: p.size,
+      avgPrice: p.avgPrice,
+      curPrice: p.curPrice,
+      initialValue: p.initialValue,
+      currentValue: p.currentValue,
+      cashPnl: p.cashPnl,
+      percentPnl: p.percentPnl,
+      realizedPnl: p.realizedPnl,
+      endDate: p.endDate,
+      slug: p.slug,
+      eventSlug: p.eventSlug,
+      redeemable: p.redeemable,
+    }));
+
+    // Sort by currentValue descending
+    positions.sort((a, b) => b.currentValue - a.currentValue);
+
+    const totalValue = positions.reduce((s, p) => s + p.currentValue, 0);
+    const totalCost = positions.reduce((s, p) => s + p.initialValue, 0);
+    const totalPnl = positions.reduce((s, p) => s + p.cashPnl, 0);
+
+    return NextResponse.json({
+      positions,
+      summary: {
+        totalValue,
+        totalCost,
+        totalPnl,
+        totalPnlPercent: totalCost > 0 ? (totalPnl / totalCost) * 100 : 0,
+        positionCount: positions.length,
+        redeemableCount: positions.filter((p) => p.redeemable).length,
+      },
+      fetchedAt: new Date().toISOString(),
+    });
+  } catch (err) {
+    console.error('Positions API error:', err);
+    return NextResponse.json(
+      { error: 'Failed to fetch positions' },
+      { status: 500 },
+    );
+  }
+}

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

@@ -82,7 +82,7 @@ function emptyResult(signalId: string, error?: string): PriceResult {
 
 /**
  * Fetch latest CLOB prices for a set of signals.
- * Queries BOTH YES and NO orderbooks directly — no synthetic conversion.
+ * Merges YES + NO orderbooks (neg-risk) so NO prices match Polymarket website.
  */
 export async function POST(request: Request) {
   try {
@@ -93,16 +93,25 @@ export async function POST(request: Request) {
     }
 
     // Build token map: keyed by city|date|bucket to avoid cross-date collisions
+    // Prefer Gamma API (always has current token IDs) over DB snapshots (may be stale)
     const tokenMap = new Map<string, TokenPair>();
 
-    // 1. Try DB (with date filter)
+    // 1. Try Gamma first (authoritative source for current token IDs)
+    const gammaTokens = await fetchAllGammaTokens();
+    for (const signal of signals) {
+      const dateStr = signal.targetDate?.slice(0, 10) || '';
+      const key = `${signal.city}|${dateStr}|${signal.bucket}`;
+      if (gammaTokens.has(key)) {
+        tokenMap.set(key, gammaTokens.get(key)!);
+      }
+    }
+
+    // 2. DB fallback for any missing (e.g. if Gamma API is down)
     for (const signal of signals) {
-      // 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;
 
-      // Build date range filter
       const whereClause: Record<string, unknown> = {
         city: signal.city,
         bucketLabel: signal.bucket,
@@ -125,24 +134,6 @@ export async function POST(request: Request) {
       }
     }
 
-    // 2. Gamma fallback for missing (now keyed by city|date|bucket)
-    const hasMissing = signals.some((s) => {
-      // All signal types need token lookup
-      const dateStr = s.targetDate?.slice(0, 10) || '';
-      return !tokenMap.has(`${s.city}|${dateStr}|${s.bucket}`);
-    });
-
-    if (hasMissing) {
-      const gammaTokens = await fetchAllGammaTokens();
-      for (const signal of signals) {
-        const dateStr = signal.targetDate?.slice(0, 10) || '';
-        const key = `${signal.city}|${dateStr}|${signal.bucket}`;
-        if (!tokenMap.has(key) && gammaTokens.has(key)) {
-          tokenMap.set(key, gammaTokens.get(key)!);
-        }
-      }
-    }
-
     // Fetch prices for each signal
     const results: PriceResult[] = await Promise.all(
       signals.map(async (signal): Promise<PriceResult> => {
@@ -168,33 +159,50 @@ export async function POST(request: Request) {
             return emptyResult(signal.signalId, 'Orderbook unavailable');
           }
 
-          // Parse raw YES orderbook
-          const rawYesBids: BookLevel[] = (yesBook?.bids || [])
+          // Merge YES + NO books (neg-risk: NO prices derived from YES complement)
+          const mergedYesBids: BookLevel[] = (yesBook?.bids || [])
             .map((b) => ({ price: parseFloat(b.price), size: parseFloat(b.size) }));
-          const rawYesAsks: BookLevel[] = (yesBook?.asks || [])
+          const mergedYesAsks: BookLevel[] = (yesBook?.asks || [])
             .map((a) => ({ price: parseFloat(a.price), size: parseFloat(a.size) }));
 
-          // Parse raw NO orderbook
-          const rawNoBids: BookLevel[] = (noBook?.bids || [])
-            .map((b) => ({ price: parseFloat(b.price), size: parseFloat(b.size) }));
-          const rawNoAsks: BookLevel[] = (noBook?.asks || [])
-            .map((a) => ({ price: parseFloat(a.price), size: parseFloat(a.size) }));
+          if (noBook) {
+            // Synthetic YES bids from NO asks: NO ask @ P → YES bid @ (1-P)
+            for (const a of noBook.asks || []) {
+              const syn = 1 - parseFloat(a.price);
+              if (syn > 0 && syn < 1) mergedYesBids.push({ price: syn, size: parseFloat(a.size) });
+            }
+            // Synthetic YES asks from NO bids: NO bid @ P → YES ask @ (1-P)
+            for (const b of noBook.bids || []) {
+              const syn = 1 - parseFloat(b.price);
+              if (syn > 0 && syn < 1) mergedYesAsks.push({ price: syn, size: parseFloat(b.size) });
+            }
+          }
 
-          // Use DIRECT orderbook prices (matches Polymarket website)
-          rawYesBids.sort((a, b) => b.price - a.price);
-          rawYesAsks.sort((a, b) => a.price - b.price);
-          rawNoBids.sort((a, b) => b.price - a.price);
-          rawNoAsks.sort((a, b) => a.price - b.price);
+          // Deduplicate by aggregating sizes at same price level
+          const dedup = (levels: BookLevel[]) => {
+            const map = new Map<number, number>();
+            for (const l of levels) {
+              const rounded = Math.round(l.price * 10000) / 10000;
+              map.set(rounded, (map.get(rounded) ?? 0) + l.size);
+            }
+            return Array.from(map, ([price, size]) => ({ price, size }));
+          };
+          const dedupBids = dedup(mergedYesBids);
+          const dedupAsks = dedup(mergedYesAsks);
+          dedupBids.sort((a, b) => b.price - a.price);
+          dedupAsks.sort((a, b) => a.price - b.price);
 
-          const yesBestBid = rawYesBids[0]?.price ?? 0;
-          const yesBestAsk = rawYesAsks[0]?.price ?? 1;
+          const yesBestBid = dedupBids[0]?.price ?? 0;
+          const yesBestAsk = dedupAsks[0]?.price ?? 1;
           const yesSpread = yesBestAsk - yesBestBid;
+          const yesMidpoint = (yesBestBid + yesBestAsk) / 2;
 
-          const noBestBid = rawNoBids[0]?.price ?? 0;
-          const noBestAsk = rawNoAsks[0]?.price ?? 1;
+          // Derive NO prices from merged YES book (matches Polymarket website)
+          // NO best ask = 1 - YES best bid (to buy NO, someone sells YES)
+          // NO best bid = 1 - YES best ask (to sell NO, someone buys YES)
+          const noBestAsk = yesBestBid > 0 ? 1 - yesBestBid : 1;
+          const noBestBid = yesBestAsk < 1 ? 1 - yesBestAsk : 0;
           const noSpread = noBestAsk - noBestBid;
-
-          const yesMidpoint = (yesBestBid + yesBestAsk) / 2;
           const noMidpoint = (noBestBid + noBestAsk) / 2;
 
           return {
@@ -209,8 +217,9 @@ export async function POST(request: Request) {
             noBestAsk,
             noSpread,
             noMidpoint,
-            noAsks: rawNoAsks.slice(0, 5),
-            yesAsks: rawYesAsks.slice(0, 5),
+            // Derive NO asks from YES bids (NO ask @ (1-P) for each YES bid @ P)
+            noAsks: dedupBids.slice(0, 5).map((b) => ({ price: 1 - b.price, size: b.size })),
+            yesAsks: dedupAsks.slice(0, 5),
             // Legacy
             bestBid: yesBestBid,
             bestAsk: yesBestAsk,

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

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

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

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

+ 236 - 0
packages/dashboard/src/app/positions/PositionsClient.tsx

@@ -0,0 +1,236 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { Badge } from '@/components/ui/badge';
+
+interface Position {
+  asset: string;
+  conditionId: string;
+  title: string;
+  outcome: string;
+  size: number;
+  avgPrice: number;
+  curPrice: number;
+  initialValue: number;
+  currentValue: number;
+  cashPnl: number;
+  percentPnl: number;
+  realizedPnl: number;
+  endDate: string;
+  slug: string;
+  eventSlug: string;
+  redeemable: boolean;
+}
+
+interface Summary {
+  totalValue: number;
+  totalCost: number;
+  totalPnl: number;
+  totalPnlPercent: number;
+  positionCount: number;
+  redeemableCount: number;
+}
+
+function pnlColor(val: number): string {
+  if (val > 0) return 'text-green-600 dark:text-green-400';
+  if (val < 0) return 'text-red-600 dark:text-red-400';
+  return 'text-sub';
+}
+
+function formatPnl(val: number): string {
+  const sign = val >= 0 ? '+' : '';
+  return `${sign}$${val.toFixed(2)}`;
+}
+
+function formatPct(val: number): string {
+  const sign = val >= 0 ? '+' : '';
+  return `${sign}${val.toFixed(1)}%`;
+}
+
+/** Extract city name from title like "Will the highest temperature in Seoul be 12°C on March 19?" */
+function extractCity(title: string): string {
+  const match = title.match(/temperature in (.+?) (?:be|on)/i);
+  return match ? match[1] : '';
+}
+
+/** Extract bucket from title like "...be 12°C on..." */
+function extractBucket(title: string): string {
+  const match = title.match(/be (.+?) on/i);
+  return match ? match[1] : '';
+}
+
+export function PositionsClient() {
+  const [positions, setPositions] = useState<Position[]>([]);
+  const [summary, setSummary] = useState<Summary | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const [fetchedAt, setFetchedAt] = useState<string>('');
+
+  const fetchPositions = useCallback(async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const res = await fetch('/api/positions');
+      if (!res.ok) {
+        const data = await res.json().catch(() => ({}));
+        throw new Error(data.error || `HTTP ${res.status}`);
+      }
+      const data = await res.json();
+      setPositions(data.positions);
+      setSummary(data.summary);
+      setFetchedAt(data.fetchedAt);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to load positions');
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    fetchPositions();
+  }, [fetchPositions]);
+
+  return (
+    <div className="space-y-6">
+      {/* Summary Cards */}
+      {summary && (
+        <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+          <div className="card p-4">
+            <p className="text-xs text-sub font-medium uppercase tracking-wide">Positions</p>
+            <p className="text-2xl font-bold text-heading mt-1">{summary.positionCount}</p>
+            {summary.redeemableCount > 0 && (
+              <p className="text-xs text-green-600 dark:text-green-400 mt-0.5">
+                {summary.redeemableCount} redeemable
+              </p>
+            )}
+          </div>
+          <div className="card p-4">
+            <p className="text-xs text-sub font-medium uppercase tracking-wide">Total Cost</p>
+            <p className="text-2xl font-bold text-heading mt-1">${summary.totalCost.toFixed(2)}</p>
+          </div>
+          <div className="card p-4">
+            <p className="text-xs text-sub font-medium uppercase tracking-wide">Market Value</p>
+            <p className="text-2xl font-bold text-heading mt-1">${summary.totalValue.toFixed(2)}</p>
+          </div>
+          <div className="card p-4">
+            <p className="text-xs text-sub font-medium uppercase tracking-wide">Unrealized PnL</p>
+            <p className={`text-2xl font-bold mt-1 ${pnlColor(summary.totalPnl)}`}>
+              {formatPnl(summary.totalPnl)}
+            </p>
+            <p className={`text-xs mt-0.5 ${pnlColor(summary.totalPnlPercent)}`}>
+              {formatPct(summary.totalPnlPercent)}
+            </p>
+          </div>
+        </div>
+      )}
+
+      {/* Toolbar */}
+      <div className="flex items-center justify-between">
+        <div className="text-xs text-dim">
+          {fetchedAt && `Updated ${new Date(fetchedAt).toLocaleTimeString()}`}
+        </div>
+        <button
+          onClick={fetchPositions}
+          disabled={loading}
+          className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800 text-sub transition-colors disabled:opacity-50"
+        >
+          <svg className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} 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>
+          Refresh
+        </button>
+      </div>
+
+      {/* Error */}
+      {error && (
+        <div className="p-4 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-sm text-red-700 dark:text-red-400">
+          {error}
+        </div>
+      )}
+
+      {/* Loading skeleton */}
+      {loading && positions.length === 0 && (
+        <div className="card divide-y divide-gray-100 dark:divide-slate-800">
+          {[1, 2, 3].map((i) => (
+            <div key={i} className="p-4 animate-pulse">
+              <div className="h-4 bg-gray-200 dark:bg-slate-700 rounded w-1/3 mb-2" />
+              <div className="h-3 bg-gray-100 dark:bg-slate-800 rounded w-1/2" />
+            </div>
+          ))}
+        </div>
+      )}
+
+      {/* Empty state */}
+      {!loading && !error && positions.length === 0 && (
+        <div className="card p-8 text-center">
+          <p className="text-sub">No open positions on Polymarket.</p>
+        </div>
+      )}
+
+      {/* Positions Table */}
+      {positions.length > 0 && (
+        <div className="card overflow-hidden">
+          <div className="divide-y divide-gray-100 dark:divide-slate-800">
+            {positions.map((pos) => {
+              const city = extractCity(pos.title);
+              const bucket = extractBucket(pos.title);
+              const isNo = pos.outcome === 'No';
+              const polyUrl = `https://polymarket.com/event/${pos.eventSlug}`;
+
+              return (
+                <div key={pos.asset} className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-slate-800/50 transition-colors">
+                  {/* Market info */}
+                  <div className="flex-1 min-w-0">
+                    <a href={polyUrl} target="_blank" rel="noopener noreferrer" className="group">
+                      <div className="flex items-center gap-1.5">
+                        <Badge variant={isNo ? 'red' : 'green'} className="text-[10px] font-semibold px-1.5 py-0 shrink-0">
+                          {pos.outcome}
+                        </Badge>
+                        <span className="text-sm font-medium text-heading group-hover:text-blue-600 dark:group-hover:text-blue-400 truncate">
+                          {city || pos.title.slice(0, 30)}
+                        </span>
+                        <svg className="w-3 h-3 text-dim opacity-0 group-hover:opacity-100 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+                        </svg>
+                      </div>
+                    </a>
+                    <div className="flex items-center gap-2 mt-0.5">
+                      <span className="text-xs text-sub font-mono">{bucket}</span>
+                      <span className="text-[10px] text-dim">{pos.endDate}</span>
+                      {pos.redeemable && (
+                        <Badge variant="green" className="text-[10px] px-1 py-0">Redeem</Badge>
+                      )}
+                    </div>
+                  </div>
+
+                  {/* Shares & Price */}
+                  <div className="text-right shrink-0 w-20">
+                    <span className="text-sm font-mono text-heading">{pos.size.toFixed(1)}</span>
+                    <div className="text-[10px] text-dim font-mono">
+                      {(pos.avgPrice * 100).toFixed(1)}c → {(pos.curPrice * 100).toFixed(1)}c
+                    </div>
+                  </div>
+
+                  {/* Value */}
+                  <div className="text-right shrink-0 w-16">
+                    <span className="text-sm font-mono font-medium text-heading">${pos.currentValue.toFixed(2)}</span>
+                  </div>
+
+                  {/* PnL */}
+                  <div className="text-right shrink-0 w-16">
+                    <span className={`text-sm font-mono font-medium ${pnlColor(pos.cashPnl)}`}>
+                      {formatPnl(pos.cashPnl)}
+                    </span>
+                    <div className={`text-[10px] font-mono ${pnlColor(pos.percentPnl)}`}>
+                      {formatPct(pos.percentPnl)}
+                    </div>
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 17 - 0
packages/dashboard/src/app/positions/page.tsx

@@ -0,0 +1,17 @@
+import { PositionsClient } from './PositionsClient';
+
+export const dynamic = 'force-dynamic';
+
+export default function PositionsPage() {
+  return (
+    <div className="space-y-8">
+      <div>
+        <h1 className="text-2xl font-bold text-heading">Positions</h1>
+        <p className="text-sm text-sub mt-1">
+          Live Polymarket holdings
+        </p>
+      </div>
+      <PositionsClient />
+    </div>
+  );
+}

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

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

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

@@ -59,11 +59,11 @@ const navItems: NavItem[] = [
     ),
   },
   {
-    label: 'PnL',
-    href: '/pnl',
+    label: 'Positions',
+    href: '/positions',
     icon: (
       <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
       </svg>
     ),
   },

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

@@ -10,8 +10,6 @@ import type {
   TradeRecord,
   WeatherMarket,
   MarketBucket,
-  PnLDataPoint,
-  PnLMetrics,
 } from './types';
 
 // ─── Dashboard Summary ──────────────────────────────────────────────────────
@@ -286,63 +284,6 @@ export async function getMarkets(city?: string): Promise<WeatherMarket[]> {
   return markets;
 }
 
-// ─── PnL ────────────────────────────────────────────────────────────────────
-
-export async function getPnLData(): Promise<{ data: PnLDataPoint[]; metrics: PnLMetrics }> {
-  const dailyMetrics = await prisma.dailyMetrics.findMany({
-    orderBy: { date: 'asc' },
-  });
-
-  // Build cumulative PnL series
-  let cumulative = 0;
-  type DM = typeof dailyMetrics[number];
-  const data: PnLDataPoint[] = dailyMetrics.map((dm: DM) => {
-    cumulative += dm.netPnl;
-    return {
-      date: dm.date.toISOString().slice(0, 10),
-      daily: dm.netPnl,
-      cumulative,
-    };
-  });
-
-  // Aggregate metrics
-  const totalTrades = dailyMetrics.reduce((s: number, d: DM) => s + d.totalTrades, 0);
-  const winningTrades = dailyMetrics.reduce((s: number, d: DM) => s + d.winningTrades, 0);
-  const totalPnl = cumulative;
-  const winRate = totalTrades > 0 ? winningTrades / totalTrades : 0;
-
-  // Sharpe ratio (annualized from daily)
-  const dailyReturns = dailyMetrics.map((d: DM) => d.netPnl);
-  const avgReturn = dailyReturns.length > 0 ? dailyReturns.reduce((s: number, r: number) => s + r, 0) / dailyReturns.length : 0;
-  const variance = dailyReturns.length > 1
-    ? dailyReturns.reduce((s: number, r: number) => s + (r - avgReturn) ** 2, 0) / (dailyReturns.length - 1)
-    : 0;
-  const stdDev = Math.sqrt(variance);
-  const sharpe = stdDev > 0 ? (avgReturn / stdDev) * Math.sqrt(252) : 0;
-
-  // Max drawdown
-  let peak = 0;
-  let maxDrawdown = 0;
-  let runningPnl = 0;
-  for (const dm of dailyMetrics) {
-    runningPnl += dm.netPnl;
-    if (runningPnl > peak) peak = runningPnl;
-    const dd = runningPnl - peak;
-    if (dd < maxDrawdown) maxDrawdown = dd;
-  }
-
-  const metrics: PnLMetrics = {
-    totalPnl,
-    winRate,
-    sharpe: +sharpe.toFixed(2),
-    maxDrawdown: +maxDrawdown.toFixed(2),
-    totalTrades,
-    winningTrades,
-  };
-
-  return { data, metrics };
-}
-
 // ─── Observed Temperature ───────────────────────────────────────────────────
 
 /**

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

@@ -100,21 +100,6 @@ export interface TradeRecord {
   signalType?: string;
 }
 
-export interface PnLDataPoint {
-  date: string;
-  daily: number;
-  cumulative: number;
-}
-
-export interface PnLMetrics {
-  totalPnl: number;
-  winRate: number;
-  sharpe: number;
-  maxDrawdown: number;
-  totalTrades: number;
-  winningTrades: number;
-}
-
 // ─── HKO (Hong Kong Observatory) Types ──────────────────────────────────────
 
 export interface HKOStation {