Переглянути джерело

feat: per-city auto-execute (OBSERVED_NO only), HKO daily temperature curve

Replace global auto-execute toggle with per-city control on the Hong Kong
city page. Only OBSERVED_NO signals are auto-executed when enabled; all
other signal types are always skipped. Add daily temperature curve chart
using observed HKO readings from the database. Compact HK page card heights.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
lushdog@outlook.com 7 місяців тому
батько
коміт
49434eb832

+ 0 - 1
.env.example

@@ -38,7 +38,6 @@ PARITY_MIN_DEVIATION=0.02
 
 # === Mode ===
 DRY_RUN=true
-AUTO_EXECUTE=false    # When false, signals are detected but orders are NOT auto-placed
 LOG_LEVEL=info
 DASHBOARD_PORT=3000
 BOT_HEALTH_PORT=3001

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

@@ -32,7 +32,6 @@ function envInt(key: string, defaultValue: number): number {
 export function loadConfig(): BotConfig {
   return {
     dryRun: envOrDefault('DRY_RUN', 'true') === 'true',
-    autoExecute: envOrDefault('AUTO_EXECUTE', 'true') === 'true',
     logLevel: envOrDefault('LOG_LEVEL', 'info'),
 
     databaseUrl: envOrDefault('DATABASE_URL', 'postgresql://bot:botpass@localhost:5432/weather_bot'),

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

@@ -8,7 +8,6 @@ import type { CityConfig, WeatherModel } from './weather.js';
 export interface BotConfig {
   // Mode
   dryRun: boolean;
-  autoExecute: boolean;  // When false, signals are generated but orders are NOT placed
   logLevel: string;
 
   // Database

+ 15 - 10
packages/dashboard/src/app/api/bot-control/route.ts

@@ -5,7 +5,7 @@ export const dynamic = 'force-dynamic';
 const BOT_HEALTH_URL = process.env.BOT_HEALTH_URL || 'http://localhost:3001';
 
 /**
- * GET /api/bot-control — get current bot status (autoExecute, dryRun, etc.)
+ * GET /api/bot-control — get current bot status (autoExecuteCities, dryRun, etc.)
  * When bot is offline, falls back to reading DRY_RUN from dashboard's env.
  */
 export async function GET() {
@@ -18,14 +18,19 @@ export async function GET() {
     if (!res.ok) {
       return NextResponse.json({
         connected: false,
-        autoExecute: false,
+        autoExecuteCities: [],
         dryRun: envDryRun,
       }, { status: 200 });
     }
     const data = await res.json();
+    // Compat: old bot returns autoExecute (boolean), new bot returns autoExecuteCities (string[])
+    let autoExecuteCities: string[] = data.autoExecuteCities ?? [];
+    if (!data.autoExecuteCities && data.autoExecute === true) {
+      autoExecuteCities = ['hong_kong'];
+    }
     return NextResponse.json({
       connected: true,
-      autoExecute: data.autoExecute ?? false,
+      autoExecuteCities,
       dryRun: data.dryRun ?? envDryRun,
       isRunning: data.isRunning ?? false,
       uptime: data.uptime ?? 0,
@@ -35,27 +40,27 @@ export async function GET() {
     // Bot not reachable — use dashboard env
     return NextResponse.json({
       connected: false,
-      autoExecute: false,
+      autoExecuteCities: [],
       dryRun: envDryRun,
     }, { status: 200 });
   }
 }
 
 /**
- * POST /api/bot-control — toggle auto-execute
- * Body: { autoExecute: boolean }
+ * POST /api/bot-control — toggle auto-execute for a city
+ * Body: { city: string, enabled: boolean }
  */
 export async function POST(request: Request) {
   try {
-    const { autoExecute } = await request.json();
-    if (typeof autoExecute !== 'boolean') {
-      return NextResponse.json({ error: 'autoExecute must be a boolean' }, { status: 400 });
+    const { city, enabled } = await request.json();
+    if (typeof city !== 'string' || typeof enabled !== 'boolean') {
+      return NextResponse.json({ error: 'city (string) and enabled (boolean) are required' }, { status: 400 });
     }
 
     const res = await fetch(`${BOT_HEALTH_URL}/auto-execute`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ enabled: autoExecute }),
+      body: JSON.stringify({ city, enabled }),
       signal: AbortSignal.timeout(3000),
     });
 

+ 27 - 0
packages/dashboard/src/app/api/hko-daily-readings/route.ts

@@ -0,0 +1,27 @@
+import { NextResponse } from 'next/server';
+import { getObservedDailyReadings } from '@/lib/queries';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * GET /api/hko-daily-readings — get all observed HKO temperature readings for today.
+ * Returns array of { temp, time } sorted chronologically.
+ */
+export async function GET() {
+  try {
+    // Use Hong Kong timezone to determine "today"
+    const nowHKT = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Hong_Kong' });
+    const todayDate = new Date(nowHKT + 'T00:00:00+08:00');
+
+    const readings = await getObservedDailyReadings('hong_kong', todayDate);
+
+    const data = readings.map((r) => ({
+      temp: r.temp,
+      time: r.recordTime.toISOString(),
+    }));
+
+    return NextResponse.json({ readings: data });
+  } catch (err) {
+    return NextResponse.json({ error: String(err) }, { status: 500 });
+  }
+}

+ 73 - 13
packages/dashboard/src/app/cities/hong-kong/HongKongClient.tsx

@@ -1,10 +1,12 @@
 'use client';
 
-import { useState, useEffect, useMemo, useCallback } from 'react';
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
 import { SignalTable } from '@/components/SignalTable';
 import { OrderPanel } from '@/components/OrderPanel';
 import { HKOTempCard, HKODailyHighCard } from './components/HKOTempCard';
 import { HKOForecastChart } from './components/HKOForecastChart';
+import { HKODailyCurve } from './components/HKODailyCurve';
+import { Switch } from '@/components/ui/switch';
 import type { Signal, WeatherMarket, HKOCurrentTemp, HKOForecast } from '@/lib/types';
 
 const strategyTypes = ['ALL', 'TAIL_NO', 'OBSERVED_YES', 'OBSERVED_NO'] as const;
@@ -30,6 +32,10 @@ export function HongKongClient({ signals, markets }: Props) {
   const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
   const [showOrderPanel, setShowOrderPanel] = useState(false);
   const [hktTime, setHktTime] = useState('');
+  const [autoExecute, setAutoExecute] = useState(false);
+  const [botConnected, setBotConnected] = useState(false);
+  const [toggling, setToggling] = useState(false);
+  const togglingRef = useRef(false);
 
   // Fetch HKO current temperature
   const fetchCurrent = useCallback(async () => {
@@ -61,17 +67,55 @@ export function HongKongClient({ signals, markets }: Props) {
     }
   }, []);
 
+  // Fetch bot status including auto-execute state
+  const fetchBotStatus = useCallback(async () => {
+    try {
+      const res = await fetch('/api/bot-control');
+      if (res.ok) {
+        const data = await res.json();
+        setBotConnected(data.connected);
+        if (!togglingRef.current) {
+          setAutoExecute((data.autoExecuteCities ?? []).includes('hong_kong'));
+        }
+      }
+    } catch {
+      setBotConnected(false);
+    }
+  }, []);
+
+  const toggleAutoExecute = useCallback(async (enabled: boolean) => {
+    togglingRef.current = true;
+    setToggling(true);
+    setAutoExecute(enabled); // optimistic
+    try {
+      await fetch('/api/bot-control', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ city: 'hong_kong', enabled }),
+      });
+    } catch {
+      setAutoExecute(!enabled); // revert
+    } finally {
+      setToggling(false);
+      // Allow polling to resume after a short delay so the bot state is updated
+      setTimeout(() => { togglingRef.current = false; }, 2000);
+    }
+  }, []);
+
   // Initialize data + refresh intervals
   useEffect(() => {
     fetchCurrent();
     fetchForecast();
+    fetchBotStatus();
     const tempInterval = setInterval(fetchCurrent, 3 * 60 * 1000); // 3 min
     const forecastInterval = setInterval(fetchForecast, 30 * 60 * 1000); // 30 min
+    const botInterval = setInterval(fetchBotStatus, 10_000);
     return () => {
       clearInterval(tempInterval);
       clearInterval(forecastInterval);
+      clearInterval(botInterval);
     };
-  }, [fetchCurrent, fetchForecast]);
+  }, [fetchCurrent, fetchForecast, fetchBotStatus]);
 
   // Live HKT clock
   useEffect(() => {
@@ -125,23 +169,36 @@ export function HongKongClient({ signals, markets }: Props) {
             HKO-powered temperature trading &bull; {hktTime} HKT
           </p>
         </div>
-        <a
-          href="https://www.weather.gov.hk/en/wxinfo/currwx/current.htm"
-          target="_blank"
-          rel="noopener noreferrer"
-          className="btn-secondary text-xs px-3 py-1.5 rounded-lg"
-        >
-          HKO Website &rarr;
-        </a>
+        <div className="flex items-center gap-4">
+          {botConnected && (
+            <div className="flex items-center gap-3 px-4 py-2 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800/50">
+              <div className="flex flex-col">
+                <span className="text-xs font-medium text-heading">Auto-Execute</span>
+                <span className="text-[10px] text-dim">OBSERVED_NO only</span>
+              </div>
+              <Switch
+                checked={autoExecute}
+                onCheckedChange={toggleAutoExecute}
+                disabled={toggling}
+              />
+            </div>
+          )}
+          <a
+            href="https://www.weather.gov.hk/en/wxinfo/currwx/current.htm"
+            target="_blank"
+            rel="noopener noreferrer"
+            className="btn-secondary text-xs px-3 py-1.5 rounded-lg"
+          >
+            HKO Website &rarr;
+          </a>
+        </div>
       </div>
 
       {/* Top row: Temp card + Daily High + Forecast chart */}
       <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
         <div className="lg:col-span-1 flex flex-col gap-6">
           <HKOTempCard data={currentTemp} loading={tempLoading} />
-          <div className="flex-1">
-            <HKODailyHighCard data={currentTemp} loading={tempLoading} />
-          </div>
+          <HKODailyHighCard data={currentTemp} loading={tempLoading} />
         </div>
         <div className="lg:col-span-2">
           <HKOForecastChart
@@ -152,6 +209,9 @@ export function HongKongClient({ signals, markets }: Props) {
         </div>
       </div>
 
+      {/* Today's temperature curve */}
+      <HKODailyCurve />
+
       {/* Markets section */}
       {markets.length > 0 && (
         <div className="card p-6">

+ 164 - 0
packages/dashboard/src/app/cities/hong-kong/components/HKODailyCurve.tsx

@@ -0,0 +1,164 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import {
+  AreaChart,
+  Area,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+  ReferenceLine,
+} from 'recharts';
+
+interface Reading {
+  temp: number;
+  time: string; // HH:MM HKT
+  fullTime: string; // for tooltip
+}
+
+export function HKODailyCurve() {
+  const [readings, setReadings] = useState<Reading[]>([]);
+  const [loading, setLoading] = useState(true);
+
+  const fetchReadings = useCallback(async () => {
+    try {
+      const res = await fetch('/api/hko-daily-readings');
+      if (res.ok) {
+        const data = await res.json();
+        const parsed: Reading[] = (data.readings ?? []).map(
+          (r: { temp: number; time: string }) => {
+            const d = new Date(r.time);
+            return {
+              temp: r.temp,
+              time: d.toLocaleTimeString('en-HK', {
+                timeZone: 'Asia/Hong_Kong',
+                hour: '2-digit',
+                minute: '2-digit',
+                hour12: false,
+              }),
+              fullTime: d.toLocaleTimeString('en-HK', {
+                timeZone: 'Asia/Hong_Kong',
+                hour: '2-digit',
+                minute: '2-digit',
+                second: '2-digit',
+                hour12: false,
+              }),
+            };
+          },
+        );
+        setReadings(parsed);
+      }
+    } catch {
+      // ignore
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    fetchReadings();
+    const interval = setInterval(fetchReadings, 3 * 60 * 1000); // refresh every 3 min
+    return () => clearInterval(interval);
+  }, [fetchReadings]);
+
+  if (loading) {
+    return (
+      <div className="card p-6 animate-pulse">
+        <div className="h-4 w-48 bg-gray-200 dark:bg-slate-700 rounded mb-4" />
+        <div className="h-48 bg-gray-200 dark:bg-slate-700 rounded" />
+      </div>
+    );
+  }
+
+  if (readings.length < 2) {
+    return (
+      <div className="card p-6">
+        <h3 className="text-sm font-medium text-heading mb-2">
+          Today&apos;s Temperature Curve
+        </h3>
+        <p className="text-dim text-sm text-center py-8">
+          Not enough readings yet. Data accumulates throughout the day.
+        </p>
+      </div>
+    );
+  }
+
+  const temps = readings.map((r) => r.temp);
+  const minTemp = Math.min(...temps);
+  const maxTemp = Math.max(...temps);
+  const yMin = Math.floor(minTemp - 1);
+  const yMax = Math.ceil(maxTemp + 1);
+
+  // Show ticks at reasonable intervals (every ~2 hours)
+  const tickInterval = Math.max(1, Math.floor(readings.length / 12));
+
+  return (
+    <div className="card p-6">
+      <div className="flex items-start justify-between mb-4">
+        <div>
+          <h3 className="text-sm font-medium text-heading">
+            Today&apos;s Temperature Curve
+          </h3>
+          <p className="text-xs text-dim mt-0.5">
+            HKO Observatory &middot; {readings.length} readings
+          </p>
+        </div>
+        <div className="text-right">
+          <div className="text-xs text-dim">
+            <span className="text-red-500 font-medium">{maxTemp.toFixed(1)}°</span>
+            {' / '}
+            <span className="text-blue-500 font-medium">{minTemp.toFixed(1)}°</span>
+          </div>
+          <div className="text-[10px] text-dim">High / Low</div>
+        </div>
+      </div>
+
+      <ResponsiveContainer width="100%" height={200}>
+        <AreaChart data={readings} margin={{ top: 5, right: 10, left: -10, bottom: 0 }}>
+          <defs>
+            <linearGradient id="tempGradient" x1="0" y1="0" x2="0" y2="1">
+              <stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
+              <stop offset="95%" stopColor="#f59e0b" stopOpacity={0.02} />
+            </linearGradient>
+          </defs>
+          <CartesianGrid strokeDasharray="3 3" className="opacity-30" />
+          <XAxis
+            dataKey="time"
+            tick={{ fontSize: 10 }}
+            className="text-dim"
+            interval={tickInterval}
+          />
+          <YAxis
+            domain={[yMin, yMax]}
+            tick={{ fontSize: 10 }}
+            className="text-dim"
+            tickFormatter={(v: number) => `${v}°`}
+          />
+          <Tooltip
+            contentStyle={{
+              backgroundColor: 'var(--tooltip-bg, #fff)',
+              border: '1px solid var(--tooltip-border, #e5e7eb)',
+              borderRadius: '8px',
+              fontSize: '12px',
+            }}
+            formatter={(value: number) => [`${value.toFixed(1)}°C`, 'Temp']}
+            labelFormatter={(label: string) => `${label} HKT`}
+          />
+          <ReferenceLine y={maxTemp} stroke="#ef4444" strokeDasharray="3 3" strokeOpacity={0.5} />
+          <ReferenceLine y={minTemp} stroke="#3b82f6" strokeDasharray="3 3" strokeOpacity={0.5} />
+          <Area
+            type="monotone"
+            dataKey="temp"
+            stroke="#f59e0b"
+            strokeWidth={2}
+            fill="url(#tempGradient)"
+            dot={false}
+            activeDot={{ r: 4, fill: '#f59e0b', stroke: '#fff', strokeWidth: 2 }}
+          />
+        </AreaChart>
+      </ResponsiveContainer>
+    </div>
+  );
+}

+ 10 - 10
packages/dashboard/src/app/cities/hong-kong/components/HKOForecastChart.tsx

@@ -22,7 +22,7 @@ interface HKOForecastChartProps {
 export function HKOForecastChart({ forecast, currentTemp, loading }: HKOForecastChartProps) {
   if (loading) {
     return (
-      <div className="card p-6 animate-pulse">
+      <div className="card p-4 animate-pulse">
         <div className="h-4 w-48 bg-gray-200 dark:bg-slate-700 rounded mb-4" />
         <div className="h-64 bg-gray-200 dark:bg-slate-700 rounded" />
       </div>
@@ -31,8 +31,8 @@ export function HKOForecastChart({ forecast, currentTemp, loading }: HKOForecast
 
   if (!forecast || !forecast.forecast.length) {
     return (
-      <div className="card p-6">
-        <h3 className="text-sm font-medium text-sub mb-2">9-Day Forecast</h3>
+      <div className="card p-4">
+        <h3 className="text-xs font-medium text-sub mb-2">9-Day Forecast</h3>
         <p className="text-dim text-sm">Unable to fetch forecast data</p>
       </div>
     );
@@ -75,17 +75,17 @@ export function HKOForecastChart({ forecast, currentTemp, loading }: HKOForecast
   const yMax = Math.ceil(Math.max(...allTemps) + 2);
 
   return (
-    <div className="card p-6">
-      <div className="flex items-start justify-between mb-4">
+    <div className="card p-4">
+      <div className="flex items-start justify-between mb-2">
         <div>
-          <h3 className="text-sm font-medium text-heading">9-Day Temperature Forecast</h3>
-          <p className="text-xs text-dim mt-0.5">
+          <h3 className="text-xs font-medium text-heading">9-Day Temperature Forecast</h3>
+          <p className="text-[10px] text-dim mt-0.5">
             HKO forecast updated {updateTime} HKT
           </p>
         </div>
       </div>
 
-      <ResponsiveContainer width="100%" height={280}>
+      <ResponsiveContainer width="100%" height={170}>
         <LineChart data={chartData} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
           <CartesianGrid strokeDasharray="3 3" className="opacity-30" />
           <XAxis
@@ -147,11 +147,11 @@ export function HKOForecastChart({ forecast, currentTemp, loading }: HKOForecast
       </ResponsiveContainer>
 
       {/* Forecast cards row */}
-      <div className="mt-4 flex gap-2 overflow-x-auto pb-1">
+      <div className="mt-2 flex gap-1.5 overflow-x-auto pb-1">
         {chartData.map((day) => (
           <div
             key={day.rawDate}
-            className={`flex-shrink-0 rounded-lg px-3 py-2 text-center text-xs min-w-[72px] ${
+            className={`flex-shrink-0 rounded-lg px-2 py-1.5 text-center text-[11px] min-w-[60px] ${
               day.isToday
                 ? 'bg-blue-50 border border-blue-200 dark:bg-blue-500/10 dark:border-blue-500/20'
                 : 'bg-gray-50 dark:bg-slate-800/50'

+ 20 - 18
packages/dashboard/src/app/cities/hong-kong/components/HKOTempCard.tsx

@@ -41,32 +41,32 @@ export function HKOTempCard({ data, loading }: HKOTempCardProps) {
   }
 
   return (
-    <div className="card p-6">
+    <div className="card p-4">
       <div className="flex items-start justify-between mb-1">
-        <h3 className="text-sm font-medium text-sub">HKO Observatory</h3>
+        <h3 className="text-xs font-medium text-sub">HKO Observatory</h3>
         <a
           href="https://www.hko.gov.hk/en/wxinfo/currwx/current.htm"
           target="_blank"
           rel="noopener noreferrer"
-          className="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
+          className="text-[10px] text-blue-500 hover:text-blue-600 dark:text-blue-400"
         >
           hko.gov.hk &rarr;
         </a>
       </div>
 
-      <div className="flex items-end gap-3 mb-3">
-        <span className="text-5xl font-bold text-heading tabular-nums">
+      <div className="flex items-end gap-2">
+        <span className="text-3xl font-bold text-heading tabular-nums">
           {fmtTemp(data.current.temp)}
         </span>
-        <span className="text-2xl text-sub mb-1">°{data.current.unit}</span>
+        <span className="text-lg text-sub mb-0.5">°{data.current.unit}</span>
         {data.humidity && (
-          <span className="text-sm text-dim mb-2 ml-auto">
+          <span className="text-xs text-dim mb-1 ml-auto">
             💧 {data.humidity.value}%
           </span>
         )}
       </div>
 
-      <p className="text-xs text-dim">
+      <p className="text-[10px] text-dim mt-1">
         Updated {fmtTime(data.current.recordTime)} HKT
       </p>
     </div>
@@ -99,25 +99,27 @@ export function HKODailyHighCard({ data, loading }: HKOTempCardProps) {
   }
 
   return (
-    <div className="card p-6 h-full flex flex-col">
-      <h3 className="text-sm font-medium text-sub mb-1">Today&apos;s High</h3>
-      <div className="flex items-end gap-2 mb-1">
-        <span className="text-4xl font-bold tabular-nums text-red-500 dark:text-red-400">
-          {fmtTemp(data.dailyHigh.temp)}
-        </span>
-        <span className="text-xl text-sub mb-0.5">°{data.current.unit}</span>
+    <div className="flex flex-col justify-evenly card p-4 flex-1">
+      <div>
+        <h3 className="text-xs font-medium text-sub mb-1">Today&apos;s High</h3>
+        <div className="flex items-end gap-2">
+          <span className="text-2xl font-bold tabular-nums text-red-500 dark:text-red-400">
+            {fmtTemp(data.dailyHigh.temp)}
+          </span>
+          <span className="text-base text-sub mb-0.5">°{data.current.unit}</span>
+        </div>
       </div>
 
       {/* Weather conditions */}
       {data.weather && (
-        <div className="mt-auto pt-4 flex items-start gap-3">
+        <div className="mt-2 flex items-start gap-2">
           {/* eslint-disable-next-line @next/next/no-img-element */}
           <img
             src={hkoIconUrl(data.weather.icon)}
             alt="Weather icon"
-            className="w-14 h-14 flex-shrink-0"
+            className="w-10 h-10 flex-shrink-0"
           />
-          <p className="text-xs text-body leading-relaxed">
+          <p className="text-[11px] text-body leading-snug">
             {data.weather.forecastDesc}
           </p>
         </div>

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

@@ -5,7 +5,6 @@ import Image from 'next/image';
 import Link from 'next/link';
 import { usePathname } from 'next/navigation';
 import { ThemeToggle } from './ThemeToggle';
-import { Switch } from '@/components/ui/switch';
 import { Badge } from '@/components/ui/badge';
 
 interface NavItem {
@@ -71,7 +70,6 @@ const navItems: NavItem[] = [
 
 interface BotStatus {
   connected: boolean;
-  autoExecute: boolean;
   dryRun: boolean;
   isRunning: boolean;
 }
@@ -80,11 +78,9 @@ export function Sidebar() {
   const pathname = usePathname();
   const [botStatus, setBotStatus] = useState<BotStatus>({
     connected: false,
-    autoExecute: false,
     dryRun: true,
     isRunning: false,
   });
-  const [toggling, setToggling] = useState(false);
   const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
     // Auto-expand Cities if we're on a city page
     if (typeof window !== 'undefined') return new Set<string>();
@@ -128,24 +124,6 @@ export function Sidebar() {
     return () => clearInterval(interval);
   }, [fetchStatus]);
 
-  const toggleAutoExecute = async () => {
-    setToggling(true);
-    try {
-      const res = await fetch('/api/bot-control', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ autoExecute: !botStatus.autoExecute }),
-      });
-      if (res.ok) {
-        setBotStatus((prev) => ({ ...prev, autoExecute: !prev.autoExecute }));
-      }
-    } catch {
-      // ignore
-    } finally {
-      setToggling(false);
-    }
-  };
-
   return (
     <aside className="w-64 bg-white dark:bg-slate-950 border-r border-gray-200 dark:border-slate-800 flex flex-col">
       <div className="p-6 border-b border-gray-200 dark:border-slate-800">
@@ -257,25 +235,6 @@ export function Sidebar() {
           )}
         </div>
 
-        {/* Auto-Execute Toggle */}
-        {botStatus.connected && (
-          <div className="flex items-center justify-between">
-            <span className="text-xs text-gray-600 dark:text-slate-400 font-medium">
-              Auto-Execute
-            </span>
-            <Switch
-              checked={botStatus.autoExecute}
-              onCheckedChange={toggleAutoExecute}
-              disabled={toggling}
-            />
-          </div>
-        )}
-
-        {botStatus.connected && !botStatus.autoExecute && (
-          <p className="text-[10px] text-yellow-600 dark:text-yellow-400 leading-tight">
-            Signals detected but orders NOT auto-placed. Use Manual Order on Signals page.
-          </p>
-        )}
       </div>
     </aside>
   );

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

@@ -311,6 +311,29 @@ export async function getObservedDailyHigh(
   return result;
 }
 
+/**
+ * Get all observed temperature readings for a city on a given date.
+ * Returns readings sorted by time for plotting a daily curve.
+ */
+export async function getObservedDailyReadings(
+  city: string,
+  date: Date,
+): Promise<{ temp: number; recordTime: Date }[]> {
+  const startOfDay = new Date(date);
+  startOfDay.setHours(0, 0, 0, 0);
+  const endOfDay = new Date(date);
+  endOfDay.setHours(23, 59, 59, 999);
+
+  return prisma.observedTemperature.findMany({
+    where: {
+      city,
+      recordTime: { gte: startOfDay, lte: endOfDay },
+    },
+    orderBy: { recordTime: 'asc' },
+    select: { temp: true, recordTime: true },
+  });
+}
+
 /**
  * Persist an observed temperature reading (deduped by city + recordTime).
  * Fire-and-forget — callers should not await this.

+ 10 - 11
packages/trading-engine/src/bot.ts

@@ -84,14 +84,13 @@ export class WeatherArbitrageBot {
       orderManager,
       this.config.cities,
       this.config.dryRun,
-      this.config.autoExecute,
     );
 
     // Start health check server
-    this.healthCheck = new HealthCheckServer(this.config.dryRun, this.config.autoExecute);
-    this.healthCheck.setAutoExecuteCallback((enabled) => {
-      this.setAutoExecute(enabled);
-      this.healthCheck.setAutoExecute(enabled);
+    this.healthCheck = new HealthCheckServer(this.config.dryRun);
+    this.healthCheck.setAutoExecuteCityCallback((city, enabled) => {
+      this.pipeline.setAutoExecuteCity(city, enabled);
+      this.healthCheck.setAutoExecuteCities(this.pipeline.autoExecuteCities);
     });
     this.healthCheck.start(this.config.healthPort);
 
@@ -193,16 +192,16 @@ export class WeatherArbitrageBot {
     }
   }
 
-  /** Toggle auto-execute at runtime. */
-  setAutoExecute(enabled: boolean): void {
+  /** Toggle auto-execute for a specific city at runtime. */
+  setAutoExecuteCity(city: string, enabled: boolean): void {
     if (this.pipeline) {
-      this.pipeline.autoExecute = enabled;
+      this.pipeline.setAutoExecuteCity(city, enabled);
     }
   }
 
-  /** Check if auto-execute is enabled. */
-  get autoExecute(): boolean {
-    return this.pipeline?.autoExecute ?? this.config.autoExecute;
+  /** Get cities with auto-execute enabled. */
+  get autoExecuteCities(): string[] {
+    return this.pipeline?.autoExecuteCities ?? [];
   }
 
   /**

+ 25 - 19
packages/trading-engine/src/health-check.ts

@@ -21,16 +21,16 @@ export interface BotMetrics {
   dailyPnl: number;
   isRunning: boolean;
   dryRun: boolean;
-  autoExecute: boolean;
+  autoExecuteCities: string[];  // city slugs with auto-execute enabled
 }
 
 export class HealthCheckServer {
   private server: Server | null = null;
   private startTime = Date.now();
   private metrics: BotMetrics;
-  private onAutoExecuteToggle?: (enabled: boolean) => void;
+  private onAutoExecuteCityToggle?: (city: string, enabled: boolean) => void;
 
-  constructor(dryRun: boolean = true, autoExecute: boolean = true) {
+  constructor(dryRun: boolean = true) {
     this.metrics = {
       uptime: 0,
       lastCycleTime: null,
@@ -39,18 +39,18 @@ export class HealthCheckServer {
       dailyPnl: 0,
       isRunning: false,
       dryRun,
-      autoExecute,
+      autoExecuteCities: [],
     };
   }
 
-  /** Register a callback to toggle auto-execute from HTTP endpoint. */
-  setAutoExecuteCallback(cb: (enabled: boolean) => void): void {
-    this.onAutoExecuteToggle = cb;
+  /** Register a callback to toggle auto-execute per city from HTTP endpoint. */
+  setAutoExecuteCityCallback(cb: (city: string, enabled: boolean) => void): void {
+    this.onAutoExecuteCityToggle = cb;
   }
 
-  /** Update the auto-execute state in metrics. */
-  setAutoExecute(enabled: boolean): void {
-    this.metrics.autoExecute = enabled;
+  /** Update the auto-execute cities in metrics. */
+  setAutoExecuteCities(cities: string[]): void {
+    this.metrics.autoExecuteCities = cities;
   }
 
   /**
@@ -80,18 +80,24 @@ export class HealthCheckServer {
         req.on('data', (chunk) => { body += chunk; });
         req.on('end', () => {
           try {
-            const { enabled } = JSON.parse(body);
-            if (typeof enabled === 'boolean') {
-              this.metrics.autoExecute = enabled;
-              if (this.onAutoExecuteToggle) {
-                this.onAutoExecuteToggle(enabled);
+            const { city, enabled } = JSON.parse(body);
+            if (typeof city === 'string' && typeof enabled === 'boolean') {
+              if (enabled) {
+                if (!this.metrics.autoExecuteCities.includes(city)) {
+                  this.metrics.autoExecuteCities.push(city);
+                }
+              } else {
+                this.metrics.autoExecuteCities = this.metrics.autoExecuteCities.filter((c) => c !== city);
               }
-              logger.info({ autoExecute: enabled }, 'Auto-execute toggled via API');
+              if (this.onAutoExecuteCityToggle) {
+                this.onAutoExecuteCityToggle(city, enabled);
+              }
+              logger.info({ city, autoExecute: enabled }, 'Auto-execute toggled for city via API');
               res.writeHead(200, { 'Content-Type': 'application/json' });
-              res.end(JSON.stringify({ autoExecute: enabled }));
+              res.end(JSON.stringify({ autoExecuteCities: this.metrics.autoExecuteCities }));
             } else {
               res.writeHead(400, { 'Content-Type': 'application/json' });
-              res.end(JSON.stringify({ error: 'Missing boolean "enabled" field' }));
+              res.end(JSON.stringify({ error: 'Missing "city" (string) and "enabled" (boolean) fields' }));
             }
           } catch {
             res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -101,7 +107,7 @@ export class HealthCheckServer {
         return;
       } else if (req.url === '/auto-execute' && req.method === 'GET') {
         res.writeHead(200, { 'Content-Type': 'application/json' });
-        res.end(JSON.stringify({ autoExecute: this.metrics.autoExecute }));
+        res.end(JSON.stringify({ autoExecuteCities: this.metrics.autoExecuteCities }));
       } else {
         res.writeHead(404);
         res.end('Not Found');

+ 16 - 11
packages/trading-engine/src/pipeline.ts

@@ -52,19 +52,18 @@ export class TradingPipeline {
   private clobReader: CLOBReadClient;
   private cities: CityConfig[];
   private dryRun: boolean;
-  private _autoExecute: boolean;
+  private _autoExecuteCities: Set<string>;
 
   constructor(
     orderManager: OrderManager,
     cities: CityConfig[],
     dryRun: boolean = true,
-    autoExecute: boolean = true,
   ) {
     this.orderManager = orderManager;
     this.clobReader = new CLOBReadClient();
     this.cities = cities;
     this.dryRun = dryRun;
-    this._autoExecute = autoExecute;
+    this._autoExecuteCities = new Set();
 
     this.strategies = [
       new TailNOStrategy(),
@@ -76,13 +75,19 @@ export class TradingPipeline {
     this.riskManager = new RiskManager();
   }
 
-  /** Toggle auto-execution at runtime. */
-  get autoExecute(): boolean { return this._autoExecute; }
-  set autoExecute(val: boolean) {
-    logger.info({ autoExecute: val }, 'Auto-execute toggled');
-    this._autoExecute = val;
+  /** Toggle auto-execution for a specific city. */
+  setAutoExecuteCity(city: string, enabled: boolean): void {
+    if (enabled) {
+      this._autoExecuteCities.add(city);
+    } else {
+      this._autoExecuteCities.delete(city);
+    }
+    logger.info({ city, autoExecute: enabled, activeCities: [...this._autoExecuteCities] }, 'Auto-execute toggled for city');
   }
 
+  /** Get list of cities with auto-execute enabled. */
+  get autoExecuteCities(): string[] { return [...this._autoExecuteCities]; }
+
   /**
    * Run a full evaluation cycle.
    */
@@ -238,11 +243,11 @@ export class TradingPipeline {
       // Persist signal to DB first
       const signalId = await persistSignal(signal);
 
-      // Skip order execution if auto-execute is disabled
-      if (!this._autoExecute) {
+      // Only auto-execute OBSERVED_NO signals for enabled cities
+      if (!this._autoExecuteCities.has(signal.city) || signal.type !== 'OBSERVED_NO') {
         logger.info(
           { type: signal.type, city: signal.city },
-          'Signal detected (auto-execute OFF — skipping order)',
+          `Signal detected (${!this._autoExecuteCities.has(signal.city) ? 'auto-execute OFF for city' : 'only OBSERVED_NO auto-executes'} — skipping order)`,
         );
         continue;
       }