diff options
author | Navan Chauhan <navanchauhan@gmail.com> | 2025-04-27 20:44:14 -0600 |
---|---|---|
committer | Navan Chauhan <navanchauhan@gmail.com> | 2025-04-27 20:44:14 -0600 |
commit | 2bc0555caa595967a28593aec2abd52500c5ddf9 (patch) | |
tree | f64875ade75adb690e93bd5b5d47e25d9b0df46a | |
parent | ca784c19f48814094c91a2c1a5652c20ca381c13 (diff) |
switch markets for market data
-rw-r--r-- | client/src/App.js | 71 | ||||
-rw-r--r-- | client/src/MarketDataPage.jsx | 141 | ||||
-rw-r--r-- | server/api/market.py | 73 |
3 files changed, 183 insertions, 102 deletions
diff --git a/client/src/App.js b/client/src/App.js index 4e08a17..a83b628 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -1,10 +1,9 @@ -import React from 'react'; +import React, { useState, createContext } from 'react'; import { Routes, Route, Navigate, Link} from 'react-router-dom'; import MarketDataPage from './MarketDataPage'; import BidsPage from './BidsPage'; import SubmitBidPage from './SubmitBidPage'; -import { Menu } from '@arco-design/web-react'; -import { PageHeader, Typography } from '@arco-design/web-react'; +import { Menu, Select, PageHeader, Typography } from '@arco-design/web-react'; import '@arco-design/web-react/dist/css/arco.css'; const ghostBgStyle = { @@ -13,13 +12,29 @@ const ghostBgStyle = { padding: 20, }; +export const MarketContext = createContext(); +export const MARKET_FULL_NAMES = { + CAISO: 'California ISO', + ERCOT: 'Electric Reliability Council of Texas', + ISONE: 'ISO New England', + MISO: 'Midcontinent ISO', + NYISO: 'New York ISO', + PJM: 'PJM Interconnection', +}; + function App() { + const [selectedMarket, setSelectedMarket] = useState('ISONE'); return ( + <MarketContext.Provider value={{ selectedMarket, setSelectedMarket }}> <div> <style> {` @media (max-width: 600px) { - .page-header-title { + .page-header-container { + flex-direction: column; + align-items: center; + } + .page-header-title { font-size: 20px !important; text-align: center; } @@ -27,22 +42,45 @@ function App() { font-size: 14px !important; text-align: center; } + .market-select-container { + width: 100%; + display: flex; + justify-content: center; + margin-top: 10px; + } + .market-select { + width: 100% !important; + text-align: center; + } } `} </style> <div style={ghostBgStyle}> - <PageHeader - title={ - <Typography.Title heading={3} className="page-header-title"> - PolyEnergy - </Typography.Title> - } - subTitle={ - <Typography.Title type="secondary" heading={4} className="page-header-subtitle"> - Virtual Energy Trading - </Typography.Title> - } - /> + <div className="page-header-container" style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap' }}> + <div> + <PageHeader + title={ + <Typography.Title heading={3} className="page-header-title"> + PolyEnergy + </Typography.Title> + } + subTitle={ + <Typography.Title type="secondary" heading={4} className="page-header-subtitle"> + Virtual Energy Trading + </Typography.Title> + } + /> + </div> + <div className="market-select-container"> + <Select + className="market-select" + value={selectedMarket} + onChange={setSelectedMarket} + style={{ width: 200 }} + options={['ISONE', 'MISO', 'NYISO'].map(market => ({ label: market, value: market }))} + /> + </div> + </div> <Menu mode="horizontal" defaultSelectedKeys={['market-data']}> <Menu.Item key="market-data"> <Link to="/market-data">Market Data</Link> @@ -63,6 +101,7 @@ function App() { <Route path="/bids" element={<BidsPage />} /> </Routes> </div> + </MarketContext.Provider> ); } diff --git a/client/src/MarketDataPage.jsx b/client/src/MarketDataPage.jsx index 3ffa099..76d8284 100644 --- a/client/src/MarketDataPage.jsx +++ b/client/src/MarketDataPage.jsx @@ -1,73 +1,99 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useContext } from 'react'; import { Typography, Spin, Message, Card, Grid } from '@arco-design/web-react'; import '@arco-design/web-react/dist/css/arco.css'; import { initVChartArcoTheme } from '@visactor/vchart-arco-theme'; import { VChart } from '@visactor/react-vchart'; import API_BASE_URL from './config'; +import { MarketContext, MARKET_FULL_NAMES } from './App'; + const { Row, Col } = Grid; -function MarketDataPage() { - const [fiveMinData, setFiveMinData] = useState([]); - const [dayAheadData, setDayAheadData] = useState([]); - const [loading, setLoading] = useState(true); - const [fiveMinLastUpdated, setFiveMinLastUpdated] = useState(null); - const [dayAheadLastUpdated, setDayAheadLastUpdated] = useState(null); +function TimeAgo({ timestamp, prefix }) { const [now, setNow] = useState(new Date()); useEffect(() => { - const fetchFiveMinData = async () => { - try { - const res = await fetch(`${API_BASE_URL}/market/real-time`); - if (!res.ok) throw new Error('Failed to fetch real-time data'); - const json = await res.json(); - setFiveMinData(json); - setFiveMinLastUpdated(new Date()); - } catch (err) { - console.error(err); - Message.error('Failed to load real-time market data.'); - } - }; + const timer = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(timer); + }, []); - const fetchDayAheadData = async () => { - try { - const res = await fetch(`${API_BASE_URL}/market/day-ahead`); - if (!res.ok) throw new Error('Failed to fetch day-ahead data'); - const json = await res.json(); - setDayAheadData(json); - setDayAheadLastUpdated(new Date()); - } catch (err) { - console.error(err); - Message.error('Failed to load day-ahead market data.'); - } - }; + const formatTimeAgo = () => { + if (!timestamp) return ''; + const seconds = Math.floor((now - timestamp) / 1000); + if (seconds < 60) return `${seconds} sec ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + return `${hours} hr ago`; + }; - const initialize = async () => { - setLoading(true); - await Promise.all([fetchFiveMinData(), fetchDayAheadData()]); - setLoading(false); + return ( + <Typography.Text type="secondary" style={{ display: 'block' }}> + {prefix}: {formatTimeAgo()} + </Typography.Text> + ); +} - initVChartArcoTheme({ - defaultMode: 'light', - isWatchingMode: true - }); - }; - initialize(); +function MarketDataPage() { + const { selectedMarket } = useContext(MarketContext); + const [fiveMinData, setFiveMinData] = useState([]); + const [dayAheadData, setDayAheadData] = useState([]); + const [loading, setLoading] = useState(true); + const [fiveMinLastUpdated, setFiveMinLastUpdated] = useState(null); + const [dayAheadLastUpdated, setDayAheadLastUpdated] = useState(null); + const [now, setNow] = useState(new Date()); + + useEffect(() => { + const fetchFiveMinData = async () => { + try { + const res = await fetch(`${API_BASE_URL}/market/real-time?market=${selectedMarket}`); + if (!res.ok) throw new Error('Failed to fetch real-time data'); + const json = await res.json(); + setFiveMinData(json); + setFiveMinLastUpdated(new Date()); + } catch (err) { + console.error(err); + Message.error('Failed to load real-time market data.'); + } + }; + + const fetchDayAheadData = async () => { + try { + const res = await fetch(`${API_BASE_URL}/market/day-ahead?market=${selectedMarket}`); + if (!res.ok) throw new Error('Failed to fetch day-ahead data'); + const json = await res.json(); + setDayAheadData(json); + setDayAheadLastUpdated(new Date()); + } catch (err) { + console.error(err); + Message.error('Failed to load day-ahead market data.'); + } + }; - const fiveMinInterval = setInterval(fetchFiveMinData, 5 * 60 * 1000); // 5 minutes - const dayAheadInterval = setInterval(fetchDayAheadData, 60 * 60 * 1000); // 1 hour + const initialize = async () => { + setLoading(true); + await Promise.all([fetchFiveMinData(), fetchDayAheadData()]); + setLoading(false); - const timer = setInterval(() => { - setNow(new Date()); - }, 1000); + initVChartArcoTheme({ + defaultMode: 'light', + isWatchingMode: true + }); + }; - return () => { - clearInterval(fiveMinInterval); - clearInterval(dayAheadInterval); - clearInterval(timer); - }; - }, []); + initialize(); + + const fiveMinInterval = setInterval(fetchFiveMinData, 5 * 60 * 1000); // 5 minutes + const dayAheadInterval = setInterval(fetchDayAheadData, 60 * 60 * 1000); // 1 hour + const timer = setInterval(() => setNow(new Date()), 1000); + + return () => { + clearInterval(fiveMinInterval); + clearInterval(dayAheadInterval); + clearInterval(timer); + }; + }, [selectedMarket]); const getChartSpec = (data, title) => ({ type: 'line', @@ -151,18 +177,15 @@ function MarketDataPage() { return ( <div style={{ padding: 20, backgroundColor: 'var(--color-fill-2)' }}> - <Typography.Title heading={4}>Market Data Visualization</Typography.Title> - <Typography.Text type="secondary" style={{ marginTop: 10, display: 'block' }}> - Real-Time Data Last Updated: {formatTimeAgo(fiveMinLastUpdated)} - </Typography.Text> - <Typography.Text type="secondary" style={{ marginBottom: 20, display: 'block' }}> - Day-Ahead Data Last Updated: {formatTimeAgo(dayAheadLastUpdated)} - </Typography.Text> + <Typography.Title heading={4}>Market Data for {MARKET_FULL_NAMES[selectedMarket]}</Typography.Title> + {loading ? ( <Spin /> ) : ( <> + <TimeAgo timestamp={fiveMinLastUpdated} prefix="Real-Time Data Last Updated" /> + <TimeAgo timestamp={dayAheadLastUpdated} prefix="Day-Ahead Data Last Updated" /> <Row gutter={24} style={{ marginTop: 20, marginBottom: 20 }}> <Col xs={12} md={6} style={{ marginTop: 10}}> <Card style={{ textAlign: 'center' }} title="Avg Real-Time LMP ($/MWh)"> diff --git a/server/api/market.py b/server/api/market.py index 5102f03..a1f25ec 100644 --- a/server/api/market.py +++ b/server/api/market.py @@ -1,30 +1,45 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Query, HTTPException from models.market import MarketData -from gridstatus import ISONE +from gridstatus import ISONE, CAISO, Ercot, MISO, NYISO, PJM from datetime import datetime, timedelta -from typing import List +from typing import List, Dict, Type router = APIRouter() -# Keeping the scope of this api to just one market right now -iso = ISONE() +MARKET_CLASSES: Dict[str, Type] = { + "ISONE": ISONE, + # "CAISO": CAISO, + # "ERCOT": Ercot, + "MISO": MISO, + "NYISO": NYISO, + # "PJM": PJM, +} # In-memory cache -_cached_day_ahead: List[MarketData] = [] -_cache_timestamp: datetime | None = None -_cached_real_time: List[MarketData] = [] -_cache_real_time_timestamp: datetime | None = None +_cached_day_ahead: Dict[str, List[MarketData]] = {} +_cached_day_ahead_timestamp: Dict[str, datetime] = {} +_cached_real_time: Dict[str, List[MarketData]] = {} +_cached_real_time_timestamp: Dict[str, datetime] = {} -# TODO: Error Handling -@router.get("/day-ahead", response_model=list[MarketData]) -def get_day_ahead_data(): - global _cached_day_ahead, _cache_timestamp +def get_iso_instance(market: str): + market = market.upper() + if market not in MARKET_CLASSES: + raise HTTPException(status_code=400, detail=f"Unsupported market '{market}'. Supported: {list(MARKET_CLASSES.keys())}") + return MARKET_CLASSES[market]() +@router.get("/day-ahead", response_model=List[MarketData]) +def get_day_ahead_data(market: str = Query("ISONE")): now = datetime.utcnow() - if _cache_timestamp is None or now - _cache_timestamp > timedelta(hours=1): + market = market.upper() + + if (market not in _cached_day_ahead_timestamp or + now - _cached_day_ahead_timestamp[market] > timedelta(hours=1)): + + iso = get_iso_instance(market) df = iso.get_lmp(date=datetime.now().date(), market="DAY_AHEAD_HOURLY", locations="ALL") - grouped = (df.groupby("Interval Start")[["LMP", "Energy", "Congestion", "Loss"]].mean().reset_index()) - _cached_day_ahead = [ + grouped = df.groupby("Interval Start")[["LMP", "Energy", "Congestion", "Loss"]].mean().reset_index() + + _cached_day_ahead[market] = [ MarketData( timestamp=row["Interval Start"], lmp=row["LMP"], @@ -34,19 +49,23 @@ def get_day_ahead_data(): ) for _, row in grouped.iterrows() ] - _cache_timestamp = now - - return _cached_day_ahead + _cached_day_ahead_timestamp[market] = now -@router.get("/real-time", response_model=list[MarketData]) -def get_real_time_data(): - global _cached_real_time, _cache_real_time_timestamp + return _cached_day_ahead[market] +@router.get("/real-time", response_model=List[MarketData]) +def get_real_time_data(market: str = Query("ISONE")): now = datetime.utcnow() - if _cache_real_time_timestamp is None or now - _cache_real_time_timestamp > timedelta(minutes=5): + market = market.upper() + + if (market not in _cached_real_time_timestamp or + now - _cached_real_time_timestamp[market] > timedelta(minutes=5)): + + iso = get_iso_instance(market) df = iso.get_lmp(date="today", market="REAL_TIME_5_MIN", locations="ALL") - grouped = (df.groupby("Interval Start")[["LMP", "Energy", "Congestion", "Loss"]].mean().reset_index()) - _cached_real_time = [ + grouped = df.groupby("Interval Start")[["LMP", "Energy", "Congestion", "Loss"]].mean().reset_index() + + _cached_real_time[market] = [ MarketData( timestamp=row["Interval Start"], lmp=row["LMP"], @@ -56,6 +75,6 @@ def get_real_time_data(): ) for _, row in grouped.iterrows() ] - _cache_real_time_timestamp = now + _cached_real_time_timestamp[market] = now - return _cached_real_time + return _cached_real_time[market] |