diff --git a/backend/app/config.py b/backend/app/config.py index 73f41ed..cb30889 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -59,12 +59,14 @@ def get_base_model_configs() -> List[Tuple[str, str]]: "trading_cycle": "daily_before_close", "realtime_prices": 600, # Stock prices: 10 minutes "polymarket_prices": 1800, # Polymarket prices: 30 minutes by default + "forex_prices": 900, # Forex prices: 15 minutes } TRADING_CONFIG = { "initial_cash_stock": 1000, "initial_cash_polymarket": 500, "initial_cash_bitmex": 1000, + "initial_cash_forex": 1000, "max_consecutive_failures": 3, "recovery_wait_time": 3600, "error_retry_time": 600, @@ -169,3 +171,4 @@ class MockMode(str, Enum): STOCK_MOCK_MODE = MockMode.NONE POLYMARKET_MOCK_MODE = MockMode.NONE BITMEX_MOCK_MODE = MockMode.NONE +FOREX_MOCK_MODE = MockMode.NONE diff --git a/backend/app/main.py b/backend/app/main.py index 7172f90..a9310ea 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,7 @@ ) from live_trade_bench.systems import ( BitMEXPortfolioSystem, + ForexPortfolioSystem, PolymarketPortfolioSystem, StockPortfolioSystem, ) @@ -31,6 +32,7 @@ from .config import ( ALLOWED_ORIGINS, BITMEX_MOCK_MODE, + FOREX_MOCK_MODE, MODELS_DATA_FILE, MODELS_DATA_HIST_FILE, MODELS_DATA_INIT_FILE, @@ -46,6 +48,7 @@ from .price_data import ( get_next_price_update_time, update_bitmex_prices_and_values, + update_forex_prices_and_values, update_polymarket_prices_and_values, update_stock_prices_and_values, ) @@ -59,6 +62,7 @@ stock_system = None polymarket_system = None bitmex_system = None +forex_system = None # Background scheduler instance; assigned during startup to keep reference alive scheduler = None @@ -81,10 +85,15 @@ MockMode.NONE: BitMEXPortfolioSystem, } +FOREX_SYSTEMS = { + MockMode.NONE: ForexPortfolioSystem, +} + # Initialize systems immediately when module loads stock_system = STOCK_SYSTEMS[STOCK_MOCK_MODE].get_instance() polymarket_system = POLYMARKET_SYSTEMS[POLYMARKET_MOCK_MODE].get_instance() bitmex_system = BITMEX_SYSTEMS[BITMEX_MOCK_MODE].get_instance() +forex_system = FOREX_SYSTEMS[FOREX_MOCK_MODE].get_instance() # Add agents for real systems if STOCK_MOCK_MODE == MockMode.NONE: @@ -99,14 +108,21 @@ for display_name, model_id in get_base_model_configs(): bitmex_system.add_agent(display_name, 1000.0, model_id) +# Add Forex agents (paper trading with $1,000 each) +for display_name, model_id in get_base_model_configs(): + forex_system.add_agent(display_name, 1000.0, model_id) + # ๐Ÿ†• ๅŠ ่ฝฝๅކๅฒๆ•ฐๆฎๅˆฐAccountๅ†…ๅญ˜ไธญ print("๐Ÿ”„ Loading historical data to account memory...") -load_historical_data_to_accounts(stock_system, polymarket_system, bitmex_system) +load_historical_data_to_accounts( + stock_system, polymarket_system, bitmex_system, forex_system +) print("โœ… Historical data loading completed") stock_system.initialize_for_live() polymarket_system.initialize_for_live() bitmex_system.initialize_for_live() +forex_system.initialize_for_live() def get_stock_system(): @@ -127,6 +143,11 @@ def get_bitmex_system(): return bitmex_system +def get_forex_system(): + """Get the forex system instance.""" + global forex_system + return forex_system + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -178,10 +199,12 @@ def get_next_price_update(): """Expose the next scheduled realtime price update.""" stock_time = get_next_price_update_time("stock") poly_time = get_next_price_update_time("polymarket") + forex_time = get_next_price_update_time("forex") response = { "stock": stock_time.isoformat() if stock_time else None, "polymarket": poly_time.isoformat() if poly_time else None, + "forex": forex_time.isoformat() if forex_time else None, } # Backward compatibility for older clients expecting single field @@ -222,7 +245,7 @@ def load_backtest_as_initial_data(): def safe_generate_models_data(): if should_run_trading_cycle(): logger.info("๐Ÿ• Running trading cycle at market close time...") - generate_models_data(stock_system, polymarket_system, bitmex_system) + generate_models_data(stock_system, polymarket_system, bitmex_system, forex_system) else: logger.info("โฐ Skipping trading cycle - not in market time window") @@ -301,6 +324,17 @@ def schedule_background_tasks(scheduler: BackgroundScheduler): id="update_bitmex_prices", replace_existing=True, ) + forex_interval = UPDATE_FREQUENCY["forex_prices"] + logger.info( + f"๐Ÿ’ฑ Scheduled forex price update job for every {forex_interval} seconds ({forex_interval//60} minutes)" + ) + scheduler.add_job( + update_forex_prices_and_values, + "interval", + seconds=forex_interval, + id="update_forex_prices", + replace_existing=True, + ) scheduler.add_job( update_news_data, "interval", diff --git a/backend/app/models_data.py b/backend/app/models_data.py index fc1dd51..fd289da 100644 --- a/backend/app/models_data.py +++ b/backend/app/models_data.py @@ -123,7 +123,9 @@ def _serialize_positions(model_data): return model_data -def load_historical_data_to_accounts(stock_system, polymarket_system, bitmex_system=None): +def load_historical_data_to_accounts( + stock_system, polymarket_system, bitmex_system=None, forex_system=None +): """Load historical data to account memory on every startup. This function ALWAYS loads data to restore account state, regardless of whether @@ -165,6 +167,8 @@ def load_historical_data_to_accounts(stock_system, polymarket_system, bitmex_sys system = polymarket_system elif category == "bitmex" and bitmex_system is not None: system = bitmex_system + elif category == "forex" and forex_system is not None: + system = forex_system else: continue @@ -207,7 +211,9 @@ def restore_account_from_historical_data(account, historical_model_data): account.total_fees = historical_model_data.get("total_fees", 0.0) -def generate_models_data(stock_system, polymarket_system, bitmex_system=None) -> None: +def generate_models_data( + stock_system, polymarket_system, bitmex_system=None, forex_system=None +) -> None: """Generate and save model data for all systems""" try: print("๐Ÿš€ Starting data generation for all markets...") @@ -218,6 +224,8 @@ def generate_models_data(stock_system, polymarket_system, bitmex_system=None) -> systems = {"stock": stock_system, "polymarket": polymarket_system} if bitmex_system is not None: systems["bitmex"] = bitmex_system + if forex_system is not None: + systems["forex"] = forex_system for market_type, system in systems.items(): print(f"--- Processing {market_type.upper()} market ---") diff --git a/backend/app/news_data.py b/backend/app/news_data.py index bce370d..ab273ae 100644 --- a/backend/app/news_data.py +++ b/backend/app/news_data.py @@ -6,16 +6,22 @@ def update_news_data() -> None: print("๐Ÿ“ฐ Updating news data...") - all_news_data = {"stock": [], "polymarket": [], "bitmex": []} + all_news_data = {"stock": [], "polymarket": [], "bitmex": [], "forex": []} try: - from .main import get_bitmex_system, get_polymarket_system, get_stock_system + from .main import ( + get_bitmex_system, + get_forex_system, + get_polymarket_system, + get_stock_system, + ) stock_system = get_stock_system() polymarket_system = get_polymarket_system() bitmex_system = get_bitmex_system() + forex_system = get_forex_system() - if not stock_system or not polymarket_system or not bitmex_system: + if not stock_system or not polymarket_system or not bitmex_system or not forex_system: print("โŒ Failed to get system instances") return @@ -26,11 +32,14 @@ def update_news_data() -> None: polymarket_system.initialize_for_live() if not bitmex_system.universe: bitmex_system.initialize_for_live() + if not forex_system.universe: + forex_system.initialize_for_live() # Fetch market data stock_market_data = stock_system._fetch_market_data(for_date=None) polymarket_market_data = polymarket_system._fetch_market_data(for_date=None) bitmex_market_data = bitmex_system._fetch_market_data(for_date=None) + forex_market_data = forex_system._fetch_market_data(for_date=None) # Fetch news data stock_news = stock_system._fetch_news_data(stock_market_data, for_date=None) @@ -38,6 +47,7 @@ def update_news_data() -> None: polymarket_market_data, for_date=None ) bitmex_news = bitmex_system._fetch_news_data(bitmex_market_data, for_date=None) + forex_news = forex_system._fetch_news_data(forex_market_data, for_date=None) all_news_data["stock"] = [ item for sublist in stock_news.values() for item in sublist @@ -48,6 +58,9 @@ def update_news_data() -> None: all_news_data["bitmex"] = [ item for sublist in bitmex_news.values() for item in sublist ] + all_news_data["forex"] = [ + item for sublist in forex_news.values() for item in sublist + ] with open(NEWS_DATA_FILE, "w") as f: json.dump(all_news_data, f, indent=4) diff --git a/backend/app/price_data.py b/backend/app/price_data.py index 8e9dfb5..f3df060 100644 --- a/backend/app/price_data.py +++ b/backend/app/price_data.py @@ -22,6 +22,7 @@ _NEXT_UPDATE_TIMES: Dict[str, Optional[datetime]] = { "stock": None, "polymarket": None, + "forex": None, } @@ -47,6 +48,9 @@ def _compute_next_price_update_time(market: str, now_utc: datetime) -> datetime: if market == "polymarket": interval = timedelta(seconds=UPDATE_FREQUENCY["polymarket_prices"]) return (now_utc + interval).astimezone(pytz.UTC) + if market == "forex": + interval = timedelta(seconds=UPDATE_FREQUENCY["forex_prices"]) + return (now_utc + interval).astimezone(pytz.UTC) # Default to stock market schedule tz = pytz.timezone("US/Eastern") @@ -521,6 +525,115 @@ def _update_single_model( return False +class ForexPriceUpdater: + def __init__(self) -> None: + self.initial_cash = TRADING_CONFIG["initial_cash_forex"] + + def update_realtime_prices_and_values(self) -> None: + try: + models_data = _load_models_data() + if not models_data: + logger.warning("โš ๏ธ No models data found, skipping forex update") + return + + price_cache = self._build_price_cache() + if not price_cache: + logger.warning("โš ๏ธ No forex price data available, skipping update") + return + + updated_count = 0 + for model in models_data: + if model.get("category") != "forex": + continue + if self._update_single_model(model, price_cache): + updated_count += 1 + + _save_models_data(models_data) + logger.info(f"โœ… Successfully updated {updated_count} forex models") + except Exception as exc: + logger.error(f"โŒ Failed to update forex prices: {exc}") + raise + finally: + next_time = _compute_next_price_update_time("forex", datetime.now(pytz.UTC)) + _set_next_price_update_time("forex", next_time) + logger.info(f"๐Ÿ•’ Next forex price update target: {next_time.isoformat()}") + + def _build_price_cache(self) -> Dict[str, float]: + system = self._get_forex_system() + if system is None: + return {} + + try: + market_data = system._fetch_market_data() + except Exception as exc: + logger.error(f"โŒ Failed to fetch forex market data: {exc}") + return {} + + price_cache: Dict[str, float] = {} + for pair, payload in market_data.items(): + price = payload.get("current_price") + if price is None: + continue + try: + price_cache[pair] = float(price) + except (TypeError, ValueError): + continue + return price_cache + + def _get_forex_system(self): + try: + from .main import get_forex_system + + system = get_forex_system() + if system is not None: + return system + except Exception: + pass + + try: + from live_trade_bench.systems import ForexPortfolioSystem + + return ForexPortfolioSystem.get_instance() + except Exception as exc: + logger.error(f"โŒ Unable to access forex system: {exc}") + return None + + def _update_single_model( + self, model: Dict[str, Any], price_cache: Dict[str, float] + ) -> bool: + try: + portfolio = model.get("portfolio", {}) + positions = portfolio.get("positions", {}) or {} + cash = float(portfolio.get("cash", 0.0)) + total_value = cash + + for pair, position in positions.items(): + price = price_cache.get(pair) + if price is not None: + position["current_price"] = price + else: + price = float(position.get("current_price", 0.0)) + + quantity = float(position.get("quantity", 0.0)) + total_value += quantity * price + + portfolio["total_value"] = total_value + + profit = total_value - self.initial_cash + model["profit"] = profit + model["performance"] = ( + (profit / self.initial_cash) * 100 if self.initial_cash else 0.0 + ) + + _update_profit_history(model, total_value, profit) + return True + except Exception as exc: + logger.error( + f"โŒ Failed to update forex model {model.get('name', 'Unknown')}: {exc}" + ) + return False + + class BitMEXPriceUpdater: """Price updater for BitMEX perpetual contracts.""" @@ -768,6 +881,7 @@ def _create_crypto_benchmark( stock_price_updater = RealtimePriceUpdater() polymarket_price_updater = PolymarketPriceUpdater() bitmex_price_updater = BitMEXPriceUpdater() +forex_price_updater = ForexPriceUpdater() def update_stock_prices_and_values() -> None: @@ -783,6 +897,10 @@ def update_bitmex_prices_and_values() -> None: bitmex_price_updater.update_realtime_prices_and_values() +def update_forex_prices_and_values() -> None: + forex_price_updater.update_realtime_prices_and_values() + + def update_realtime_prices_and_values() -> None: """Backward-compatible alias for stock price updates.""" update_stock_prices_and_values() diff --git a/backend/app/routers/news.py b/backend/app/routers/news.py index 23c045e..79f30c5 100644 --- a/backend/app/routers/news.py +++ b/backend/app/routers/news.py @@ -10,7 +10,7 @@ @router.get("/news/{market_type}", response_model=List[Dict[str, Any]]) def get_news(market_type: str, limit: int = 100): - if market_type not in ["stock", "polymarket", "bitmex"]: + if market_type not in ["stock", "polymarket", "bitmex", "forex"]: raise HTTPException(status_code=404, detail="Market type not found") data = read_json_or_404(NEWS_DATA_FILE) diff --git a/backend/app/routers/social.py b/backend/app/routers/social.py index 257b927..d7144d4 100644 --- a/backend/app/routers/social.py +++ b/backend/app/routers/social.py @@ -10,7 +10,7 @@ @router.get("/social/{market_type}", response_model=List[Dict[str, Any]]) def get_social_feed(market_type: str, limit: int = 100): - if market_type not in ["stock", "polymarket", "bitmex"]: + if market_type not in ["stock", "polymarket", "bitmex", "forex"]: raise HTTPException(status_code=404, detail="Market type not found") data = read_json_or_404(SOCIAL_DATA_FILE) diff --git a/backend/app/social_data.py b/backend/app/social_data.py index 8e4aeaf..82ca45a 100644 --- a/backend/app/social_data.py +++ b/backend/app/social_data.py @@ -7,11 +7,17 @@ def update_social_data() -> None: print("๐Ÿ“ฑ Updating social media data...") - all_social_data: Dict[str, List[Dict]] = {"stock": [], "polymarket": [], "bitmex": []} + all_social_data: Dict[str, List[Dict]] = { + "stock": [], + "polymarket": [], + "bitmex": [], + "forex": [], + } try: - from .main import ( # Import system getters + from .main import ( get_bitmex_system, + get_forex_system, get_polymarket_system, get_stock_system, ) @@ -19,8 +25,14 @@ def update_social_data() -> None: stock_system = get_stock_system() polymarket_system = get_polymarket_system() bitmex_system = get_bitmex_system() - - if not stock_system or not polymarket_system or not bitmex_system: + forex_system = get_forex_system() + + if ( + not stock_system + or not polymarket_system + or not bitmex_system + or not forex_system + ): print("โŒ Failed to get system instances") return @@ -31,6 +43,8 @@ def update_social_data() -> None: polymarket_system.initialize_for_live() if not bitmex_system.universe: bitmex_system.initialize_for_live() + if not forex_system.universe: + forex_system.initialize_for_live() # Fetch social data using system methods print(" - Fetching stock social media data...") @@ -51,6 +65,12 @@ def update_social_data() -> None: f" - Fetched {len([item for sublist in bitmex_social.values() for item in sublist])} bitmex social media posts." ) + print(" - Fetching forex social media data...") + forex_social = forex_system._fetch_social_data() + print( + f" - Fetched {len([item for sublist in forex_social.values() for item in sublist])} forex social media posts." + ) + all_social_data["stock"] = [ item for sublist in stock_social.values() for item in sublist ] @@ -60,6 +80,9 @@ def update_social_data() -> None: all_social_data["bitmex"] = [ item for sublist in bitmex_social.values() for item in sublist ] + all_social_data["forex"] = [ + item for sublist in forex_social.values() for item in sublist + ] except Exception as e: print(f"โŒ Error updating social media data: {e}") diff --git a/backend/app/system_data.py b/backend/app/system_data.py index 3c5c8ff..923f830 100644 --- a/backend/app/system_data.py +++ b/backend/app/system_data.py @@ -5,6 +5,7 @@ from live_trade_bench.systems import ( BitMEXPortfolioSystem, + ForexPortfolioSystem, PolymarketPortfolioSystem, StockPortfolioSystem, ) @@ -22,6 +23,7 @@ def update_system_status() -> None: stock_system = StockPortfolioSystem.get_instance() polymarket_system = PolymarketPortfolioSystem.get_instance() bitmex_system = BitMEXPortfolioSystem.get_instance() + forex_system = ForexPortfolioSystem.get_instance() model_configs = get_base_model_configs() for display_name, model_id in model_configs: @@ -38,10 +40,15 @@ def update_system_status() -> None: bitmex_system.add_agent( display_name, TRADING_CONFIG["initial_cash_bitmex"], model_id ) + if display_name not in forex_system.agents: + forex_system.add_agent( + display_name, TRADING_CONFIG["initial_cash_forex"], model_id + ) stock_count = len(stock_system.agents) poly_count = len(polymarket_system.agents) bitmex_count = len(bitmex_system.agents) + forex_count = len(forex_system.agents) total_value_stock = sum( acc.get_total_value() for acc in stock_system.accounts.values() ) @@ -51,6 +58,9 @@ def update_system_status() -> None: total_value_bitmex = sum( acc.get_total_value() for acc in bitmex_system.accounts.values() ) + total_value_forex = sum( + acc.get_total_value() for acc in forex_system.accounts.values() + ) status = { "timestamp": datetime.now().isoformat(), @@ -58,11 +68,18 @@ def update_system_status() -> None: "stock_agents": stock_count, "polymarket_agents": poly_count, "bitmex_agents": bitmex_count, - "total_agents": stock_count + poly_count + bitmex_count, + "forex_agents": forex_count, + "total_agents": stock_count + poly_count + bitmex_count + forex_count, "total_value_stock": total_value_stock, "total_value_polymarket": total_value_poly, "total_value_bitmex": total_value_bitmex, - "combined_total_value": total_value_stock + total_value_poly + total_value_bitmex, + "total_value_forex": total_value_forex, + "combined_total_value": ( + total_value_stock + + total_value_poly + + total_value_bitmex + + total_value_forex + ), } with open(SYSTEM_DATA_FILE, "w") as f: diff --git a/frontend/public/team-photos/boyu.png b/frontend/public/team-photos/boyu.png new file mode 100644 index 0000000..82ad4e9 Binary files /dev/null and b/frontend/public/team-photos/boyu.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9ef8558..3cbca63 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,17 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; -import Dashboard from './components/Dashboard'; -import StockDashboard from './components/StockDashboard'; -import PolymarketDashboard from './components/PolymarketDashboard'; -import BitMEXDashboard from './components/BitMEXDashboard'; -import News from './components/News'; -import SocialMedia from './components/SocialMedia'; -import About from './components/About'; -import Navigation from './components/Navigation'; -import Footnote from './components/Footnote'; -import './App.css'; -import type { Model } from './types'; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import Dashboard from "./components/Dashboard"; +import StockDashboard from "./components/StockDashboard"; +import PolymarketDashboard from "./components/PolymarketDashboard"; +import BitMEXDashboard from "./components/BitMEXDashboard"; +import ForexDashboard from "./components/ForexDashboard"; +import News from "./components/News"; +import SocialMedia from "./components/SocialMedia"; +import About from "./components/About"; +import Navigation from "./components/Navigation"; +import Footnote from "./components/Footnote"; +import "./App.css"; +import type { Model } from "./types"; // Global data interfaces export interface NewsItem { @@ -50,19 +51,23 @@ function App() { stock: NewsItem[]; polymarket: NewsItem[]; bitmex: NewsItem[]; + forex: NewsItem[]; }>({ stock: [], polymarket: [], - bitmex: [] + bitmex: [], + forex: [], }); const [socialData, setSocialData] = useState<{ stock: SocialPost[]; polymarket: SocialPost[]; bitmex: SocialPost[]; + forex: SocialPost[]; }>({ stock: [], polymarket: [], - bitmex: [] + bitmex: [], + forex: [], }); const [systemStatus, setSystemStatus] = useState(null); const [views, setViews] = useState(0); @@ -76,7 +81,9 @@ function App() { // Last refresh timestamps for all data types const [modelsLastRefresh, setModelsLastRefresh] = useState(new Date()); const [stockNextRefresh, setStockNextRefresh] = useState(null); - const [polymarketNextRefresh, setPolymarketNextRefresh] = useState(null); + const [polymarketNextRefresh, setPolymarketNextRefresh] = + useState(null); + const [forexNextRefresh, setForexNextRefresh] = useState(null); const [newsLastRefresh, setNewsLastRefresh] = useState(new Date()); const [socialLastRefresh, setSocialLastRefresh] = useState(new Date()); const [systemLastRefresh, setSystemLastRefresh] = useState(new Date()); @@ -84,19 +91,24 @@ function App() { // Background data fetching functions const fetchNewsData = useCallback(async () => { try { - console.log('๐Ÿ”„ Background fetching news data...'); - const [stockResponse, polymarketResponse] = await Promise.all([ - fetch('/api/news/stock?limit=500'), - fetch('/api/news/polymarket?limit=500') - ]); + console.log("๐Ÿ”„ Background fetching news data..."); + const [stockResponse, polymarketResponse, forexResponse] = + await Promise.all([ + fetch("/api/news/stock?limit=500"), + fetch("/api/news/polymarket?limit=500"), + fetch("/api/news/forex?limit=500"), + ]); const stockNews = stockResponse.ok ? await stockResponse.json() : []; - const polymarketNews = polymarketResponse.ok ? await polymarketResponse.json() : []; + const polymarketNews = polymarketResponse.ok + ? await polymarketResponse.json() + : []; + const forexNews = forexResponse.ok ? await forexResponse.json() : []; // Fetch BitMEX news separately (optional endpoint) let bitmexNews: NewsItem[] = []; try { - const bitmexResponse = await fetch('/api/news/bitmex?limit=500'); + const bitmexResponse = await fetch("/api/news/bitmex?limit=500"); if (bitmexResponse.ok) { bitmexNews = await bitmexResponse.json(); } @@ -107,32 +119,44 @@ function App() { setNewsData({ stock: stockNews, polymarket: polymarketNews, - bitmex: bitmexNews + bitmex: bitmexNews, + forex: forexNews, }); setNewsLastRefresh(new Date()); - console.log(`โœ… Background news updated: ${stockNews.length} stock, ${polymarketNews.length} polymarket, ${bitmexNews.length} bitmex`); + console.log( + `โœ… Background news updated: ${stockNews.length} stock, ${polymarketNews.length} polymarket, ${bitmexNews.length} bitmex, ${forexNews.length} forex` + ); } catch (error) { - console.error('โŒ Background news fetch failed:', error); + console.error("โŒ Background news fetch failed:", error); } }, []); const fetchSocialData = useCallback(async () => { try { - console.log('๐Ÿ”„ Background fetching social data...'); - const [stockResponse, polymarketResponse] = await Promise.all([ - fetch('/api/social/stock?limit=500'), - fetch('/api/social/polymarket?limit=500') - ]); - - console.log("DEBUG: 1. Raw API Response (Social)", { stock: stockResponse, polymarket: polymarketResponse }); + console.log("๐Ÿ”„ Background fetching social data..."); + const [stockResponse, polymarketResponse, forexResponse] = + await Promise.all([ + fetch("/api/social/stock?limit=500"), + fetch("/api/social/polymarket?limit=500"), + fetch("/api/social/forex?limit=500"), + ]); + + console.log("DEBUG: 1. Raw API Response (Social)", { + stock: stockResponse, + polymarket: polymarketResponse, + forex: forexResponse, + }); const stockPosts = stockResponse.ok ? await stockResponse.json() : []; - const polymarketPosts = polymarketResponse.ok ? await polymarketResponse.json() : []; + const polymarketPosts = polymarketResponse.ok + ? await polymarketResponse.json() + : []; + const forexPostsRaw = forexResponse.ok ? await forexResponse.json() : []; // Fetch BitMEX social separately (optional endpoint) let bitmexPosts: any[] = []; try { - const bitmexResponse = await fetch('/api/social/bitmex?limit=500'); + const bitmexResponse = await fetch("/api/social/bitmex?limit=500"); if (bitmexResponse.ok) { bitmexPosts = await bitmexResponse.json(); } @@ -140,76 +164,106 @@ function App() { // BitMEX social endpoint optional, silently fail } - console.log("DEBUG: 2. Parsed JSON Data (Social)", { stockPosts, polymarketPosts }); + console.log("DEBUG: 2. Parsed JSON Data (Social)", { + stockPosts, + polymarketPosts, + forexPostsRaw, + }); // Transform data to match expected format - const transformStockPosts = stockPosts.map((post: any, index: number) => ({ - ...post, // Keep original post data first - id: post.id || `stock_${index}`, - platform: post.platform || 'Reddit', - username: `u/${post.author}`, - displayName: `u/${post.author}`, - title: post.title, // Explicitly map title - content: post.content || '', // Correctly map content, fallback to empty string - created_at: post.created_at || '', - upvotes: post.upvotes || 0, - num_comments: post.num_comments || 0, - avatar: '๐Ÿ“ˆ', - tag: post.tag, - stock_symbols: post.stock_symbols || [], // Ensure stock_symbols is an array - url: post.url || '', - })); - - const transformPolymarketPosts = polymarketPosts.map((post: any, index: number) => ({ - ...post, // Keep original post data first - id: post.id || `poly_${index}`, - platform: post.platform || 'Reddit', - username: `u/${post.author}`, - displayName: `u/${post.author}`, - title: post.title, // Also map for polymarket in case it exists - content: post.content || '', // Correctly map content, fallback to empty string - created_at: post.created_at || '', - upvotes: post.upvotes || 0, - num_comments: post.num_comments || 0, - avatar: '๐ŸŽฏ', - question: post.question, - url: post.url || '', - })); - - const transformBitmexPosts = bitmexPosts.map((post: any, index: number) => ({ - ...post, - id: post.id || `bitmex_${index}`, - platform: post.platform || 'Reddit', - username: `u/${post.author}`, - displayName: `u/${post.author}`, - title: post.title, - content: post.content || '', - created_at: post.created_at || '', - upvotes: post.upvotes || 0, - num_comments: post.num_comments || 0, - avatar: 'โ‚ฟ', - tag: post.tag, - url: post.url || '', - })); + const transformStockPosts = stockPosts.map( + (post: any, index: number) => ({ + ...post, // Keep original post data first + id: post.id || `stock_${index}`, + platform: post.platform || "Reddit", + username: `u/${post.author}`, + displayName: `u/${post.author}`, + title: post.title, // Explicitly map title + content: post.content || "", // Correctly map content, fallback to empty string + created_at: post.created_at || "", + upvotes: post.upvotes || 0, + num_comments: post.num_comments || 0, + avatar: "๐Ÿ“ˆ", + tag: post.tag, + stock_symbols: post.stock_symbols || [], // Ensure stock_symbols is an array + url: post.url || "", + }) + ); + + const transformPolymarketPosts = polymarketPosts.map( + (post: any, index: number) => ({ + ...post, // Keep original post data first + id: post.id || `poly_${index}`, + platform: post.platform || "Reddit", + username: `u/${post.author}`, + displayName: `u/${post.author}`, + title: post.title, // Also map for polymarket in case it exists + content: post.content || "", // Correctly map content, fallback to empty string + created_at: post.created_at || "", + upvotes: post.upvotes || 0, + num_comments: post.num_comments || 0, + avatar: "๐ŸŽฏ", + question: post.question, + url: post.url || "", + }) + ); + + const transformBitmexPosts = bitmexPosts.map( + (post: any, index: number) => ({ + ...post, + id: post.id || `bitmex_${index}`, + platform: post.platform || "Reddit", + username: `u/${post.author}`, + displayName: `u/${post.author}`, + title: post.title, + content: post.content || "", + created_at: post.created_at || "", + upvotes: post.upvotes || 0, + num_comments: post.num_comments || 0, + avatar: "โ‚ฟ", + tag: post.tag, + url: post.url || "", + }) + ); + const transformForexPosts = forexPostsRaw.map( + (post: any, index: number) => ({ + ...post, + id: post.id || `forex_${index}`, + platform: post.platform || "Reddit", + username: `u/${post.author}`, + displayName: `u/${post.author}`, + title: post.title, + content: post.content || "", + created_at: post.created_at || "", + upvotes: post.upvotes || 0, + num_comments: post.num_comments || 0, + avatar: "๐Ÿ’ฑ", + tag: post.tag, + url: post.url || "", + }) + ); setSocialData({ stock: transformStockPosts, polymarket: transformPolymarketPosts, - bitmex: transformBitmexPosts + bitmex: transformBitmexPosts, + forex: transformForexPosts, }); setSocialLastRefresh(new Date()); - console.log(`โœ… Background social updated: ${transformStockPosts.length} stock, ${transformPolymarketPosts.length} polymarket, ${transformBitmexPosts.length} bitmex`); + console.log( + `โœ… Background social updated: ${transformStockPosts.length} stock, ${transformPolymarketPosts.length} polymarket, ${transformBitmexPosts.length} bitmex, ${transformForexPosts.length} forex` + ); } catch (error) { - console.error('โŒ Background social fetch failed:', error); + console.error("โŒ Background social fetch failed:", error); } }, []); const fetchModelsData = useCallback(async () => { try { - console.log('๐Ÿ”„ Background fetching models data...'); + console.log("๐Ÿ”„ Background fetching models data..."); const [modelsResponse, nextUpdateResponse] = await Promise.all([ - fetch('/api/models/'), - fetch('/api/schedule/next-price-update') + fetch("/api/models/"), + fetch("/api/schedule/next-price-update"), ]); if (modelsResponse.ok) { @@ -217,7 +271,9 @@ function App() { // --- RANKING LOGIC --- // 1. Sort models by performance to determine rank - const sortedModels = [...currentModels].sort((a, b) => b.performance - a.performance); + const sortedModels = [...currentModels].sort( + (a, b) => b.performance - a.performance + ); // 2. Create a map of model.id -> rank const newRanks: { [key: string]: number } = {}; @@ -227,7 +283,7 @@ function App() { // 3. Calculate rank changes by comparing with previous ranks const newRankChanges: { [key: string]: number } = {}; - currentModels.forEach(model => { + currentModels.forEach((model) => { const oldRank = prevRanks.current[model.id]; const newRank = newRanks[model.id]; if (oldRank && newRank) { @@ -240,7 +296,7 @@ function App() { }); // 4. Add rank and rank_change to each model object - const modelsWithRanks = currentModels.map(model => ({ + const modelsWithRanks = currentModels.map((model) => ({ ...model, rank: newRanks[model.id], rank_change: newRankChanges[model.id] || 0, @@ -251,44 +307,50 @@ function App() { setModelsLastRefresh(new Date()); prevRanks.current = newRanks; // Store for next comparison - console.log(`โœ… Background models updated: ${currentModels.length} models with ranks`); + console.log( + `โœ… Background models updated: ${currentModels.length} models with ranks` + ); } if (nextUpdateResponse.ok) { const scheduleData = await nextUpdateResponse.json(); const parseTime = (value: any) => { - if (typeof value !== 'string') return null; + if (typeof value !== "string") return null; const dt = new Date(value); return Number.isNaN(dt.getTime()) ? null : dt; }; - setStockNextRefresh(parseTime(scheduleData?.stock ?? scheduleData?.next_run_time)); + setStockNextRefresh( + parseTime(scheduleData?.stock ?? scheduleData?.next_run_time) + ); setPolymarketNextRefresh(parseTime(scheduleData?.polymarket)); + setForexNextRefresh(parseTime(scheduleData?.forex)); } } catch (error) { - console.error('โŒ Background models fetch failed:', error); + console.error("โŒ Background models fetch failed:", error); } }, []); const fetchSystemStatus = useCallback(async () => { try { - console.log('๐Ÿ”„ Background fetching system status...'); - const response = await fetch('/api/system'); + console.log("๐Ÿ”„ Background fetching system status..."); + const response = await fetch("/api/system"); if (response.ok) { const status = await response.json(); setSystemStatus(status); setSystemLastRefresh(new Date()); - console.log('โœ… Background system status updated'); + console.log("โœ… Background system status updated"); } } catch (error) { - console.error('โŒ Background system status fetch failed:', error); + console.error("โŒ Background system status fetch failed:", error); } }, []); const fetchViews = useCallback(async () => { try { - const baseUrl = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5001'; + const baseUrl = + process.env.NODE_ENV === "production" ? "" : "http://localhost:5001"; const response = await fetch(`${baseUrl}/api/views`); if (response.ok) { @@ -296,7 +358,7 @@ function App() { setViews(data.views); } - await fetch(`${baseUrl}/api/views`, { method: 'POST' }); + await fetch(`${baseUrl}/api/views`, { method: "POST" }); const updatedResponse = await fetch(`${baseUrl}/api/views`); if (updatedResponse.ok) { @@ -304,13 +366,13 @@ function App() { setViews(updatedData.views); } } catch (error) { - console.error('Failed to fetch views:', error); + console.error("Failed to fetch views:", error); } }, []); // Parallel data fetching function const fetchAllDataInParallel = useCallback(async () => { - console.log('๐Ÿ”„ Starting parallel data fetch...'); + console.log("๐Ÿ”„ Starting parallel data fetch..."); const startTime = Date.now(); try { @@ -320,13 +382,13 @@ function App() { fetchNewsData(), fetchSocialData(), fetchSystemStatus(), - fetchViews() + fetchViews(), ]); const elapsed = Date.now() - startTime; console.log(`โœ… Parallel fetch completed in ${elapsed}ms`); } catch (error) { - console.error('โŒ Parallel fetch failed:', error); + console.error("โŒ Parallel fetch failed:", error); } finally { // Set loading to false after the first fetch completes if (initialFetchComplete.current) { @@ -339,12 +401,14 @@ function App() { useEffect(() => { // This effect should only run ONCE during the app's entire lifecycle if (initialFetchComplete.current) { - console.log('โ†ฉ๏ธ Initial fetch already completed, skipping effect.'); + console.log("โ†ฉ๏ธ Initial fetch already completed, skipping effect."); return; } initialFetchComplete.current = true; - console.log('๐Ÿš€ Starting unified background data management for the first time...'); + console.log( + "๐Ÿš€ Starting unified background data management for the first time..." + ); // Fetch all data immediately on app start in parallel fetchAllDataInParallel(); @@ -354,26 +418,33 @@ function App() { // High frequency: Models + System (every 1 minute for debugging) const highFreqInterval = setInterval(async () => { - console.log('๐Ÿ”„ High frequency parallel update (1 min)...'); + console.log("๐Ÿ”„ High frequency parallel update (1 min)..."); await Promise.all([fetchModelsData(), fetchSystemStatus()]); }, 1 * 60 * 1000); // Medium frequency: News + Social (every 10 minutes, parallel) const lowFreqInterval = setInterval(async () => { - console.log('๐Ÿ”„ Low frequency parallel update...'); + console.log("๐Ÿ”„ Low frequency parallel update..."); await Promise.all([fetchNewsData(), fetchSocialData()]); }, 10 * 60 * 1000); - console.log('โฐ Smart parallel intervals set: High(1m), Medium(10m)'); + console.log("โฐ Smart parallel intervals set: High(1m), Medium(10m)"); // Cleanup all intervals on unmount return () => { clearInterval(highFreqInterval); clearInterval(lowFreqInterval); - console.log('๐Ÿ›‘ All parallel intervals cleared'); + console.log("๐Ÿ›‘ All parallel intervals cleared"); }; // The dependency array is correct, but we've added a ref guard to prevent re-runs from HMR. - }, [fetchAllDataInParallel, fetchModelsData, fetchNewsData, fetchSocialData, fetchSystemStatus, fetchViews]); + }, [ + fetchAllDataInParallel, + fetchModelsData, + fetchNewsData, + fetchSocialData, + fetchSystemStatus, + fetchViews, + ]); return ( @@ -381,52 +452,81 @@ function App() {
- - } /> - - } /> - - } /> - - } /> - - } /> - - } /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> } />
diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx index c1f9b98..6692811 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -25,13 +25,6 @@ const MEMBERS: Member[] = [ initials: "FH", website: "https://fenghaili.com" }, - { - name: "Jiaxuan You", - role: "Core Advisor", - photo: "/team-photos/jiaxuan.png", - initials: "JX", - website: "https://cs.stanford.edu/~jiaxuan/" - }, { name: "Andriy Batutin", role: "Core contributor", @@ -39,6 +32,20 @@ const MEMBERS: Member[] = [ initials: "AB", website: "https://www.linkedin.com/in/andrewbatutin/" }, + { + name: "Boyu Liu", + role: "Core Contributor", + photo: "/team-photos/boyu.png", + initials: "BL", + website: "https://boyul.vercel.app/" + }, + { + name: "Jiaxuan You", + role: "Core Advisor", + photo: "/team-photos/jiaxuan.png", + initials: "JX", + website: "https://cs.stanford.edu/~jiaxuan/" + }, ]; const About: React.FC = () => { diff --git a/frontend/src/components/Dashboard.css b/frontend/src/components/Dashboard.css index a0b2410..dba8832 100644 --- a/frontend/src/components/Dashboard.css +++ b/frontend/src/components/Dashboard.css @@ -150,6 +150,10 @@ /* Green 500 - BitMEX theme */ } +.leaderboard-card.forex .card-title { + color: #34d399; +} + .card-updated { color: rgba(255, 255, 255, 0.5); font-size: 0.875rem; diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 0f4998b..de9bdf8 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -17,6 +17,7 @@ export type DashboardProps = { modelsLastRefresh?: Date | string; stockNextRefresh?: Date | string; polymarketNextRefresh?: Date | string; + forexNextRefresh?: Date | string; systemStatus?: any; systemLastRefresh?: Date | string; views?: number; // ๆทปๅŠ viewsๅฑžๆ€ง @@ -236,7 +237,7 @@ const LeaderboardCard: React.FC<{ updatedAt?: Date | string; nextUpdate?: Date | string; items: ModelRow[]; - category: "stock" | "polymarket" | "bitmex"; + category: "stock" | "polymarket" | "bitmex" | "forex"; }> = ({ title, updatedAt, nextUpdate, items, category }) => { const [showAll, setShowAll] = useState(false); @@ -346,7 +347,7 @@ const LeaderboardCard: React.FC<{ // ------- Main Dashboard Component ------- -const TwoPanelLeaderboard: React.FC = ({ modelsData = [], modelsLastRefresh = new Date(), stockNextRefresh, polymarketNextRefresh, systemStatus, systemLastRefresh, views = 0 }) => { +const TwoPanelLeaderboard: React.FC = ({ modelsData = [], modelsLastRefresh = new Date(), stockNextRefresh, polymarketNextRefresh, forexNextRefresh, systemStatus, systemLastRefresh, views = 0 }) => { const navigate = useNavigate(); // ๆ ผๅผๅŒ–ๆ•ฐๅญ—ๆ˜พ็คบ @@ -373,6 +374,9 @@ const TwoPanelLeaderboard: React.FC = ({ modelsData = [], models return category === "bitmex" || category === "bitmex-benchmark"; }) .map(normalize); + const forex = modelsData + .filter((m) => (m?.category ?? "").toString().toLowerCase() === "forex") + .map(normalize); return (
@@ -467,6 +471,26 @@ const TwoPanelLeaderboard: React.FC = ({ modelsData = [], models > BitMEX {" "} + {", or "} + {" "} for more information.
@@ -496,6 +520,14 @@ const TwoPanelLeaderboard: React.FC = ({ modelsData = [], models items={bitmex} category="bitmex" /> + ); diff --git a/frontend/src/components/ForexDashboard.tsx b/frontend/src/components/ForexDashboard.tsx new file mode 100644 index 0000000..3e9c07a --- /dev/null +++ b/frontend/src/components/ForexDashboard.tsx @@ -0,0 +1,86 @@ +import React, { useMemo } from "react"; +import ModelsDisplay from "./ModelsDisplay"; +import "./Dashboard.css"; +import { Model } from "../types"; + +interface ForexDashboardProps { + modelsData: Model[]; + modelsLastRefresh: Date; + isLoading: boolean; +} + +const ForexDashboard: React.FC = ({ + modelsData, + isLoading, +}) => { + const forexModels = useMemo( + () => modelsData.filter((m) => m.category === "forex"), + [modelsData] + ); + + if (isLoading) { + return
Loading...
; + } + + return ( +
+
+

+ Forex Models +

+

+ AI-powered FX allocation across G10 currency pairs +

+ +
+ Each model manages a diversified FX portfolio spanning USD, EUR, JPY, + GBP and other liquid pairs. Agents ingest macro news, momentum + signals, and historical allocation context before proposing + allocations that must sum to 100%. Explore every model card to inspect + recent trades, allocation history, profit curves, and the underlying + LLM reasoning. +
+
+ + +
+ ); +}; + +export default ForexDashboard; diff --git a/frontend/src/components/ModelsDisplay.css b/frontend/src/components/ModelsDisplay.css index e967feb..32aab0c 100644 --- a/frontend/src/components/ModelsDisplay.css +++ b/frontend/src/components/ModelsDisplay.css @@ -11,6 +11,16 @@ /* Cyan 500 */ --polymarket-color-hover-border: #06b6d4; --polymarket-color-positive: #06b6d4; + + /* BitMEX Theme */ + --bitmex-color-primary: #10b981; + --bitmex-color-hover-border: #10b981; + --bitmex-color-positive: #10b981; + + /* Forex Theme */ + --forex-color-primary: #34d399; + --forex-color-hover-border: #34d399; + --forex-color-positive: #34d399; } .theme-stock { @@ -25,6 +35,18 @@ --theme-color-positive: var(--polymarket-color-positive); } +.theme-bitmex { + --theme-color-primary: var(--bitmex-color-primary); + --theme-color-hover-border: var(--bitmex-color-hover-border); + --theme-color-positive: var(--bitmex-color-positive); +} + +.theme-forex { + --theme-color-primary: var(--forex-color-primary); + --theme-color-hover-border: var(--forex-color-hover-border); + --theme-color-positive: var(--forex-color-positive); +} + .models-container { padding: 1rem; max-width: 1200px; @@ -64,6 +86,14 @@ /* Cyan 500 */ } +.theme-bitmex .models-header h2 { + color: #10b981; +} + +.theme-forex .models-header h2 { + color: #34d399; +} + .refresh-btn { background: none; border: 1px solid #374151; diff --git a/frontend/src/components/ModelsDisplay.tsx b/frontend/src/components/ModelsDisplay.tsx index 5246001..d3b45d3 100644 --- a/frontend/src/components/ModelsDisplay.tsx +++ b/frontend/src/components/ModelsDisplay.tsx @@ -5,6 +5,7 @@ import { getAssetColor, getCashColor } from '../utils/colors'; // Removed: import { AllocationHistoryItem, AssetAllocation, AssetMetadata } from '../types'; type TimestampedHistoryItem = { timestamp?: string }; +type ThemeCategory = 'stock' | 'polymarket' | 'bitmex' | 'forex'; const DAYS_IN_MONTH = 30; @@ -88,6 +89,9 @@ const ModelsDisplay: React.FC = ({ const outcome = name.substring(lastUnderscoreIndex + 1); return `${question} Buy ${outcome}`; } + if (category === 'forex' && name.length >= 6) { + return `${name.substring(0, 3)}/${name.substring(3, 6)}`; + } return name; }, []); const [selectedCategory, setSelectedCategory] = useState<'all' | 'polymarket' | 'stock'>('all'); @@ -208,19 +212,27 @@ const ModelsDisplay: React.FC = ({ const getChartColor = (category: string) => { switch (category) { case 'stock': - return '#f59e0b'; // Yellow for stock + return '#f59e0b'; case 'polymarket': - return '#06b6d4'; // Cyan for polymarket + return '#06b6d4'; + case 'bitmex': + return '#10b981'; + case 'forex': + return '#34d399'; default: - return '#3b82f6'; // Blue as default + return '#3b82f6'; } }; const chartColor = getChartColor(category); - const initialCash = category === 'stock' ? 1000 - : category === 'bitmex' ? 1000 - : 500; + const initialCash = category === 'stock' + ? 1000 + : category === 'bitmex' + ? 1000 + : category === 'forex' + ? 1000 + : 500; const { maxPerformance, minPerformance, range, pathData } = useMemo(() => { // Ensure data is an array before mapping @@ -471,7 +483,7 @@ const ModelsDisplay: React.FC = ({ allocation, isCash: name === 'CASH', isPolymarket: category === 'polymarket', - color: getAssetColor(name, index, category as 'stock' | 'polymarket'), + color: getAssetColor(name, index, category as ThemeCategory), }; }); }, [portfolioData, category]); @@ -697,7 +709,7 @@ const ModelsDisplay: React.FC = ({ if (b === 'CASH') return -1; return a.localeCompare(b); }).indexOf(name), - model.category as 'stock' | 'polymarket') + model.category as ThemeCategory) })) .sort((a, b) => { if (a.name === 'CASH') return 1; @@ -988,7 +1000,7 @@ const AssetRatioChart: React.FC<{ (assetName: string) => { const foundAssetIndex = allAssets.indexOf(assetName); if (foundAssetIndex !== -1) { - return getAssetColor(assetName, foundAssetIndex, category as 'stock' | 'polymarket'); // Add type assertion here + return getAssetColor(assetName, foundAssetIndex, category as ThemeCategory); // Add type assertion here } return getCashColor(); }, diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index 57a22be..aaf6ab8 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -58,6 +58,12 @@ const Navigation: React.FC = () => { > BitMEX + + ))} @@ -130,7 +138,7 @@ const News: React.FC = ({ newsData, lastRefresh, isLoading }) => { {/* Desktop Layout */}
- {(['stock', 'polymarket', 'bitmex'] as const).map((market) => ( + {(['stock', 'polymarket', 'bitmex', 'forex'] as const).map((market) => ( ))}
diff --git a/frontend/src/components/SocialMedia.tsx b/frontend/src/components/SocialMedia.tsx index ec86506..62ec5c8 100644 --- a/frontend/src/components/SocialMedia.tsx +++ b/frontend/src/components/SocialMedia.tsx @@ -6,20 +6,28 @@ import { formatTimeAgo } from '../utils/time'; interface SocialMediaProps { socialData: { - stock: SocialPost[]; // Use SocialPost type - polymarket: SocialPost[]; // Use SocialPost type - bitmex: SocialPost[]; // Use SocialPost type + stock: SocialPost[]; + polymarket: SocialPost[]; + bitmex: SocialPost[]; + forex: SocialPost[]; }; lastRefresh: Date; isLoading: boolean; } const SocialMedia: React.FC = ({ socialData, lastRefresh, isLoading }) => { - const [activeCategory, setActiveCategory] = useState<'stock' | 'polymarket' | 'bitmex'>('stock'); + const [activeCategory, setActiveCategory] = useState<'stock' | 'polymarket' | 'bitmex' | 'forex'>('stock'); const [sortBy, setSortBy] = useState<'ticker' | 'time'>('time'); const posts = useMemo(() => { - const rawPosts = activeCategory === 'stock' ? socialData.stock : activeCategory === 'polymarket' ? socialData.polymarket : socialData.bitmex; + const rawPosts = + activeCategory === 'stock' + ? socialData.stock + : activeCategory === 'polymarket' + ? socialData.polymarket + : activeCategory === 'bitmex' + ? socialData.bitmex + : socialData.forex; console.log("DEBUG: activeCategory in posts useMemo", activeCategory); // Debug activeCategory const mappedPosts = rawPosts.map((post: SocialPost, index: number) => { @@ -62,6 +70,9 @@ const SocialMedia: React.FC = ({ socialData, lastRefresh, isLo socialData.bitmex.forEach(item => { item.tag && tags.add(item.tag); }); + socialData.forex.forEach(item => { + item.tag && tags.add(item.tag); + }); return Array.from(tags).sort((a, b) => a.localeCompare(b)); }, [socialData]); @@ -88,20 +99,26 @@ const SocialMedia: React.FC = ({ socialData, lastRefresh, isLo

Social Media

- Track real-time social media discussions about stocks, polymarkets, and crypto. + Track real-time social media discussions about stocks, polymarkets, crypto, and FX.

{/* Mobile Layout */}
- {(['stock', 'polymarket', 'bitmex'] as const).map((market) => ( + {(['stock', 'polymarket', 'bitmex', 'forex'] as const).map((market) => ( ))}
@@ -126,13 +143,19 @@ const SocialMedia: React.FC = ({ socialData, lastRefresh, isLo {/* Desktop Layout */}
- {(['stock', 'polymarket', 'bitmex'] as const).map((market) => ( + {(['stock', 'polymarket', 'bitmex', 'forex'] as const).map((market) => ( ))}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index aed753a..3a14782 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,6 +1,11 @@ // Centralized shared types to avoid duplication across components -export type Category = 'polymarket' | 'stock' | 'bitmex' | 'bitmex-benchmark'; +export type Category = + | 'polymarket' + | 'stock' + | 'bitmex' + | 'bitmex-benchmark' + | 'forex'; export interface Position { symbol: string; diff --git a/frontend/src/utils/colors.ts b/frontend/src/utils/colors.ts index 1135e9f..30d7f23 100644 --- a/frontend/src/utils/colors.ts +++ b/frontend/src/utils/colors.ts @@ -171,6 +171,21 @@ const BASE_BITMEX_COLORS = [ const STOCK_COLORS = processColors([...BASE_STOCK_COLORS], 0.075); const POLYMARKET_COLORS = processColors([...BASE_POLYMARKET_COLORS], 0.075); const BITMEX_COLORS = processColors([...BASE_BITMEX_COLORS], 0.075); +const BASE_FOREX_COLORS = [ + '#34d399', // Emerald + '#22d3ee', // Cyan + '#facc15', // Yellow + '#a78bfa', // Violet + '#f97316', // Orange + '#2dd4bf', // Teal + '#f472b6', // Pink + '#60a5fa', // Blue + '#38bdf8', // Sky + '#fbbf24', // Amber + '#4ade80', // Light green + '#fb7185', // Rose +]; +const FOREX_COLORS = processColors([...BASE_FOREX_COLORS], 0.05); // Special color for CASH (consistent across both systems) const CASH_COLOR = '#6b7280'; // Gray-500 @@ -181,7 +196,7 @@ const CASH_COLOR = '#6b7280'; // Gray-500 export function getAssetColor( ticker: string, index: number, - category: 'stock' | 'polymarket' | 'bitmex' + category: 'stock' | 'polymarket' | 'bitmex' | 'forex' ): string { // CASH always uses the same color if (ticker === 'CASH') { @@ -189,7 +204,14 @@ export function getAssetColor( } // Select the processed color palette - const colors = category === 'stock' ? STOCK_COLORS : category === 'polymarket' ? POLYMARKET_COLORS : BITMEX_COLORS; + const colors = + category === 'stock' + ? STOCK_COLORS + : category === 'polymarket' + ? POLYMARKET_COLORS + : category === 'bitmex' + ? BITMEX_COLORS + : FOREX_COLORS; // Return consistent color from the sorted and desaturated palette based on the sorted index return colors[index % colors.length]; @@ -199,9 +221,15 @@ export function getAssetColor( * Get all available colors for a category (useful for legends) */ export function getColorPalette( - category: 'stock' | 'polymarket' | 'bitmex' + category: 'stock' | 'polymarket' | 'bitmex' | 'forex' ): readonly string[] { - return category === 'stock' ? STOCK_COLORS : category === 'polymarket' ? POLYMARKET_COLORS : BITMEX_COLORS; + return category === 'stock' + ? STOCK_COLORS + : category === 'polymarket' + ? POLYMARKET_COLORS + : category === 'bitmex' + ? BITMEX_COLORS + : FOREX_COLORS; } /** @@ -216,7 +244,7 @@ export function getCashColor(): string { */ export function generateColorMap( assets: string[], - category: 'stock' | 'polymarket' | 'bitmex' + category: 'stock' | 'polymarket' | 'bitmex' | 'forex' ): Record { const colorMap: Record = {}; diff --git a/live_trade_bench/__init__.py b/live_trade_bench/__init__.py index bf669f0..e5a81fb 100644 --- a/live_trade_bench/__init__.py +++ b/live_trade_bench/__init__.py @@ -13,16 +13,19 @@ # Accounts from .accounts import ( BaseAccount, + ForexAccount, PolymarketAccount, Position, StockAccount, Transaction, + create_forex_account, create_polymarket_account, create_stock_account, ) # Agents from .agents.base_agent import BaseAgent +from .agents.forex_agent import LLMForexAgent from .agents.polymarket_agent import LLMPolyMarketAgent from .agents.stock_agent import LLMStockAgent @@ -35,10 +38,15 @@ # Constants from .fetchers.constants import CATEGORY_SUBREDDITS, TICKER_TO_COMPANY from .fetchers.news_fetcher import NewsFetcher, fetch_news_data +from .fetchers.forex_fetcher import ForexFetcher, fetch_trending_fx_pairs from .fetchers.polymarket_fetcher import PolymarketFetcher, fetch_trending_markets from .fetchers.stock_fetcher import StockFetcher # Core Systems +from .systems.forex_system import ( + ForexPortfolioSystem, + create_forex_portfolio_system, +) from .systems.polymarket_system import ( PolymarketPortfolioSystem, create_polymarket_portfolio_system, @@ -55,6 +63,8 @@ "create_stock_portfolio_system", "PolymarketPortfolioSystem", "create_polymarket_portfolio_system", + "ForexPortfolioSystem", + "create_forex_portfolio_system", # Accounts "BaseAccount", "Position", @@ -63,10 +73,13 @@ "create_stock_account", "PolymarketAccount", "create_polymarket_account", + "ForexAccount", + "create_forex_account", # Agents "BaseAgent", "LLMStockAgent", "LLMPolyMarketAgent", + "LLMForexAgent", # Fetchers "BaseFetcher", "StockFetcher", @@ -75,6 +88,8 @@ "OptionFetcher", "PolymarketFetcher", "fetch_trending_markets", + "ForexFetcher", + "fetch_trending_fx_pairs", "fetch_current_market_price", "fetch_token_price", "RedditFetcher", diff --git a/live_trade_bench/accounts/__init__.py b/live_trade_bench/accounts/__init__.py index d9f5273..9aae5bc 100644 --- a/live_trade_bench/accounts/__init__.py +++ b/live_trade_bench/accounts/__init__.py @@ -6,6 +6,7 @@ from .base_account import BaseAccount, Position, Transaction from .bitmex_account import BitMEXAccount, create_bitmex_account +from .forex_account import ForexAccount, create_forex_account from .polymarket_account import PolymarketAccount, create_polymarket_account from .stock_account import StockAccount, create_stock_account @@ -19,4 +20,6 @@ "create_polymarket_account", "BitMEXAccount", "create_bitmex_account", + "ForexAccount", + "create_forex_account", ] diff --git a/live_trade_bench/accounts/forex_account.py b/live_trade_bench/accounts/forex_account.py new file mode 100644 index 0000000..2ee2b9b --- /dev/null +++ b/live_trade_bench/accounts/forex_account.py @@ -0,0 +1,105 @@ +""" +Account representation for forex portfolios. + +Modeled after the stock account but keeps terminology currency-agnostic so the +same accounting logic can be reused by the portfolio system and visualization +layer. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional + +from .base_account import BaseAccount, Position, Transaction + + +@dataclass +class ForexAccount(BaseAccount[Position, Transaction]): + positions: Dict[str, Position] = field(default_factory=dict) + transactions: List[Transaction] = field(default_factory=list) + total_fees: float = 0.0 + + def get_positions(self) -> Dict[str, Position]: + return { + symbol: pos for symbol, pos in self.positions.items() if abs(pos.quantity) > 0.0001 + } + + def get_position(self, symbol: str) -> Optional[Position]: + return self.positions.get(symbol) + + def _get_position_value(self, symbol: str) -> float: + position = self.positions.get(symbol) + return position.market_value if position else 0.0 + + def update_position_price(self, symbol: str, current_price: float) -> None: + if symbol in self.positions: + self.positions[symbol].current_price = current_price + + def apply_allocation( + self, + target_allocations: Dict[str, float], + price_map: Optional[Dict[str, float]] = None, + metadata_map: Optional[Dict[str, Dict[str, Any]]] = None, + ) -> None: + if not price_map: + price_map = { + symbol: pos.current_price for symbol, pos in self.positions.items() + } + + for symbol, pos in self.positions.items(): + if symbol in price_map: + pos.current_price = price_map[symbol] + + total_value = self.get_total_value() + self.cash_balance = total_value + self.positions.clear() + + for symbol, target_ratio in target_allocations.items(): + if symbol == "CASH" or target_ratio <= 0: + continue + + price = price_map.get(symbol) + if price is None or price <= 0: + continue + + target_value = total_value * target_ratio + quantity = target_value / price + url = metadata_map.get(symbol, {}).get("url") if metadata_map else None + + self.positions[symbol] = Position( + symbol=symbol, + quantity=quantity, + average_price=price, + current_price=price, + url=url, + ) + self.cash_balance -= target_value + + self.last_rebalance = datetime.now().isoformat() + + def get_market_type(self) -> str: + return "forex" + + def serialize_positions(self) -> Dict[str, Any]: + serialized = {} + for symbol, position in self.positions.items(): + pos_dict = { + "symbol": position.symbol, + "quantity": position.quantity, + "average_price": position.average_price, + "current_price": position.current_price, + } + if position.url: + pos_dict["url"] = position.url + serialized[symbol] = pos_dict + return serialized + + def get_additional_account_data(self) -> Dict[str, Any]: + return {"total_fees": self.total_fees} + + +def create_forex_account(initial_cash: float = 1000.0) -> ForexAccount: + return ForexAccount(initial_cash=initial_cash, cash_balance=initial_cash) + diff --git a/live_trade_bench/agents/__init__.py b/live_trade_bench/agents/__init__.py index 274d3f9..6ccf282 100644 --- a/live_trade_bench/agents/__init__.py +++ b/live_trade_bench/agents/__init__.py @@ -4,6 +4,7 @@ from .base_agent import BaseAgent from .bitmex_agent import LLMBitMEXAgent, create_bitmex_agent +from .forex_agent import LLMForexAgent, create_forex_agent from .polymarket_agent import LLMPolyMarketAgent, create_polymarket_agent from .stock_agent import LLMStockAgent, create_stock_agent @@ -11,8 +12,10 @@ "BaseAgent", "LLMStockAgent", "LLMPolyMarketAgent", + "LLMForexAgent", "LLMBitMEXAgent", "create_stock_agent", + "create_forex_agent", "create_polymarket_agent", "create_bitmex_agent", ] diff --git a/live_trade_bench/agents/forex_agent.py b/live_trade_bench/agents/forex_agent.py new file mode 100644 index 0000000..9e57bae --- /dev/null +++ b/live_trade_bench/agents/forex_agent.py @@ -0,0 +1,92 @@ +""" +LLM agent specialized for forex portfolio allocation. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from ..accounts import ForexAccount +from .base_agent import BaseAgent + + +class LLMForexAgent(BaseAgent[ForexAccount, Dict[str, Any]]): + """LLM-powered trading agent for major currency pairs.""" + + def __init__(self, name: str, model_name: str = "gpt-4o-mini") -> None: + super().__init__(name, model_name) + + def _prepare_market_analysis(self, market_data: Dict[str, Dict[str, Any]]) -> str: + parts = [] + for pair, data in market_data.items(): + price = data.get("current_price", 0.0) + base = data.get("base", pair[:3]) + quote = data.get("quote", pair[3:6]) + parts.append(f"{base}/{quote}: {price:.5f}") + + price_history = data.get("price_history", []) + history_lines = self._format_price_history( + price_history, pair, is_stock=False + ) + parts.extend(history_lines) + parts.append("") + self._update_price_history(pair, price) + + return "FOREX MARKET ANALYSIS:\n" + "\n".join(parts) + + def _create_news_query(self, pair: str, data: Dict[str, Any]) -> str: + base = data.get("base", pair[:3]) + quote = data.get("quote", pair[3:6]) + return f"{base} {quote} forex news" + + def _get_portfolio_prompt( + self, + analysis: str, + market_data: Dict[str, Dict[str, Any]], + date: Optional[str] = None, + ) -> str: + current_date_str = f"Today is {date} (UTC)." if date else "" + pair_list = list(market_data.keys()) + pair_str = ", ".join(pair_list) + sample = [ + pair_list[i] if i < len(pair_list) else f"PAIR_{i+1}" + for i in range(3) + ] + + return ( + f"{current_date_str}\n\n" + "You are an institutional FX portfolio manager allocating capital across liquid G10 pairs. " + "Use the market analysis, news context, and historical allocations to produce a diversified portfolio.\n\n" + f"{analysis}\n\n" + "OBJECTIVE:\n" + "- Generate allocations for the next 1-3 weeks with focus on macro catalysts, rate differentials, and risk sentiment.\n" + "- Maintain disciplined risk, prefer liquid pairs, and keep allocations sum to 1.0 (CASH allowed).\n" + "- Balance trend-following with mean reversion when momentum is stretched.\n\n" + "EVALUATION CRITERIA:\n" + "- Consider rate expectations, macro data, and dollar strength.\n" + "- Account for volatility and correlation across pairs.\n" + "- Preserve capital during risk-off periods by raising CASH.\n" + "- Highlight conviction trades clearly in the reasoning.\n\n" + "REQUIRED JSON FORMAT:\n" + "{\n" + ' "reasoning": "Why this allocation makes sense for FX right now",\n' + ' "allocations": {\n' + f' "{sample[0]}": 0.25,\n' + f' "{sample[1]}": 0.25,\n' + f' "{sample[2]}": 0.25,\n' + ' "CASH": 0.25\n' + " }\n" + "}\n\n" + "RULES:\n" + "1. Return ONLY JSON (no prose outside the object).\n" + "2. All allocations must be floats between 0 and 1 that sum to 1.\n" + "3. CASH is optional but recommended in high-vol environments.\n" + "4. Incorporate macro fundamentals, technicals, and positioning.\n" + "5. Use double quotes for all keys/strings; no trailing commas.\n\n" + f"AVAILABLE PAIRS: {pair_str}, CASH" + ) + + +def create_forex_agent(name: str, model_name: str = "gpt-4o-mini") -> LLMForexAgent: + return LLMForexAgent(name, model_name) + diff --git a/live_trade_bench/fetchers/__init__.py b/live_trade_bench/fetchers/__init__.py index e7a5fe9..77bc580 100644 --- a/live_trade_bench/fetchers/__init__.py +++ b/live_trade_bench/fetchers/__init__.py @@ -6,6 +6,7 @@ from .base_fetcher import BaseFetcher from .bitmex_fetcher import BitMEXFetcher +from .forex_fetcher import ForexFetcher, fetch_trending_fx_pairs from .news_fetcher import NewsFetcher from .polymarket_fetcher import PolymarketFetcher, fetch_trending_markets @@ -40,9 +41,11 @@ __all__ = [ "BaseFetcher", "BitMEXFetcher", + "ForexFetcher", "NewsFetcher", "PolymarketFetcher", "fetch_trending_markets", + "fetch_trending_fx_pairs", ] if StockFetcher is not None: diff --git a/live_trade_bench/fetchers/constants.py b/live_trade_bench/fetchers/constants.py index 7b5fe6a..f4aaa67 100644 --- a/live_trade_bench/fetchers/constants.py +++ b/live_trade_bench/fetchers/constants.py @@ -23,4 +23,5 @@ "market": ["StockMarket", "investing", "stocks"], "tech": ["technology", "stocks"], "crypto": ["cryptocurrency", "CryptoMarkets", "Bitcoin", "ethereum", "CryptoCurrency", "altcoin"], + "forex": ["Forex", "ForexTrading", "Daytrading", "wallstreetbets"], } diff --git a/live_trade_bench/fetchers/forex_fetcher.py b/live_trade_bench/fetchers/forex_fetcher.py new file mode 100644 index 0000000..33efe11 --- /dev/null +++ b/live_trade_bench/fetchers/forex_fetcher.py @@ -0,0 +1,196 @@ +""" +Forex data fetcher built on top of yfinance. + +This fetcher mirrors the interface of other fetchers in the project so the +portfolio system can treat FX pairs the same way as stocks or crypto. It uses +Yahoo Finance symbols (e.g., EURUSD=X) underneath and exposes utilities for +major currency pairs, price snapshots, and short history windows that feed the +LLM agents. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +import yfinance as yf + +from .base_fetcher import BaseFetcher + + +class ForexFetcher(BaseFetcher): + """Fetcher for major forex pairs using Yahoo Finance.""" + + # Ordered by global trading volume / relevance + MAJOR_PAIRS: List[str] = [ + "EURUSD", + "USDJPY", + "GBPUSD", + "USDCHF", + "AUDUSD", + "USDCAD", + "NZDUSD", + "EURJPY", + "EURGBP", + "EURCHF", + "AUDJPY", + "GBPJPY", + "CHFJPY", + "EURNZD", + "USDMXN", + ] + + def __init__(self, min_delay: float = 0.5, max_delay: float = 1.5) -> None: + super().__init__(min_delay, max_delay) + + # ------------------------------------------------------------------ helpers + @staticmethod + def _to_yahoo_symbol(pair: str) -> str: + clean = pair.upper().replace("=X", "") + return f"{clean}=X" + + @staticmethod + def _split_pair(pair: str) -> Dict[str, str]: + clean = pair.upper().replace("=X", "") + base, quote = clean[:3], clean[3:] + return {"base": base, "quote": quote} + + # ------------------------------------------------------------------- public + def get_major_pairs(self, limit: int = 10) -> List[str]: + return self.MAJOR_PAIRS[:limit] + + def get_price(self, pair: str, date: Optional[str] = None) -> Optional[float]: + if date: + return self._get_price_on_date(pair, date) + return self._get_current_price(pair) + + def get_price_with_history( + self, + pair: str, + lookback_days: int = 7, + date: Optional[str] = None, + ) -> Dict[str, Any]: + if date: + end_date = datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1) + else: + end_date = datetime.now(timezone.utc) + + start_date = end_date - timedelta(days=lookback_days) + price_data = self._download_price_data( + pair, + start_date.strftime("%Y-%m-%d"), + end_date.strftime("%Y-%m-%d"), + interval="1d", + ) + + history: List[Dict[str, Any]] = [] + if price_data is not None and not price_data.empty: + for idx, row in price_data.iterrows(): + history.append( + { + "date": idx.strftime("%Y-%m-%d"), + "price": float(row["Close"]) if "Close" in row else 0.0, + "volume": int(row["Volume"]) if "Volume" in row else 0, + } + ) + + return { + "pair": pair, + "current_price": self.get_price(pair, date), + "price_history": history, + **self._split_pair(pair), + } + + def fetch(self, mode: str, **kwargs: Any) -> Any: + if mode == "trending_pairs": + return self.get_major_pairs(limit=int(kwargs.get("limit", 10))) + if mode == "price": + pair = kwargs.get("pair") + if not pair: + raise ValueError("pair is required for forex price fetch") + return self.get_price(str(pair), date=kwargs.get("date")) + if mode == "price_with_history": + pair = kwargs.get("pair") + if not pair: + raise ValueError("pair is required for price_with_history") + return self.get_price_with_history( + str(pair), + lookback_days=int(kwargs.get("lookback_days", 7)), + date=kwargs.get("date"), + ) + raise ValueError(f"Unknown fetch mode for ForexFetcher: {mode}") + + # ----------------------------------------------------------------- internals + def _get_current_price(self, pair: str) -> Optional[float]: + symbol = self._to_yahoo_symbol(pair) + ticker = yf.Ticker(symbol) + try: + fast_info = ticker.fast_info + price = getattr(fast_info, "last_price", None) + if price: + price_float = float(price) + if price_float > 0: + return price_float + except Exception: + pass + + try: + history = ticker.history(period="5d", interval="1h") + if not history.empty and "Close" in history: + return float(history["Close"].iloc[-1]) + except Exception: + pass + return None + + def _download_price_data( + self, pair: str, start_date: str, end_date: str, interval: str + ): + symbol = self._to_yahoo_symbol(pair) + try: + df = yf.download( + tickers=symbol, + start=start_date, + end=(datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).strftime( + "%Y-%m-%d" + ), + interval=interval, + progress=False, + auto_adjust=True, + prepost=True, + threads=True, + ) + return df + except Exception: + return None + + def _get_price_on_date(self, pair: str, date: str) -> Optional[float]: + try: + start_date = datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1) + end_date = datetime.strptime(date, "%Y-%m-%d") + timedelta(days=1) + df = self._download_price_data( + pair, + start_date.strftime("%Y-%m-%d"), + end_date.strftime("%Y-%m-%d"), + interval="1d", + ) + if df is not None and not df.empty: + for col in ("Close", "Adj Close", "close"): + if col in df.columns and not df[col].empty: + try: + return float(df[col].iloc[-1]) + except Exception: + continue + except Exception: + return None + return None + + +def fetch_trending_fx_pairs(limit: int = 10) -> List[str]: + return ForexFetcher().get_major_pairs(limit=limit) + + +def fetch_fx_price_with_history( + pair: str, lookback_days: int = 7, date: Optional[str] = None +) -> Dict[str, Any]: + return ForexFetcher().get_price_with_history(pair, lookback_days, date) + diff --git a/live_trade_bench/systems/__init__.py b/live_trade_bench/systems/__init__.py index 46c6d7b..1458388 100644 --- a/live_trade_bench/systems/__init__.py +++ b/live_trade_bench/systems/__init__.py @@ -3,6 +3,7 @@ """ from .bitmex_system import BitMEXPortfolioSystem, create_bitmex_portfolio_system +from .forex_system import ForexPortfolioSystem, create_forex_portfolio_system from .polymarket_system import ( PolymarketPortfolioSystem, create_polymarket_portfolio_system, @@ -13,7 +14,9 @@ "BitMEXPortfolioSystem", "PolymarketPortfolioSystem", "StockPortfolioSystem", + "ForexPortfolioSystem", "create_bitmex_portfolio_system", "create_polymarket_portfolio_system", "create_stock_portfolio_system", + "create_forex_portfolio_system", ] diff --git a/live_trade_bench/systems/forex_system.py b/live_trade_bench/systems/forex_system.py new file mode 100644 index 0000000..caa6853 --- /dev/null +++ b/live_trade_bench/systems/forex_system.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Dict, List + +from ..accounts import ForexAccount, create_forex_account +from ..agents.forex_agent import LLMForexAgent +from ..fetchers.forex_fetcher import ForexFetcher +from ..fetchers.news_fetcher import fetch_news_data + + +class ForexPortfolioSystem: + """Portfolio system that manages FX allocations across major pairs.""" + + def __init__(self, universe_size: int = 12) -> None: + self.agents: Dict[str, LLMForexAgent] = {} + self.accounts: Dict[str, ForexAccount] = {} + self.universe: List[str] = [] + self.pair_info: Dict[str, Dict[str, Any]] = {} + self.cycle_count = 0 + self.universe_size = universe_size + self.fetcher = ForexFetcher() + + # ---------------------------------------------------------------- lifecycle + def initialize_for_live(self) -> None: + pairs = self.fetcher.get_major_pairs(limit=self.universe_size) + self.set_universe(pairs) + + def initialize_for_backtest(self, trading_days: List[datetime]) -> None: + _ = trading_days # For potential future use + self.initialize_for_live() + + def set_universe(self, pairs: List[str]) -> None: + self.universe = pairs + self.pair_info = {} + for pair in pairs: + base, quote = pair[:3], pair[3:6] + self.pair_info[pair] = {"base": base, "quote": quote} + + def add_agent( + self, name: str, initial_cash: float = 1000.0, model_name: str = "gpt-4o-mini" + ) -> None: + if name in self.agents: + return + agent = LLMForexAgent(name, model_name) + account = create_forex_account(initial_cash) + self.agents[name] = agent + self.accounts[name] = account + + def run_cycle(self, for_date: str | None = None) -> None: + self.cycle_count += 1 + current_time_str = for_date or datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + + market_data = self._fetch_market_data(current_time_str if for_date else None) + if not market_data: + print("No FX market data available, skipping cycle.") + return + + news_data = self._fetch_news_data(market_data, current_time_str if for_date else None) + allocations = self._generate_allocations(market_data, news_data, current_time_str) + self._update_accounts(allocations, market_data, current_time_str) + + # ----------------------------------------------------------------- fetchers + def _fetch_market_data( + self, for_date: str | None = None + ) -> Dict[str, Dict[str, Any]]: + market_data: Dict[str, Dict[str, Any]] = {} + + for pair in self.universe: + try: + price_payload = self.fetcher.get_price_with_history( + pair, lookback_days=10, date=for_date + ) + current_price = price_payload.get("current_price") + if current_price is None: + continue + + yahoo_symbol = self.fetcher._to_yahoo_symbol(pair) + url = f"https://finance.yahoo.com/quote/{yahoo_symbol}" + market_data[pair] = { + "pair": pair, + "base": price_payload.get("base", pair[:3]), + "quote": price_payload.get("quote", pair[3:6]), + "current_price": current_price, + "price_history": price_payload.get("price_history", []), + "url": url, + } + + for account in self.accounts.values(): + account.update_position_price(pair, current_price) + except Exception as exc: + print(f"Failed to fetch FX data for {pair}: {exc}") + + return market_data + + def _fetch_news_data( + self, market_data: Dict[str, Any], for_date: str | None + ) -> Dict[str, Any]: + news_map: Dict[str, Any] = {} + try: + if for_date: + ref = datetime.strptime(for_date[:10], "%Y-%m-%d") - timedelta(days=1) + else: + ref = datetime.utcnow() + + start_date = (ref - timedelta(days=3)).strftime("%Y-%m-%d") + end_date = ref.strftime("%Y-%m-%d") + + for pair, data in market_data.items(): + base = data.get("base", pair[:3]) + quote = data.get("quote", pair[3:6]) + query = f"{base} {quote} forex news" + news_map[pair] = fetch_news_data( + query, + start_date, + end_date, + max_pages=1, + ticker=pair, + target_date=for_date, + ) + except Exception as exc: + print(f"FX news fetch failed: {exc}") + return news_map + + def _fetch_social_data(self) -> Dict[str, List[Dict[str, Any]]]: + from ..fetchers.reddit_fetcher import RedditFetcher + + social_map: Dict[str, List[Dict[str, Any]]] = {} + fetcher = RedditFetcher() + + for pair in self.universe: + try: + base, quote = pair[:3], pair[3:6] + query = f"{base} {quote} forex" + posts = fetcher.fetch(category="forex", query=query, max_limit=8) + formatted = [] + for post in posts: + formatted.append( + { + "id": post.get("id", ""), + "title": post.get("title", ""), + "content": post.get("content", ""), + "author": post.get("author", "Unknown"), + "platform": "Reddit", + "url": post.get("url", ""), + "created_at": post.get("created_utc", ""), + "subreddit": post.get("subreddit", ""), + "upvotes": post.get("score", 0), + "num_comments": post.get("num_comments", 0), + "tag": pair, + } + ) + social_map[pair] = formatted + except Exception as exc: + print(f"FX social data fetch failed for {pair}: {exc}") + social_map[pair] = [] + + return social_map + + # --------------------------------------------------------------- allocations + def _generate_allocations( + self, + market_data: Dict[str, Any], + news_data: Dict[str, Any], + for_date: str | None, + ) -> Dict[str, Dict[str, float]]: + all_allocations: Dict[str, Dict[str, float]] = {} + for agent_name, agent in self.agents.items(): + account = self.accounts[agent_name] + account_data = account.get_account_data() + allocation = agent.generate_allocation( + market_data, account_data, for_date, news_data=news_data + ) + if allocation: + all_allocations[agent_name] = allocation + else: + all_allocations[agent_name] = account.target_allocations + return all_allocations + + def _update_accounts( + self, + allocations: Dict[str, Dict[str, float]], + market_data: Dict[str, Any], + for_date: str | None = None, + ) -> None: + price_map = {pair: data.get("current_price") for pair, data in market_data.items()} + + for agent_name, allocation in allocations.items(): + account = self.accounts[agent_name] + account.target_allocations = allocation + try: + account.apply_allocation( + allocation, price_map=price_map, metadata_map=market_data + ) + agent = self.agents.get(agent_name) + llm_input = getattr(agent, "last_llm_input", None) if agent else None + llm_output = getattr(agent, "last_llm_output", None) if agent else None + account.record_allocation( + metadata_map=market_data, + backtest_date=for_date, + llm_input=llm_input, + llm_output=llm_output, + ) + except Exception as exc: + print(f"Failed to update FX account for {agent_name}: {exc}") + + # --------------------------------------------------------------- singletons + @classmethod + def get_instance(cls): + if not hasattr(cls, "_instance"): + cls._instance = create_forex_portfolio_system() + return cls._instance + + +def create_forex_portfolio_system() -> ForexPortfolioSystem: + return ForexPortfolioSystem() + diff --git a/tests/test_data_fetcher.py b/tests/test_data_fetcher.py index be61dcf..8c5c938 100644 --- a/tests/test_data_fetcher.py +++ b/tests/test_data_fetcher.py @@ -7,6 +7,7 @@ import pytest from live_trade_bench.fetchers.base_fetcher import BaseFetcher +from live_trade_bench.fetchers.forex_fetcher import ForexFetcher from live_trade_bench.fetchers.news_fetcher import fetch_news_data from live_trade_bench.fetchers.polymarket_fetcher import ( PolymarketFetcher, @@ -83,6 +84,12 @@ def test_fetch_news_data_date_conversion() -> None: @patch("live_trade_bench.fetchers.base_fetcher.BaseFetcher.make_request") def test_fetch_news_data_no_results(mock_make_request: Mock) -> None: """Test news data fetching when no results are found.""" +def test_forex_fetcher_major_pairs() -> None: + """Ensure forex fetcher returns ordered major pairs.""" + fetcher = ForexFetcher() + pairs = fetcher.get_major_pairs(limit=5) + assert len(pairs) == 5 + assert all(isinstance(pair, str) for pair in pairs) # Mock empty response mock_response = Mock() mock_response.content = "" diff --git a/tests/test_forex_fetcher.py b/tests/test_forex_fetcher.py new file mode 100644 index 0000000..5478ca4 --- /dev/null +++ b/tests/test_forex_fetcher.py @@ -0,0 +1,76 @@ +"""Tests for the ForexFetcher helper.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import pandas as pd +import pytest + +from live_trade_bench.fetchers.forex_fetcher import ForexFetcher + + +def test_get_major_pairs_respects_limit() -> None: + fetcher = ForexFetcher() + pairs = fetcher.get_major_pairs(limit=3) + assert pairs == ["EURUSD", "USDJPY", "GBPUSD"] + assert len(pairs) == 3 + + +def test_get_price_with_history(monkeypatch: pytest.MonkeyPatch) -> None: + fetcher = ForexFetcher() + + df = pd.DataFrame( + { + "Close": [1.10, 1.12, 1.15], + "Volume": [1_000, 1_200, 1_500], + }, + index=pd.to_datetime( + ["2024-01-01", "2024-01-02", "2024-01-03"], utc=True + ), + ) + + def fake_download( + self: ForexFetcher, pair: str, start: str, end: str, interval: str + ) -> Any: + assert pair == "EURUSD" + assert interval == "1d" + return df + + def fake_get_price( + self: ForexFetcher, pair: str, date: str | None = None + ) -> float: + assert pair == "EURUSD" + return 1.2345 + + monkeypatch.setattr(ForexFetcher, "_download_price_data", fake_download) + monkeypatch.setattr(ForexFetcher, "get_price", fake_get_price) + + payload = fetcher.get_price_with_history("EURUSD", lookback_days=3) + + assert payload["pair"] == "EURUSD" + assert payload["base"] == "EUR" + assert payload["quote"] == "USD" + assert payload["current_price"] == pytest.approx(1.2345) + assert len(payload["price_history"]) == 3 + assert payload["price_history"][0]["price"] == pytest.approx(1.10) + + +def test_fetch_trending_pairs_mode(monkeypatch: pytest.MonkeyPatch) -> None: + fetcher = ForexFetcher() + + monkeypatch.setattr(fetcher, "get_major_pairs", lambda limit=10: ["EURUSD"]) + + result = fetcher.fetch("trending_pairs", limit=1) + assert result == ["EURUSD"] + + +def test_fetch_price_mode(monkeypatch: pytest.MonkeyPatch) -> None: + fetcher = ForexFetcher() + + monkeypatch.setattr(fetcher, "get_price", lambda pair, date=None: 1.1111) + + price = fetcher.fetch("price", pair="EURUSD") + assert price == pytest.approx(1.1111) +