diff --git a/.gitignore b/.gitignore index 3369bad9..d929ad86 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,14 @@ env/ __pycache__/ .DS_Store *.csv -src/ +/src/ eval_results/ eval_data/ *.egg-info/ .env + +# Node.js +node_modules/ + +# Frontend dev artifacts +.frontend-dev/ diff --git a/.playwright-mcp/01-dashboard.png b/.playwright-mcp/01-dashboard.png new file mode 100644 index 00000000..43f80757 Binary files /dev/null and b/.playwright-mcp/01-dashboard.png differ diff --git a/.playwright-mcp/02-settings-modal.png b/.playwright-mcp/02-settings-modal.png new file mode 100644 index 00000000..3b67507e Binary files /dev/null and b/.playwright-mcp/02-settings-modal.png differ diff --git a/.playwright-mcp/03-stock-detail-overview.png b/.playwright-mcp/03-stock-detail-overview.png new file mode 100644 index 00000000..07f7fb7f Binary files /dev/null and b/.playwright-mcp/03-stock-detail-overview.png differ diff --git a/.playwright-mcp/04-analysis-pipeline.png b/.playwright-mcp/04-analysis-pipeline.png new file mode 100644 index 00000000..ff13ced7 Binary files /dev/null and b/.playwright-mcp/04-analysis-pipeline.png differ diff --git a/.playwright-mcp/05-debates-tab.png b/.playwright-mcp/05-debates-tab.png new file mode 100644 index 00000000..45c40c5d Binary files /dev/null and b/.playwright-mcp/05-debates-tab.png differ diff --git a/.playwright-mcp/06-investment-debate-expanded.png b/.playwright-mcp/06-investment-debate-expanded.png new file mode 100644 index 00000000..02bc602f Binary files /dev/null and b/.playwright-mcp/06-investment-debate-expanded.png differ diff --git a/.playwright-mcp/07-data-sources-tab.png b/.playwright-mcp/07-data-sources-tab.png new file mode 100644 index 00000000..2df93b64 Binary files /dev/null and b/.playwright-mcp/07-data-sources-tab.png differ diff --git a/.playwright-mcp/08-dashboard-dark-mode.png b/.playwright-mcp/08-dashboard-dark-mode.png new file mode 100644 index 00000000..36680cd3 Binary files /dev/null and b/.playwright-mcp/08-dashboard-dark-mode.png differ diff --git a/.playwright-mcp/09-how-it-works.png b/.playwright-mcp/09-how-it-works.png new file mode 100644 index 00000000..95140dae Binary files /dev/null and b/.playwright-mcp/09-how-it-works.png differ diff --git a/.playwright-mcp/10-history-page.png b/.playwright-mcp/10-history-page.png new file mode 100644 index 00000000..0a5d01c4 Binary files /dev/null and b/.playwright-mcp/10-history-page.png differ diff --git a/.playwright-mcp/analysis-in-progress.png b/.playwright-mcp/analysis-in-progress.png new file mode 100644 index 00000000..e9314a9f Binary files /dev/null and b/.playwright-mcp/analysis-in-progress.png differ diff --git a/.playwright-mcp/analysis-running.png b/.playwright-mcp/analysis-running.png new file mode 100644 index 00000000..4f6a5a9e Binary files /dev/null and b/.playwright-mcp/analysis-running.png differ diff --git a/.playwright-mcp/analysis-working-network.png b/.playwright-mcp/analysis-working-network.png new file mode 100644 index 00000000..2a8d1247 Binary files /dev/null and b/.playwright-mcp/analysis-working-network.png differ diff --git a/.playwright-mcp/analysis-working-tcs.png b/.playwright-mcp/analysis-working-tcs.png new file mode 100644 index 00000000..7e6c7dfa Binary files /dev/null and b/.playwright-mcp/analysis-working-tcs.png differ diff --git a/.playwright-mcp/chrome-headless-test.png b/.playwright-mcp/chrome-headless-test.png new file mode 100644 index 00000000..1bd12bc4 Binary files /dev/null and b/.playwright-mcp/chrome-headless-test.png differ diff --git a/.playwright-mcp/current-state.png b/.playwright-mcp/current-state.png new file mode 100644 index 00000000..5ed48249 Binary files /dev/null and b/.playwright-mcp/current-state.png differ diff --git a/.playwright-mcp/dashboard-analyze-all.png b/.playwright-mcp/dashboard-analyze-all.png new file mode 100644 index 00000000..93a8a4f8 Binary files /dev/null and b/.playwright-mcp/dashboard-analyze-all.png differ diff --git a/.playwright-mcp/dashboard-before.png b/.playwright-mcp/dashboard-before.png new file mode 100644 index 00000000..f45dc554 Binary files /dev/null and b/.playwright-mcp/dashboard-before.png differ diff --git a/.playwright-mcp/dashboard-buy-filter-active.png b/.playwright-mcp/dashboard-buy-filter-active.png new file mode 100644 index 00000000..c42e1fdb Binary files /dev/null and b/.playwright-mcp/dashboard-buy-filter-active.png differ diff --git a/.playwright-mcp/dashboard-compact.png b/.playwright-mcp/dashboard-compact.png new file mode 100644 index 00000000..5a6e3049 Binary files /dev/null and b/.playwright-mcp/dashboard-compact.png differ diff --git a/.playwright-mcp/dashboard-hold-filter-final.png b/.playwright-mcp/dashboard-hold-filter-final.png new file mode 100644 index 00000000..b9dbea78 Binary files /dev/null and b/.playwright-mcp/dashboard-hold-filter-final.png differ diff --git a/.playwright-mcp/dashboard-scrolled.png b/.playwright-mcp/dashboard-scrolled.png new file mode 100644 index 00000000..367aae9b Binary files /dev/null and b/.playwright-mcp/dashboard-scrolled.png differ diff --git a/.playwright-mcp/dashboard-search-visible.png b/.playwright-mcp/dashboard-search-visible.png new file mode 100644 index 00000000..1efea958 Binary files /dev/null and b/.playwright-mcp/dashboard-search-visible.png differ diff --git a/.playwright-mcp/dashboard-with-search.png b/.playwright-mcp/dashboard-with-search.png new file mode 100644 index 00000000..25855731 Binary files /dev/null and b/.playwright-mcp/dashboard-with-search.png differ diff --git a/.playwright-mcp/history-compact.png b/.playwright-mcp/history-compact.png new file mode 100644 index 00000000..9a2afb97 Binary files /dev/null and b/.playwright-mcp/history-compact.png differ diff --git a/.playwright-mcp/history-new-calc.png b/.playwright-mcp/history-new-calc.png new file mode 100644 index 00000000..95f70d68 Binary files /dev/null and b/.playwright-mcp/history-new-calc.png differ diff --git a/.playwright-mcp/history-page-current.png b/.playwright-mcp/history-page-current.png new file mode 100644 index 00000000..39990d98 Binary files /dev/null and b/.playwright-mcp/history-page-current.png differ diff --git a/.playwright-mcp/history-page-updated.png b/.playwright-mcp/history-page-updated.png new file mode 100644 index 00000000..608254ca Binary files /dev/null and b/.playwright-mcp/history-page-updated.png differ diff --git a/.playwright-mcp/history-sparklines-2.png b/.playwright-mcp/history-sparklines-2.png new file mode 100644 index 00000000..7d9c5af3 Binary files /dev/null and b/.playwright-mcp/history-sparklines-2.png differ diff --git a/.playwright-mcp/history-sparklines-more.png b/.playwright-mcp/history-sparklines-more.png new file mode 100644 index 00000000..71225674 Binary files /dev/null and b/.playwright-mcp/history-sparklines-more.png differ diff --git a/.playwright-mcp/history-sparklines-normalized.png b/.playwright-mcp/history-sparklines-normalized.png new file mode 100644 index 00000000..0d5ce53b Binary files /dev/null and b/.playwright-mcp/history-sparklines-normalized.png differ diff --git a/.playwright-mcp/history-sparklines-scrolled.png b/.playwright-mcp/history-sparklines-scrolled.png new file mode 100644 index 00000000..33129dd4 Binary files /dev/null and b/.playwright-mcp/history-sparklines-scrolled.png differ diff --git a/.playwright-mcp/history-sparklines.png b/.playwright-mcp/history-sparklines.png new file mode 100644 index 00000000..7d9c5af3 Binary files /dev/null and b/.playwright-mcp/history-sparklines.png differ diff --git a/.playwright-mcp/history-stock-list.png b/.playwright-mcp/history-stock-list.png new file mode 100644 index 00000000..a3c88907 Binary files /dev/null and b/.playwright-mcp/history-stock-list.png differ diff --git a/.playwright-mcp/mobile-view.png b/.playwright-mcp/mobile-view.png new file mode 100644 index 00000000..eae6f0b6 Binary files /dev/null and b/.playwright-mcp/mobile-view.png differ diff --git a/.playwright-mcp/overall-modal-fixed.png b/.playwright-mcp/overall-modal-fixed.png new file mode 100644 index 00000000..3a06d7f0 Binary files /dev/null and b/.playwright-mcp/overall-modal-fixed.png differ diff --git a/.playwright-mcp/overall-modal-table.png b/.playwright-mcp/overall-modal-table.png new file mode 100644 index 00000000..cdcbcf36 Binary files /dev/null and b/.playwright-mcp/overall-modal-table.png differ diff --git a/.playwright-mcp/overall-modal.png b/.playwright-mcp/overall-modal.png new file mode 100644 index 00000000..3a06d7f0 Binary files /dev/null and b/.playwright-mcp/overall-modal.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-39-38-424Z.png b/.playwright-mcp/page-2026-01-31T10-39-38-424Z.png new file mode 100644 index 00000000..cd6f8045 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-39-38-424Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-41-56-205Z.png b/.playwright-mcp/page-2026-01-31T10-41-56-205Z.png new file mode 100644 index 00000000..e0df99eb Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-41-56-205Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-42-07-250Z.png b/.playwright-mcp/page-2026-01-31T10-42-07-250Z.png new file mode 100644 index 00000000..96ab48c6 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-42-07-250Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-42-21-398Z.png b/.playwright-mcp/page-2026-01-31T10-42-21-398Z.png new file mode 100644 index 00000000..71be46f7 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-42-21-398Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-43-02-673Z.png b/.playwright-mcp/page-2026-01-31T10-43-02-673Z.png new file mode 100644 index 00000000..5970ecb6 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-43-02-673Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-43-38-177Z.png b/.playwright-mcp/page-2026-01-31T10-43-38-177Z.png new file mode 100644 index 00000000..ad8898b0 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-43-38-177Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-44-36-104Z.png b/.playwright-mcp/page-2026-01-31T10-44-36-104Z.png new file mode 100644 index 00000000..fc31ccb0 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-44-36-104Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-44-56-012Z.png b/.playwright-mcp/page-2026-01-31T10-44-56-012Z.png new file mode 100644 index 00000000..8bb9d2ae Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-44-56-012Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-45-15-489Z.png b/.playwright-mcp/page-2026-01-31T10-45-15-489Z.png new file mode 100644 index 00000000..916b50bf Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-45-15-489Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-45-42-676Z.png b/.playwright-mcp/page-2026-01-31T10-45-42-676Z.png new file mode 100644 index 00000000..4d1b71e4 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-45-42-676Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-45-58-686Z.png b/.playwright-mcp/page-2026-01-31T10-45-58-686Z.png new file mode 100644 index 00000000..c50f2026 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-45-58-686Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-46-33-307Z.png b/.playwright-mcp/page-2026-01-31T10-46-33-307Z.png new file mode 100644 index 00000000..ca2e0763 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-46-33-307Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-47-05-151Z.png b/.playwright-mcp/page-2026-01-31T10-47-05-151Z.png new file mode 100644 index 00000000..40448610 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-47-05-151Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-47-42-171Z.png b/.playwright-mcp/page-2026-01-31T10-47-42-171Z.png new file mode 100644 index 00000000..742cb46f Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-47-42-171Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-49-11-278Z.png b/.playwright-mcp/page-2026-01-31T10-49-11-278Z.png new file mode 100644 index 00000000..425d3016 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-49-11-278Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-49-27-614Z.png b/.playwright-mcp/page-2026-01-31T10-49-27-614Z.png new file mode 100644 index 00000000..4c04faae Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-49-27-614Z.png differ diff --git a/.playwright-mcp/page-2026-01-31T10-49-46-409Z.png b/.playwright-mcp/page-2026-01-31T10-49-46-409Z.png new file mode 100644 index 00000000..68827663 Binary files /dev/null and b/.playwright-mcp/page-2026-01-31T10-49-46-409Z.png differ diff --git a/.playwright-mcp/return-modal-formula.png b/.playwright-mcp/return-modal-formula.png new file mode 100644 index 00000000..bd655874 Binary files /dev/null and b/.playwright-mcp/return-modal-formula.png differ diff --git a/.playwright-mcp/return-modal-scrolled.png b/.playwright-mcp/return-modal-scrolled.png new file mode 100644 index 00000000..468e1059 Binary files /dev/null and b/.playwright-mcp/return-modal-scrolled.png differ diff --git a/.playwright-mcp/return-modal.png b/.playwright-mcp/return-modal.png new file mode 100644 index 00000000..57878255 Binary files /dev/null and b/.playwright-mcp/return-modal.png differ diff --git a/.playwright-mcp/settings-api-key.png b/.playwright-mcp/settings-api-key.png new file mode 100644 index 00000000..3b67507e Binary files /dev/null and b/.playwright-mcp/settings-api-key.png differ diff --git a/.playwright-mcp/settings-modal.png b/.playwright-mcp/settings-modal.png new file mode 100644 index 00000000..dc1b3608 Binary files /dev/null and b/.playwright-mcp/settings-modal.png differ diff --git a/.playwright-mcp/stock-detail-compact.png b/.playwright-mcp/stock-detail-compact.png new file mode 100644 index 00000000..1d5665a6 Binary files /dev/null and b/.playwright-mcp/stock-detail-compact.png differ diff --git a/.playwright-mcp/stocks-page-compact.png b/.playwright-mcp/stocks-page-compact.png new file mode 100644 index 00000000..3cbec6f0 Binary files /dev/null and b/.playwright-mcp/stocks-page-compact.png differ diff --git a/README.md b/README.md index 7e90c60f..a3f7f112 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,143 @@ An interface will appear showing results as they load, letting you track the age

+--- + +## 🌐 Nifty50 AI Trading Dashboard (Web Frontend) + +A modern, feature-rich web dashboard for TradingAgents, specifically built for **Indian Nifty 50 stocks**. This dashboard provides a complete visual interface for AI-powered stock analysis with full transparency into the multi-agent decision process. + +### 🚀 Quick Start + +```bash +# Start the backend server +cd frontend/backend +pip install -r requirements.txt +python server.py # Runs on http://localhost:8001 + +# Start the frontend (in a new terminal) +cd frontend +npm install +npm run dev # Runs on http://localhost:5173 +``` + +### ✨ Key Features + +#### Dashboard - AI Recommendations at a Glance +View all 50 Nifty stocks with AI recommendations, top picks, stocks to avoid, and one-click bulk analysis. + +

+ +

+ +#### 🌙 Dark Mode Support +Full dark mode with automatic system theme detection for comfortable viewing. + +

+ +

+ +#### ⚙️ Configurable Settings Panel +Configure your AI analysis directly from the browser: +- **LLM Provider**: Claude Subscription or Anthropic API +- **Model Selection**: Choose Deep Think (Opus) and Quick Think (Sonnet/Haiku) models +- **API Key Management**: Securely stored in browser localStorage +- **Debate Rounds**: Adjust thoroughness (1-5 rounds) + +

+ +

+ +#### 📊 Stock Detail View +Detailed analysis for each stock with interactive price charts, recommendation history, and AI analysis summaries. + +

+ +

+ +#### 🔬 Analysis Pipeline Visualization +See exactly how the AI reached its decision with a 9-step pipeline showing: +- Data collection progress +- Individual agent reports (Market, News, Social Media, Fundamentals) +- Real-time status tracking + +

+ +

+ +#### 💬 Investment Debates (Bull vs Bear) +Watch AI agents debate investment decisions with full transparency: +- **Bull Analyst**: Makes the case for buying +- **Bear Analyst**: Presents risks and concerns +- **Research Manager**: Weighs both sides and decides + +

+ +

+ +
+📜 View Full Debate Example (Click to expand) +

+ +

+
+ +#### 📈 Historical Analysis & Backtesting +Track AI performance over time with comprehensive analytics: +- Prediction accuracy metrics (Buy/Sell/Hold) +- Risk metrics (Sharpe ratio, max drawdown, win rate) +- Portfolio simulator with customizable starting amounts +- AI Strategy vs Nifty50 Index comparison + +

+ +

+ +#### 📚 How It Works +Educational content explaining the multi-agent AI system and decision process. + +

+ +

+ +### 🛠️ Frontend Tech Stack + +| Technology | Purpose | +|------------|---------| +| React 18 + TypeScript | Core framework | +| Vite | Build tool & dev server | +| Tailwind CSS | Styling with dark mode | +| Recharts | Interactive charts | +| Lucide React | Icons | +| FastAPI (Python) | Backend API | +| SQLite | Data persistence | + +### 📁 Frontend Project Structure + +``` +frontend/ +├── src/ +│ ├── components/ +│ │ ├── pipeline/ # Pipeline visualization +│ │ ├── SettingsModal.tsx # Settings UI +│ │ └── Header.tsx +│ ├── contexts/ +│ │ └── SettingsContext.tsx +│ ├── pages/ +│ │ ├── Dashboard.tsx +│ │ ├── StockDetail.tsx +│ │ ├── History.tsx +│ │ └── About.tsx +│ └── services/ +│ └── api.ts +├── backend/ +│ ├── server.py +│ └── database.py +└── docs/screenshots/ +``` + +--- + ## TradingAgents Package ### Implementation Details diff --git a/cli/main.py b/cli/main.py index 2e06d50c..3f4ddc0c 100644 --- a/cli/main.py +++ b/cli/main.py @@ -26,6 +26,7 @@ from rich.rule import Rule from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.default_config import DEFAULT_CONFIG +from tradingagents.dataflows.markets import is_nifty_50_stock, NIFTY_50_STOCKS from cli.models import AnalystType from cli.utils import * @@ -429,29 +430,42 @@ def get_user_selections(): box_content += f"\n[dim]Default: {default}[/dim]" return Panel(box_content, border_style="blue", padding=(1, 2)) - # Step 1: Ticker symbol + # Step 1: Market selection console.print( create_question_box( - "Step 1: Ticker Symbol", "Enter the ticker symbol to analyze", "SPY" + "Step 1: Market Selection", "Select the market for your analysis" ) ) - selected_ticker = get_ticker() + selected_market = select_market() - # Step 2: Analysis date + # Show Nifty 50 stocks if Indian market is selected + if selected_market == "india_nse": + show_nifty_50_stocks() + + # Step 2: Ticker symbol + console.print( + create_question_box( + "Step 2: Ticker Symbol", "Enter the ticker symbol to analyze", + "RELIANCE" if selected_market == "india_nse" else "SPY" + ) + ) + selected_ticker = get_ticker_with_market_hint(selected_market) + + # Step 3: Analysis date default_date = datetime.datetime.now().strftime("%Y-%m-%d") console.print( create_question_box( - "Step 2: Analysis Date", + "Step 3: Analysis Date", "Enter the analysis date (YYYY-MM-DD)", default_date, ) ) analysis_date = get_analysis_date() - # Step 3: Select analysts + # Step 4: Select analysts console.print( create_question_box( - "Step 3: Analysts Team", "Select your LLM analyst agents for the analysis" + "Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" ) ) selected_analysts = select_analysts() @@ -459,26 +473,26 @@ def get_user_selections(): f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}" ) - # Step 4: Research depth + # Step 5: Research depth console.print( create_question_box( - "Step 4: Research Depth", "Select your research depth level" + "Step 5: Research Depth", "Select your research depth level" ) ) selected_research_depth = select_research_depth() - # Step 5: OpenAI backend + # Step 6: OpenAI backend console.print( create_question_box( - "Step 5: OpenAI backend", "Select which service to talk to" + "Step 6: LLM Provider", "Select which service to talk to" ) ) selected_llm_provider, backend_url = select_llm_provider() - - # Step 6: Thinking agents + + # Step 7: Thinking agents console.print( create_question_box( - "Step 6: Thinking Agents", "Select your thinking agents for analysis" + "Step 7: Thinking Agents", "Select your thinking agents for analysis" ) ) selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider) @@ -493,6 +507,7 @@ def get_user_selections(): "backend_url": backend_url, "shallow_thinker": selected_shallow_thinker, "deep_thinker": selected_deep_thinker, + "market": selected_market, } @@ -747,6 +762,13 @@ def run_analysis(): config["deep_think_llm"] = selections["deep_thinker"] config["backend_url"] = selections["backend_url"] config["llm_provider"] = selections["llm_provider"].lower() + config["market"] = selections["market"] + + # Display market info for NSE stocks + if is_nifty_50_stock(selections["ticker"]): + company_name = NIFTY_50_STOCKS.get(selections["ticker"].replace(".NS", ""), "") + console.print(f"[cyan]Analyzing NSE stock:[/cyan] {selections['ticker']} - {company_name}") + console.print("[dim]Using jugaad-data for NSE stock data, yfinance for fundamentals[/dim]") # Initialize the graph graph = TradingAgentsGraph( @@ -808,10 +830,17 @@ def run_analysis(): update_display(layout) # Add initial messages - message_buffer.add_message("System", f"Selected ticker: {selections['ticker']}") + ticker_info = selections['ticker'] + if is_nifty_50_stock(selections['ticker']): + company_name = NIFTY_50_STOCKS.get(selections['ticker'].replace(".NS", ""), "") + ticker_info = f"{selections['ticker']} ({company_name}) [NSE]" + message_buffer.add_message("System", f"Selected ticker: {ticker_info}") message_buffer.add_message( "System", f"Analysis date: {selections['analysis_date']}" ) + message_buffer.add_message( + "System", f"Market: {selections['market'].upper()}" + ) message_buffer.add_message( "System", f"Selected analysts: {', '.join(analyst.value for analyst in selections['analysts'])}", diff --git a/cli/utils.py b/cli/utils.py index 7b9682a6..93f6d7aa 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -1,7 +1,13 @@ import questionary from typing import List, Optional, Tuple, Dict +from rich.console import Console +from rich.table import Table +from rich import box from cli.models import AnalystType +from tradingagents.dataflows.markets import NIFTY_50_STOCKS, is_nifty_50_stock + +console = Console() ANALYST_ORDER = [ ("Market Analyst", AnalystType.MARKET), @@ -272,5 +278,117 @@ def select_llm_provider() -> tuple[str, str]: display_name, url = choice print(f"You selected: {display_name}\tURL: {url}") - + return display_name, url + + +def select_market() -> str: + """Select market using an interactive selection.""" + + MARKET_OPTIONS = [ + ("Auto-detect (Recommended)", "auto"), + ("US Markets (NYSE, NASDAQ)", "us"), + ("Indian NSE (Nifty 50)", "india_nse"), + ] + + choice = questionary.select( + "Select Your [Market]:", + choices=[ + questionary.Choice(display, value=value) + for display, value in MARKET_OPTIONS + ], + instruction="\n- Use arrow keys to navigate\n- Press Enter to select", + style=questionary.Style( + [ + ("selected", "fg:cyan noinherit"), + ("highlighted", "fg:cyan noinherit"), + ("pointer", "fg:cyan noinherit"), + ] + ), + ).ask() + + if choice is None: + console.print("\n[red]No market selected. Exiting...[/red]") + exit(1) + + return choice + + +def display_nifty_50_stocks(): + """Display the list of Nifty 50 stocks in a formatted table.""" + table = Table( + title="Nifty 50 Stocks", + box=box.ROUNDED, + show_header=True, + header_style="bold cyan", + ) + + table.add_column("Symbol", style="green", width=15) + table.add_column("Company Name", style="white", width=45) + + # Sort stocks alphabetically + sorted_stocks = sorted(NIFTY_50_STOCKS.items()) + + for symbol, company_name in sorted_stocks: + table.add_row(symbol, company_name) + + console.print(table) + console.print() + + +def show_nifty_50_stocks() -> bool: + """Ask user if they want to see Nifty 50 stocks list.""" + show = questionary.confirm( + "Would you like to see the list of Nifty 50 stocks?", + default=False, + style=questionary.Style( + [ + ("selected", "fg:cyan noinherit"), + ("highlighted", "fg:cyan noinherit"), + ] + ), + ).ask() + + if show: + display_nifty_50_stocks() + + return show + + +def get_ticker_with_market_hint(market: str) -> str: + """Get ticker symbol with market-specific hints.""" + if market == "india_nse": + hint = "Enter NSE symbol (e.g., RELIANCE, TCS, INFY)" + default = "RELIANCE" + elif market == "us": + hint = "Enter US ticker symbol (e.g., AAPL, GOOGL, MSFT)" + default = "SPY" + else: + hint = "Enter ticker symbol (auto-detects market)" + default = "SPY" + + ticker = questionary.text( + hint + ":", + default=default, + validate=lambda x: len(x.strip()) > 0 or "Please enter a valid ticker symbol.", + style=questionary.Style( + [ + ("text", "fg:green"), + ("highlighted", "noinherit"), + ] + ), + ).ask() + + if not ticker: + console.print("\n[red]No ticker symbol provided. Exiting...[/red]") + exit(1) + + ticker = ticker.strip().upper() + + # Provide feedback for NSE stocks + if is_nifty_50_stock(ticker): + company_name = NIFTY_50_STOCKS.get(ticker.replace(".NS", ""), "") + if company_name: + console.print(f"[green]Detected NSE stock:[/green] {ticker} - {company_name}") + + return ticker diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..17b788a1 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,210 @@ +# Nifty50 AI Trading Dashboard + +A modern, feature-rich frontend for the TradingAgents multi-agent AI stock analysis system. This dashboard provides real-time AI-powered recommendations for all 50 stocks in the Nifty 50 index, with full visibility into the analysis pipeline, agent reports, and debate processes. + +## Features Overview + +### Dashboard - Main View +The main dashboard displays AI recommendations for all 50 Nifty stocks with: +- **Summary Statistics**: Quick view of Buy/Hold/Sell distribution +- **Top Picks**: Highlighted stocks with the strongest buy signals +- **Stocks to Avoid**: High-confidence sell recommendations +- **Analyze All**: One-click bulk analysis of all stocks +- **Filter & Search**: Filter by recommendation type or search by symbol + +![Dashboard](docs/screenshots/01-dashboard.png) + +### Dark Mode Support +Full dark mode support with automatic system theme detection: + +![Dashboard Dark Mode](docs/screenshots/08-dashboard-dark-mode.png) + +### Settings Panel +Configure the AI analysis system directly from the browser: +- **LLM Provider Selection**: Choose between Claude Subscription or Anthropic API +- **API Key Management**: Securely store API keys in browser localStorage +- **Model Selection**: Configure Deep Think (Opus) and Quick Think (Sonnet/Haiku) models +- **Analysis Settings**: Adjust max debate rounds for thoroughness vs speed + +![Settings Modal](docs/screenshots/02-settings-modal.png) + +### Stock Detail View +Detailed analysis view for individual stocks with: +- **Price Chart**: Interactive price history with buy/sell/hold signal markers +- **Recommendation Details**: Decision, confidence level, and risk assessment +- **Recommendation History**: Historical AI decisions for the stock +- **AI Analysis Summary**: Expandable detailed analysis sections + +![Stock Detail Overview](docs/screenshots/03-stock-detail-overview.png) + +### Analysis Pipeline Visualization +See exactly how the AI reached its decision with the full analysis pipeline: +- **9-Step Pipeline**: Track progress through data collection, analysis, debates, and final decision +- **Agent Reports**: View individual reports from Market, News, Social Media, and Fundamentals analysts +- **Real-time Status**: See which steps are completed, running, or pending + +![Analysis Pipeline](docs/screenshots/04-analysis-pipeline.png) + +### Investment Debates +The AI uses a debate system where Bull and Bear analysts argue their cases: +- **Bull vs Bear**: Opposing viewpoints with detailed arguments +- **Research Manager Decision**: Final judgment weighing both sides +- **Full Debate History**: Complete transcript of the debate rounds + +![Debates Tab](docs/screenshots/05-debates-tab.png) + +#### Expanded Debate View +Full debate content with Bull and Bear arguments: + +![Investment Debate Expanded](docs/screenshots/06-investment-debate-expanded.png) + +### Data Sources Tracking +View all raw data sources used for analysis: +- **Source Types**: Market data, news, fundamentals, social media +- **Fetch Status**: Success/failure indicators for each data source +- **Data Preview**: Expandable view of fetched data + +![Data Sources Tab](docs/screenshots/07-data-sources-tab.png) + +### How It Works Page +Educational content explaining the multi-agent AI system: +- **Multi-Agent Architecture**: Overview of the specialized AI agents +- **Analysis Process**: Step-by-step breakdown of the pipeline +- **Agent Profiles**: Details about each analyst type +- **Debate Process**: Explanation of how consensus is reached + +![How It Works](docs/screenshots/09-how-it-works.png) + +### Historical Analysis & Backtesting +Track AI performance over time with comprehensive analytics: +- **Prediction Accuracy**: Overall and per-recommendation-type accuracy +- **Accuracy Trend**: Visualize accuracy over time +- **Risk Metrics**: Sharpe ratio, max drawdown, win rate +- **Portfolio Simulator**: Test different investment amounts +- **AI vs Nifty50**: Compare AI strategy performance against the index +- **Return Distribution**: Histogram of next-day returns + +![History Page](docs/screenshots/10-history-page.png) + +## Tech Stack + +- **Frontend**: React 18 + TypeScript + Vite +- **Styling**: Tailwind CSS with dark mode support +- **Charts**: Recharts for interactive visualizations +- **Icons**: Lucide React +- **State Management**: React Context API +- **Backend**: FastAPI (Python) with SQLite database + +## Getting Started + +### Prerequisites +- Node.js 18+ +- Python 3.10+ +- npm or yarn + +### Installation + +1. **Install frontend dependencies:** +```bash +cd frontend +npm install +``` + +2. **Install backend dependencies:** +```bash +cd frontend/backend +pip install -r requirements.txt +``` + +### Running the Application + +1. **Start the backend server:** +```bash +cd frontend/backend +python server.py +``` +The backend runs on `http://localhost:8001` + +2. **Start the frontend development server:** +```bash +cd frontend +npm run dev +``` +The frontend runs on `http://localhost:5173` + +## Project Structure + +``` +frontend/ +├── src/ +│ ├── components/ +│ │ ├── pipeline/ # Pipeline visualization components +│ │ │ ├── PipelineOverview.tsx +│ │ │ ├── AgentReportCard.tsx +│ │ │ ├── DebateViewer.tsx +│ │ │ ├── RiskDebateViewer.tsx +│ │ │ └── DataSourcesPanel.tsx +│ │ ├── Header.tsx +│ │ ├── SettingsModal.tsx +│ │ └── ... +│ ├── contexts/ +│ │ └── SettingsContext.tsx # Settings state management +│ ├── pages/ +│ │ ├── Dashboard.tsx +│ │ ├── StockDetail.tsx +│ │ ├── History.tsx +│ │ └── About.tsx +│ ├── services/ +│ │ └── api.ts # API client +│ ├── types/ +│ │ └── pipeline.ts # TypeScript types for pipeline data +│ └── App.tsx +├── backend/ +│ ├── server.py # FastAPI server +│ ├── database.py # SQLite database operations +│ └── recommendations.db # SQLite database +└── docs/ + └── screenshots/ # Feature screenshots +``` + +## API Endpoints + +### Recommendations +- `GET /recommendations/{date}` - Get all recommendations for a date +- `GET /recommendations/{date}/{symbol}` - Get recommendation for a specific stock +- `POST /recommendations` - Save new recommendations + +### Pipeline Data +- `GET /recommendations/{date}/{symbol}/pipeline` - Get full pipeline data +- `GET /recommendations/{date}/{symbol}/agents` - Get agent reports +- `GET /recommendations/{date}/{symbol}/debates` - Get debate history +- `GET /recommendations/{date}/{symbol}/data-sources` - Get data source logs + +### Analysis +- `POST /analyze/{symbol}` - Run analysis for a single stock +- `POST /analyze-bulk` - Run analysis for multiple stocks + +## Configuration + +Settings are stored in browser localStorage and include: +- `deepThinkModel`: Model for complex analysis (opus/sonnet/haiku) +- `quickThinkModel`: Model for fast operations (opus/sonnet/haiku) +- `provider`: LLM provider (claude_subscription/anthropic_api) +- `anthropicApiKey`: API key for Anthropic API provider +- `maxDebateRounds`: Number of debate rounds (1-5) + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests and linting +5. Submit a pull request + +## License + +This project is part of the TradingAgents research project. + +## Disclaimer + +AI-generated recommendations are for educational and informational purposes only. These do not constitute financial advice. Always conduct your own research and consult with a qualified financial advisor before making investment decisions. diff --git a/frontend/backend/database.py b/frontend/backend/database.py new file mode 100644 index 00000000..da3a72a2 --- /dev/null +++ b/frontend/backend/database.py @@ -0,0 +1,702 @@ +"""SQLite database module for storing stock recommendations.""" +import sqlite3 +import json +from pathlib import Path +from datetime import datetime +from typing import Optional + +DB_PATH = Path(__file__).parent / "recommendations.db" + + +def get_connection(): + """Get SQLite database connection.""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + """Initialize the database with required tables.""" + conn = get_connection() + cursor = conn.cursor() + + # Create recommendations table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS daily_recommendations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT UNIQUE NOT NULL, + summary_total INTEGER, + summary_buy INTEGER, + summary_sell INTEGER, + summary_hold INTEGER, + top_picks TEXT, + stocks_to_avoid TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Create stock analysis table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS stock_analysis ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + symbol TEXT NOT NULL, + company_name TEXT, + decision TEXT, + confidence TEXT, + risk TEXT, + raw_analysis TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(date, symbol) + ) + """) + + # Create index for faster queries + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_stock_analysis_date ON stock_analysis(date) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_stock_analysis_symbol ON stock_analysis(symbol) + """) + + # Create agent_reports table (stores each analyst's detailed report) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS agent_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + symbol TEXT NOT NULL, + agent_type TEXT NOT NULL, + report_content TEXT, + data_sources_used TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(date, symbol, agent_type) + ) + """) + + # Create debate_history table (stores investment and risk debates) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS debate_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + symbol TEXT NOT NULL, + debate_type TEXT NOT NULL, + bull_arguments TEXT, + bear_arguments TEXT, + risky_arguments TEXT, + safe_arguments TEXT, + neutral_arguments TEXT, + judge_decision TEXT, + full_history TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(date, symbol, debate_type) + ) + """) + + # Create pipeline_steps table (stores step-by-step execution log) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS pipeline_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + symbol TEXT NOT NULL, + step_number INTEGER, + step_name TEXT, + status TEXT, + started_at TEXT, + completed_at TEXT, + duration_ms INTEGER, + output_summary TEXT, + UNIQUE(date, symbol, step_number) + ) + """) + + # Create data_source_logs table (stores what raw data was fetched) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS data_source_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + symbol TEXT NOT NULL, + source_type TEXT, + source_name TEXT, + data_fetched TEXT, + fetch_timestamp TEXT, + success INTEGER DEFAULT 1, + error_message TEXT + ) + """) + + # Create indexes for new tables + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_agent_reports_date_symbol ON agent_reports(date, symbol) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_debate_history_date_symbol ON debate_history(date, symbol) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_pipeline_steps_date_symbol ON pipeline_steps(date, symbol) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_data_source_logs_date_symbol ON data_source_logs(date, symbol) + """) + + conn.commit() + conn.close() + + +def save_recommendation(date: str, analysis_data: dict, summary: dict, + top_picks: list, stocks_to_avoid: list): + """Save a daily recommendation to the database.""" + conn = get_connection() + cursor = conn.cursor() + + try: + # Insert or replace daily recommendation + cursor.execute(""" + INSERT OR REPLACE INTO daily_recommendations + (date, summary_total, summary_buy, summary_sell, summary_hold, top_picks, stocks_to_avoid) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + date, + summary.get('total', 0), + summary.get('buy', 0), + summary.get('sell', 0), + summary.get('hold', 0), + json.dumps(top_picks), + json.dumps(stocks_to_avoid) + )) + + # Insert stock analysis for each stock + for symbol, analysis in analysis_data.items(): + cursor.execute(""" + INSERT OR REPLACE INTO stock_analysis + (date, symbol, company_name, decision, confidence, risk, raw_analysis) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + date, + symbol, + analysis.get('company_name', ''), + analysis.get('decision'), + analysis.get('confidence'), + analysis.get('risk'), + analysis.get('raw_analysis', '') + )) + + conn.commit() + finally: + conn.close() + + +def get_recommendation_by_date(date: str) -> Optional[dict]: + """Get recommendation for a specific date.""" + conn = get_connection() + cursor = conn.cursor() + + try: + # Get daily summary + cursor.execute(""" + SELECT * FROM daily_recommendations WHERE date = ? + """, (date,)) + row = cursor.fetchone() + + if not row: + return None + + # Get stock analysis for this date + cursor.execute(""" + SELECT * FROM stock_analysis WHERE date = ? + """, (date,)) + analysis_rows = cursor.fetchall() + + analysis = {} + for a in analysis_rows: + analysis[a['symbol']] = { + 'symbol': a['symbol'], + 'company_name': a['company_name'], + 'decision': a['decision'], + 'confidence': a['confidence'], + 'risk': a['risk'], + 'raw_analysis': a['raw_analysis'] + } + + return { + 'date': row['date'], + 'analysis': analysis, + 'summary': { + 'total': row['summary_total'], + 'buy': row['summary_buy'], + 'sell': row['summary_sell'], + 'hold': row['summary_hold'] + }, + 'top_picks': json.loads(row['top_picks']) if row['top_picks'] else [], + 'stocks_to_avoid': json.loads(row['stocks_to_avoid']) if row['stocks_to_avoid'] else [] + } + finally: + conn.close() + + +def get_latest_recommendation() -> Optional[dict]: + """Get the most recent recommendation.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + SELECT date FROM daily_recommendations ORDER BY date DESC LIMIT 1 + """) + row = cursor.fetchone() + + if not row: + return None + + return get_recommendation_by_date(row['date']) + finally: + conn.close() + + +def get_all_dates() -> list: + """Get all available dates.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + SELECT date FROM daily_recommendations ORDER BY date DESC + """) + return [row['date'] for row in cursor.fetchall()] + finally: + conn.close() + + +def get_stock_history(symbol: str) -> list: + """Get historical recommendations for a specific stock.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + SELECT date, decision, confidence, risk + FROM stock_analysis + WHERE symbol = ? + ORDER BY date DESC + """, (symbol,)) + + return [ + { + 'date': row['date'], + 'decision': row['decision'], + 'confidence': row['confidence'], + 'risk': row['risk'] + } + for row in cursor.fetchall() + ] + finally: + conn.close() + + +def get_all_recommendations() -> list: + """Get all daily recommendations.""" + dates = get_all_dates() + return [get_recommendation_by_date(date) for date in dates] + + +# ============== Pipeline Data Functions ============== + +def save_agent_report(date: str, symbol: str, agent_type: str, + report_content: str, data_sources_used: list = None): + """Save an individual agent's report.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + INSERT OR REPLACE INTO agent_reports + (date, symbol, agent_type, report_content, data_sources_used) + VALUES (?, ?, ?, ?, ?) + """, ( + date, symbol, agent_type, report_content, + json.dumps(data_sources_used) if data_sources_used else '[]' + )) + conn.commit() + finally: + conn.close() + + +def save_agent_reports_bulk(date: str, symbol: str, reports: dict): + """Save all agent reports for a stock at once. + + Args: + date: Date string (YYYY-MM-DD) + symbol: Stock symbol + reports: Dict with keys 'market', 'news', 'social_media', 'fundamentals' + """ + conn = get_connection() + cursor = conn.cursor() + + try: + for agent_type, report_data in reports.items(): + if isinstance(report_data, str): + report_content = report_data + data_sources = [] + else: + report_content = report_data.get('content', '') + data_sources = report_data.get('data_sources', []) + + cursor.execute(""" + INSERT OR REPLACE INTO agent_reports + (date, symbol, agent_type, report_content, data_sources_used) + VALUES (?, ?, ?, ?, ?) + """, (date, symbol, agent_type, report_content, json.dumps(data_sources))) + + conn.commit() + finally: + conn.close() + + +def get_agent_reports(date: str, symbol: str) -> dict: + """Get all agent reports for a stock on a date.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + SELECT agent_type, report_content, data_sources_used, created_at + FROM agent_reports + WHERE date = ? AND symbol = ? + """, (date, symbol)) + + reports = {} + for row in cursor.fetchall(): + reports[row['agent_type']] = { + 'agent_type': row['agent_type'], + 'report_content': row['report_content'], + 'data_sources_used': json.loads(row['data_sources_used']) if row['data_sources_used'] else [], + 'created_at': row['created_at'] + } + return reports + finally: + conn.close() + + +def save_debate_history(date: str, symbol: str, debate_type: str, + bull_arguments: str = None, bear_arguments: str = None, + risky_arguments: str = None, safe_arguments: str = None, + neutral_arguments: str = None, judge_decision: str = None, + full_history: str = None): + """Save debate history for investment or risk debate.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + INSERT OR REPLACE INTO debate_history + (date, symbol, debate_type, bull_arguments, bear_arguments, + risky_arguments, safe_arguments, neutral_arguments, + judge_decision, full_history) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + date, symbol, debate_type, + bull_arguments, bear_arguments, + risky_arguments, safe_arguments, neutral_arguments, + judge_decision, full_history + )) + conn.commit() + finally: + conn.close() + + +def get_debate_history(date: str, symbol: str) -> dict: + """Get all debate history for a stock on a date.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + SELECT * FROM debate_history + WHERE date = ? AND symbol = ? + """, (date, symbol)) + + debates = {} + for row in cursor.fetchall(): + debates[row['debate_type']] = { + 'debate_type': row['debate_type'], + 'bull_arguments': row['bull_arguments'], + 'bear_arguments': row['bear_arguments'], + 'risky_arguments': row['risky_arguments'], + 'safe_arguments': row['safe_arguments'], + 'neutral_arguments': row['neutral_arguments'], + 'judge_decision': row['judge_decision'], + 'full_history': row['full_history'], + 'created_at': row['created_at'] + } + return debates + finally: + conn.close() + + +def save_pipeline_step(date: str, symbol: str, step_number: int, step_name: str, + status: str, started_at: str = None, completed_at: str = None, + duration_ms: int = None, output_summary: str = None): + """Save a pipeline step status.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + INSERT OR REPLACE INTO pipeline_steps + (date, symbol, step_number, step_name, status, + started_at, completed_at, duration_ms, output_summary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + date, symbol, step_number, step_name, status, + started_at, completed_at, duration_ms, output_summary + )) + conn.commit() + finally: + conn.close() + + +def save_pipeline_steps_bulk(date: str, symbol: str, steps: list): + """Save all pipeline steps at once. + + Args: + date: Date string + symbol: Stock symbol + steps: List of step dicts with step_number, step_name, status, etc. + """ + conn = get_connection() + cursor = conn.cursor() + + try: + for step in steps: + cursor.execute(""" + INSERT OR REPLACE INTO pipeline_steps + (date, symbol, step_number, step_name, status, + started_at, completed_at, duration_ms, output_summary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + date, symbol, + step.get('step_number'), + step.get('step_name'), + step.get('status'), + step.get('started_at'), + step.get('completed_at'), + step.get('duration_ms'), + step.get('output_summary') + )) + conn.commit() + finally: + conn.close() + + +def get_pipeline_steps(date: str, symbol: str) -> list: + """Get all pipeline steps for a stock on a date.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + SELECT * FROM pipeline_steps + WHERE date = ? AND symbol = ? + ORDER BY step_number + """, (date, symbol)) + + return [ + { + 'step_number': row['step_number'], + 'step_name': row['step_name'], + 'status': row['status'], + 'started_at': row['started_at'], + 'completed_at': row['completed_at'], + 'duration_ms': row['duration_ms'], + 'output_summary': row['output_summary'] + } + for row in cursor.fetchall() + ] + finally: + conn.close() + + +def save_data_source_log(date: str, symbol: str, source_type: str, + source_name: str, data_fetched: dict = None, + fetch_timestamp: str = None, success: bool = True, + error_message: str = None): + """Log a data source fetch.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + INSERT INTO data_source_logs + (date, symbol, source_type, source_name, data_fetched, + fetch_timestamp, success, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + date, symbol, source_type, source_name, + json.dumps(data_fetched) if data_fetched else None, + fetch_timestamp or datetime.now().isoformat(), + 1 if success else 0, + error_message + )) + conn.commit() + finally: + conn.close() + + +def save_data_source_logs_bulk(date: str, symbol: str, logs: list): + """Save multiple data source logs at once.""" + conn = get_connection() + cursor = conn.cursor() + + try: + for log in logs: + cursor.execute(""" + INSERT INTO data_source_logs + (date, symbol, source_type, source_name, data_fetched, + fetch_timestamp, success, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + date, symbol, + log.get('source_type'), + log.get('source_name'), + json.dumps(log.get('data_fetched')) if log.get('data_fetched') else None, + log.get('fetch_timestamp') or datetime.now().isoformat(), + 1 if log.get('success', True) else 0, + log.get('error_message') + )) + conn.commit() + finally: + conn.close() + + +def get_data_source_logs(date: str, symbol: str) -> list: + """Get all data source logs for a stock on a date.""" + conn = get_connection() + cursor = conn.cursor() + + try: + cursor.execute(""" + SELECT * FROM data_source_logs + WHERE date = ? AND symbol = ? + ORDER BY fetch_timestamp + """, (date, symbol)) + + return [ + { + 'source_type': row['source_type'], + 'source_name': row['source_name'], + 'data_fetched': json.loads(row['data_fetched']) if row['data_fetched'] else None, + 'fetch_timestamp': row['fetch_timestamp'], + 'success': bool(row['success']), + 'error_message': row['error_message'] + } + for row in cursor.fetchall() + ] + finally: + conn.close() + + +def get_full_pipeline_data(date: str, symbol: str) -> dict: + """Get complete pipeline data for a stock on a date.""" + return { + 'date': date, + 'symbol': symbol, + 'agent_reports': get_agent_reports(date, symbol), + 'debates': get_debate_history(date, symbol), + 'pipeline_steps': get_pipeline_steps(date, symbol), + 'data_sources': get_data_source_logs(date, symbol) + } + + +def save_full_pipeline_data(date: str, symbol: str, pipeline_data: dict): + """Save complete pipeline data for a stock. + + Args: + date: Date string + symbol: Stock symbol + pipeline_data: Dict containing agent_reports, debates, pipeline_steps, data_sources + """ + if 'agent_reports' in pipeline_data: + save_agent_reports_bulk(date, symbol, pipeline_data['agent_reports']) + + if 'investment_debate' in pipeline_data: + debate = pipeline_data['investment_debate'] + save_debate_history( + date, symbol, 'investment', + bull_arguments=debate.get('bull_history'), + bear_arguments=debate.get('bear_history'), + judge_decision=debate.get('judge_decision'), + full_history=debate.get('history') + ) + + if 'risk_debate' in pipeline_data: + debate = pipeline_data['risk_debate'] + save_debate_history( + date, symbol, 'risk', + risky_arguments=debate.get('risky_history'), + safe_arguments=debate.get('safe_history'), + neutral_arguments=debate.get('neutral_history'), + judge_decision=debate.get('judge_decision'), + full_history=debate.get('history') + ) + + if 'pipeline_steps' in pipeline_data: + save_pipeline_steps_bulk(date, symbol, pipeline_data['pipeline_steps']) + + if 'data_sources' in pipeline_data: + save_data_source_logs_bulk(date, symbol, pipeline_data['data_sources']) + + +def get_pipeline_summary_for_date(date: str) -> list: + """Get pipeline summary for all stocks on a date.""" + conn = get_connection() + cursor = conn.cursor() + + try: + # Get all symbols for this date + cursor.execute(""" + SELECT DISTINCT symbol FROM stock_analysis WHERE date = ? + """, (date,)) + symbols = [row['symbol'] for row in cursor.fetchall()] + + # Batch fetch all pipeline steps for the date (avoids N+1) + cursor.execute(""" + SELECT symbol, step_name, status FROM pipeline_steps + WHERE date = ? + ORDER BY symbol, step_number + """, (date,)) + all_steps = cursor.fetchall() + steps_by_symbol = {} + for row in all_steps: + if row['symbol'] not in steps_by_symbol: + steps_by_symbol[row['symbol']] = [] + steps_by_symbol[row['symbol']].append({'step_name': row['step_name'], 'status': row['status']}) + + # Batch fetch agent report counts (avoids N+1) + cursor.execute(""" + SELECT symbol, COUNT(*) as count FROM agent_reports + WHERE date = ? + GROUP BY symbol + """, (date,)) + agent_counts = {row['symbol']: row['count'] for row in cursor.fetchall()} + + # Batch fetch debates existence (avoids N+1) + cursor.execute(""" + SELECT DISTINCT symbol FROM debate_history WHERE date = ? + """, (date,)) + symbols_with_debates = {row['symbol'] for row in cursor.fetchall()} + + summaries = [] + for symbol in symbols: + summaries.append({ + 'symbol': symbol, + 'pipeline_steps': steps_by_symbol.get(symbol, []), + 'agent_reports_count': agent_counts.get(symbol, 0), + 'has_debates': symbol in symbols_with_debates + }) + + return summaries + finally: + conn.close() + + +# Initialize database on module import +init_db() diff --git a/frontend/backend/recommendations.db b/frontend/backend/recommendations.db new file mode 100644 index 00000000..0ec5c455 Binary files /dev/null and b/frontend/backend/recommendations.db differ diff --git a/frontend/backend/requirements.txt b/frontend/backend/requirements.txt new file mode 100644 index 00000000..57e2622f --- /dev/null +++ b/frontend/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.109.0 +uvicorn>=0.27.0 +pydantic>=2.0.0 diff --git a/frontend/backend/seed_data.py b/frontend/backend/seed_data.py new file mode 100644 index 00000000..9c631849 --- /dev/null +++ b/frontend/backend/seed_data.py @@ -0,0 +1,135 @@ +"""Seed the database with sample data from the Jan 30, 2025 analysis.""" +import database as db + +# Sample data from the Jan 30, 2025 analysis +SAMPLE_DATA = { + "date": "2025-01-30", + "analysis": { + "RELIANCE": {"symbol": "RELIANCE", "company_name": "Reliance Industries Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "TCS": {"symbol": "TCS", "company_name": "Tata Consultancy Services Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "HDFCBANK": {"symbol": "HDFCBANK", "company_name": "HDFC Bank Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "INFY": {"symbol": "INFY", "company_name": "Infosys Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "ICICIBANK": {"symbol": "ICICIBANK", "company_name": "ICICI Bank Ltd", "decision": "BUY", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "HINDUNILVR": {"symbol": "HINDUNILVR", "company_name": "Hindustan Unilever Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "ITC": {"symbol": "ITC", "company_name": "ITC Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "SBIN": {"symbol": "SBIN", "company_name": "State Bank of India", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "BHARTIARTL": {"symbol": "BHARTIARTL", "company_name": "Bharti Airtel Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "KOTAKBANK": {"symbol": "KOTAKBANK", "company_name": "Kotak Mahindra Bank Ltd", "decision": "BUY", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "LT": {"symbol": "LT", "company_name": "Larsen & Toubro Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "AXISBANK": {"symbol": "AXISBANK", "company_name": "Axis Bank Ltd", "decision": "SELL", "confidence": "HIGH", "risk": "HIGH"}, + "ASIANPAINT": {"symbol": "ASIANPAINT", "company_name": "Asian Paints Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "MARUTI": {"symbol": "MARUTI", "company_name": "Maruti Suzuki India Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "HCLTECH": {"symbol": "HCLTECH", "company_name": "HCL Technologies Ltd", "decision": "SELL", "confidence": "MEDIUM", "risk": "HIGH"}, + "SUNPHARMA": {"symbol": "SUNPHARMA", "company_name": "Sun Pharmaceutical Industries Ltd", "decision": "SELL", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "TITAN": {"symbol": "TITAN", "company_name": "Titan Company Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "BAJFINANCE": {"symbol": "BAJFINANCE", "company_name": "Bajaj Finance Ltd", "decision": "BUY", "confidence": "HIGH", "risk": "MEDIUM"}, + "WIPRO": {"symbol": "WIPRO", "company_name": "Wipro Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "ULTRACEMCO": {"symbol": "ULTRACEMCO", "company_name": "UltraTech Cement Ltd", "decision": "BUY", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "NESTLEIND": {"symbol": "NESTLEIND", "company_name": "Nestle India Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "NTPC": {"symbol": "NTPC", "company_name": "NTPC Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "POWERGRID": {"symbol": "POWERGRID", "company_name": "Power Grid Corporation of India Ltd", "decision": "SELL", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "M&M": {"symbol": "M&M", "company_name": "Mahindra & Mahindra Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "TATAMOTORS": {"symbol": "TATAMOTORS", "company_name": "Tata Motors Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "ONGC": {"symbol": "ONGC", "company_name": "Oil & Natural Gas Corporation Ltd", "decision": "SELL", "confidence": "MEDIUM", "risk": "HIGH"}, + "JSWSTEEL": {"symbol": "JSWSTEEL", "company_name": "JSW Steel Ltd", "decision": "BUY", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "TATASTEEL": {"symbol": "TATASTEEL", "company_name": "Tata Steel Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "ADANIENT": {"symbol": "ADANIENT", "company_name": "Adani Enterprises Ltd", "decision": "HOLD", "confidence": "LOW", "risk": "HIGH"}, + "ADANIPORTS": {"symbol": "ADANIPORTS", "company_name": "Adani Ports and SEZ Ltd", "decision": "SELL", "confidence": "MEDIUM", "risk": "HIGH"}, + "COALINDIA": {"symbol": "COALINDIA", "company_name": "Coal India Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "BAJAJFINSV": {"symbol": "BAJAJFINSV", "company_name": "Bajaj Finserv Ltd", "decision": "BUY", "confidence": "HIGH", "risk": "MEDIUM"}, + "TECHM": {"symbol": "TECHM", "company_name": "Tech Mahindra Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "HDFCLIFE": {"symbol": "HDFCLIFE", "company_name": "HDFC Life Insurance Company Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "SBILIFE": {"symbol": "SBILIFE", "company_name": "SBI Life Insurance Company Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "GRASIM": {"symbol": "GRASIM", "company_name": "Grasim Industries Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "DIVISLAB": {"symbol": "DIVISLAB", "company_name": "Divi's Laboratories Ltd", "decision": "SELL", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "DRREDDY": {"symbol": "DRREDDY", "company_name": "Dr. Reddy's Laboratories Ltd", "decision": "SELL", "confidence": "HIGH", "risk": "HIGH"}, + "CIPLA": {"symbol": "CIPLA", "company_name": "Cipla Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "BRITANNIA": {"symbol": "BRITANNIA", "company_name": "Britannia Industries Ltd", "decision": "BUY", "confidence": "MEDIUM", "risk": "LOW"}, + "EICHERMOT": {"symbol": "EICHERMOT", "company_name": "Eicher Motors Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "APOLLOHOSP": {"symbol": "APOLLOHOSP", "company_name": "Apollo Hospitals Enterprise Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "INDUSINDBK": {"symbol": "INDUSINDBK", "company_name": "IndusInd Bank Ltd", "decision": "SELL", "confidence": "HIGH", "risk": "HIGH"}, + "HEROMOTOCO": {"symbol": "HEROMOTOCO", "company_name": "Hero MotoCorp Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "TATACONSUM": {"symbol": "TATACONSUM", "company_name": "Tata Consumer Products Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "BPCL": {"symbol": "BPCL", "company_name": "Bharat Petroleum Corporation Ltd", "decision": "SELL", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "UPL": {"symbol": "UPL", "company_name": "UPL Ltd", "decision": "HOLD", "confidence": "LOW", "risk": "HIGH"}, + "HINDALCO": {"symbol": "HINDALCO", "company_name": "Hindalco Industries Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "BAJAJ-AUTO": {"symbol": "BAJAJ-AUTO", "company_name": "Bajaj Auto Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + "LTIM": {"symbol": "LTIM", "company_name": "LTIMindtree Ltd", "decision": "HOLD", "confidence": "MEDIUM", "risk": "MEDIUM"}, + }, + "summary": { + "total": 50, + "buy": 7, + "sell": 10, + "hold": 33, + }, + "top_picks": [ + { + "rank": 1, + "symbol": "BAJFINANCE", + "company_name": "Bajaj Finance Ltd", + "decision": "BUY", + "reason": "13.7% gain over 30 days (Rs.678 to Rs.771), strongest bullish momentum with robust upward trend.", + "risk_level": "MEDIUM", + }, + { + "rank": 2, + "symbol": "BAJAJFINSV", + "company_name": "Bajaj Finserv Ltd", + "decision": "BUY", + "reason": "14% gain in one month (Rs.1,567 to Rs.1,789) demonstrates clear bullish momentum with sector-wide tailwinds.", + "risk_level": "MEDIUM", + }, + { + "rank": 3, + "symbol": "KOTAKBANK", + "company_name": "Kotak Mahindra Bank Ltd", + "decision": "BUY", + "reason": "Significant breakout on January 20th with 9.2% gain on exceptionally high volume (66.6M shares).", + "risk_level": "MEDIUM", + }, + ], + "stocks_to_avoid": [ + { + "symbol": "DRREDDY", + "company_name": "Dr. Reddy's Laboratories Ltd", + "reason": "HIGH CONFIDENCE SELL with 14.9% decline in one month. Severe downtrend with high risk.", + }, + { + "symbol": "AXISBANK", + "company_name": "Axis Bank Ltd", + "reason": "HIGH CONFIDENCE SELL with 10.5% sustained decline. Clear and persistent downtrend.", + }, + { + "symbol": "HCLTECH", + "company_name": "HCL Technologies Ltd", + "reason": "SELL with 9.4% drop from recent highs. High risk rating with continued selling pressure.", + }, + { + "symbol": "ADANIPORTS", + "company_name": "Adani Ports and SEZ Ltd", + "reason": "SELL with 12% monthly decline and consistently lower lows. High risk profile.", + }, + ], +} + + +def seed_database(): + """Seed the database with sample data.""" + print("Seeding database...") + + db.save_recommendation( + date=SAMPLE_DATA["date"], + analysis_data=SAMPLE_DATA["analysis"], + summary=SAMPLE_DATA["summary"], + top_picks=SAMPLE_DATA["top_picks"], + stocks_to_avoid=SAMPLE_DATA["stocks_to_avoid"], + ) + + print(f"Saved recommendation for {SAMPLE_DATA['date']}") + print(f" - {len(SAMPLE_DATA['analysis'])} stocks analyzed") + print(f" - Summary: {SAMPLE_DATA['summary']['buy']} BUY, {SAMPLE_DATA['summary']['sell']} SELL, {SAMPLE_DATA['summary']['hold']} HOLD") + print("Database seeded successfully!") + + +if __name__ == "__main__": + seed_database() diff --git a/frontend/backend/server.py b/frontend/backend/server.py new file mode 100644 index 00000000..110b4fd3 --- /dev/null +++ b/frontend/backend/server.py @@ -0,0 +1,592 @@ +"""FastAPI server for Nifty50 AI recommendations.""" +from fastapi import FastAPI, HTTPException, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional +import database as db +import sys +import os +from pathlib import Path +from datetime import datetime +import threading + +# Add parent directories to path for importing trading agents +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +# Track running analyses +# NOTE: This is not thread-safe for production multi-worker deployments. +# For production, use Redis or a database-backed job queue instead. +running_analyses = {} # {symbol: {"status": "running", "started_at": datetime, "progress": str}} + +app = FastAPI( + title="Nifty50 AI API", + description="API for Nifty 50 stock recommendations", + version="1.0.0" +) + +# Enable CORS for frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, replace with specific origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class StockAnalysis(BaseModel): + symbol: str + company_name: str + decision: Optional[str] = None + confidence: Optional[str] = None + risk: Optional[str] = None + raw_analysis: Optional[str] = None + + +class TopPick(BaseModel): + rank: int + symbol: str + company_name: str + decision: str + reason: str + risk_level: str + + +class StockToAvoid(BaseModel): + symbol: str + company_name: str + reason: str + + +class Summary(BaseModel): + total: int + buy: int + sell: int + hold: int + + +class DailyRecommendation(BaseModel): + date: str + analysis: dict[str, StockAnalysis] + summary: Summary + top_picks: list[TopPick] + stocks_to_avoid: list[StockToAvoid] + + +class SaveRecommendationRequest(BaseModel): + date: str + analysis: dict + summary: dict + top_picks: list + stocks_to_avoid: list + + +# ============== Pipeline Data Models ============== + +class AgentReport(BaseModel): + agent_type: str + report_content: str + data_sources_used: Optional[list] = [] + created_at: Optional[str] = None + + +class DebateHistory(BaseModel): + debate_type: str + bull_arguments: Optional[str] = None + bear_arguments: Optional[str] = None + risky_arguments: Optional[str] = None + safe_arguments: Optional[str] = None + neutral_arguments: Optional[str] = None + judge_decision: Optional[str] = None + full_history: Optional[str] = None + + +class PipelineStep(BaseModel): + step_number: int + step_name: str + status: str + started_at: Optional[str] = None + completed_at: Optional[str] = None + duration_ms: Optional[int] = None + output_summary: Optional[str] = None + + +class DataSourceLog(BaseModel): + source_type: str + source_name: str + data_fetched: Optional[dict] = None + fetch_timestamp: Optional[str] = None + success: bool = True + error_message: Optional[str] = None + + +class SavePipelineDataRequest(BaseModel): + date: str + symbol: str + agent_reports: Optional[dict] = None + investment_debate: Optional[dict] = None + risk_debate: Optional[dict] = None + pipeline_steps: Optional[list] = None + data_sources: Optional[list] = None + + +class AnalysisConfig(BaseModel): + deep_think_model: Optional[str] = "opus" + quick_think_model: Optional[str] = "sonnet" + provider: Optional[str] = "claude_subscription" # claude_subscription or anthropic_api + api_key: Optional[str] = None + max_debate_rounds: Optional[int] = 1 + + +class RunAnalysisRequest(BaseModel): + symbol: str + date: Optional[str] = None # Defaults to today if not provided + config: Optional[AnalysisConfig] = None + + +def run_analysis_task(symbol: str, date: str, analysis_config: dict = None): + """Background task to run trading analysis for a stock.""" + global running_analyses + + # Default config values + if analysis_config is None: + analysis_config = {} + + deep_think_model = analysis_config.get("deep_think_model", "opus") + quick_think_model = analysis_config.get("quick_think_model", "sonnet") + provider = analysis_config.get("provider", "claude_subscription") + api_key = analysis_config.get("api_key") + max_debate_rounds = analysis_config.get("max_debate_rounds", 1) + + try: + running_analyses[symbol] = { + "status": "initializing", + "started_at": datetime.now().isoformat(), + "progress": "Loading trading agents..." + } + + # Import trading agents + from tradingagents.graph.trading_graph import TradingAgentsGraph + from tradingagents.default_config import DEFAULT_CONFIG + + running_analyses[symbol]["progress"] = "Initializing analysis pipeline..." + + # Create config from user settings + config = DEFAULT_CONFIG.copy() + config["llm_provider"] = "anthropic" # Use Claude for all LLM + config["deep_think_llm"] = deep_think_model + config["quick_think_llm"] = quick_think_model + config["max_debate_rounds"] = max_debate_rounds + + # If using API provider and key is provided, set it in environment + if provider == "anthropic_api" and api_key: + os.environ["ANTHROPIC_API_KEY"] = api_key + + running_analyses[symbol]["status"] = "running" + running_analyses[symbol]["progress"] = f"Running market analysis (model: {deep_think_model})..." + + # Initialize and run + ta = TradingAgentsGraph(debug=False, config=config) + + running_analyses[symbol]["progress"] = f"Analyzing {symbol}..." + final_state, decision = ta.propagate(symbol, date) + + running_analyses[symbol] = { + "status": "completed", + "completed_at": datetime.now().isoformat(), + "progress": f"Analysis complete: {decision}", + "decision": decision + } + + except Exception as e: + error_msg = str(e) if str(e) else f"{type(e).__name__}: No details provided" + running_analyses[symbol] = { + "status": "error", + "error": error_msg, + "progress": f"Error: {error_msg[:100]}" + } + import traceback + print(f"Analysis error for {symbol}: {type(e).__name__}: {error_msg}") + traceback.print_exc() + + +@app.get("/") +async def root(): + """API root endpoint.""" + return { + "name": "Nifty50 AI API", + "version": "2.0.0", + "endpoints": { + "GET /recommendations": "Get all recommendations", + "GET /recommendations/latest": "Get latest recommendation", + "GET /recommendations/{date}": "Get recommendation by date", + "GET /recommendations/{date}/{symbol}/pipeline": "Get full pipeline data for a stock", + "GET /recommendations/{date}/{symbol}/agents": "Get agent reports for a stock", + "GET /recommendations/{date}/{symbol}/debates": "Get debate history for a stock", + "GET /recommendations/{date}/{symbol}/data-sources": "Get data source logs for a stock", + "GET /recommendations/{date}/pipeline-summary": "Get pipeline summary for all stocks on a date", + "GET /stocks/{symbol}/history": "Get stock history", + "GET /dates": "Get all available dates", + "POST /recommendations": "Save a new recommendation", + "POST /pipeline": "Save pipeline data for a stock" + } + } + + +@app.get("/recommendations") +async def get_all_recommendations(): + """Get all daily recommendations.""" + recommendations = db.get_all_recommendations() + return {"recommendations": recommendations, "count": len(recommendations)} + + +@app.get("/recommendations/latest") +async def get_latest_recommendation(): + """Get the most recent recommendation.""" + recommendation = db.get_latest_recommendation() + if not recommendation: + raise HTTPException(status_code=404, detail="No recommendations found") + return recommendation + + +@app.get("/recommendations/{date}") +async def get_recommendation_by_date(date: str): + """Get recommendation for a specific date (format: YYYY-MM-DD).""" + recommendation = db.get_recommendation_by_date(date) + if not recommendation: + raise HTTPException(status_code=404, detail=f"No recommendation found for {date}") + return recommendation + + +@app.get("/stocks/{symbol}/history") +async def get_stock_history(symbol: str): + """Get historical recommendations for a specific stock.""" + history = db.get_stock_history(symbol.upper()) + return {"symbol": symbol.upper(), "history": history, "count": len(history)} + + +@app.get("/dates") +async def get_available_dates(): + """Get all dates with recommendations.""" + dates = db.get_all_dates() + return {"dates": dates, "count": len(dates)} + + +@app.post("/recommendations") +async def save_recommendation(request: SaveRecommendationRequest): + """Save a new daily recommendation.""" + try: + db.save_recommendation( + date=request.date, + analysis_data=request.analysis, + summary=request.summary, + top_picks=request.top_picks, + stocks_to_avoid=request.stocks_to_avoid + ) + return {"message": f"Recommendation for {request.date} saved successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "database": "connected"} + + +# ============== Pipeline Data Endpoints ============== + +@app.get("/recommendations/{date}/{symbol}/pipeline") +async def get_pipeline_data(date: str, symbol: str): + """Get full pipeline data for a stock on a specific date.""" + pipeline_data = db.get_full_pipeline_data(date, symbol.upper()) + + # Check if we have any data + has_data = ( + pipeline_data.get('agent_reports') or + pipeline_data.get('debates') or + pipeline_data.get('pipeline_steps') or + pipeline_data.get('data_sources') + ) + + if not has_data: + # Return empty structure with mock pipeline steps if no data + return { + "date": date, + "symbol": symbol.upper(), + "agent_reports": {}, + "debates": {}, + "pipeline_steps": [], + "data_sources": [], + "status": "no_data" + } + + return {**pipeline_data, "status": "complete"} + + +@app.get("/recommendations/{date}/{symbol}/agents") +async def get_agent_reports(date: str, symbol: str): + """Get agent reports for a stock on a specific date.""" + reports = db.get_agent_reports(date, symbol.upper()) + return { + "date": date, + "symbol": symbol.upper(), + "reports": reports, + "count": len(reports) + } + + +@app.get("/recommendations/{date}/{symbol}/debates") +async def get_debate_history(date: str, symbol: str): + """Get debate history for a stock on a specific date.""" + debates = db.get_debate_history(date, symbol.upper()) + return { + "date": date, + "symbol": symbol.upper(), + "debates": debates + } + + +@app.get("/recommendations/{date}/{symbol}/data-sources") +async def get_data_sources(date: str, symbol: str): + """Get data source logs for a stock on a specific date.""" + logs = db.get_data_source_logs(date, symbol.upper()) + return { + "date": date, + "symbol": symbol.upper(), + "data_sources": logs, + "count": len(logs) + } + + +@app.get("/recommendations/{date}/pipeline-summary") +async def get_pipeline_summary(date: str): + """Get pipeline summary for all stocks on a specific date.""" + summary = db.get_pipeline_summary_for_date(date) + return { + "date": date, + "stocks": summary, + "count": len(summary) + } + + +@app.post("/pipeline") +async def save_pipeline_data(request: SavePipelineDataRequest): + """Save pipeline data for a stock.""" + try: + db.save_full_pipeline_data( + date=request.date, + symbol=request.symbol.upper(), + pipeline_data={ + 'agent_reports': request.agent_reports, + 'investment_debate': request.investment_debate, + 'risk_debate': request.risk_debate, + 'pipeline_steps': request.pipeline_steps, + 'data_sources': request.data_sources + } + ) + return {"message": f"Pipeline data for {request.symbol} on {request.date} saved successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============== Analysis Endpoints ============== + +# Track bulk analysis state +bulk_analysis_state = { + "status": "idle", # idle, running, completed, error + "total": 0, + "completed": 0, + "failed": 0, + "current_symbol": None, + "started_at": None, + "completed_at": None, + "results": {} +} + +# List of Nifty 50 stocks +NIFTY_50_SYMBOLS = [ + "RELIANCE", "TCS", "HDFCBANK", "INFY", "ICICIBANK", "HINDUNILVR", "ITC", "SBIN", + "BHARTIARTL", "KOTAKBANK", "LT", "AXISBANK", "ASIANPAINT", "MARUTI", "HCLTECH", + "SUNPHARMA", "TITAN", "BAJFINANCE", "WIPRO", "ULTRACEMCO", "NESTLEIND", "NTPC", + "POWERGRID", "M&M", "TATAMOTORS", "ONGC", "JSWSTEEL", "TATASTEEL", "ADANIENT", + "ADANIPORTS", "COALINDIA", "BAJAJFINSV", "TECHM", "HDFCLIFE", "SBILIFE", "GRASIM", + "DIVISLAB", "DRREDDY", "CIPLA", "BRITANNIA", "EICHERMOT", "APOLLOHOSP", "INDUSINDBK", + "HEROMOTOCO", "TATACONSUM", "BPCL", "UPL", "HINDALCO", "BAJAJ-AUTO", "LTIM" +] + + +class BulkAnalysisRequest(BaseModel): + deep_think_model: Optional[str] = "opus" + quick_think_model: Optional[str] = "sonnet" + provider: Optional[str] = "claude_subscription" + api_key: Optional[str] = None + max_debate_rounds: Optional[int] = 1 + + +@app.post("/analyze/all") +async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date: Optional[str] = None): + """Trigger analysis for all Nifty 50 stocks. Runs in background.""" + global bulk_analysis_state + + # Check if bulk analysis is already running + if bulk_analysis_state.get("status") == "running": + return { + "message": "Bulk analysis already running", + "status": bulk_analysis_state + } + + # Use today's date if not provided + if not date: + date = datetime.now().strftime("%Y-%m-%d") + + # Build analysis config from request + analysis_config = {} + if request: + analysis_config = { + "deep_think_model": request.deep_think_model, + "quick_think_model": request.quick_think_model, + "provider": request.provider, + "api_key": request.api_key, + "max_debate_rounds": request.max_debate_rounds + } + + # Start bulk analysis in background thread + def run_bulk(): + global bulk_analysis_state + bulk_analysis_state = { + "status": "running", + "total": len(NIFTY_50_SYMBOLS), + "completed": 0, + "failed": 0, + "current_symbol": None, + "started_at": datetime.now().isoformat(), + "completed_at": None, + "results": {} + } + + for symbol in NIFTY_50_SYMBOLS: + try: + bulk_analysis_state["current_symbol"] = symbol + run_analysis_task(symbol, date, analysis_config) + + # Wait for completion + import time + while symbol in running_analyses and running_analyses[symbol].get("status") == "running": + time.sleep(2) + + if symbol in running_analyses: + status = running_analyses[symbol].get("status", "unknown") + bulk_analysis_state["results"][symbol] = status + if status == "completed": + bulk_analysis_state["completed"] += 1 + else: + bulk_analysis_state["failed"] += 1 + else: + bulk_analysis_state["results"][symbol] = "unknown" + bulk_analysis_state["failed"] += 1 + + except Exception as e: + bulk_analysis_state["results"][symbol] = f"error: {str(e)}" + bulk_analysis_state["failed"] += 1 + + bulk_analysis_state["status"] = "completed" + bulk_analysis_state["current_symbol"] = None + bulk_analysis_state["completed_at"] = datetime.now().isoformat() + + thread = threading.Thread(target=run_bulk) + thread.start() + + return { + "message": "Bulk analysis started for all Nifty 50 stocks", + "date": date, + "total_stocks": len(NIFTY_50_SYMBOLS), + "status": "started" + } + + +@app.get("/analyze/all/status") +async def get_bulk_analysis_status(): + """Get the status of bulk analysis.""" + return bulk_analysis_state + + +@app.get("/analyze/running") +async def get_running_analyses(): + """Get all currently running analyses.""" + running = {k: v for k, v in running_analyses.items() if v.get("status") == "running"} + return { + "running": running, + "count": len(running) + } + + +class SingleAnalysisRequest(BaseModel): + deep_think_model: Optional[str] = "opus" + quick_think_model: Optional[str] = "sonnet" + provider: Optional[str] = "claude_subscription" + api_key: Optional[str] = None + max_debate_rounds: Optional[int] = 1 + + +@app.post("/analyze/{symbol}") +async def run_analysis(symbol: str, background_tasks: BackgroundTasks, request: Optional[SingleAnalysisRequest] = None, date: Optional[str] = None): + """Trigger analysis for a stock. Runs in background.""" + symbol = symbol.upper() + + # Check if analysis is already running + if symbol in running_analyses and running_analyses[symbol].get("status") == "running": + return { + "message": f"Analysis already running for {symbol}", + "status": running_analyses[symbol] + } + + # Use today's date if not provided + if not date: + date = datetime.now().strftime("%Y-%m-%d") + + # Build analysis config from request + analysis_config = {} + if request: + analysis_config = { + "deep_think_model": request.deep_think_model, + "quick_think_model": request.quick_think_model, + "provider": request.provider, + "api_key": request.api_key, + "max_debate_rounds": request.max_debate_rounds + } + + # Start analysis in background thread + thread = threading.Thread(target=run_analysis_task, args=(symbol, date, analysis_config)) + thread.start() + + return { + "message": f"Analysis started for {symbol}", + "symbol": symbol, + "date": date, + "status": "started" + } + + +@app.get("/analyze/{symbol}/status") +async def get_analysis_status(symbol: str): + """Get the status of a running or completed analysis.""" + symbol = symbol.upper() + + if symbol not in running_analyses: + return { + "symbol": symbol, + "status": "not_started", + "message": "No analysis has been run for this stock" + } + + return { + "symbol": symbol, + **running_analyses[symbol] + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/frontend/docs/screenshots/01-dashboard.png b/frontend/docs/screenshots/01-dashboard.png new file mode 100644 index 00000000..43f80757 Binary files /dev/null and b/frontend/docs/screenshots/01-dashboard.png differ diff --git a/frontend/docs/screenshots/02-settings-modal.png b/frontend/docs/screenshots/02-settings-modal.png new file mode 100644 index 00000000..3b67507e Binary files /dev/null and b/frontend/docs/screenshots/02-settings-modal.png differ diff --git a/frontend/docs/screenshots/03-stock-detail-overview.png b/frontend/docs/screenshots/03-stock-detail-overview.png new file mode 100644 index 00000000..07f7fb7f Binary files /dev/null and b/frontend/docs/screenshots/03-stock-detail-overview.png differ diff --git a/frontend/docs/screenshots/04-analysis-pipeline.png b/frontend/docs/screenshots/04-analysis-pipeline.png new file mode 100644 index 00000000..ff13ced7 Binary files /dev/null and b/frontend/docs/screenshots/04-analysis-pipeline.png differ diff --git a/frontend/docs/screenshots/05-debates-tab.png b/frontend/docs/screenshots/05-debates-tab.png new file mode 100644 index 00000000..45c40c5d Binary files /dev/null and b/frontend/docs/screenshots/05-debates-tab.png differ diff --git a/frontend/docs/screenshots/06-investment-debate-expanded.png b/frontend/docs/screenshots/06-investment-debate-expanded.png new file mode 100644 index 00000000..02bc602f Binary files /dev/null and b/frontend/docs/screenshots/06-investment-debate-expanded.png differ diff --git a/frontend/docs/screenshots/07-data-sources-tab.png b/frontend/docs/screenshots/07-data-sources-tab.png new file mode 100644 index 00000000..2df93b64 Binary files /dev/null and b/frontend/docs/screenshots/07-data-sources-tab.png differ diff --git a/frontend/docs/screenshots/08-dashboard-dark-mode.png b/frontend/docs/screenshots/08-dashboard-dark-mode.png new file mode 100644 index 00000000..36680cd3 Binary files /dev/null and b/frontend/docs/screenshots/08-dashboard-dark-mode.png differ diff --git a/frontend/docs/screenshots/09-how-it-works.png b/frontend/docs/screenshots/09-how-it-works.png new file mode 100644 index 00000000..95140dae Binary files /dev/null and b/frontend/docs/screenshots/09-how-it-works.png differ diff --git a/frontend/docs/screenshots/10-history-page.png b/frontend/docs/screenshots/10-history-page.png new file mode 100644 index 00000000..0a5d01c4 Binary files /dev/null and b/frontend/docs/screenshots/10-history-page.png differ diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 00000000..5e6b472f --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..5a738c33 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,86 @@ + + + + + + + + Nifty50 AI - Daily Stock Recommendations for Indian Markets + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..9b9cc012 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5433 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/postcss": "^4.1.18", + "date-fns": "^4.1.0", + "lucide-react": "^0.563.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0", + "recharts": "^3.7.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.24", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "playwright": "^1.58.1", + "postcss": "^8.5.6", + "puppeteer": "^24.36.1", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.2.tgz", + "integrity": "sha512-GBY0+2lI9fDrjgb5dFL9+enKXqyOPok9PXg/69NVkjW3bikbK9RQrNrI3qccQXmDNN7ln4j/yL89Qgvj/tfqrw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.3.tgz", + "integrity": "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/basic-ftp": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chromium-bidi": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.0.1.tgz", + "integrity": "sha512-c+RLxH0Vg2x2syS9wPw378oJgiJNXtYXUvnVAldUlt5uaHekn0CCU7gPksNgHjrH1qFhmjVXQj4esvuthuC7OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1551306", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz", + "integrity": "sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.283", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", + "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer": { + "version": "24.36.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.36.1.tgz", + "integrity": "sha512-uPiDUyf7gd7Il1KnqfNUtHqntL0w1LapEw5Zsuh8oCK8GsqdxySX1PzdIHKB2Dw273gWY4MW0zC5gy3Re9XlqQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.11.2", + "chromium-bidi": "13.0.1", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1551306", + "puppeteer-core": "24.36.1", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.36.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.36.1.tgz", + "integrity": "sha512-L7ykMWc3lQf3HS7ME3PSjp7wMIjJeW6+bKfH/RSTz5l6VUDGubnrC2BKj3UvM28Y5PMDFW0xniJOZHBZPpW1dQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.11.2", + "chromium-bidi": "13.0.1", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1551306", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.4.0", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.0.tgz", + "integrity": "sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..360f155c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/postcss": "^4.1.18", + "date-fns": "^4.1.0", + "lucide-react": "^0.563.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0", + "recharts": "^3.7.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.24", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "playwright": "^1.58.1", + "postcss": "^8.5.6", + "puppeteer": "^24.36.1", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 00000000..1c878468 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..45c3cb4c --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 00000000..b9d355df --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..bef80a5b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,34 @@ +import { Routes, Route } from 'react-router-dom'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { SettingsProvider } from './contexts/SettingsContext'; +import Header from './components/Header'; +import Footer from './components/Footer'; +import SettingsModal from './components/SettingsModal'; +import Dashboard from './pages/Dashboard'; +import History from './pages/History'; +import StockDetail from './pages/StockDetail'; +import About from './pages/About'; + +function App() { + return ( + + +
+
+
+ + } /> + } /> + } /> + } /> + +
+
+
+
+ ); +} + +export default App; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/AIAnalysisPanel.tsx b/frontend/src/components/AIAnalysisPanel.tsx new file mode 100644 index 00000000..52e3dff0 --- /dev/null +++ b/frontend/src/components/AIAnalysisPanel.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { Brain, ChevronDown, ChevronUp, TrendingUp, BarChart2, MessageSquare, AlertTriangle, Target } from 'lucide-react'; +import type { Decision } from '../types'; + +interface AIAnalysisPanelProps { + analysis: string; + decision?: Decision | null; + defaultExpanded?: boolean; +} + +interface Section { + title: string; + content: string; + icon: typeof Brain; +} + +function parseAnalysis(analysis: string): Section[] { + const sections: Section[] = []; + const iconMap: Record = { + 'Summary': Target, + 'Technical Analysis': BarChart2, + 'Fundamental Analysis': TrendingUp, + 'Sentiment': MessageSquare, + 'Risks': AlertTriangle, + }; + + // Split by markdown headers (##) + const parts = analysis.split(/^## /gm).filter(Boolean); + + for (const part of parts) { + const lines = part.trim().split('\n'); + const title = lines[0].trim(); + const content = lines.slice(1).join('\n').trim(); + + if (title && content) { + sections.push({ + title, + content, + icon: iconMap[title] || Brain, + }); + } + } + + // If no sections found, treat the whole thing as a summary + if (sections.length === 0 && analysis.trim()) { + sections.push({ + title: 'Analysis', + content: analysis.trim(), + icon: Brain, + }); + } + + return sections; +} + +function AnalysisSection({ section, defaultOpen = true }: { section: Section; defaultOpen?: boolean }) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const Icon = section.icon; + + return ( +
+ + {isOpen && ( +
+ {section.content.split('\n').map((line, i) => { + // Handle bullet points + if (line.trim().startsWith('- ')) { + return ( +
+ + {line.trim().substring(2)} +
+ ); + } + return

{line}

; + })} +
+ )} +
+ ); +} + +export default function AIAnalysisPanel({ + analysis, + decision, + defaultExpanded = false, +}: AIAnalysisPanelProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const sections = parseAnalysis(analysis); + + const decisionGradient = { + BUY: 'from-green-500 to-emerald-600', + SELL: 'from-red-500 to-rose-600', + HOLD: 'from-amber-500 to-orange-600', + }; + + const gradient = decision ? decisionGradient[decision] : 'from-nifty-500 to-nifty-700'; + + return ( +
+ {/* Header with gradient */} + + + {/* Content */} + {isExpanded && ( +
+ {sections.map((section, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/AccuracyBadge.tsx b/frontend/src/components/AccuracyBadge.tsx new file mode 100644 index 00000000..b2e895cf --- /dev/null +++ b/frontend/src/components/AccuracyBadge.tsx @@ -0,0 +1,72 @@ +import { Check, X, Minus } from 'lucide-react'; + +interface AccuracyBadgeProps { + correct: boolean | null; + returnPercent: number; + size?: 'small' | 'default'; +} + +export default function AccuracyBadge({ + correct, + returnPercent, + size = 'default', +}: AccuracyBadgeProps) { + const isPositiveReturn = returnPercent >= 0; + const sizeClasses = size === 'small' ? 'text-xs px-1.5 py-0.5 gap-1' : 'text-sm px-2 py-1 gap-1.5'; + const iconSize = size === 'small' ? 'w-3 h-3' : 'w-3.5 h-3.5'; + + if (correct === null) { + return ( + + + Pending + + ); + } + + if (correct) { + return ( + + + + {isPositiveReturn ? '+' : ''}{returnPercent.toFixed(1)}% + + + ); + } + + return ( + + + + {isPositiveReturn ? '+' : ''}{returnPercent.toFixed(1)}% + + + ); +} + +interface AccuracyRateProps { + rate: number; + label?: string; + size?: 'small' | 'default'; +} + +export function AccuracyRate({ rate, label = 'Accuracy', size = 'default' }: AccuracyRateProps) { + const percentage = rate * 100; + const isGood = percentage >= 60; + const isModerate = percentage >= 40 && percentage < 60; + + const sizeClasses = size === 'small' ? 'text-xs' : 'text-sm'; + const colorClass = isGood + ? 'text-green-600 dark:text-green-400' + : isModerate + ? 'text-amber-600 dark:text-amber-400' + : 'text-red-600 dark:text-red-400'; + + return ( +
+ {label}: + {percentage.toFixed(0)}% +
+ ); +} diff --git a/frontend/src/components/AccuracyExplainModal.tsx b/frontend/src/components/AccuracyExplainModal.tsx new file mode 100644 index 00000000..361a4278 --- /dev/null +++ b/frontend/src/components/AccuracyExplainModal.tsx @@ -0,0 +1,177 @@ +import { X, HelpCircle, TrendingUp, TrendingDown, Minus, CheckCircle } from 'lucide-react'; +import type { AccuracyMetrics } from '../types'; + +interface AccuracyExplainModalProps { + isOpen: boolean; + onClose: () => void; + metrics: AccuracyMetrics; +} + +export default function AccuracyExplainModal({ isOpen, onClose, metrics }: AccuracyExplainModalProps) { + if (!isOpen) return null; + + const buyCorrect = Math.round(metrics.buy_accuracy * metrics.total_predictions * 0.14); // ~7 buy signals + const buyTotal = Math.round(metrics.total_predictions * 0.14); + const sellCorrect = Math.round(metrics.sell_accuracy * metrics.total_predictions * 0.2); // ~10 sell signals + const sellTotal = Math.round(metrics.total_predictions * 0.2); + const holdCorrect = Math.round(metrics.hold_accuracy * metrics.total_predictions * 0.66); // ~33 hold signals + const holdTotal = Math.round(metrics.total_predictions * 0.66); + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ +

+ How Accuracy is Calculated +

+
+ +
+ + {/* Content */} +
+ {/* Overview */} +
+

Overall Accuracy

+
+ {(metrics.success_rate * 100).toFixed(1)}% +
+

+ {metrics.correct_predictions} correct out of {metrics.total_predictions} predictions +

+
+ + {/* Formula */} +
+

Calculation Method

+
+

+ Accuracy = (Correct Predictions / Total Predictions) × 100 +

+

+ = ({metrics.correct_predictions} / {metrics.total_predictions}) × 100 = {(metrics.success_rate * 100).toFixed(1)}% +

+
+
+ + {/* Decision Type Breakdown */} +
+

Breakdown by Decision Type

+
+ {/* BUY */} +
+
+
+ + BUY Predictions +
+ + {(metrics.buy_accuracy * 100).toFixed(0)}% + +
+

+ A BUY prediction is correct if the stock price increased after the recommendation +

+
+ + ~{buyCorrect} correct / {buyTotal} total BUY signals +
+
+ + {/* SELL */} +
+
+
+ + SELL Predictions +
+ + {(metrics.sell_accuracy * 100).toFixed(0)}% + +
+

+ A SELL prediction is correct if the stock price decreased after the recommendation +

+
+ + ~{sellCorrect} correct / {sellTotal} total SELL signals +
+
+ + {/* HOLD */} +
+
+
+ + HOLD Predictions +
+ + {(metrics.hold_accuracy * 100).toFixed(0)}% + +
+

+ A HOLD prediction is correct if the stock price stayed relatively stable (±2% range) +

+
+ + ~{holdCorrect} correct / {holdTotal} total HOLD signals +
+
+
+
+ + {/* Timeframe */} +
+

Evaluation Timeframe

+
+
    +
  • + + 1-week return: Short-term price movement validation +
  • +
  • + + 1-month return: Primary accuracy metric (shown in results) +
  • +
+
+
+ + {/* Disclaimer */} +
+

+ Note: Past performance does not guarantee future results. + Accuracy metrics are based on historical data and are for educational purposes only. + Market conditions can change rapidly and predictions may not hold in future periods. +

+
+
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/AccuracyTrendChart.tsx b/frontend/src/components/AccuracyTrendChart.tsx new file mode 100644 index 00000000..ab56a20b --- /dev/null +++ b/frontend/src/components/AccuracyTrendChart.tsx @@ -0,0 +1,92 @@ +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; +import { getAccuracyTrend } from '../data/recommendations'; + +interface AccuracyTrendChartProps { + height?: number; + className?: string; +} + +export default function AccuracyTrendChart({ height = 200, className = '' }: AccuracyTrendChartProps) { + const data = getAccuracyTrend(); + + if (data.length === 0) { + return ( +
+ No accuracy data available +
+ ); + } + + // Format dates for display + const formattedData = data.map(d => ({ + ...d, + displayDate: new Date(d.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }), + })); + + return ( +
+ + + + + `${v}%`} + className="text-gray-500 dark:text-gray-400" + /> + [`${value}%`, '']} + labelFormatter={(label) => `Date: ${label}`} + /> + value.charAt(0).toUpperCase() + value.slice(1)} + /> + + + + + + +
+ ); +} diff --git a/frontend/src/components/BackgroundSparkline.tsx b/frontend/src/components/BackgroundSparkline.tsx new file mode 100644 index 00000000..52c1973e --- /dev/null +++ b/frontend/src/components/BackgroundSparkline.tsx @@ -0,0 +1,64 @@ +import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts'; +import type { PricePoint } from '../types'; + +interface BackgroundSparklineProps { + data: PricePoint[]; + trend: 'up' | 'down' | 'flat'; + className?: string; +} + +export default function BackgroundSparkline({ + data, + trend, + className = '', +}: BackgroundSparklineProps) { + if (!data || data.length < 2) { + return null; + } + + // Normalize data to percentage change from first point + const basePrice = data[0].price; + const normalizedData = data.map(point => ({ + ...point, + normalizedPrice: ((point.price - basePrice) / basePrice) * 100, + })); + + // Calculate min/max for domain padding + const prices = normalizedData.map(d => d.normalizedPrice); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const padding = Math.max(Math.abs(maxPrice - minPrice) * 0.2, 1); + + // Colors based on trend + const colors = { + up: { stroke: '#22c55e', fill: '#22c55e' }, + down: { stroke: '#ef4444', fill: '#ef4444' }, + flat: { stroke: '#94a3b8', fill: '#94a3b8' }, + }; + + const { stroke, fill } = colors[trend]; + + return ( +
+ + + + + + + + + + + + +
+ ); +} diff --git a/frontend/src/components/Charts.tsx b/frontend/src/components/Charts.tsx new file mode 100644 index 00000000..98415f90 --- /dev/null +++ b/frontend/src/components/Charts.tsx @@ -0,0 +1,219 @@ +import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'; + +interface SummaryChartProps { + buy: number; + sell: number; + hold: number; +} + +const COLORS = { + buy: '#22c55e', + sell: '#ef4444', + hold: '#f59e0b', +}; + +export function SummaryPieChart({ buy, sell, hold }: SummaryChartProps) { + const data = [ + { name: 'Buy', value: buy, color: COLORS.buy }, + { name: 'Hold', value: hold, color: COLORS.hold }, + { name: 'Sell', value: sell, color: COLORS.sell }, + ]; + + return ( +
+ + + `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} + labelLine={false} + > + {data.map((entry, index) => ( + + ))} + + [`${value} stocks`, '']} + /> + {value}} + /> + + +
+ ); +} + +interface HistoricalDataPoint { + date: string; + buy: number; + sell: number; + hold: number; +} + +interface HistoricalChartProps { + data: HistoricalDataPoint[]; +} + +export function HistoricalBarChart({ data }: HistoricalChartProps) { + const formattedData = data.map(d => ({ + ...d, + date: new Date(d.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }), + })); + + return ( +
+ + + + + + + {value}} + /> + + + + + +
+ ); +} + +interface StockHistoryEntry { + date: string; + decision: string; +} + +interface StockHistoryChartProps { + history: StockHistoryEntry[]; + symbol: string; +} + +export function StockHistoryTimeline({ history, symbol }: StockHistoryChartProps) { + if (history.length === 0) { + return ( +
+ No historical data available for {symbol} +
+ ); + } + + return ( +
+ {history.map((entry, idx) => { + const bgColor = entry.decision === 'BUY' ? 'bg-green-500' : + entry.decision === 'SELL' ? 'bg-red-500' : 'bg-amber-500'; + const textColor = entry.decision === 'BUY' ? 'text-green-700' : + entry.decision === 'SELL' ? 'text-red-700' : 'text-amber-700'; + + return ( +
+
+ {new Date(entry.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })} +
+
+
+ {entry.decision} +
+
+ ); + })} +
+ ); +} + +interface DecisionDistributionProps { + total: number; + buy: number; + sell: number; + hold: number; +} + +export function DecisionDistribution({ total, buy, sell, hold }: DecisionDistributionProps) { + const buyPercent = ((buy / total) * 100).toFixed(1); + const sellPercent = ((sell / total) * 100).toFixed(1); + const holdPercent = ((hold / total) * 100).toFixed(1); + + return ( +
+
+
+
+
+
+ +
+
+
+
+ Buy +
+
{buy}
+
{buyPercent}%
+
+ +
+
+
+ Hold +
+
{hold}
+
{holdPercent}%
+
+ +
+
+
+ Sell +
+
{sell}
+
{sellPercent}%
+
+
+
+ ); +} diff --git a/frontend/src/components/CumulativeReturnChart.tsx b/frontend/src/components/CumulativeReturnChart.tsx new file mode 100644 index 00000000..d5b22c9c --- /dev/null +++ b/frontend/src/components/CumulativeReturnChart.tsx @@ -0,0 +1,73 @@ +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; +import { getCumulativeReturns } from '../data/recommendations'; + +interface CumulativeReturnChartProps { + height?: number; + className?: string; +} + +export default function CumulativeReturnChart({ height = 160, className = '' }: CumulativeReturnChartProps) { + const data = getCumulativeReturns(); + + if (data.length === 0) { + return ( +
+ No data available +
+ ); + } + + // Format dates for display + const formattedData = data.map(d => ({ + ...d, + displayDate: new Date(d.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }), + })); + + const lastPoint = formattedData[formattedData.length - 1]; + const isPositive = lastPoint.aiReturn >= 0; + + return ( +
+ + + + + + + + + + + `${v}%`} + className="text-gray-500 dark:text-gray-400" + width={40} + /> + [`${(value as number).toFixed(1)}%`, 'Return']} + labelFormatter={(label) => `Date: ${label}`} + /> + + + + +
+ ); +} diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx new file mode 100644 index 00000000..5b51ba58 --- /dev/null +++ b/frontend/src/components/FilterPanel.tsx @@ -0,0 +1,100 @@ +import { SlidersHorizontal, ArrowUpDown } from 'lucide-react'; +import { getAllSectors } from '../data/recommendations'; +import type { FilterState } from '../types'; + +interface FilterPanelProps { + filters: FilterState; + onFilterChange: (filters: FilterState) => void; + className?: string; +} + +export default function FilterPanel({ filters, onFilterChange, className = '' }: FilterPanelProps) { + const sectors = getAllSectors(); + + const decisions: Array = ['ALL', 'BUY', 'SELL', 'HOLD']; + const sortOptions: Array<{ value: FilterState['sortBy']; label: string }> = [ + { value: 'symbol', label: 'Symbol' }, + { value: 'return', label: 'Return' }, + { value: 'accuracy', label: 'Accuracy' }, + ]; + + const handleDecisionChange = (decision: FilterState['decision']) => { + onFilterChange({ ...filters, decision }); + }; + + const handleSectorChange = (e: React.ChangeEvent) => { + onFilterChange({ ...filters, sector: e.target.value }); + }; + + const handleSortChange = (e: React.ChangeEvent) => { + onFilterChange({ ...filters, sortBy: e.target.value as FilterState['sortBy'] }); + }; + + const toggleSortOrder = () => { + onFilterChange({ ...filters, sortOrder: filters.sortOrder === 'asc' ? 'desc' : 'asc' }); + }; + + return ( +
+
+ + Filters: +
+ + {/* Decision Toggle */} +
+ {decisions.map(decision => ( + + ))} +
+ + {/* Sector Dropdown */} + + + {/* Sort */} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx new file mode 100644 index 00000000..adf9dffa --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -0,0 +1,47 @@ +import { TrendingUp, Github, Twitter } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +export default function Footer() { + return ( +
+
+ {/* Compact single-row layout */} +
+ {/* Brand */} +
+
+ +
+ Nifty50 AI +
+ + {/* Links */} +
+ Dashboard + History + How It Works + | + Disclaimer + Privacy +
+ + {/* Social & Copyright */} +
+ + + + + + + © {new Date().getFullYear()} +
+
+ + {/* Compact Disclaimer */} +

+ AI-generated recommendations for educational purposes only. Not financial advice. Do your own research. +

+
+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 00000000..73faaf45 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,104 @@ +import { Link, useLocation } from 'react-router-dom'; +import { TrendingUp, BarChart3, History, Menu, X, Sparkles, Settings } from 'lucide-react'; +import { useState } from 'react'; +import ThemeToggle from './ThemeToggle'; +import { useSettings } from '../contexts/SettingsContext'; + +export default function Header() { + const location = useLocation(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const { openSettings } = useSettings(); + + const navItems = [ + { path: '/', label: 'Dashboard', icon: BarChart3 }, + { path: '/history', label: 'History', icon: History }, + { path: '/about', label: 'How It Works', icon: Sparkles }, + ]; + + const isActive = (path: string) => location.pathname === path; + + return ( +
+
+
+ {/* Logo */} + +
+ +
+ Nifty50 AI + + + {/* Desktop Navigation */} + + + {/* Settings, Theme Toggle & Mobile Menu */} +
+ {/* Settings Button */} + +
+ +
+
+ +
+ +
+
+ + {/* Mobile Navigation */} + {mobileMenuOpen && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/HowItWorks.tsx b/frontend/src/components/HowItWorks.tsx new file mode 100644 index 00000000..b0f06ff2 --- /dev/null +++ b/frontend/src/components/HowItWorks.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronUp, Database, BarChart2, MessageSquare, Sparkles, Brain, TrendingUp, Shield } from 'lucide-react'; + +interface HowItWorksProps { + collapsed?: boolean; +} + +const agents = [ + { + name: 'Market Data', + icon: Database, + color: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400', + description: 'Real-time price data, volume, and market indicators from NSE', + }, + { + name: 'Technical Analyst', + icon: BarChart2, + color: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400', + description: 'RSI, MACD, moving averages, and chart pattern analysis', + }, + { + name: 'Fundamental Analyst', + icon: TrendingUp, + color: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400', + description: 'Earnings, P/E ratios, revenue growth, and financial health', + }, + { + name: 'Sentiment Analyst', + icon: MessageSquare, + color: 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400', + description: 'News sentiment, social media trends, and analyst ratings', + }, + { + name: 'Risk Manager', + icon: Shield, + color: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400', + description: 'Volatility assessment, sector risk, and position sizing', + }, + { + name: 'AI Debate', + icon: Brain, + color: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400', + description: 'Agents debate and challenge each other to reach consensus', + }, +]; + +export default function HowItWorks({ collapsed = true }: HowItWorksProps) { + const [isExpanded, setIsExpanded] = useState(!collapsed); + + return ( +
+ + + {isExpanded && ( +
+ {/* Flow diagram */} +
+ Data + + Analysis + + Debate + + Decision +
+ + {/* Agents grid */} +
+ {agents.map((agent) => { + const Icon = agent.icon; + return ( +
+
+
+ +
+ {agent.name} +
+

+ {agent.description} +

+
+ ); + })} +
+ + {/* Disclaimer */} +

+ Multiple AI agents analyze each stock independently, then debate to reach a consensus recommendation. +

+
+ )} +
+ ); +} + +// Simpler badge version for inline use +export function AIAgentBadge({ type }: { type: 'technical' | 'fundamental' | 'sentiment' | 'risk' | 'debate' }) { + const config = { + technical: { icon: BarChart2, label: 'Technical', color: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400' }, + fundamental: { icon: TrendingUp, label: 'Fundamental', color: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' }, + sentiment: { icon: MessageSquare, label: 'Sentiment', color: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' }, + risk: { icon: Shield, label: 'Risk', color: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' }, + debate: { icon: Brain, label: 'Debate', color: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400' }, + }; + + const { icon: Icon, label, color } = config[type]; + + return ( + + + {label} + + ); +} diff --git a/frontend/src/components/IndexComparisonChart.tsx b/frontend/src/components/IndexComparisonChart.tsx new file mode 100644 index 00000000..59de0ab6 --- /dev/null +++ b/frontend/src/components/IndexComparisonChart.tsx @@ -0,0 +1,115 @@ +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine } from 'recharts'; +import { TrendingUp, TrendingDown } from 'lucide-react'; +import { getCumulativeReturns } from '../data/recommendations'; + +interface IndexComparisonChartProps { + height?: number; + className?: string; +} + +export default function IndexComparisonChart({ height = 220, className = '' }: IndexComparisonChartProps) { + const data = getCumulativeReturns(); + + if (data.length === 0) { + return ( +
+ No comparison data available +
+ ); + } + + // Format dates for display + const formattedData = data.map(d => ({ + ...d, + displayDate: new Date(d.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }), + })); + + const lastPoint = formattedData[formattedData.length - 1]; + const aiReturn = lastPoint?.aiReturn || 0; + const indexReturn = lastPoint?.indexReturn || 0; + const outperformance = aiReturn - indexReturn; + const isOutperforming = outperformance >= 0; + + return ( +
+ {/* Summary Card */} +
+
+ {isOutperforming ? ( + + ) : ( + + )} + + AI Strategy {isOutperforming ? 'outperformed' : 'underperformed'} Nifty50 by{' '} + + {Math.abs(outperformance).toFixed(1)}% + + +
+
+
+
+ AI: {aiReturn >= 0 ? '+' : ''}{aiReturn.toFixed(1)}% +
+
+
+ Nifty: {indexReturn >= 0 ? '+' : ''}{indexReturn.toFixed(1)}% +
+
+
+ + {/* Chart */} +
+ + + + + `${v}%`} + className="text-gray-500 dark:text-gray-400" + /> + [`${(value as number).toFixed(1)}%`, '']} + labelFormatter={(label) => `Date: ${label}`} + /> + value === 'aiReturn' ? 'AI Strategy' : 'Nifty50 Index'} + /> + + + + + +
+
+ ); +} diff --git a/frontend/src/components/OverallReturnModal.tsx b/frontend/src/components/OverallReturnModal.tsx new file mode 100644 index 00000000..4eddfe67 --- /dev/null +++ b/frontend/src/components/OverallReturnModal.tsx @@ -0,0 +1,240 @@ +import { X, Activity } from 'lucide-react'; +import { getOverallReturnBreakdown } from '../data/recommendations'; +import CumulativeReturnChart from './CumulativeReturnChart'; + +interface OverallReturnModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function OverallReturnModal({ isOpen, onClose }: OverallReturnModalProps) { + if (!isOpen) return null; + + const breakdown = getOverallReturnBreakdown(); + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ +

+ Overall Return Calculation +

+
+ +
+ + {/* Content */} +
+ {/* Final Result */} +
+
Compound Return
+
+ {breakdown.finalReturn >= 0 ? '+' : ''}{breakdown.finalReturn.toFixed(1)}% +
+
+ Multiplier: {breakdown.finalMultiplier.toFixed(4)}x +
+
+ + {/* Cumulative Return Chart */} +
+

Portfolio Growth

+
+ +
+
+ + {/* Method Explanation */} +
+

Why Compound Returns?

+
+

+ In real trading, gains and losses compound over time. If you start with ₹10,000: +

+
    +
  • • Day 1: +2% → ₹10,000 × 1.02 = ₹10,200
  • +
  • • Day 2: +1% → ₹10,200 × 1.01 = ₹10,302
  • +
  • • Day 3: -1% → ₹10,302 × 0.99 = ₹10,199
  • +
+

+ Simple average would give (2+1-1)/3 = 0.67%, but actual return is +1.99% +

+
+
+ + {/* Formula */} +
+

Formula

+
+
+ Overall = (1 + r₁) × (1 + r₂) × ... × (1 + rₙ) - 1 +
+

+ Where r₁, r₂, ... rₙ are the daily weighted returns +

+
+
+ + {/* Daily Breakdown */} +
+

Daily Breakdown

+ + {/* Desktop Table */} +
+ + + + + + + + + + + {breakdown.dailyReturns.map((day: { date: string; return: number; multiplier: number; cumulative: number }) => ( + + + + + + + ))} + + + + + + + + + +
DateReturnMultiplierCumulative
+ {new Date(day.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })} + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' + }`}> + {day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}% + + ×{day.multiplier.toFixed(4)} + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' + }`}> + {day.cumulative >= 0 ? '+' : ''}{day.cumulative.toFixed(1)}% +
Total- + ×{breakdown.finalMultiplier.toFixed(4)} + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' + }`}> + {breakdown.finalReturn >= 0 ? '+' : ''}{breakdown.finalReturn.toFixed(1)}% +
+
+ + {/* Mobile Cards */} +
+ {breakdown.dailyReturns.map((day: { date: string; return: number; multiplier: number; cumulative: number }) => ( +
+
+
+ {new Date(day.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })} +
+
+ ×{day.multiplier.toFixed(4)} +
+
+
+
= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' + }`}> + {day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}% +
+
= 0 ? 'text-green-500 dark:text-green-500' : 'text-red-500 dark:text-red-500' + }`}> + {day.cumulative >= 0 ? '+' : ''}{day.cumulative.toFixed(1)}% total +
+
+
+ ))} + {/* Total Card */} +
+
Total
+
+
= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' + }`}> + {breakdown.finalReturn >= 0 ? '+' : ''}{breakdown.finalReturn.toFixed(1)}% +
+
+ ×{breakdown.finalMultiplier.toFixed(4)} +
+
+
+
+
+ + {/* Visual Formula */} + {breakdown.dailyReturns.length > 0 && ( +
+

Calculation

+
+ {breakdown.dailyReturns.map((d: { date: string; return: number }, i: number) => ( + + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}> + (1 {d.return >= 0 ? '+' : ''} {d.return.toFixed(1)}%) + + {i < breakdown.dailyReturns.length - 1 && ' × '} + + ))} + {' = '} + + {breakdown.finalMultiplier.toFixed(4)} + + {' → '} + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}> + {breakdown.finalReturn >= 0 ? '+' : ''}{breakdown.finalReturn.toFixed(1)}% + +
+
+ )} + + {/* Disclaimer */} +
+

+ Note: This compound return represents theoretical portfolio growth + if all recommendations were followed. Real trading results depend on execution, + position sizing, and market conditions. +

+
+
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/PortfolioSimulator.tsx b/frontend/src/components/PortfolioSimulator.tsx new file mode 100644 index 00000000..285ca059 --- /dev/null +++ b/frontend/src/components/PortfolioSimulator.tsx @@ -0,0 +1,194 @@ +import { useState, useMemo } from 'react'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; +import { Calculator, ChevronDown, ChevronUp, IndianRupee } from 'lucide-react'; +import { getOverallReturnBreakdown } from '../data/recommendations'; + +interface PortfolioSimulatorProps { + className?: string; +} + +export default function PortfolioSimulator({ className = '' }: PortfolioSimulatorProps) { + const [startingAmount, setStartingAmount] = useState(100000); + const [showBreakdown, setShowBreakdown] = useState(false); + + const breakdown = useMemo(() => getOverallReturnBreakdown(), []); + + // Calculate portfolio values over time + const portfolioData = useMemo(() => { + let value = startingAmount; + return breakdown.dailyReturns.map(day => { + value = value * day.multiplier; + return { + date: new Date(day.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }), + value: Math.round(value), + return: day.return, + cumulative: day.cumulative, + }; + }); + }, [breakdown.dailyReturns, startingAmount]); + + const currentValue = portfolioData.length > 0 + ? portfolioData[portfolioData.length - 1].value + : startingAmount; + const totalReturn = ((currentValue - startingAmount) / startingAmount) * 100; + const profitLoss = currentValue - startingAmount; + const isPositive = profitLoss >= 0; + + const handleAmountChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value.replace(/,/g, ''), 10); + if (!isNaN(value) && value >= 0) { + setStartingAmount(value); + } + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: 'INR', + maximumFractionDigits: 0, + }).format(value); + }; + + return ( +
+
+ +

Portfolio Simulator

+
+ + {/* Input Section */} +
+ +
+ + +
+
+ {[10000, 50000, 100000, 500000].map(amount => ( + + ))} +
+
+ + {/* Results Section */} +
+
+
Current Value
+
+ {formatCurrency(currentValue)} +
+
+
+
Profit/Loss
+
+ {isPositive ? '+' : ''}{formatCurrency(profitLoss)} + ({isPositive ? '+' : ''}{totalReturn.toFixed(1)}%) +
+
+
+ + {/* Chart */} + {portfolioData.length > 0 && ( +
+ + + + + formatCurrency(v).replace('₹', '')} + className="text-gray-500 dark:text-gray-400" + width={60} + /> + [formatCurrency(value as number), 'Value']} + /> + + + + +
+ )} + + {/* Daily Breakdown (Collapsible) */} + + + {showBreakdown && ( +
+ + + + + + + + + + {portfolioData.map((day, idx) => ( + + + + + + ))} + +
DateReturnValue
{day.date}= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' + }`}> + {day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}% + + {formatCurrency(day.value)} +
+
+ )} + +

+ Simulated returns based on AI recommendation performance. Past performance does not guarantee future results. +

+
+ ); +} diff --git a/frontend/src/components/ReturnDistributionChart.tsx b/frontend/src/components/ReturnDistributionChart.tsx new file mode 100644 index 00000000..2276d30b --- /dev/null +++ b/frontend/src/components/ReturnDistributionChart.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { X } from 'lucide-react'; +import { getReturnDistribution } from '../data/recommendations'; + +interface ReturnDistributionChartProps { + height?: number; + className?: string; +} + +export default function ReturnDistributionChart({ height = 200, className = '' }: ReturnDistributionChartProps) { + const [selectedBucket, setSelectedBucket] = useState<{ range: string; stocks: string[] } | null>(null); + const data = getReturnDistribution(); + + if (data.every(d => d.count === 0)) { + return ( +
+ No distribution data available +
+ ); + } + + // Color gradient from red (negative) to green (positive) + const getBarColor = (range: string) => { + if (range.includes('< -3') || range.includes('-3 to -2')) return '#ef4444'; + if (range.includes('-2 to -1')) return '#f87171'; + if (range.includes('-1 to 0')) return '#fca5a5'; + if (range.includes('0 to 1')) return '#86efac'; + if (range.includes('1 to 2')) return '#4ade80'; + if (range.includes('2 to 3') || range.includes('> 3')) return '#22c55e'; + return '#94a3b8'; + }; + + const handleBarClick = (data: { range: string; stocks: string[] }) => { + if (data.stocks.length > 0) { + setSelectedBucket(data); + } + }; + + return ( +
+
+ + + + + + [`${value} stocks`, 'Count']} + labelFormatter={(label) => `Return: ${label}`} + /> + { + if (typeof index === 'number' && data[index]) { + handleBarClick(data[index]); + } + }} + fill="#0ea5e9" + shape={(props: { x: number; y: number; width: number; height: number; index?: number }) => { + const { x, y, width, height, index: idx } = props; + const fill = typeof idx === 'number' ? getBarColor(data[idx]?.range || '') : '#0ea5e9'; + return ; + }} + /> + + +
+ + {/* Selected bucket modal */} + {selectedBucket && ( +
+
setSelectedBucket(null)} /> +
+
+

+ Stocks with {selectedBucket.range} return +

+ +
+
+ {selectedBucket.stocks.map(symbol => ( + + {symbol} + + ))} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ReturnExplainModal.tsx b/frontend/src/components/ReturnExplainModal.tsx new file mode 100644 index 00000000..0e8280fc --- /dev/null +++ b/frontend/src/components/ReturnExplainModal.tsx @@ -0,0 +1,219 @@ +import { X, CheckCircle, XCircle, Calculator } from 'lucide-react'; +import type { ReturnBreakdown } from '../data/recommendations'; + +interface ReturnExplainModalProps { + isOpen: boolean; + onClose: () => void; + breakdown: ReturnBreakdown | null; + date: string; +} + +export default function ReturnExplainModal({ isOpen, onClose, breakdown, date }: ReturnExplainModalProps) { + if (!isOpen || !breakdown) return null; + + const formattedDate = new Date(date).toLocaleDateString('en-IN', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ +

+ Return Calculation +

+
+ +
+ + {/* Content */} +
+ {/* Date & Result */} +
+
{formattedDate}
+
= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}> + {breakdown.weightedReturn >= 0 ? '+' : ''}{breakdown.weightedReturn.toFixed(1)}% +
+

+ Weighted Average Return +

+
+ + {/* Method Explanation */} +
+

Calculation Method

+
+

+ 1. Correct Predictions → Contribute positively +

+
    +
  • • BUY that went up → add the gain
  • +
  • • SELL that went down → add the avoided loss
  • +
  • • HOLD that stayed flat → small positive
  • +
+

+ 2. Incorrect Predictions → Contribute negatively +

+
    +
  • • BUY that went down → subtract the loss
  • +
  • • SELL that went up → subtract missed gain
  • +
  • • HOLD that moved → subtract missed opportunity
  • +
+

+ 3. Weighted Average +

+
+ (Correct Avg × Correct Weight) + (Incorrect Avg × Incorrect Weight) +
+
+
+ + {/* Correct Predictions Breakdown */} +
+
+ +

Correct Predictions

+ + ({breakdown.correctPredictions.count} stocks) + +
+
+
+
+
Average Return
+
+ +{breakdown.correctPredictions.avgReturn.toFixed(1)}% +
+
+
+
Weight
+
+ {breakdown.correctPredictions.count}/{breakdown.correctPredictions.count + breakdown.incorrectPredictions.count} +
+
+
+ {breakdown.correctPredictions.stocks.length > 0 && ( +
+
Top performers:
+
+ {breakdown.correctPredictions.stocks.map((stock: { symbol: string; decision: string; return1d: number }) => ( +
+ + {stock.symbol} + + ({stock.decision}) + + + +{stock.return1d.toFixed(1)}% +
+ ))} +
+
+ )} +
+
+ + {/* Incorrect Predictions Breakdown */} +
+
+ +

Incorrect Predictions

+ + ({breakdown.incorrectPredictions.count} stocks) + +
+
+
+
+
Average Return
+
+ {breakdown.incorrectPredictions.avgReturn.toFixed(1)}% +
+
+
+
Weight
+
+ {breakdown.incorrectPredictions.count}/{breakdown.correctPredictions.count + breakdown.incorrectPredictions.count} +
+
+
+ {breakdown.incorrectPredictions.stocks.length > 0 && ( +
+
Worst performers:
+
+ {breakdown.incorrectPredictions.stocks.map((stock: { symbol: string; decision: string; return1d: number }) => ( +
+ + {stock.symbol} + + ({stock.decision}) + + + {stock.return1d.toFixed(1)}% +
+ ))} +
+
+ )} +
+
+ + {/* Final Calculation */} +
+

Final Calculation

+
+
+ {breakdown.formula} +
+
+
+ + {/* Disclaimer */} +
+

+ Note: This weighted return represents the theoretical gain/loss + if you followed all predictions for the day. Actual results may vary based on + execution timing, transaction costs, and market conditions. +

+
+
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/RiskMetricsCard.tsx b/frontend/src/components/RiskMetricsCard.tsx new file mode 100644 index 00000000..238c0799 --- /dev/null +++ b/frontend/src/components/RiskMetricsCard.tsx @@ -0,0 +1,101 @@ +import { HelpCircle, TrendingUp, TrendingDown, Activity, Target } from 'lucide-react'; +import { calculateRiskMetrics } from '../data/recommendations'; +import { useState } from 'react'; + +interface RiskMetricsCardProps { + className?: string; +} + +export default function RiskMetricsCard({ className = '' }: RiskMetricsCardProps) { + const [showTooltip, setShowTooltip] = useState(null); + const metrics = calculateRiskMetrics(); + + const tooltips: Record = { + sharpe: 'Sharpe Ratio measures risk-adjusted returns. Higher is better (>1 is good, >2 is excellent).', + drawdown: 'Maximum Drawdown shows the largest peak-to-trough decline. Lower is better.', + winloss: 'Win/Loss Ratio compares average winning trade to average losing trade. Higher means bigger wins than losses.', + winrate: 'Win Rate is the percentage of predictions that were correct.', + }; + + const getColor = (metric: string, value: number) => { + switch (metric) { + case 'sharpe': + return value >= 1 ? 'text-green-600 dark:text-green-400' : value >= 0 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400'; + case 'drawdown': + return value <= 5 ? 'text-green-600 dark:text-green-400' : value <= 15 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400'; + case 'winloss': + return value >= 1.5 ? 'text-green-600 dark:text-green-400' : value >= 1 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400'; + case 'winrate': + return value >= 70 ? 'text-green-600 dark:text-green-400' : value >= 50 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400'; + default: + return 'text-gray-700 dark:text-gray-300'; + } + }; + + const cards = [ + { + id: 'sharpe', + label: 'Sharpe Ratio', + value: metrics.sharpeRatio.toFixed(2), + icon: Activity, + color: getColor('sharpe', metrics.sharpeRatio), + }, + { + id: 'drawdown', + label: 'Max Drawdown', + value: `${metrics.maxDrawdown.toFixed(1)}%`, + icon: TrendingDown, + color: getColor('drawdown', metrics.maxDrawdown), + }, + { + id: 'winloss', + label: 'Win/Loss Ratio', + value: metrics.winLossRatio.toFixed(2), + icon: TrendingUp, + color: getColor('winloss', metrics.winLossRatio), + }, + { + id: 'winrate', + label: 'Win Rate', + value: `${metrics.winRate}%`, + icon: Target, + color: getColor('winrate', metrics.winRate), + }, + ]; + + return ( +
+ {cards.map((card) => { + const Icon = card.icon; + return ( +
+
+ + {card.value} +
+
+ {card.label} + +
+ + {/* Tooltip */} + {showTooltip === card.id && ( +
+ {tooltips[card.id]} +
+
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx new file mode 100644 index 00000000..6e743bc3 --- /dev/null +++ b/frontend/src/components/SettingsModal.tsx @@ -0,0 +1,297 @@ +import { useState } from 'react'; +import { + X, Settings, Cpu, Key, Zap, Brain, Sparkles, + Eye, EyeOff, Check, AlertCircle, RefreshCw +} from 'lucide-react'; +import { useSettings, MODELS, PROVIDERS } from '../contexts/SettingsContext'; +import type { ModelId, ProviderId } from '../contexts/SettingsContext'; + +export default function SettingsModal() { + const { settings, updateSettings, resetSettings, isSettingsOpen, closeSettings } = useSettings(); + const [showApiKey, setShowApiKey] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + + if (!isSettingsOpen) return null; + + const handleProviderChange = (providerId: ProviderId) => { + updateSettings({ provider: providerId }); + }; + + const handleModelChange = (type: 'deepThinkModel' | 'quickThinkModel', modelId: ModelId) => { + updateSettings({ [type]: modelId }); + }; + + const handleApiKeyChange = (value: string) => { + updateSettings({ anthropicApiKey: value }); + setTestResult(null); + }; + + const testApiKey = async () => { + if (!settings.anthropicApiKey) { + setTestResult({ success: false, message: 'Please enter an API key' }); + return; + } + + setIsTesting(true); + setTestResult(null); + + try { + // Simple validation - just check format + if (!settings.anthropicApiKey.startsWith('sk-ant-')) { + setTestResult({ success: false, message: 'Invalid API key format. Should start with sk-ant-' }); + } else { + setTestResult({ success: true, message: 'API key format looks valid' }); + } + } catch (error) { + setTestResult({ success: false, message: 'Failed to validate API key' }); + } finally { + setIsTesting(false); + } + }; + + const selectedProvider = PROVIDERS[settings.provider]; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+
+ {/* Header */} +
+
+
+ +
+
+

Settings

+

Configure AI models and API settings

+
+
+ +
+ + {/* Content */} +
+ {/* Provider Selection */} +
+

+ + LLM Provider +

+
+ {Object.values(PROVIDERS).map(provider => ( + + ))} +
+
+ + {/* API Key (only shown for API provider) */} + {selectedProvider.requiresApiKey && ( +
+

+ + API Key +

+
+
+ handleApiKeyChange(e.target.value)} + placeholder="sk-ant-..." + className="w-full px-4 py-2.5 pr-20 rounded-xl border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-nifty-500 font-mono text-sm" + /> + +
+
+ + {testResult && ( + + {testResult.success ? : } + {testResult.message} + + )} +
+

+ Your API key is stored locally in your browser and never sent to our servers. +

+
+
+ )} + + {/* Model Selection */} +
+

+ + Model Selection +

+ + {/* Deep Think Model */} +
+ +
+ {Object.values(MODELS).map(model => ( + + ))} +
+
+ + {/* Quick Think Model */} +
+ +
+ {Object.values(MODELS).map(model => ( + + ))} +
+
+
+ + {/* Analysis Settings */} +
+

+ + Analysis Settings +

+
+ + updateSettings({ maxDebateRounds: parseInt(e.target.value) })} + className="w-full h-2 bg-gray-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-nifty-600" + /> +
+ 1 (Faster) + 5 (More thorough) +
+
+
+
+ + {/* Footer */} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/Sparkline.tsx b/frontend/src/components/Sparkline.tsx new file mode 100644 index 00000000..30552d3b --- /dev/null +++ b/frontend/src/components/Sparkline.tsx @@ -0,0 +1,65 @@ +import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts'; +import type { PricePoint } from '../types'; + +interface SparklineProps { + data: PricePoint[]; + width?: number; + height?: number; + positive?: boolean; + className?: string; +} + +export default function Sparkline({ + data, + width = 80, + height = 24, + positive = true, + className = '', +}: SparklineProps) { + if (!data || data.length < 2) { + return ( +
+ No data +
+ ); + } + + // Normalize data to percentage change from first point for better visual variation + const basePrice = data[0].price; + const normalizedData = data.map(point => ({ + ...point, + normalizedPrice: ((point.price - basePrice) / basePrice) * 100, + })); + + // Calculate min/max for domain padding + const prices = normalizedData.map(d => d.normalizedPrice); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const padding = Math.max(Math.abs(maxPrice - minPrice) * 0.15, 0.5); + + const color = positive ? '#22c55e' : '#ef4444'; + + return ( +
+ + + + + + +
+ ); +} diff --git a/frontend/src/components/StockCard.tsx b/frontend/src/components/StockCard.tsx new file mode 100644 index 00000000..bb90ec47 --- /dev/null +++ b/frontend/src/components/StockCard.tsx @@ -0,0 +1,147 @@ +import { Link } from 'react-router-dom'; +import { TrendingUp, TrendingDown, Minus, ChevronRight } from 'lucide-react'; +import type { StockAnalysis, Decision } from '../types'; + +interface StockCardProps { + stock: StockAnalysis; + showDetails?: boolean; + compact?: boolean; +} + +export function DecisionBadge({ decision, size = 'default' }: { decision: Decision | null; size?: 'small' | 'default' }) { + if (!decision) return null; + + const config = { + BUY: { + bg: 'bg-green-100 dark:bg-green-900/30', + text: 'text-green-800 dark:text-green-400', + icon: TrendingUp, + }, + SELL: { + bg: 'bg-red-100 dark:bg-red-900/30', + text: 'text-red-800 dark:text-red-400', + icon: TrendingDown, + }, + HOLD: { + bg: 'bg-amber-100 dark:bg-amber-900/30', + text: 'text-amber-800 dark:text-amber-400', + icon: Minus, + }, + }; + + const { bg, text, icon: Icon } = config[decision]; + const sizeClasses = size === 'small' + ? 'px-2 py-0.5 text-xs gap-1' + : 'px-2.5 py-0.5 text-xs gap-1'; + const iconSize = size === 'small' ? 'w-3 h-3' : 'w-3.5 h-3.5'; + + return ( + + + {decision} + + ); +} + +export function ConfidenceBadge({ confidence }: { confidence?: string }) { + if (!confidence) return null; + + const colors = { + HIGH: 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800', + MEDIUM: 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-800', + LOW: 'bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600', + }; + + return ( + + {confidence} Confidence + + ); +} + +export function RiskBadge({ risk }: { risk?: string }) { + if (!risk) return null; + + const colors = { + HIGH: 'text-red-600 dark:text-red-400', + MEDIUM: 'text-amber-600 dark:text-amber-400', + LOW: 'text-green-600 dark:text-green-400', + }; + + return ( + + {risk} Risk + + ); +} + +export default function StockCard({ stock, showDetails = true, compact = false }: StockCardProps) { + if (compact) { + return ( + +
+ +
+ +
+ + ); + } + + return ( + +
+
+

{stock.symbol}

+ +
+

{stock.company_name}

+ {showDetails && ( +
+ + +
+ )} +
+ + + ); +} + +export function StockCardCompact({ stock }: { stock: StockAnalysis }) { + return ( + +
+
+
+ {stock.symbol} + · + {stock.company_name} +
+
+ + + ); +} diff --git a/frontend/src/components/StockPriceChart.tsx b/frontend/src/components/StockPriceChart.tsx new file mode 100644 index 00000000..2e3a3578 --- /dev/null +++ b/frontend/src/components/StockPriceChart.tsx @@ -0,0 +1,265 @@ +import { useMemo } from 'react'; +import { + ComposedChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Area, +} from 'recharts'; +import { TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import { useTheme } from '../contexts/ThemeContext'; +import type { PricePoint, Decision } from '../types'; + +interface PredictionPoint { + date: string; + decision: Decision; + price?: number; +} + +interface StockPriceChartProps { + priceHistory: PricePoint[]; + predictions?: PredictionPoint[]; + symbol: string; + showArea?: boolean; +} + +// Custom tooltip component +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

+ {new Date(label).toLocaleDateString('en-IN', { + weekday: 'short', + month: 'short', + day: 'numeric', + })} +

+

+ ₹{data.price.toLocaleString('en-IN', { minimumFractionDigits: 2 })} +

+ {data.prediction && ( +
+ {data.prediction === 'BUY' && } + {data.prediction === 'SELL' && } + {data.prediction === 'HOLD' && } + AI: {data.prediction} +
+ )} +
+ ); + } + return null; +}; + +// Custom prediction marker component with arrow symbols +const PredictionMarker = (props: any) => { + const { cx, cy, payload } = props; + if (!payload?.prediction || cx === undefined || cy === undefined) return null; + + const colors = { + BUY: { fill: '#22c55e', stroke: '#16a34a' }, + SELL: { fill: '#ef4444', stroke: '#dc2626' }, + HOLD: { fill: '#f59e0b', stroke: '#d97706' }, + }; + + const color = colors[payload.prediction as Decision] || colors.HOLD; + + // Render different shapes based on prediction type + if (payload.prediction === 'BUY') { + // Up arrow + return ( + + + + + ); + } else if (payload.prediction === 'SELL') { + // Down arrow + return ( + + + + + ); + } else { + // Equal/minus sign for HOLD + return ( + + + + + + ); + } +}; + +export default function StockPriceChart({ + priceHistory, + predictions = [], + symbol, + showArea = true, +}: StockPriceChartProps) { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; + + // Theme-aware colors + const gridColor = isDark ? '#475569' : '#e5e7eb'; + const tickColor = isDark ? '#94a3b8' : '#6b7280'; + + // Merge price history with predictions + const chartData = useMemo(() => { + const predictionMap = new Map( + predictions.map(p => [p.date, p.decision]) + ); + + return priceHistory.map(point => ({ + ...point, + prediction: predictionMap.get(point.date) || null, + })); + }, [priceHistory, predictions]); + + // Calculate price range for Y-axis + const { minPrice, maxPrice } = useMemo(() => { + const prices = priceHistory.map(p => p.price); + const min = Math.min(...prices); + const max = Math.max(...prices); + const padding = (max - min) * 0.1; + return { + minPrice: Math.floor(min - padding), + maxPrice: Math.ceil(max + padding), + }; + }, [priceHistory]); + + // Calculate overall trend + const trend = useMemo(() => { + if (priceHistory.length < 2) return 'flat'; + const first = priceHistory[0].price; + const last = priceHistory[priceHistory.length - 1].price; + const change = ((last - first) / first) * 100; + return change > 0 ? 'up' : change < 0 ? 'down' : 'flat'; + }, [priceHistory]); + + const trendColor = trend === 'up' ? '#22c55e' : trend === 'down' ? '#ef4444' : '#6b7280'; + const gradientId = `gradient-${symbol}`; + + if (priceHistory.length === 0) { + return ( +
+ No price data available +
+ ); + } + + // Background color based on theme + const chartBgColor = isDark ? '#1e293b' : '#ffffff'; + + return ( +
+ + + + + + + + + + + + new Date(date).toLocaleDateString('en-IN', { + month: 'short', + day: 'numeric', + })} + interval="preserveStartEnd" + minTickGap={50} + /> + + `₹${value}`} + width={60} + /> + + } /> + + {showArea && ( + + )} + + { + const { payload, cx, cy } = props; + if (payload?.prediction && cx !== undefined && cy !== undefined) { + return ; + } + return ; // Return empty group for non-prediction points + }} + activeDot={{ r: 4, fill: trendColor }} + isAnimationActive={false} + /> + + + + {/* Legend */} +
+
+ + BUY Signal +
+
+ + HOLD Signal +
+
+ + SELL Signal +
+
+
+ ); +} diff --git a/frontend/src/components/SummaryStats.tsx b/frontend/src/components/SummaryStats.tsx new file mode 100644 index 00000000..6a68afc7 --- /dev/null +++ b/frontend/src/components/SummaryStats.tsx @@ -0,0 +1,99 @@ +import { TrendingUp, TrendingDown, Minus, BarChart2 } from 'lucide-react'; + +interface SummaryStatsProps { + total: number; + buy: number; + sell: number; + hold: number; + date: string; +} + +export default function SummaryStats({ total, buy, sell, hold, date }: SummaryStatsProps) { + const stats = [ + { + label: 'Total Analyzed', + value: total, + icon: BarChart2, + color: 'text-nifty-600', + bg: 'bg-nifty-50', + }, + { + label: 'Buy', + value: buy, + icon: TrendingUp, + color: 'text-green-600', + bg: 'bg-green-50', + percentage: ((buy / total) * 100).toFixed(0), + }, + { + label: 'Sell', + value: sell, + icon: TrendingDown, + color: 'text-red-600', + bg: 'bg-red-50', + percentage: ((sell / total) * 100).toFixed(0), + }, + { + label: 'Hold', + value: hold, + icon: Minus, + color: 'text-amber-600', + bg: 'bg-amber-50', + percentage: ((hold / total) * 100).toFixed(0), + }, + ]; + + return ( +
+
+

Today's Summary

+ + {new Date(date).toLocaleDateString('en-IN', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
+ +
+ {stats.map(({ label, value, icon: Icon, color, bg, percentage }) => ( +
+
+ + {percentage && ( + {percentage}% + )} +
+

{value}

+

{label}

+
+ ))} +
+ + {/* Progress bar */} +
+
+
+
+
+
+
+ Buy ({buy}) + Hold ({hold}) + Sell ({sell}) +
+
+
+ ); +} diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx new file mode 100644 index 00000000..b197d12a --- /dev/null +++ b/frontend/src/components/ThemeToggle.tsx @@ -0,0 +1,61 @@ +import { Sun, Moon, Monitor } from 'lucide-react'; +import { useTheme } from '../contexts/ThemeContext'; + +interface ThemeToggleProps { + compact?: boolean; +} + +export default function ThemeToggle({ compact = false }: ThemeToggleProps) { + const { theme, setTheme } = useTheme(); + + const themes = [ + { value: 'light' as const, icon: Sun, label: 'Light' }, + { value: 'dark' as const, icon: Moon, label: 'Dark' }, + { value: 'system' as const, icon: Monitor, label: 'System' }, + ]; + + if (compact) { + // Simple cycling button for mobile + const currentIndex = themes.findIndex(t => t.value === theme); + const nextTheme = themes[(currentIndex + 1) % themes.length]; + const CurrentIcon = themes[currentIndex].icon; + + return ( + + ); + } + + return ( +
+ {themes.map(({ value, icon: Icon, label }) => { + const isActive = theme === value; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/TopPicks.tsx b/frontend/src/components/TopPicks.tsx new file mode 100644 index 00000000..db8faec0 --- /dev/null +++ b/frontend/src/components/TopPicks.tsx @@ -0,0 +1,123 @@ +import { Link } from 'react-router-dom'; +import { Trophy, AlertTriangle, TrendingUp, TrendingDown } from 'lucide-react'; +import type { TopPick, StockToAvoid } from '../types'; +import BackgroundSparkline from './BackgroundSparkline'; +import { getBacktestResult } from '../data/recommendations'; + +interface TopPicksProps { + picks: TopPick[]; +} + +export default function TopPicks({ picks }: TopPicksProps) { + const medals = ['🥇', '🥈', '🥉']; + + return ( +
+
+ +

Top Picks

+ ({picks.length}) +
+ +
+ {picks.map((pick, index) => { + const backtest = getBacktestResult(pick.symbol); + return ( + + {/* Background Chart */} + {backtest && ( +
+ +
+ )} + + {/* Content */} +
+
+
+ {medals[index]} + {pick.symbol} +
+
+ + BUY +
+
+

{pick.reason}

+
+ + {pick.risk_level} Risk + + View → +
+
+ + ); + })} +
+
+ ); +} + +interface StocksToAvoidProps { + stocks: StockToAvoid[]; +} + +export function StocksToAvoid({ stocks }: StocksToAvoidProps) { + return ( +
+
+ +

Stocks to Avoid

+ ({stocks.length}) +
+ +
+ {stocks.map((stock) => { + const backtest = getBacktestResult(stock.symbol); + return ( + + {/* Background Chart */} + {backtest && ( +
+ +
+ )} + + {/* Content */} +
+
+ {stock.symbol} +
+ + SELL +
+
+

{stock.reason}

+ View → +
+ + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/pipeline/AgentReportCard.tsx b/frontend/src/components/pipeline/AgentReportCard.tsx new file mode 100644 index 00000000..4b5610b0 --- /dev/null +++ b/frontend/src/components/pipeline/AgentReportCard.tsx @@ -0,0 +1,194 @@ +import { useState } from 'react'; +import { + TrendingUp, Newspaper, Users, FileText, + ChevronDown, ChevronUp, Database, Clock, CheckCircle +} from 'lucide-react'; +import type { AgentReport, AgentType } from '../../types/pipeline'; +import { AGENT_METADATA } from '../../types/pipeline'; + +interface AgentReportCardProps { + agentType: AgentType; + report?: AgentReport; + isLoading?: boolean; +} + +const AGENT_ICONS: Record = { + market: TrendingUp, + news: Newspaper, + social_media: Users, + fundamentals: FileText, +}; + +const AGENT_COLORS: Record = { + market: { + bg: 'bg-blue-50 dark:bg-blue-900/20', + border: 'border-blue-200 dark:border-blue-800', + text: 'text-blue-700 dark:text-blue-300', + accent: 'bg-blue-500' + }, + news: { + bg: 'bg-purple-50 dark:bg-purple-900/20', + border: 'border-purple-200 dark:border-purple-800', + text: 'text-purple-700 dark:text-purple-300', + accent: 'bg-purple-500' + }, + social_media: { + bg: 'bg-pink-50 dark:bg-pink-900/20', + border: 'border-pink-200 dark:border-pink-800', + text: 'text-pink-700 dark:text-pink-300', + accent: 'bg-pink-500' + }, + fundamentals: { + bg: 'bg-green-50 dark:bg-green-900/20', + border: 'border-green-200 dark:border-green-800', + text: 'text-green-700 dark:text-green-300', + accent: 'bg-green-500' + }, +}; + +export function AgentReportCard({ agentType, report, isLoading }: AgentReportCardProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const Icon = AGENT_ICONS[agentType]; + const colors = AGENT_COLORS[agentType]; + const metadata = AGENT_METADATA[agentType]; + const hasReport = report && report.report_content; + + // Parse markdown-like content into sections + const parseContent = (content: string) => { + const lines = content.split('\n'); + const sections: { title: string; content: string[] }[] = []; + let currentSection: { title: string; content: string[] } | null = null; + + lines.forEach(line => { + if (line.startsWith('##') || line.startsWith('**')) { + if (currentSection) { + sections.push(currentSection); + } + currentSection = { + title: line.replace(/^#+\s*/, '').replace(/\*\*/g, ''), + content: [] + }; + } else if (currentSection && line.trim()) { + currentSection.content.push(line); + } + }); + + if (currentSection) { + sections.push(currentSection); + } + + return sections; + }; + + const sections = hasReport ? parseContent(report.report_content) : []; + const previewText = hasReport + ? report.report_content.slice(0, 200).replace(/[#*]/g, '') + '...' + : 'No analysis available'; + + return ( +
+ {/* Header */} +
hasReport && setIsExpanded(!isExpanded)} + > +
+
+ +
+
+

{metadata.label}

+

+ {metadata.description} +

+
+
+ +
+ {hasReport ? ( + + ) : isLoading ? ( +
+ ) : ( + + )} + + {hasReport && ( + isExpanded ? ( + + ) : ( + + ) + )} +
+
+ + {/* Preview (collapsed) */} + {!isExpanded && hasReport && ( +
+

+ {previewText} +

+
+ )} + + {/* Expanded content */} + {isExpanded && hasReport && ( +
+ {/* Data sources */} + {report.data_sources_used && report.data_sources_used.length > 0 && ( +
+ + Sources: + {report.data_sources_used.map((source, idx) => ( + + {source} + + ))} +
+ )} + + {/* Report content */} +
+ {sections.length > 0 ? ( + sections.map((section, idx) => ( +
+

+ {section.title} +

+
+ {section.content.map((line, lineIdx) => ( +

{line}

+ ))} +
+
+ )) + ) : ( +
+
+                  {report.report_content}
+                
+
+ )} +
+ + {/* Timestamp */} + {report.created_at && ( +
+ + + Generated: {new Date(report.created_at).toLocaleString()} + +
+ )} +
+ )} +
+ ); +} + +export default AgentReportCard; diff --git a/frontend/src/components/pipeline/DataSourcesPanel.tsx b/frontend/src/components/pipeline/DataSourcesPanel.tsx new file mode 100644 index 00000000..37985d6c --- /dev/null +++ b/frontend/src/components/pipeline/DataSourcesPanel.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { + Database, ChevronDown, ChevronUp, CheckCircle, + XCircle, Clock, Server +} from 'lucide-react'; +import type { DataSourceLog } from '../../types/pipeline'; + +interface DataSourcesPanelProps { + dataSources: DataSourceLog[]; + isLoading?: boolean; +} + +const SOURCE_TYPE_COLORS: Record = { + market_data: { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300' }, + news: { bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-300' }, + fundamentals: { bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-700 dark:text-green-300' }, + social_media: { bg: 'bg-pink-100 dark:bg-pink-900/30', text: 'text-pink-700 dark:text-pink-300' }, + indicators: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300' }, + default: { bg: 'bg-slate-100 dark:bg-slate-800', text: 'text-slate-700 dark:text-slate-300' } +}; + +export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [expandedSources, setExpandedSources] = useState>(new Set()); + + const hasData = dataSources.length > 0; + const successCount = dataSources.filter(s => s.success).length; + const errorCount = dataSources.filter(s => !s.success).length; + + const toggleSourceExpanded = (index: number) => { + const newSet = new Set(expandedSources); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + setExpandedSources(newSet); + }; + + const getSourceColors = (sourceType: string) => { + return SOURCE_TYPE_COLORS[sourceType] || SOURCE_TYPE_COLORS.default; + }; + + const formatTimestamp = (timestamp?: string) => { + if (!timestamp) return 'Unknown'; + try { + return new Date(timestamp).toLocaleString(); + } catch { + return timestamp; + } + }; + + return ( +
+ {/* Header */} +
hasData && setIsExpanded(!isExpanded)} + > +
+
+ +
+
+

+ Data Sources +

+

+ Raw data fetched for analysis +

+
+
+ +
+ {hasData ? ( +
+ + + {successCount} + + {errorCount > 0 && ( + + + {errorCount} + + )} +
+ ) : isLoading ? ( +
+ ) : ( + + No Data + + )} + {hasData && ( + isExpanded ? : + )} +
+
+ + {/* Expanded content */} + {isExpanded && hasData && ( +
+
+ {dataSources.map((source, index) => { + const colors = getSourceColors(source.source_type); + const isSourceExpanded = expandedSources.has(index); + + return ( +
+ {/* Source header */} +
toggleSourceExpanded(index)} + > +
+ +
+
+ + {source.source_type} + + + {source.source_name} + +
+
+ + {formatTimestamp(source.fetch_timestamp)} +
+
+
+ +
+ {source.success ? ( + + ) : ( + + )} + {isSourceExpanded ? ( + + ) : ( + + )} +
+
+ + {/* Source details (expanded) */} + {isSourceExpanded && ( +
+ {source.error_message ? ( +
+

+ Error: {source.error_message} +

+
+ ) : source.data_fetched ? ( +
+

+ Data Summary: +

+
+                            {typeof source.data_fetched === 'string'
+                              ? source.data_fetched.slice(0, 500) + (source.data_fetched.length > 500 ? '...' : '')
+                              : JSON.stringify(source.data_fetched, null, 2).slice(0, 500)}
+                          
+
+ ) : ( +

+ No data details available +

+ )} +
+ )} +
+ ); + })} +
+
+ )} +
+ ); +} + +export default DataSourcesPanel; diff --git a/frontend/src/components/pipeline/DebateViewer.tsx b/frontend/src/components/pipeline/DebateViewer.tsx new file mode 100644 index 00000000..54260888 --- /dev/null +++ b/frontend/src/components/pipeline/DebateViewer.tsx @@ -0,0 +1,254 @@ +import { useState } from 'react'; +import { + TrendingUp, TrendingDown, Scale, ChevronDown, ChevronUp, + MessageSquare, Award +} from 'lucide-react'; +import type { DebateHistory } from '../../types/pipeline'; + +interface DebateViewerProps { + debate?: DebateHistory; + isLoading?: boolean; +} + +export function DebateViewer({ debate, isLoading }: DebateViewerProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [activeTab, setActiveTab] = useState<'bull' | 'bear' | 'history'>('history'); + + const hasDebate = debate && (debate.bull_arguments || debate.bear_arguments || debate.full_history); + + // Parse debate rounds from full history + const parseDebateRounds = (history: string) => { + const rounds: { speaker: string; content: string }[] = []; + const lines = history.split('\n'); + + let currentSpeaker = ''; + let currentContent: string[] = []; + + lines.forEach(line => { + if (line.startsWith('Bull') || line.startsWith('Bear') || line.startsWith('Judge')) { + if (currentSpeaker && currentContent.length > 0) { + rounds.push({ + speaker: currentSpeaker, + content: currentContent.join('\n') + }); + } + currentSpeaker = line.split(':')[0] || line.split(' ')[0]; + currentContent = [line.substring(line.indexOf(':') + 1).trim()]; + } else if (line.trim()) { + currentContent.push(line); + } + }); + + if (currentSpeaker && currentContent.length > 0) { + rounds.push({ + speaker: currentSpeaker, + content: currentContent.join('\n') + }); + } + + return rounds; + }; + + const debateRounds = hasDebate && debate.full_history + ? parseDebateRounds(debate.full_history) + : []; + + const getSpeakerStyle = (speaker: string) => { + if (speaker.toLowerCase().includes('bull')) { + return { + bg: 'bg-green-50 dark:bg-green-900/20', + border: 'border-l-green-500', + icon: TrendingUp, + color: 'text-green-600 dark:text-green-400' + }; + } else if (speaker.toLowerCase().includes('bear')) { + return { + bg: 'bg-red-50 dark:bg-red-900/20', + border: 'border-l-red-500', + icon: TrendingDown, + color: 'text-red-600 dark:text-red-400' + }; + } else { + return { + bg: 'bg-blue-50 dark:bg-blue-900/20', + border: 'border-l-blue-500', + icon: Scale, + color: 'text-blue-600 dark:text-blue-400' + }; + } + }; + + return ( +
+ {/* Header */} +
hasDebate && setIsExpanded(!isExpanded)} + > +
+
+
+ +
+
+ +
+
+ +
+
+
+

+ Investment Debate +

+

+ Bull vs Bear Analysis with Research Manager Decision +

+
+
+ +
+ {hasDebate ? ( + + Complete + + ) : isLoading ? ( +
+ ) : ( + + No Data + + )} + {hasDebate && ( + isExpanded ? : + )} +
+
+ + {/* Expanded content */} + {isExpanded && hasDebate && ( +
+ {/* Tabs */} +
+ + + +
+ + {/* Content */} +
+ {activeTab === 'history' && ( +
+ {debateRounds.length > 0 ? ( + debateRounds.map((round, idx) => { + const style = getSpeakerStyle(round.speaker); + const Icon = style.icon; + return ( +
+
+ + + {round.speaker} + +
+

+ {round.content} +

+
+ ); + }) + ) : debate.full_history ? ( +
+                    {debate.full_history}
+                  
+ ) : ( +

No debate history available

+ )} +
+ )} + + {activeTab === 'bull' && ( +
+
+ + + Bull Analyst Arguments + +
+

+ {debate.bull_arguments || 'No bull arguments recorded'} +

+
+ )} + + {activeTab === 'bear' && ( +
+
+ + + Bear Analyst Arguments + +
+

+ {debate.bear_arguments || 'No bear arguments recorded'} +

+
+ )} +
+ + {/* Judge Decision */} + {debate.judge_decision && ( +
+
+ +
+

+ Research Manager Decision +

+

+ {debate.judge_decision} +

+
+
+
+ )} +
+ )} +
+ ); +} + +export default DebateViewer; diff --git a/frontend/src/components/pipeline/PipelineOverview.tsx b/frontend/src/components/pipeline/PipelineOverview.tsx new file mode 100644 index 00000000..791b27c0 --- /dev/null +++ b/frontend/src/components/pipeline/PipelineOverview.tsx @@ -0,0 +1,157 @@ +import { + Database, TrendingUp, Newspaper, Users, FileText, + MessageSquare, Target, Shield, CheckCircle, Loader2, + AlertCircle, Clock +} from 'lucide-react'; +import type { PipelineStep, PipelineStepStatus } from '../../types/pipeline'; + +interface PipelineOverviewProps { + steps: PipelineStep[]; + onStepClick?: (step: PipelineStep) => void; + compact?: boolean; +} + +const STEP_ICONS: Record = { + data_collection: Database, + market_analysis: TrendingUp, + news_analysis: Newspaper, + social_analysis: Users, + fundamentals_analysis: FileText, + investment_debate: MessageSquare, + trader_decision: Target, + risk_debate: Shield, + final_decision: CheckCircle, +}; + +const STEP_LABELS: Record = { + data_collection: 'Data Collection', + market_analysis: 'Market Analysis', + news_analysis: 'News Analysis', + social_analysis: 'Social Analysis', + fundamentals_analysis: 'Fundamentals', + investment_debate: 'Investment Debate', + trader_decision: 'Trader Decision', + risk_debate: 'Risk Assessment', + final_decision: 'Final Decision', +}; + +const STATUS_STYLES: Record = { + pending: { + bg: 'bg-slate-100 dark:bg-slate-800', + border: 'border-slate-300 dark:border-slate-600', + text: 'text-slate-400 dark:text-slate-500', + icon: Clock + }, + running: { + bg: 'bg-blue-50 dark:bg-blue-900/30', + border: 'border-blue-400 dark:border-blue-500', + text: 'text-blue-600 dark:text-blue-400', + icon: Loader2 + }, + completed: { + bg: 'bg-green-50 dark:bg-green-900/30', + border: 'border-green-400 dark:border-green-500', + text: 'text-green-600 dark:text-green-400', + icon: CheckCircle + }, + error: { + bg: 'bg-red-50 dark:bg-red-900/30', + border: 'border-red-400 dark:border-red-500', + text: 'text-red-600 dark:text-red-400', + icon: AlertCircle + }, +}; + +// Default pipeline steps when no data is available +const DEFAULT_STEPS: PipelineStep[] = [ + { step_number: 1, step_name: 'data_collection', status: 'pending' }, + { step_number: 2, step_name: 'market_analysis', status: 'pending' }, + { step_number: 3, step_name: 'news_analysis', status: 'pending' }, + { step_number: 4, step_name: 'social_analysis', status: 'pending' }, + { step_number: 5, step_name: 'fundamentals_analysis', status: 'pending' }, + { step_number: 6, step_name: 'investment_debate', status: 'pending' }, + { step_number: 7, step_name: 'trader_decision', status: 'pending' }, + { step_number: 8, step_name: 'risk_debate', status: 'pending' }, + { step_number: 9, step_name: 'final_decision', status: 'pending' }, +]; + +export function PipelineOverview({ steps, onStepClick, compact = false }: PipelineOverviewProps) { + const displaySteps = steps.length > 0 ? steps : DEFAULT_STEPS; + + const completedCount = displaySteps.filter(s => s.status === 'completed').length; + const totalSteps = displaySteps.length; + const progress = Math.round((completedCount / totalSteps) * 100); + + if (compact) { + return ( +
+ {displaySteps.map((step) => { + const styles = STATUS_STYLES[step.status]; + return ( +
+ ); + })} + {progress}% +
+ ); + } + + return ( +
+ {/* Progress bar */} +
+
+
+
+ + {completedCount}/{totalSteps} + +
+ + {/* Pipeline steps */} +
+ {displaySteps.map((step) => { + const StepIcon = STEP_ICONS[step.step_name] || Database; + const styles = STATUS_STYLES[step.status]; + const StatusIcon = styles.icon; + const label = STEP_LABELS[step.step_name] || step.step_name; + + return ( + + ); + })} +
+
+ ); +} + +export default PipelineOverview; diff --git a/frontend/src/components/pipeline/RiskDebateViewer.tsx b/frontend/src/components/pipeline/RiskDebateViewer.tsx new file mode 100644 index 00000000..98d32b3b --- /dev/null +++ b/frontend/src/components/pipeline/RiskDebateViewer.tsx @@ -0,0 +1,256 @@ +import { useState } from 'react'; +import { + Zap, Shield, Scale, ChevronDown, ChevronUp, + ShieldCheck, AlertTriangle +} from 'lucide-react'; +import type { DebateHistory } from '../../types/pipeline'; + +interface RiskDebateViewerProps { + debate?: DebateHistory; + isLoading?: boolean; +} + +export function RiskDebateViewer({ debate, isLoading }: RiskDebateViewerProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [activeTab, setActiveTab] = useState<'all' | 'risky' | 'safe' | 'neutral'>('all'); + + const hasDebate = debate && ( + debate.risky_arguments || + debate.safe_arguments || + debate.neutral_arguments || + debate.full_history + ); + + const ROLE_STYLES = { + risky: { + bg: 'bg-red-50 dark:bg-red-900/20', + border: 'border-l-red-500', + icon: Zap, + color: 'text-red-600 dark:text-red-400', + label: 'Aggressive Analyst' + }, + safe: { + bg: 'bg-green-50 dark:bg-green-900/20', + border: 'border-l-green-500', + icon: Shield, + color: 'text-green-600 dark:text-green-400', + label: 'Conservative Analyst' + }, + neutral: { + bg: 'bg-slate-50 dark:bg-slate-800/50', + border: 'border-l-slate-500', + icon: Scale, + color: 'text-slate-600 dark:text-slate-400', + label: 'Neutral Analyst' + } + }; + + return ( +
+ {/* Header */} +
hasDebate && setIsExpanded(!isExpanded)} + > +
+
+
+ +
+
+ +
+
+ +
+
+
+

+ Risk Assessment Debate +

+

+ Aggressive vs Conservative vs Neutral with Risk Manager Decision +

+
+
+ +
+ {hasDebate ? ( + + Complete + + ) : isLoading ? ( +
+ ) : ( + + No Data + + )} + {hasDebate && ( + isExpanded ? : + )} +
+
+ + {/* Expanded content */} + {isExpanded && hasDebate && ( +
+ {/* Tabs */} +
+ + + + +
+ + {/* Content */} +
+ {activeTab === 'all' && ( +
+ {/* Aggressive */} +
+
+ + + {ROLE_STYLES.risky.label} + +
+

+ {debate.risky_arguments || 'No arguments recorded'} +

+
+ + {/* Neutral */} +
+
+ + + {ROLE_STYLES.neutral.label} + +
+

+ {debate.neutral_arguments || 'No arguments recorded'} +

+
+ + {/* Conservative */} +
+
+ + + {ROLE_STYLES.safe.label} + +
+

+ {debate.safe_arguments || 'No arguments recorded'} +

+
+
+ )} + + {activeTab === 'risky' && ( +
+
+ + + {ROLE_STYLES.risky.label} + + +
+

+ {debate.risky_arguments || 'No aggressive arguments recorded'} +

+
+ )} + + {activeTab === 'neutral' && ( +
+
+ + + {ROLE_STYLES.neutral.label} + +
+

+ {debate.neutral_arguments || 'No neutral arguments recorded'} +

+
+ )} + + {activeTab === 'safe' && ( +
+
+ + + {ROLE_STYLES.safe.label} + +
+

+ {debate.safe_arguments || 'No conservative arguments recorded'} +

+
+ )} +
+ + {/* Risk Manager Decision */} + {debate.judge_decision && ( +
+
+ +
+

+ Risk Manager Decision +

+

+ {debate.judge_decision} +

+
+
+
+ )} +
+ )} +
+ ); +} + +export default RiskDebateViewer; diff --git a/frontend/src/components/pipeline/index.ts b/frontend/src/components/pipeline/index.ts new file mode 100644 index 00000000..e33595ab --- /dev/null +++ b/frontend/src/components/pipeline/index.ts @@ -0,0 +1,5 @@ +export { PipelineOverview } from './PipelineOverview'; +export { AgentReportCard } from './AgentReportCard'; +export { DebateViewer } from './DebateViewer'; +export { RiskDebateViewer } from './RiskDebateViewer'; +export { DataSourcesPanel } from './DataSourcesPanel'; diff --git a/frontend/src/contexts/SettingsContext.tsx b/frontend/src/contexts/SettingsContext.tsx new file mode 100644 index 00000000..45d55ad7 --- /dev/null +++ b/frontend/src/contexts/SettingsContext.tsx @@ -0,0 +1,127 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import type { ReactNode } from 'react'; + +// Model options +export const MODELS = { + opus: { id: 'opus', name: 'Claude Opus', description: 'Most capable, best for complex reasoning' }, + sonnet: { id: 'sonnet', name: 'Claude Sonnet', description: 'Balanced performance and speed' }, + haiku: { id: 'haiku', name: 'Claude Haiku', description: 'Fastest, good for simple tasks' }, +} as const; + +// Provider options +export const PROVIDERS = { + claude_subscription: { + id: 'claude_subscription', + name: 'Claude Subscription', + description: 'Use your Claude Max subscription (no API key needed)', + requiresApiKey: false + }, + anthropic_api: { + id: 'anthropic_api', + name: 'Anthropic API', + description: 'Use Anthropic API directly with your API key', + requiresApiKey: true + }, +} as const; + +export type ModelId = keyof typeof MODELS; +export type ProviderId = keyof typeof PROVIDERS; + +interface Settings { + // Model settings + deepThinkModel: ModelId; + quickThinkModel: ModelId; + + // Provider settings + provider: ProviderId; + + // API keys (only used when provider is anthropic_api) + anthropicApiKey: string; + + // Analysis settings + maxDebateRounds: number; +} + +interface SettingsContextType { + settings: Settings; + updateSettings: (newSettings: Partial) => void; + resetSettings: () => void; + isSettingsOpen: boolean; + openSettings: () => void; + closeSettings: () => void; +} + +const DEFAULT_SETTINGS: Settings = { + deepThinkModel: 'opus', + quickThinkModel: 'sonnet', + provider: 'claude_subscription', + anthropicApiKey: '', + maxDebateRounds: 1, +}; + +const STORAGE_KEY = 'nifty50ai_settings'; + +const SettingsContext = createContext(undefined); + +export function SettingsProvider({ children }: { children: ReactNode }) { + const [settings, setSettings] = useState(() => { + // Load from localStorage on initial render + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored); + return { ...DEFAULT_SETTINGS, ...parsed }; + } catch (e) { + console.error('Failed to parse settings from localStorage:', e); + } + } + } + return DEFAULT_SETTINGS; + }); + + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + + // Persist settings to localStorage whenever they change + useEffect(() => { + if (typeof window !== 'undefined') { + // Don't store the API key in plain text - encrypt it or use a more secure method in production + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } + }, [settings]); + + const updateSettings = (newSettings: Partial) => { + setSettings(prev => ({ ...prev, ...newSettings })); + }; + + const resetSettings = () => { + setSettings(DEFAULT_SETTINGS); + if (typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } + }; + + const openSettings = () => setIsSettingsOpen(true); + const closeSettings = () => setIsSettingsOpen(false); + + return ( + + {children} + + ); +} + +export function useSettings() { + const context = useContext(SettingsContext); + if (context === undefined) { + throw new Error('useSettings must be used within a SettingsProvider'); + } + return context; +} diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx new file mode 100644 index 00000000..98137f7f --- /dev/null +++ b/frontend/src/contexts/ThemeContext.tsx @@ -0,0 +1,82 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; + +type Theme = 'light' | 'dark' | 'system'; + +interface ThemeContextType { + theme: Theme; + setTheme: (theme: Theme) => void; + resolvedTheme: 'light' | 'dark'; +} + +const ThemeContext = createContext(undefined); + +const STORAGE_KEY = 'nifty50-theme'; + +function getSystemTheme(): 'light' | 'dark' { + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function getStoredTheme(): Theme { + if (typeof window === 'undefined') return 'system'; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark' || stored === 'system') { + return stored; + } + return 'system'; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(getStoredTheme); + const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>( + theme === 'system' ? getSystemTheme() : theme + ); + + const setTheme = (newTheme: Theme) => { + setThemeState(newTheme); + localStorage.setItem(STORAGE_KEY, newTheme); + }; + + // Update resolved theme when theme or system preference changes + useEffect(() => { + const updateResolvedTheme = () => { + const resolved = theme === 'system' ? getSystemTheme() : theme; + setResolvedTheme(resolved); + + // Update document class + const root = document.documentElement; + if (resolved === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + }; + + updateResolvedTheme(); + + // Listen for system theme changes + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => { + if (theme === 'system') { + updateResolvedTheme(); + } + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [theme]); + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} diff --git a/frontend/src/data/recommendations.ts b/frontend/src/data/recommendations.ts new file mode 100644 index 00000000..356b21b2 --- /dev/null +++ b/frontend/src/data/recommendations.ts @@ -0,0 +1,1470 @@ +import type { DailyRecommendation, Decision, BacktestResult, AccuracyMetrics, PricePoint, DateStats, OverallStats, Nifty50IndexPoint, RiskMetrics, ReturnBucket, AccuracyTrendPoint } from '../types'; +import { NIFTY_50_STOCKS as nifty50List } from '../types'; + +// Generate AI analysis dynamically based on decision type and stock info +function generateAIAnalysis(symbol: string, companyName: string, decision: Decision, confidence: string, risk: string): string { + const sector = getSectorForStock(symbol); + + if (decision === 'BUY') { + return `## Summary +${confidence} confidence BUY signal for ${companyName} based on positive momentum and favorable sector conditions. + +## Technical Analysis +- Stock showing upward momentum in recent sessions +- RSI in bullish zone (55-65 range) +- Trading above key moving averages +- Volume supporting the uptrend +- Support levels holding firm + +## Fundamental Analysis +- Company fundamentals remain solid +- Revenue growth trajectory positive +- Margins stable or improving +- ${sector} sector showing strength +- Valuation reasonable relative to peers + +## Sentiment +- Analyst ratings predominantly positive +- Institutional interest increasing +- News flow supportive +- Management commentary optimistic + +## Risks +- ${risk === 'HIGH' ? 'Elevated volatility and market risk' : risk === 'MEDIUM' ? 'Moderate market and sector-specific risks' : 'Lower risk profile but general market exposure'} +- Sector-specific regulatory concerns +- Global macro headwinds possible`; + } else if (decision === 'SELL') { + return `## Summary +${confidence} confidence SELL signal for ${companyName} due to concerning technical and fundamental factors. + +## Technical Analysis +- Stock in clear downtrend pattern +- Trading below major moving averages +- RSI showing weakness (below 40) +- Volume increasing on down days +- Key support levels at risk + +## Fundamental Analysis +- Earnings momentum slowing +- Margin pressure evident +- ${sector} sector facing headwinds +- Competitive challenges increasing +- Valuation not justified by growth + +## Sentiment +- Analyst downgrades recent +- Institutional selling observed +- Negative news flow +- Management guidance cautious + +## Risks +- ${risk === 'HIGH' ? 'High downside risk if support breaks' : risk === 'MEDIUM' ? 'Further weakness likely' : 'Gradual decline expected'} +- Sector underperformance may persist +- Recovery timeline uncertain`; + } else { + return `## Summary +HOLD recommendation for ${companyName} as the stock consolidates with mixed signals. + +## Technical Analysis +- Stock in consolidation phase +- Trading within defined range +- RSI neutral (45-55 range) +- Volume average, no clear direction +- Awaiting breakout confirmation + +## Fundamental Analysis +- Business fundamentals stable +- Growth trajectory moderate +- ${sector} sector showing mixed trends +- Valuation fair at current levels +- No immediate catalysts visible + +## Sentiment +- Analyst views mixed +- Institutional activity balanced +- News flow neutral +- Wait-and-watch mode prevailing + +## Risks +- ${risk === 'HIGH' ? 'Volatility may increase' : risk === 'MEDIUM' ? 'Range-bound action likely to continue' : 'Stable but limited upside near-term'} +- Direction dependent on broader market +- Sector rotation risk`; + } +} + +// Get sector for a stock +function getSectorForStock(symbol: string): string { + const sectors: Record = { + 'RELIANCE': 'Energy & Retail', 'TCS': 'IT Services', 'HDFCBANK': 'Banking', + 'INFY': 'IT Services', 'ICICIBANK': 'Banking', 'HINDUNILVR': 'FMCG', + 'ITC': 'FMCG', 'SBIN': 'Banking', 'BHARTIARTL': 'Telecom', + 'KOTAKBANK': 'Banking', 'LT': 'Infrastructure', 'AXISBANK': 'Banking', + 'ASIANPAINT': 'Paints', 'MARUTI': 'Automobile', 'HCLTECH': 'IT Services', + 'SUNPHARMA': 'Pharma', 'TITAN': 'Consumer Durables', 'BAJFINANCE': 'NBFC', + 'WIPRO': 'IT Services', 'ULTRACEMCO': 'Cement', 'NESTLEIND': 'FMCG', + 'NTPC': 'Power', 'POWERGRID': 'Power', 'M&M': 'Automobile', + 'TATAMOTORS': 'Automobile', 'ONGC': 'Oil & Gas', 'JSWSTEEL': 'Steel', + 'TATASTEEL': 'Steel', 'ADANIENT': 'Conglomerate', 'ADANIPORTS': 'Ports', + 'COALINDIA': 'Mining', 'BAJAJFINSV': 'Financial Services', 'TECHM': 'IT Services', + 'HDFCLIFE': 'Insurance', 'SBILIFE': 'Insurance', 'GRASIM': 'Diversified', + 'DIVISLAB': 'Pharma', 'DRREDDY': 'Pharma', 'CIPLA': 'Pharma', + 'BRITANNIA': 'FMCG', 'EICHERMOT': 'Automobile', 'APOLLOHOSP': 'Healthcare', + 'INDUSINDBK': 'Banking', 'HEROMOTOCO': 'Automobile', 'TATACONSUM': 'FMCG', + 'BPCL': 'Oil & Gas', 'UPL': 'Chemicals', 'HINDALCO': 'Metals', + 'BAJAJ-AUTO': 'Automobile', 'LTIM': 'IT Services', + }; + return sectors[symbol] || 'Diversified'; +} + +// Raw analysis content for detailed AI reasoning +const rawAnalysisData: Record = { + 'BAJFINANCE': `## Summary +Strong BUY signal based on exceptional momentum and sector strength in the NBFC space. + +## Technical Analysis +- Price up 13.7% in 30 days (₹678 → ₹771) +- RSI at 62: Bullish but not overbought +- MACD showing positive crossover on daily chart +- Trading above 50-day and 200-day moving averages +- Volume spike on breakout confirms institutional interest + +## Fundamental Analysis +- Q3 FY25 results: 18% YoY profit growth +- AUM growth of 25% indicating strong business expansion +- NIM stable at 10.2%, best in class +- Credit costs under control at 1.8% +- ROE of 22% among highest in sector + +## Sentiment +- 12 analyst buy ratings, 3 hold +- FII net buyers in financial sector last 2 weeks +- Management guidance raised for FY25 +- Positive mentions on analyst calls + +## Risks +- Interest rate sensitivity remains key concern +- Unsecured lending exposure at 45% of book +- Premium valuation at 5.2x P/B vs sector avg of 3.1x`, + + 'BAJAJFINSV': `## Summary +BUY recommendation driven by strong holding company performance and insurance business growth. + +## Technical Analysis +- 14% gain in one month (₹1,567 → ₹1,789) +- Breaking out of 3-month consolidation range +- RSI at 58, room for further upside +- Strong support at ₹1,650 level + +## Fundamental Analysis +- Insurance subsidiary showing 28% premium growth +- Asset management AUM up 35% YoY +- Sum-of-parts valuation suggests 15% upside +- Healthy subsidiaries across financial services + +## Sentiment +- Institutional holding increased by 2.3% in Q3 +- Positive outlook from major brokerages +- Benefits from Bajaj Finance momentum + +## Risks +- Dependent on subsidiary performance +- Insurance sector regulatory changes +- Holding company discount may persist`, + + 'KOTAKBANK': `## Summary +BUY signal triggered by significant technical breakout with high volume confirmation. + +## Technical Analysis +- Significant breakout on January 20th +- 9.2% gain on exceptionally high volume (66.6M shares) +- Breaking above ₹1,850 resistance, now support +- Bullish engulfing pattern on weekly chart + +## Fundamental Analysis +- CASA ratio at 53%, best among private banks +- Asset quality stable with GNPA at 1.7% +- Q3 profit up 12% YoY +- Strong capital adequacy at 21% + +## Sentiment +- Inclusion in major index reshuffling positive +- Foreign investor interest increasing +- New CEO initiatives well received + +## Risks +- Margin compression in deposit rate war +- Competition from fintech players +- Slower loan growth vs peers at 15% YoY`, + + 'DRREDDY': `## Summary +HIGH CONFIDENCE SELL due to severe downtrend and deteriorating fundamentals. + +## Technical Analysis +- 14.9% decline in one month +- Trading below all major moving averages +- RSI at 28, approaching oversold but no reversal signs +- Death cross (50-day below 200-day) formed +- Volume increasing on down days + +## Fundamental Analysis +- US generics pricing pressure intensifying +- Q3 margins contracted 300bps YoY +- R&D pipeline delays for key molecules +- Forex headwinds from rupee depreciation + +## Sentiment +- 5 downgrades from major brokerages in January +- FDA inspection concerns linger +- Negative news flow on generic drug pricing + +## Risks +- Further downside if ₹1,150 support breaks +- US regulatory environment uncertain +- Peer competition in key therapeutic areas`, + + 'AXISBANK': `## Summary +HIGH CONFIDENCE SELL with persistent downtrend and structural concerns. + +## Technical Analysis +- 10.5% sustained decline over 4 weeks +- Clear lower highs and lower lows pattern +- Below 200-day moving average +- Support at ₹1,020 being tested + +## Fundamental Analysis +- Asset quality concerns in SME book +- NIM compression of 15bps QoQ +- Restructured book higher than peers +- Growth lagging private bank peers + +## Sentiment +- Management transition uncertainty +- FII selling observed in January +- Mixed analyst ratings with more holds than buys + +## Risks +- Economic slowdown impact on corporate loans +- Digital banking competitive pressure +- Capital adequacy adequate but tight for growth`, + + 'RELIANCE': `## Summary +HOLD recommendation as stock consolidates near all-time highs with mixed signals. + +## Technical Analysis +- Trading in tight range between ₹2,850-₹2,950 +- RSI neutral at 52 +- Consolidating after strong Q4 2024 rally +- Volume declining, suggesting indecision + +## Fundamental Analysis +- Jio Platforms showing steady growth +- Retail business margins improving +- O2C segment facing global headwinds +- New energy investments progressing + +## Sentiment +- Neutral analyst stance, waiting for Q4 results +- Domestic institutional support strong +- Global energy transition narrative supportive + +## Risks +- Oil & Gas volatility impacts earnings +- Telecom ARPU growth slowing +- Execution risk on new initiatives`, + + 'TCS': `## Summary +HOLD as IT sector faces near-term headwinds despite strong long-term positioning. + +## Technical Analysis +- Range-bound between ₹3,800-₹4,100 +- 50-day MA acting as resistance +- No clear directional momentum +- Volume average, no accumulation signs + +## Fundamental Analysis +- Deal pipeline remains healthy at $12B TCV +- Attrition stabilizing at 13% +- Margins stable at 25%+ +- Cloud and AI investments on track + +## Sentiment +- Client spending outlook cautious for H1 FY26 +- BFSI vertical showing early recovery signs +- Management guidance conservative but achievable + +## Risks +- US recession fears impacting IT budgets +- Wage inflation pressure +- Currency volatility`, + + 'HDFCBANK': `## Summary +HOLD as merger integration continues with near-term pressure on ratios. + +## Technical Analysis +- Sideways movement in ₹1,650-₹1,750 range +- Testing 200-day moving average +- Neutral momentum indicators +- Support at ₹1,620 holding firm + +## Fundamental Analysis +- Merger integration progressing well +- CASA ratio dilution temporary +- Credit costs elevated but manageable +- Strong franchise value intact + +## Sentiment +- Institutional view remains constructive long-term +- Near-term concerns on deposit costs +- Wait-and-watch mode for most analysts + +## Risks +- Deposit mobilization challenges +- Net interest margin pressure +- Integration execution risks`, +}; + +// Generate mock price history for sparklines with high volatility for visual impact +function generatePriceHistory(basePrice: number, trend: 'up' | 'down' | 'flat', days: number = 30, symbol?: string): PricePoint[] { + const history: PricePoint[] = []; + let price = basePrice; + // Much higher trend bias and volatility for very visible chart movements + const trendBias = trend === 'up' ? 0.015 : trend === 'down' ? -0.015 : 0.002; + const baseSeed = symbol ? getSymbolSeed(symbol) + 5000 : Date.now(); + const volatility = 0.12; // 12% daily volatility for very visible movements + + for (let i = days; i >= 0; i--) { + const date = new Date(); + date.setDate(date.getDate() - i); + + // Use seeded random if symbol provided, otherwise use Math.random + const randomValue = symbol ? seededRandom(baseSeed + i * 100) : Math.random(); + // Add some wave pattern for more interesting charts + const wavePattern = Math.sin(i * 0.3) * 0.02; + const dailyReturn = trendBias + (randomValue - 0.5) * volatility + wavePattern; + price = price * (1 + dailyReturn); + + history.push({ + date: date.toISOString().split('T')[0], + price: Math.round(price * 100) / 100, + }); + } + + return history; +} + +// Mock backtest results based on decision type - with next-day returns +export const mockBacktestResults: Record = { + 'BAJFINANCE': { + prediction_correct: true, + actual_return_1d: 2.1, // Next trading day return + actual_return_1w: 3.2, + actual_return_1m: 8.5, + price_at_prediction: 771, + current_price: 836, + price_history: generatePriceHistory(771, 'up', 30, 'BAJFINANCE'), + }, + 'BAJAJFINSV': { + prediction_correct: true, + actual_return_1d: 1.8, + actual_return_1w: 2.1, + actual_return_1m: 6.8, + price_at_prediction: 1789, + current_price: 1911, + price_history: generatePriceHistory(1789, 'up', 30, 'BAJAJFINSV'), + }, + 'KOTAKBANK': { + prediction_correct: true, + actual_return_1d: 1.5, + actual_return_1w: 1.8, + actual_return_1m: 4.2, + price_at_prediction: 1850, + current_price: 1928, + price_history: generatePriceHistory(1850, 'up', 30, 'KOTAKBANK'), + }, + 'DRREDDY': { + prediction_correct: true, + actual_return_1d: -1.8, + actual_return_1w: -2.8, + actual_return_1m: -7.2, + price_at_prediction: 1180, + current_price: 1095, + price_history: generatePriceHistory(1180, 'down', 30, 'DRREDDY'), + }, + 'AXISBANK': { + prediction_correct: true, + actual_return_1d: -1.2, + actual_return_1w: -1.5, + actual_return_1m: -5.3, + price_at_prediction: 1045, + current_price: 990, + price_history: generatePriceHistory(1045, 'down', 30, 'AXISBANK'), + }, + 'HCLTECH': { + prediction_correct: false, + actual_return_1d: 0.6, + actual_return_1w: 0.8, + actual_return_1m: 2.1, + price_at_prediction: 1720, + current_price: 1756, + price_history: generatePriceHistory(1720, 'up', 30, 'HCLTECH'), + }, + 'RELIANCE': { + prediction_correct: true, + actual_return_1d: 0.3, + actual_return_1w: 0.5, + actual_return_1m: 1.2, + price_at_prediction: 2890, + current_price: 2925, + price_history: generatePriceHistory(2890, 'flat', 30, 'RELIANCE'), + }, + 'TCS': { + prediction_correct: true, + actual_return_1d: 0.2, + actual_return_1w: -0.3, + actual_return_1m: 0.8, + price_at_prediction: 3950, + current_price: 3982, + price_history: generatePriceHistory(3950, 'flat', 30, 'TCS'), + }, + 'HDFCBANK': { + prediction_correct: true, + actual_return_1d: -0.1, + actual_return_1w: 0.2, + actual_return_1m: -0.5, + price_at_prediction: 1680, + current_price: 1672, + price_history: generatePriceHistory(1680, 'flat', 30, 'HDFCBANK'), + }, + 'ICICIBANK': { + prediction_correct: true, + actual_return_1d: 1.1, + actual_return_1w: 1.5, + actual_return_1m: 3.8, + price_at_prediction: 1120, + current_price: 1163, + price_history: generatePriceHistory(1120, 'up', 30, 'ICICIBANK'), + }, + 'SUNPHARMA': { + prediction_correct: true, + actual_return_1d: -0.9, + actual_return_1w: -1.2, + actual_return_1m: -3.5, + price_at_prediction: 1850, + current_price: 1785, + price_history: generatePriceHistory(1850, 'down', 30, 'SUNPHARMA'), + }, + 'ADANIPORTS': { + prediction_correct: true, + actual_return_1d: -1.5, + actual_return_1w: -2.1, + actual_return_1m: -6.8, + price_at_prediction: 1180, + current_price: 1100, + price_history: generatePriceHistory(1180, 'down', 30, 'ADANIPORTS'), + }, +}; + +// Calculate accuracy metrics from backtest results for all 50 stocks +export function calculateAccuracyMetrics(): AccuracyMetrics { + const latestRec = sampleRecommendations[0]; + if (!latestRec) { + return { + total_predictions: 0, + correct_predictions: 0, + success_rate: 0, + buy_accuracy: 0, + sell_accuracy: 0, + hold_accuracy: 0, + }; + } + + let totalBuy = 0, correctBuy = 0; + let totalSell = 0, correctSell = 0; + let totalHold = 0, correctHold = 0; + + // Calculate accuracy for each stock + Object.keys(latestRec.analysis).forEach(symbol => { + const stockAnalysis = latestRec.analysis[symbol]; + const backtest = getBacktestResult(symbol); + + if (!backtest || !stockAnalysis?.decision) return; + + if (stockAnalysis.decision === 'BUY') { + totalBuy++; + if (backtest.prediction_correct) correctBuy++; + } else if (stockAnalysis.decision === 'SELL') { + totalSell++; + if (backtest.prediction_correct) correctSell++; + } else { + totalHold++; + if (backtest.prediction_correct) correctHold++; + } + }); + + const total = totalBuy + totalSell + totalHold; + const correct = correctBuy + correctSell + correctHold; + + return { + total_predictions: total, + correct_predictions: correct, + success_rate: total > 0 ? correct / total : 0, + buy_accuracy: totalBuy > 0 ? correctBuy / totalBuy : 0, + sell_accuracy: totalSell > 0 ? correctSell / totalSell : 0, + hold_accuracy: totalHold > 0 ? correctHold / totalHold : 0, + }; +} + +// Cache for dynamically generated backtest results +const generatedBacktestCache: Record = {}; + +// Seeded random number generator for consistent results +function seededRandom(seed: number): number { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); +} + +// Get a consistent seed from symbol string +function getSymbolSeed(symbol: string): number { + let hash = 0; + for (let i = 0; i < symbol.length; i++) { + hash = ((hash << 5) - hash) + symbol.charCodeAt(i); + hash = hash & hash; + } + return Math.abs(hash); +} + +// Get backtest result for a symbol - generates dynamically if not in static data +export function getBacktestResult(symbol: string): BacktestResult | undefined { + // Return existing backtest data if available + if (mockBacktestResults[symbol]) { + return mockBacktestResults[symbol]; + } + + // Return cached generated result if available + if (generatedBacktestCache[symbol]) { + return generatedBacktestCache[symbol]; + } + + // Get the stock's decision from the latest recommendation + const latestRec = sampleRecommendations[0]; + const stockAnalysis = latestRec?.analysis[symbol]; + + if (!stockAnalysis || !stockAnalysis.decision) { + return undefined; + } + + // Generate backtest result based on decision type with consistent seeding + const decision = stockAnalysis.decision; + const seed = getSymbolSeed(symbol); + const basePrice = 1000 + seededRandom(seed) * 2000; // Consistent base price between 1000-3000 + + // Determine trend and accuracy based on decision + let trend: 'up' | 'down' | 'flat'; + let predictionCorrect: boolean; + let returnMultiplier: number; + + // Simulate varied but consistent outcomes based on symbol seed + const randomOutcome = seededRandom(seed + 1); + + if (decision === 'BUY') { + // 75% chance BUY predictions are correct (stock goes up) + predictionCorrect = randomOutcome < 0.75; + trend = predictionCorrect ? 'up' : 'down'; + returnMultiplier = predictionCorrect ? (1 + seededRandom(seed + 2) * 0.08) : (1 - seededRandom(seed + 2) * 0.05); + } else if (decision === 'SELL') { + // 83% chance SELL predictions are correct (stock goes down) + predictionCorrect = randomOutcome < 0.83; + trend = predictionCorrect ? 'down' : 'up'; + returnMultiplier = predictionCorrect ? (1 - seededRandom(seed + 2) * 0.08) : (1 + seededRandom(seed + 2) * 0.05); + } else { + // HOLD - 70% chance it stays relatively flat + predictionCorrect = randomOutcome < 0.70; + trend = 'flat'; + returnMultiplier = 1 + (seededRandom(seed + 2) - 0.5) * 0.04; // +/- 2% + } + + const currentPrice = basePrice * returnMultiplier; + const actualReturn1m = ((currentPrice - basePrice) / basePrice) * 100; + const actualReturn1w = actualReturn1m * 0.3; // Approximate + // Next trading day return - about 15-25% of weekly return with some variance + const actualReturn1d = actualReturn1w * (0.4 + seededRandom(seed + 3) * 0.3); + + const result: BacktestResult = { + prediction_correct: predictionCorrect, + actual_return_1d: Math.round(actualReturn1d * 10) / 10, + actual_return_1w: Math.round(actualReturn1w * 10) / 10, + actual_return_1m: Math.round(actualReturn1m * 10) / 10, + price_at_prediction: Math.round(basePrice * 100) / 100, + current_price: Math.round(currentPrice * 100) / 100, + price_history: generatePriceHistory(basePrice, trend, 30, symbol), + }; + + // Cache the result for consistency + generatedBacktestCache[symbol] = result; + + return result; +} + +// Get raw analysis for a symbol - returns custom analysis if available, otherwise generates one +export function getRawAnalysis(symbol: string): string | undefined { + // Return custom detailed analysis if available + if (rawAnalysisData[symbol]) { + return rawAnalysisData[symbol]; + } + + // Generate analysis dynamically for other stocks + const latestRec = sampleRecommendations[0]; + const stockAnalysis = latestRec?.analysis[symbol]; + + if (stockAnalysis && stockAnalysis.decision) { + return generateAIAnalysis( + symbol, + stockAnalysis.company_name, + stockAnalysis.decision, + stockAnalysis.confidence || 'MEDIUM', + stockAnalysis.risk || 'MEDIUM' + ); + } + + return undefined; +} + +// Generate 10 days of historical recommendations with varied but consistent data +function generateHistoricalRecommendations(): DailyRecommendation[] { + // Trading days (skip weekends) + const dates = [ + '2025-01-30', '2025-01-29', '2025-01-28', '2025-01-27', + '2025-01-24', '2025-01-23', '2025-01-22', '2025-01-21', + '2025-01-20', '2025-01-17' + ]; + + const recommendations: DailyRecommendation[] = []; + + for (let dayIndex = 0; dayIndex < dates.length; dayIndex++) { + const date = dates[dayIndex]; + const dateSeed = dayIndex * 1000; // Different seed for each day + + // Generate analysis for all 50 stocks + const analysis: Record = {}; + + let buyCount = 0; + let sellCount = 0; + let holdCount = 0; + + for (const stock of nifty50List) { + const stockSeed = getSymbolSeed(stock.symbol) + dateSeed; + const rand = seededRandom(stockSeed); + + // Determine decision with some variation per day + let decision: Decision; + if (rand < 0.14) { + decision = 'BUY'; + buyCount++; + } else if (rand < 0.34) { + decision = 'SELL'; + sellCount++; + } else { + decision = 'HOLD'; + holdCount++; + } + + // Determine confidence and risk + const confRand = seededRandom(stockSeed + 1); + const riskRand = seededRandom(stockSeed + 2); + + const confidence: 'HIGH' | 'MEDIUM' | 'LOW' = confRand < 0.2 ? 'HIGH' : confRand < 0.7 ? 'MEDIUM' : 'LOW'; + const risk: 'HIGH' | 'MEDIUM' | 'LOW' = riskRand < 0.25 ? 'HIGH' : riskRand < 0.75 ? 'MEDIUM' : 'LOW'; + + analysis[stock.symbol] = { + symbol: stock.symbol, + company_name: stock.company_name, + decision, + confidence, + risk, + raw_analysis: rawAnalysisData[stock.symbol], + }; + } + + // Generate top picks and stocks to avoid based on analysis + const topPicks = Object.values(analysis) + .filter(s => s.decision === 'BUY' && (s.confidence === 'HIGH' || s.confidence === 'MEDIUM')) + .slice(0, 3) + .map((stock, idx) => ({ + rank: idx + 1, + symbol: stock.symbol, + company_name: stock.company_name, + decision: stock.decision, + reason: `Strong ${stock.confidence?.toLowerCase()} confidence BUY signal based on positive momentum and sector conditions.`, + risk_level: stock.risk || 'MEDIUM' as const, + })); + + const stocksToAvoid = Object.values(analysis) + .filter(s => s.decision === 'SELL' && (s.confidence === 'HIGH' || s.risk === 'HIGH')) + .slice(0, 4) + .map(stock => ({ + symbol: stock.symbol, + company_name: stock.company_name, + reason: `${stock.confidence} confidence SELL with ${stock.risk} risk profile. Downward pressure detected.`, + })); + + recommendations.push({ + date, + analysis, + ranking: { + ranking: '', + stocks_analyzed: 50, + timestamp: `${date}T15:30:00.000Z`, + }, + summary: { + total: 50, + buy: buyCount, + sell: sellCount, + hold: holdCount, + }, + top_picks: topPicks, + stocks_to_avoid: stocksToAvoid, + }); + } + + // Override the first day (latest) with manually curated data for better demo + if (recommendations.length > 0) { + recommendations[0] = createLatestRecommendation(); + } + + return recommendations; +} + +// Create the latest (curated) recommendation for demo purposes +function createLatestRecommendation(): DailyRecommendation { + return { + date: '2025-01-30', + analysis: { + 'RELIANCE': { symbol: 'RELIANCE', company_name: 'Reliance Industries Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM', raw_analysis: rawAnalysisData['RELIANCE'] }, + 'TCS': { symbol: 'TCS', company_name: 'Tata Consultancy Services Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM', raw_analysis: rawAnalysisData['TCS'] }, + 'HDFCBANK': { symbol: 'HDFCBANK', company_name: 'HDFC Bank Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM', raw_analysis: rawAnalysisData['HDFCBANK'] }, + 'INFY': { symbol: 'INFY', company_name: 'Infosys Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'ICICIBANK': { symbol: 'ICICIBANK', company_name: 'ICICI Bank Ltd', decision: 'BUY', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'HINDUNILVR': { symbol: 'HINDUNILVR', company_name: 'Hindustan Unilever Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'ITC': { symbol: 'ITC', company_name: 'ITC Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'SBIN': { symbol: 'SBIN', company_name: 'State Bank of India', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'BHARTIARTL': { symbol: 'BHARTIARTL', company_name: 'Bharti Airtel Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'KOTAKBANK': { symbol: 'KOTAKBANK', company_name: 'Kotak Mahindra Bank Ltd', decision: 'BUY', confidence: 'MEDIUM', risk: 'MEDIUM', raw_analysis: rawAnalysisData['KOTAKBANK'] }, + 'LT': { symbol: 'LT', company_name: 'Larsen & Toubro Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'AXISBANK': { symbol: 'AXISBANK', company_name: 'Axis Bank Ltd', decision: 'SELL', confidence: 'HIGH', risk: 'HIGH', raw_analysis: rawAnalysisData['AXISBANK'] }, + 'ASIANPAINT': { symbol: 'ASIANPAINT', company_name: 'Asian Paints Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'MARUTI': { symbol: 'MARUTI', company_name: 'Maruti Suzuki India Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'HCLTECH': { symbol: 'HCLTECH', company_name: 'HCL Technologies Ltd', decision: 'SELL', confidence: 'MEDIUM', risk: 'HIGH' }, + 'SUNPHARMA': { symbol: 'SUNPHARMA', company_name: 'Sun Pharmaceutical Industries Ltd', decision: 'SELL', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'TITAN': { symbol: 'TITAN', company_name: 'Titan Company Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'BAJFINANCE': { symbol: 'BAJFINANCE', company_name: 'Bajaj Finance Ltd', decision: 'BUY', confidence: 'HIGH', risk: 'MEDIUM', raw_analysis: rawAnalysisData['BAJFINANCE'] }, + 'WIPRO': { symbol: 'WIPRO', company_name: 'Wipro Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'ULTRACEMCO': { symbol: 'ULTRACEMCO', company_name: 'UltraTech Cement Ltd', decision: 'BUY', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'NESTLEIND': { symbol: 'NESTLEIND', company_name: 'Nestle India Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'NTPC': { symbol: 'NTPC', company_name: 'NTPC Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'POWERGRID': { symbol: 'POWERGRID', company_name: 'Power Grid Corporation of India Ltd', decision: 'SELL', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'M&M': { symbol: 'M&M', company_name: 'Mahindra & Mahindra Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'TATAMOTORS': { symbol: 'TATAMOTORS', company_name: 'Tata Motors Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'ONGC': { symbol: 'ONGC', company_name: 'Oil & Natural Gas Corporation Ltd', decision: 'SELL', confidence: 'MEDIUM', risk: 'HIGH' }, + 'JSWSTEEL': { symbol: 'JSWSTEEL', company_name: 'JSW Steel Ltd', decision: 'BUY', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'TATASTEEL': { symbol: 'TATASTEEL', company_name: 'Tata Steel Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'ADANIENT': { symbol: 'ADANIENT', company_name: 'Adani Enterprises Ltd', decision: 'HOLD', confidence: 'LOW', risk: 'HIGH' }, + 'ADANIPORTS': { symbol: 'ADANIPORTS', company_name: 'Adani Ports and SEZ Ltd', decision: 'SELL', confidence: 'MEDIUM', risk: 'HIGH' }, + 'COALINDIA': { symbol: 'COALINDIA', company_name: 'Coal India Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'BAJAJFINSV': { symbol: 'BAJAJFINSV', company_name: 'Bajaj Finserv Ltd', decision: 'BUY', confidence: 'HIGH', risk: 'MEDIUM', raw_analysis: rawAnalysisData['BAJAJFINSV'] }, + 'TECHM': { symbol: 'TECHM', company_name: 'Tech Mahindra Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'HDFCLIFE': { symbol: 'HDFCLIFE', company_name: 'HDFC Life Insurance Company Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'SBILIFE': { symbol: 'SBILIFE', company_name: 'SBI Life Insurance Company Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'GRASIM': { symbol: 'GRASIM', company_name: 'Grasim Industries Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'DIVISLAB': { symbol: 'DIVISLAB', company_name: "Divi's Laboratories Ltd", decision: 'SELL', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'DRREDDY': { symbol: 'DRREDDY', company_name: "Dr. Reddy's Laboratories Ltd", decision: 'SELL', confidence: 'HIGH', risk: 'HIGH', raw_analysis: rawAnalysisData['DRREDDY'] }, + 'CIPLA': { symbol: 'CIPLA', company_name: 'Cipla Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'BRITANNIA': { symbol: 'BRITANNIA', company_name: 'Britannia Industries Ltd', decision: 'BUY', confidence: 'MEDIUM', risk: 'LOW' }, + 'EICHERMOT': { symbol: 'EICHERMOT', company_name: 'Eicher Motors Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'APOLLOHOSP': { symbol: 'APOLLOHOSP', company_name: 'Apollo Hospitals Enterprise Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'INDUSINDBK': { symbol: 'INDUSINDBK', company_name: 'IndusInd Bank Ltd', decision: 'SELL', confidence: 'HIGH', risk: 'HIGH' }, + 'HEROMOTOCO': { symbol: 'HEROMOTOCO', company_name: 'Hero MotoCorp Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'TATACONSUM': { symbol: 'TATACONSUM', company_name: 'Tata Consumer Products Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'BPCL': { symbol: 'BPCL', company_name: 'Bharat Petroleum Corporation Ltd', decision: 'SELL', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'UPL': { symbol: 'UPL', company_name: 'UPL Ltd', decision: 'HOLD', confidence: 'LOW', risk: 'HIGH' }, + 'HINDALCO': { symbol: 'HINDALCO', company_name: 'Hindalco Industries Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'BAJAJ-AUTO': { symbol: 'BAJAJ-AUTO', company_name: 'Bajaj Auto Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + 'LTIM': { symbol: 'LTIM', company_name: 'LTIMindtree Ltd', decision: 'HOLD', confidence: 'MEDIUM', risk: 'MEDIUM' }, + }, + ranking: { + ranking: '', + stocks_analyzed: 50, + timestamp: '2025-01-30T15:30:00.000Z', + }, + summary: { + total: 50, + buy: 7, + sell: 10, + hold: 33, + }, + top_picks: [ + { + rank: 1, + symbol: 'BAJFINANCE', + company_name: 'Bajaj Finance Ltd', + decision: 'BUY', + reason: '13.7% gain over 30 days (₹678 → ₹771), strongest bullish momentum with robust upward trend.', + risk_level: 'MEDIUM', + }, + { + rank: 2, + symbol: 'BAJAJFINSV', + company_name: 'Bajaj Finserv Ltd', + decision: 'BUY', + reason: '14% gain in one month (₹1,567 → ₹1,789) demonstrates clear bullish momentum with sector-wide tailwinds.', + risk_level: 'MEDIUM', + }, + { + rank: 3, + symbol: 'KOTAKBANK', + company_name: 'Kotak Mahindra Bank Ltd', + decision: 'BUY', + reason: 'Significant breakout on January 20th with 9.2% gain on exceptionally high volume (66.6M shares).', + risk_level: 'MEDIUM', + }, + ], + stocks_to_avoid: [ + { + symbol: 'DRREDDY', + company_name: "Dr. Reddy's Laboratories Ltd", + reason: 'HIGH CONFIDENCE SELL with 14.9% decline in one month. Severe downtrend with high risk.', + }, + { + symbol: 'AXISBANK', + company_name: 'Axis Bank Ltd', + reason: 'HIGH CONFIDENCE SELL with 10.5% sustained decline. Clear and persistent downtrend.', + }, + { + symbol: 'HCLTECH', + company_name: 'HCL Technologies Ltd', + reason: 'SELL with 9.4% drop from recent highs. High risk rating with continued selling pressure.', + }, + { + symbol: 'ADANIPORTS', + company_name: 'Adani Ports and SEZ Ltd', + reason: 'SELL with 12% monthly decline and consistently lower lows. High risk profile.', + }, + ], + }; +} + +// Generate and export sample recommendations (10 days of historical data) +export const sampleRecommendations: DailyRecommendation[] = generateHistoricalRecommendations(); + +// Function to get recommendation for a specific date +export function getRecommendationByDate(date: string): DailyRecommendation | undefined { + return sampleRecommendations.find(r => r.date === date); +} + +// Function to get latest recommendation +export function getLatestRecommendation(): DailyRecommendation | undefined { + return sampleRecommendations[0]; +} + +// Function to get all available dates +export function getAvailableDates(): string[] { + return sampleRecommendations.map(r => r.date); +} + +// Function to get stock history across all dates +export function getStockHistory(symbol: string): { date: string; decision: Decision }[] { + return sampleRecommendations + .filter(r => r.analysis[symbol]) + .map(r => ({ + date: r.date, + decision: r.analysis[symbol].decision as Decision, + })) + .reverse(); +} + +// Get decision counts for charts +export function getDecisionCounts(date: string): { buy: number; sell: number; hold: number } { + const rec = getRecommendationByDate(date); + if (!rec) return { buy: 0, sell: 0, hold: 0 }; + return rec.summary; +} + +// Get extended price history with more data points for charting +// Generates price history ending at the latest recommendation date +export function getExtendedPriceHistory(symbol: string, days: number = 60): PricePoint[] { + // Use getBacktestResult to get data for any stock (including dynamically generated) + const backtest = getBacktestResult(symbol); + const latestRec = sampleRecommendations[0]; + + // Get the end date from the latest recommendation, or use today + const endDate = latestRec ? new Date(latestRec.date) : new Date(); + + const basePrice = backtest ? backtest.price_at_prediction * 0.9 : 1000; + const trend = backtest + ? (backtest.actual_return_1m > 2 ? 'up' : backtest.actual_return_1m < -2 ? 'down' : 'flat') + : 'flat'; + + return generatePriceHistoryWithEndDate(basePrice, trend, days, endDate, symbol); +} + +// Generate price history ending at a specific date with consistent seeding +function generatePriceHistoryWithEndDate( + basePrice: number, + trend: 'up' | 'down' | 'flat', + days: number, + endDate: Date, + symbol?: string +): PricePoint[] { + const history: PricePoint[] = []; + let price = basePrice; + const trendBias = trend === 'up' ? 0.003 : trend === 'down' ? -0.003 : 0; + const baseSeed = symbol ? getSymbolSeed(symbol) : Date.now(); + + for (let i = days; i >= 0; i--) { + const date = new Date(endDate); + date.setDate(date.getDate() - i); + + // Use seeded random for consistent results + const dailyReturn = trendBias + (seededRandom(baseSeed + i * 100) - 0.5) * 0.02; + price = price * (1 + dailyReturn); + + history.push({ + date: date.toISOString().split('T')[0], + price: Math.round(price * 100) / 100, + }); + } + + return history; +} + +// Get prediction points for the chart with actual prices +// Only returns predictions that exist in the actual saved historical recommendations +export function getPredictionPointsWithPrices( + symbol: string, + priceHistory: PricePoint[] +): { date: string; decision: Decision; price: number }[] { + // Get actual historical recommendations for this stock + const stockHistory = getStockHistory(symbol); + + if (stockHistory.length === 0 || priceHistory.length === 0) { + return []; + } + + // Map actual historical recommendations to prediction points + const predictions: { date: string; decision: Decision; price: number }[] = []; + + for (const historyEntry of stockHistory) { + const historyDate = new Date(historyEntry.date).getTime(); + + // Find the closest date in price history + let closestPricePoint = priceHistory[0]; + let closestDiff = Math.abs(new Date(closestPricePoint.date).getTime() - historyDate); + + for (const pricePoint of priceHistory) { + const diff = Math.abs(new Date(pricePoint.date).getTime() - historyDate); + if (diff < closestDiff) { + closestDiff = diff; + closestPricePoint = pricePoint; + } + } + + // Use the closest price point's date (so it aligns with the chart's x-axis) + predictions.push({ + date: closestPricePoint.date, + decision: historyEntry.decision, + price: closestPricePoint.price, + }); + } + + return predictions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); +} + +// Detailed return breakdown for explanation modal +export interface ReturnBreakdown { + correctPredictions: { + count: number; + totalReturn: number; + avgReturn: number; + stocks: { symbol: string; decision: string; return1d: number }[]; + }; + incorrectPredictions: { + count: number; + totalReturn: number; + avgReturn: number; + stocks: { symbol: string; decision: string; return1d: number }[]; + }; + weightedReturn: number; + formula: string; +} + +// Get statistics for a specific recommendation date +// Uses weighted average: correct predictions contribute positively, incorrect negatively +export function getDateStats(date: string): DateStats | null { + const rec = getRecommendationByDate(date); + if (!rec) return null; + + const symbols = Object.keys(rec.analysis); + let correctCount = 0; + let incorrectCount = 0; + let correctTotalReturn = 0; // Sum of gains from correct predictions + let incorrectTotalReturn = 0; // Sum of losses from incorrect predictions + + for (const symbol of symbols) { + const stockAnalysis = rec.analysis[symbol]; + const backtest = getBacktestResult(symbol); + if (!backtest || !stockAnalysis?.decision) continue; + + const decision = stockAnalysis.decision; + const return1d = backtest.actual_return_1d; + + if (backtest.prediction_correct) { + correctCount++; + // For correct predictions: + // - BUY that went up: add the positive return + // - SELL that went down: add the absolute value (we gained by not holding/shorting) + // - HOLD that stayed flat: add the small return (we correctly avoided volatility) + if (decision === 'BUY') { + correctTotalReturn += return1d; // Positive return + } else if (decision === 'SELL') { + correctTotalReturn += Math.abs(return1d); // We avoided this loss + } else { + correctTotalReturn += Math.abs(return1d) < 2 ? 0.1 : 0; // Small gain for correct hold + } + } else { + incorrectCount++; + // For incorrect predictions: + // - BUY that went down: subtract the loss + // - SELL that went up: subtract the missed gain + // - HOLD that moved significantly: subtract the missed opportunity + if (decision === 'BUY') { + incorrectTotalReturn += return1d; // Negative return (loss) + } else if (decision === 'SELL') { + incorrectTotalReturn += -Math.abs(return1d); // We missed this gain + } else { + incorrectTotalReturn += -Math.abs(return1d); // Missed the move + } + } + } + + const totalStocks = correctCount + incorrectCount; + + // Calculate weighted average + // correct_avg * (correct_count/total) + incorrect_avg * (incorrect_count/total) + const correctAvg = correctCount > 0 ? correctTotalReturn / correctCount : 0; + const incorrectAvg = incorrectCount > 0 ? incorrectTotalReturn / incorrectCount : 0; + + const weightedReturn = totalStocks > 0 + ? (correctAvg * (correctCount / totalStocks)) + (incorrectAvg * (incorrectCount / totalStocks)) + : 0; + + return { + date, + avgReturn1d: Math.round(weightedReturn * 10) / 10, + avgReturn1m: 0, // Not used with new calculation + totalStocks, + correctPredictions: correctCount, + accuracy: totalStocks > 0 ? Math.round((correctCount / totalStocks) * 100) : 0, + buyCount: rec.summary.buy, + sellCount: rec.summary.sell, + holdCount: rec.summary.hold, + }; +} + +// Get detailed return breakdown for the explanation modal +export function getReturnBreakdown(date: string): ReturnBreakdown | null { + const rec = getRecommendationByDate(date); + if (!rec) return null; + + const correctStocks: { symbol: string; decision: string; return1d: number }[] = []; + const incorrectStocks: { symbol: string; decision: string; return1d: number }[] = []; + let correctTotalReturn = 0; + let incorrectTotalReturn = 0; + + const symbols = Object.keys(rec.analysis); + for (const symbol of symbols) { + const stockAnalysis = rec.analysis[symbol]; + const backtest = getBacktestResult(symbol); + if (!backtest || !stockAnalysis?.decision) continue; + + const decision = stockAnalysis.decision; + const return1d = backtest.actual_return_1d; + + if (backtest.prediction_correct) { + let effectiveReturn = 0; + if (decision === 'BUY') { + effectiveReturn = return1d; + } else if (decision === 'SELL') { + effectiveReturn = Math.abs(return1d); + } else { + effectiveReturn = Math.abs(return1d) < 2 ? 0.1 : 0; + } + correctTotalReturn += effectiveReturn; + correctStocks.push({ symbol, decision, return1d: effectiveReturn }); + } else { + let effectiveReturn = 0; + if (decision === 'BUY') { + effectiveReturn = return1d; + } else if (decision === 'SELL') { + effectiveReturn = -Math.abs(return1d); + } else { + effectiveReturn = -Math.abs(return1d); + } + incorrectTotalReturn += effectiveReturn; + incorrectStocks.push({ symbol, decision, return1d: effectiveReturn }); + } + } + + const correctCount = correctStocks.length; + const incorrectCount = incorrectStocks.length; + const totalStocks = correctCount + incorrectCount; + + const correctAvg = correctCount > 0 ? correctTotalReturn / correctCount : 0; + const incorrectAvg = incorrectCount > 0 ? incorrectTotalReturn / incorrectCount : 0; + + const weightedReturn = totalStocks > 0 + ? (correctAvg * (correctCount / totalStocks)) + (incorrectAvg * (incorrectCount / totalStocks)) + : 0; + + const formula = totalStocks > 0 + ? `(${correctAvg.toFixed(2)}% × ${correctCount}/${totalStocks}) + (${incorrectAvg.toFixed(2)}% × ${incorrectCount}/${totalStocks}) = ${weightedReturn.toFixed(2)}%` + : 'No data'; + + return { + correctPredictions: { + count: correctCount, + totalReturn: Math.round(correctTotalReturn * 10) / 10, + avgReturn: Math.round(correctAvg * 10) / 10, + stocks: correctStocks.sort((a, b) => b.return1d - a.return1d).slice(0, 5), // Top 5 + }, + incorrectPredictions: { + count: incorrectCount, + totalReturn: Math.round(incorrectTotalReturn * 10) / 10, + avgReturn: Math.round(incorrectAvg * 10) / 10, + stocks: incorrectStocks.sort((a, b) => a.return1d - b.return1d).slice(0, 5), // Bottom 5 + }, + weightedReturn: Math.round(weightedReturn * 10) / 10, + formula, + }; +} + +// Get overall statistics across all recommendation dates +// Uses compound returns (multiplier approach) for realistic portfolio simulation +export function getOverallStats(): OverallStats { + const dates = getAvailableDates(); + let compoundMultiplier = 1; // Start with 1 (100%) + let totalPredictions = 0; + let totalCorrect = 0; + + let bestDay: { date: string; return: number } | null = null; + let worstDay: { date: string; return: number } | null = null; + + // Sort dates chronologically for proper compounding + const sortedDates = [...dates].sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + + for (const date of sortedDates) { + const stats = getDateStats(date); + if (stats) { + // Compound the daily return: multiply by (1 + daily_return/100) + compoundMultiplier *= (1 + stats.avgReturn1d / 100); + + totalPredictions += stats.totalStocks; + totalCorrect += stats.correctPredictions; + + if (!bestDay || stats.avgReturn1d > bestDay.return) { + bestDay = { date, return: stats.avgReturn1d }; + } + if (!worstDay || stats.avgReturn1d < worstDay.return) { + worstDay = { date, return: stats.avgReturn1d }; + } + } + } + + // Convert multiplier back to percentage: (multiplier - 1) * 100 + const compoundReturn = (compoundMultiplier - 1) * 100; + + return { + totalDays: dates.length, + totalPredictions, + avgDailyReturn: Math.round(compoundReturn * 10) / 10, // This is now the compound return + avgMonthlyReturn: 0, // Not used + overallAccuracy: totalPredictions > 0 ? Math.round((totalCorrect / totalPredictions) * 100) : 0, + bestDay, + worstDay, + }; +} + +// Get detailed breakdown of overall compound return calculation +export function getOverallReturnBreakdown(): { + dailyReturns: { date: string; return: number; multiplier: number; cumulative: number }[]; + finalMultiplier: number; + finalReturn: number; + formula: string; +} { + const dates = getAvailableDates(); + const sortedDates = [...dates].sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + + const dailyReturns: { date: string; return: number; multiplier: number; cumulative: number }[] = []; + let cumulativeMultiplier = 1; + + for (const date of sortedDates) { + const stats = getDateStats(date); + if (stats) { + const dailyMultiplier = 1 + stats.avgReturn1d / 100; + cumulativeMultiplier *= dailyMultiplier; + dailyReturns.push({ + date, + return: stats.avgReturn1d, + multiplier: Math.round(dailyMultiplier * 10000) / 10000, + cumulative: Math.round((cumulativeMultiplier - 1) * 1000) / 10, // As percentage + }); + } + } + + const finalReturn = (cumulativeMultiplier - 1) * 100; + const multiplierParts = dailyReturns.map(d => `(1 + ${d.return}%)`).join(' × '); + const formula = dailyReturns.length > 0 + ? `${multiplierParts} = ${cumulativeMultiplier.toFixed(4)} → ${finalReturn.toFixed(2)}%` + : 'No data'; + + return { + dailyReturns, + finalMultiplier: Math.round(cumulativeMultiplier * 10000) / 10000, + finalReturn: Math.round(finalReturn * 10) / 10, + formula, + }; +} + +// =============================================== +// NEW FUNCTIONS FOR ENHANCED FEATURES +// =============================================== + +// Get Nifty50 Index historical data +export function getNifty50IndexHistory(): Nifty50IndexPoint[] { + const dates = getAvailableDates(); + const sortedDates = [...dates].sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + + const indexData: Nifty50IndexPoint[] = []; + let indexValue = 21500; // Starting Nifty value + + for (let i = 0; i < sortedDates.length; i++) { + const date = sortedDates[i]; + const seed = getSymbolSeed(date) + 9999; + + // Generate realistic daily return (-1.5% to +1.5% range) + const dailyReturn = (seededRandom(seed) - 0.5) * 3; + indexValue = indexValue * (1 + dailyReturn / 100); + + indexData.push({ + date, + value: Math.round(indexValue * 100) / 100, + return: Math.round(dailyReturn * 10) / 10, + }); + } + + return indexData; +} + +// Calculate risk metrics for the AI trading strategy +export function calculateRiskMetrics(): RiskMetrics { + const dates = getAvailableDates(); + const dailyReturns: number[] = []; + let wins = 0; + let losses = 0; + let totalWinReturn = 0; + let totalLossReturn = 0; + let totalCorrect = 0; + let totalPredictions = 0; + + // Collect daily returns and win/loss stats + for (const date of dates) { + const stats = getDateStats(date); + if (stats) { + dailyReturns.push(stats.avgReturn1d); + totalCorrect += stats.correctPredictions; + totalPredictions += stats.totalStocks; + + if (stats.avgReturn1d > 0) { + wins++; + totalWinReturn += stats.avgReturn1d; + } else if (stats.avgReturn1d < 0) { + losses++; + totalLossReturn += Math.abs(stats.avgReturn1d); + } + } + } + + // Calculate standard deviation (volatility) + const mean = dailyReturns.length > 0 + ? dailyReturns.reduce((a, b) => a + b, 0) / dailyReturns.length + : 0; + const variance = dailyReturns.length > 0 + ? dailyReturns.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / dailyReturns.length + : 0; + const volatility = Math.sqrt(variance); + + // Calculate Sharpe ratio (assuming 0.02% risk-free daily rate, ~5% annual) + const riskFreeRate = 0.02; + const sharpeRatio = volatility > 0 ? (mean - riskFreeRate) / volatility : 0; + + // Calculate max drawdown + let peak = 100; + let maxDrawdown = 0; + let currentValue = 100; + for (const ret of dailyReturns) { + currentValue = currentValue * (1 + ret / 100); + if (currentValue > peak) { + peak = currentValue; + } + const drawdown = ((peak - currentValue) / peak) * 100; + if (drawdown > maxDrawdown) { + maxDrawdown = drawdown; + } + } + + // Calculate win/loss ratio + const avgWin = wins > 0 ? totalWinReturn / wins : 0; + const avgLoss = losses > 0 ? totalLossReturn / losses : 1; + const winLossRatio = avgLoss > 0 ? avgWin / avgLoss : avgWin; + + // Win rate + const winRate = totalPredictions > 0 ? (totalCorrect / totalPredictions) * 100 : 0; + + return { + sharpeRatio: Math.round(sharpeRatio * 100) / 100, + maxDrawdown: Math.round(maxDrawdown * 10) / 10, + winLossRatio: Math.round(winLossRatio * 100) / 100, + winRate: Math.round(winRate), + volatility: Math.round(volatility * 100) / 100, + totalTrades: totalPredictions, + }; +} + +// Get return distribution histogram +export function getReturnDistribution(): ReturnBucket[] { + const buckets: ReturnBucket[] = [ + { range: '< -3%', min: -Infinity, max: -3, count: 0, stocks: [] }, + { range: '-3% to -2%', min: -3, max: -2, count: 0, stocks: [] }, + { range: '-2% to -1%', min: -2, max: -1, count: 0, stocks: [] }, + { range: '-1% to 0%', min: -1, max: 0, count: 0, stocks: [] }, + { range: '0% to 1%', min: 0, max: 1, count: 0, stocks: [] }, + { range: '1% to 2%', min: 1, max: 2, count: 0, stocks: [] }, + { range: '2% to 3%', min: 2, max: 3, count: 0, stocks: [] }, + { range: '> 3%', min: 3, max: Infinity, count: 0, stocks: [] }, + ]; + + // Get all stocks from latest recommendation and their returns + const latestRec = sampleRecommendations[0]; + if (!latestRec) return buckets; + + for (const symbol of Object.keys(latestRec.analysis)) { + const backtest = getBacktestResult(symbol); + if (!backtest) continue; + + const returnVal = backtest.actual_return_1d; + + for (const bucket of buckets) { + if (returnVal >= bucket.min && returnVal < bucket.max) { + bucket.count++; + bucket.stocks.push(symbol); + break; + } + } + } + + return buckets; +} + +// Get accuracy trend over time +export function getAccuracyTrend(): AccuracyTrendPoint[] { + const dates = getAvailableDates(); + const sortedDates = [...dates].sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + + return sortedDates.map(date => { + const rec = getRecommendationByDate(date); + if (!rec) { + return { date, overall: 0, buy: 0, sell: 0, hold: 0 }; + } + + let totalBuy = 0, correctBuy = 0; + let totalSell = 0, correctSell = 0; + let totalHold = 0, correctHold = 0; + + for (const symbol of Object.keys(rec.analysis)) { + const stockAnalysis = rec.analysis[symbol]; + const backtest = getBacktestResult(symbol); + if (!backtest || !stockAnalysis?.decision) continue; + + if (stockAnalysis.decision === 'BUY') { + totalBuy++; + if (backtest.prediction_correct) correctBuy++; + } else if (stockAnalysis.decision === 'SELL') { + totalSell++; + if (backtest.prediction_correct) correctSell++; + } else { + totalHold++; + if (backtest.prediction_correct) correctHold++; + } + } + + const total = totalBuy + totalSell + totalHold; + const correct = correctBuy + correctSell + correctHold; + + return { + date, + overall: total > 0 ? Math.round((correct / total) * 100) : 0, + buy: totalBuy > 0 ? Math.round((correctBuy / totalBuy) * 100) : 0, + sell: totalSell > 0 ? Math.round((correctSell / totalSell) * 100) : 0, + hold: totalHold > 0 ? Math.round((correctHold / totalHold) * 100) : 0, + }; + }); +} + +// Get cumulative portfolio data for charting +export function getCumulativeReturns(): { date: string; value: number; aiReturn: number; indexReturn: number }[] { + const dates = getAvailableDates(); + const sortedDates = [...dates].sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + const indexData = getNifty50IndexHistory(); + + const data: { date: string; value: number; aiReturn: number; indexReturn: number }[] = []; + let aiMultiplier = 1; + let indexMultiplier = 1; + + for (let i = 0; i < sortedDates.length; i++) { + const date = sortedDates[i]; + const stats = getDateStats(date); + const indexPoint = indexData.find(d => d.date === date); + + if (stats) { + aiMultiplier *= (1 + stats.avgReturn1d / 100); + } + if (indexPoint) { + indexMultiplier *= (1 + indexPoint.return / 100); + } + + data.push({ + date, + value: Math.round(aiMultiplier * 10000) / 100, // As percentage of starting value + aiReturn: Math.round((aiMultiplier - 1) * 1000) / 10, + indexReturn: Math.round((indexMultiplier - 1) * 1000) / 10, + }); + } + + return data; +} + +// Get all unique sectors from stocks +export function getAllSectors(): string[] { + const sectors = new Set(); + for (const stock of nifty50List) { + if (stock.sector) { + sectors.add(stock.sector); + } + } + return Array.from(sectors).sort(); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 00000000..53f42e27 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,205 @@ +@import "tailwindcss"; + +@theme { + /* Custom colors */ + --color-nifty-50: #f0f9ff; + --color-nifty-100: #e0f2fe; + --color-nifty-200: #bae6fd; + --color-nifty-300: #7dd3fc; + --color-nifty-400: #38bdf8; + --color-nifty-500: #0ea5e9; + --color-nifty-600: #0284c7; + --color-nifty-700: #0369a1; + --color-nifty-800: #075985; + --color-nifty-900: #0c4a6e; + + --color-bull-light: #dcfce7; + --color-bull: #22c55e; + --color-bull-dark: #15803d; + + --color-bear-light: #fee2e2; + --color-bear: #ef4444; + --color-bear-dark: #b91c1c; + + --color-hold-light: #fef3c7; + --color-hold: #f59e0b; + --color-hold-dark: #b45309; + + /* Custom fonts */ + --font-sans: 'Inter', system-ui, sans-serif; + --font-display: 'Lexend', system-ui, sans-serif; +} + +@layer base { + html { + scroll-behavior: smooth; + } + + html.dark { + color-scheme: dark; + } + + body { + @apply font-sans antialiased bg-gray-50 text-gray-900; + margin: 0; + } + + html.dark body { + @apply bg-slate-900 text-gray-100; + } +} + +@layer components { + .card { + @apply bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden; + } + + html.dark .card { + @apply bg-slate-800 border-slate-700; + } + + .card-hover { + @apply bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden transition-all duration-200 hover:shadow-md hover:border-gray-200 focus-within:ring-2 focus-within:ring-nifty-500 focus-within:ring-offset-1; + } + + html.dark .card-hover { + @apply bg-slate-800 border-slate-700 hover:border-slate-600; + } + + html.dark .card-hover:focus-within { + @apply ring-offset-slate-900; + } + + .btn { + @apply inline-flex items-center justify-center px-4 py-2 rounded-lg font-medium transition-all duration-200; + } + + .btn-primary { + @apply inline-flex items-center justify-center px-4 py-2 rounded-lg font-medium transition-all duration-200 bg-nifty-600 text-white hover:bg-nifty-700 focus:ring-2 focus:ring-nifty-500 focus:ring-offset-2; + } + + html.dark .btn-primary { + @apply focus:ring-offset-slate-900; + } + + .btn-secondary { + @apply inline-flex items-center justify-center px-4 py-2 rounded-lg font-medium transition-all duration-200 bg-gray-100 text-gray-700 hover:bg-gray-200; + } + + html.dark .btn-secondary { + @apply bg-slate-700 text-gray-200 hover:bg-slate-600; + } + + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + .badge-buy { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-bull-light text-bull-dark; + } + + html.dark .badge-buy { + @apply bg-green-900/30 text-green-400; + } + + .badge-sell { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-bear-light text-bear-dark; + } + + html.dark .badge-sell { + @apply bg-red-900/30 text-red-400; + } + + .badge-hold { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-hold-light text-hold-dark; + } + + html.dark .badge-hold { + @apply bg-amber-900/30 text-amber-400; + } + + .gradient-text { + @apply bg-gradient-to-r from-nifty-600 to-nifty-800 bg-clip-text text-transparent; + } + + html.dark .gradient-text { + @apply from-nifty-400 to-nifty-600; + } + + .section-title { + @apply text-2xl font-display font-semibold text-gray-900; + } + + html.dark .section-title { + @apply text-gray-100; + } +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } + + /* Mobile touch target - minimum 44px for accessibility */ + .touch-target { + min-height: 44px; + min-width: 44px; + } + + /* Animation utilities */ + .animate-in { + animation: animate-in 0.2s ease-out; + } + + .slide-in-from-top-2 { + --tw-enter-translate-y: -0.5rem; + } + + @keyframes animate-in { + from { + opacity: 0; + transform: translateY(var(--tw-enter-translate-y, 0)); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* Smooth scrollbar styling */ + .scroll-smooth { + scroll-behavior: smooth; + } + + /* Recharts dark mode fix */ + .recharts-wrapper, + .recharts-surface { + background-color: transparent !important; + } + + /* Custom scrollbar for stock lists */ + .overflow-y-auto::-webkit-scrollbar { + width: 6px; + } + + .overflow-y-auto::-webkit-scrollbar-track { + background: transparent; + } + + .overflow-y-auto::-webkit-scrollbar-thumb { + background: #d1d5db; + border-radius: 3px; + } + + .overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: #9ca3af; + } + + html.dark .overflow-y-auto::-webkit-scrollbar-thumb { + background: #475569; + } + + html.dark .overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: #64748b; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..25e7a34e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import './index.css'; +import App from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/frontend/src/pages/About.tsx b/frontend/src/pages/About.tsx new file mode 100644 index 00000000..38da63bf --- /dev/null +++ b/frontend/src/pages/About.tsx @@ -0,0 +1,267 @@ +import { Link } from 'react-router-dom'; +import { ArrowLeft, Brain, BarChart2, TrendingUp, MessageSquare, Shield, Database, Sparkles, Target, Users, Zap, Clock } from 'lucide-react'; + +const agents = [ + { + name: 'Technical Analyst', + icon: BarChart2, + color: 'from-purple-500 to-purple-600', + bgColor: 'bg-purple-50 dark:bg-purple-900/20', + description: 'Analyzes price charts, volume patterns, and technical indicators like RSI, MACD, Bollinger Bands, and moving averages to identify trends and momentum.', + capabilities: ['Chart pattern recognition', 'Support/resistance levels', 'Momentum indicators', 'Volume analysis'], + }, + { + name: 'Fundamental Analyst', + icon: TrendingUp, + color: 'from-green-500 to-green-600', + bgColor: 'bg-green-50 dark:bg-green-900/20', + description: 'Evaluates company financials, earnings reports, P/E ratios, debt levels, and industry position to assess intrinsic value.', + capabilities: ['Earnings analysis', 'Valuation metrics', 'Financial health', 'Growth trajectory'], + }, + { + name: 'Sentiment Analyst', + icon: MessageSquare, + color: 'from-amber-500 to-amber-600', + bgColor: 'bg-amber-50 dark:bg-amber-900/20', + description: 'Monitors news sentiment, social media trends, analyst ratings, and market psychology to gauge investor sentiment.', + capabilities: ['News sentiment', 'Social media trends', 'Analyst ratings', 'Market psychology'], + }, + { + name: 'Risk Manager', + icon: Shield, + color: 'from-red-500 to-red-600', + bgColor: 'bg-red-50 dark:bg-red-900/20', + description: 'Assesses volatility, sector risks, market conditions, and potential downsides to determine appropriate risk levels.', + capabilities: ['Volatility assessment', 'Sector correlation', 'Downside risk', 'Position sizing'], + }, +]; + +const features = [ + { + icon: Users, + title: 'Multi-Agent Collaboration', + description: 'Multiple specialized AI agents work together, each bringing unique expertise to the analysis.', + }, + { + icon: Brain, + title: 'AI Debate System', + description: 'Agents debate their findings, challenge assumptions, and reach consensus through reasoned discussion.', + }, + { + icon: Database, + title: 'Real-Time Data', + description: 'Analysis is based on current market data, company financials, and news from reliable sources.', + }, + { + icon: Target, + title: 'Clear Recommendations', + description: 'Final decisions are clear BUY, SELL, or HOLD with confidence levels and risk assessments.', + }, +]; + +const dataFlow = [ + { step: 1, title: 'Data Collection', description: 'Market data, financials, and news are gathered', icon: Database }, + { step: 2, title: 'Independent Analysis', description: 'Each agent analyzes data from their perspective', icon: BarChart2 }, + { step: 3, title: 'AI Debate', description: 'Agents discuss and challenge findings', icon: MessageSquare }, + { step: 4, title: 'Consensus Decision', description: 'Final recommendation with confidence rating', icon: Target }, +]; + +export default function About() { + return ( +
+ {/* Back Button */} + + + Back to Dashboard + + + {/* Hero Section */} +
+
+
+
+ +
+
+

How TradingAgents Works

+

AI-powered stock analysis for Nifty 50

+
+
+

+ TradingAgents uses a team of specialized AI agents that analyze stocks from multiple perspectives, + debate their findings, and reach consensus recommendations. This multi-agent approach provides + more balanced and thoroughly reasoned analysis than any single model. +

+
+ + {/* Key Features */} +
+ {features.map((feature) => { + const Icon = feature.icon; + return ( +
+
+ +
+

{feature.title}

+

{feature.description}

+
+ ); + })} +
+
+ + {/* Analysis Flow */} +
+
+ +

Analysis Process

+
+ +
+ {dataFlow.map((item, index) => { + const Icon = item.icon; + return ( +
+
+
+ + {item.step} + + +
+

{item.title}

+

{item.description}

+
+ {index < dataFlow.length - 1 && ( +
+ → +
+ )} +
+ ); + })} +
+
+ + {/* Agent Cards */} +
+
+ +

Meet the AI Agents

+
+ +
+ {agents.map((agent) => { + const Icon = agent.icon; + return ( +
+
+
+ +

{agent.name}

+
+
+
+

{agent.description}

+
+ {agent.capabilities.map((cap) => ( + + {cap} + + ))} +
+
+
+ ); + })} +
+
+ + {/* Debate Section */} +
+
+
+ +
+
+

The AI Debate Process

+

How agents reach consensus

+
+
+
+

+ After each agent completes their analysis, they engage in a structured debate. The Technical + Analyst might argue for a BUY based on strong momentum, while the Risk Manager highlights + elevated volatility concerns. +

+

+ Through multiple rounds of discussion, agents refine their positions, consider counterarguments, + and ultimately reach a consensus. This process mimics how investment committees at professional + firms make decisions. +

+

+ The final recommendation reflects the collective intelligence of all agents, weighted by the + strength of their arguments and supporting evidence. +

+
+
+ + {/* Data Sources */} +
+
+ +

Data Sources

+
+
+
+ Price Data: + NSE/BSE +
+
+ Financials: + Quarterly Reports +
+
+ News: + Financial Media +
+
+ Updates: + Daily +
+
+
+ + {/* Disclaimer */} +
+
+ +
+

Important Disclaimer

+

+ TradingAgents provides AI-generated stock analysis for educational and informational purposes only. + These recommendations do not constitute financial advice. Always conduct your own research and consult + with a qualified financial advisor before making investment decisions. Past performance does not + guarantee future results. Investing in stocks involves risk, including potential loss of principal. +

+
+
+
+ + {/* CTA */} + + View Today's Recommendations → + +
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 00000000..af955021 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,368 @@ +import { useState, useMemo, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { Calendar, RefreshCw, Filter, ChevronRight, TrendingUp, TrendingDown, Minus, History, Search, X, Play, Loader2 } from 'lucide-react'; +import TopPicks, { StocksToAvoid } from '../components/TopPicks'; +import { DecisionBadge } from '../components/StockCard'; +import HowItWorks from '../components/HowItWorks'; +import BackgroundSparkline from '../components/BackgroundSparkline'; +import { getLatestRecommendation, getBacktestResult } from '../data/recommendations'; +import { api } from '../services/api'; +import { useSettings } from '../contexts/SettingsContext'; +import type { Decision, StockAnalysis } from '../types'; + +type FilterType = 'ALL' | Decision; + +export default function Dashboard() { + const recommendation = getLatestRecommendation(); + const [filter, setFilter] = useState('ALL'); + const [searchQuery, setSearchQuery] = useState(''); + const { settings } = useSettings(); + + // Bulk analysis state + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analysisProgress, setAnalysisProgress] = useState<{ + status: string; + total: number; + completed: number; + failed: number; + current_symbol: string | null; + } | null>(null); + + // Check for running analysis on mount + useEffect(() => { + const checkAnalysisStatus = async () => { + try { + const status = await api.getBulkAnalysisStatus(); + if (status.status === 'running') { + setIsAnalyzing(true); + setAnalysisProgress(status); + } + } catch (e) { + console.error('Failed to check analysis status:', e); + } + }; + checkAnalysisStatus(); + }, []); + + // Poll for analysis progress + useEffect(() => { + if (!isAnalyzing) return; + + const pollInterval = setInterval(async () => { + try { + const status = await api.getBulkAnalysisStatus(); + setAnalysisProgress(status); + + if (status.status === 'completed' || status.status === 'idle') { + setIsAnalyzing(false); + clearInterval(pollInterval); + // Refresh the page to show updated data + window.location.reload(); + } + } catch (e) { + console.error('Failed to poll analysis status:', e); + } + }, 3000); + + return () => clearInterval(pollInterval); + }, [isAnalyzing]); + + const handleAnalyzeAll = async () => { + if (isAnalyzing) return; + + setIsAnalyzing(true); + setAnalysisProgress({ + status: 'starting', + total: 50, + completed: 0, + failed: 0, + current_symbol: null + }); + + try { + // Pass settings from context to the API + await api.runBulkAnalysis(undefined, { + deep_think_model: settings.deepThinkModel, + quick_think_model: settings.quickThinkModel, + provider: settings.provider, + api_key: settings.provider === 'anthropic_api' ? settings.anthropicApiKey : undefined, + max_debate_rounds: settings.maxDebateRounds + }); + } catch (e) { + console.error('Failed to start bulk analysis:', e); + setIsAnalyzing(false); + setAnalysisProgress(null); + } + }; + + if (!recommendation) { + return ( +
+
+ +

Loading recommendations...

+
+
+ ); + } + + const stocks = Object.values(recommendation.analysis); + const filteredStocks = useMemo(() => { + let result = filter === 'ALL' ? stocks : stocks.filter(s => s.decision === filter); + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + result = result.filter(s => + s.symbol.toLowerCase().includes(query) || + s.company_name.toLowerCase().includes(query) + ); + } + return result; + }, [stocks, filter, searchQuery]); + + const { buy, sell, hold, total } = recommendation.summary; + const buyPct = ((buy / total) * 100).toFixed(0); + const holdPct = ((hold / total) * 100).toFixed(0); + const sellPct = ((sell / total) * 100).toFixed(0); + + return ( +
+ {/* Compact Header with Stats */} +
+
+
+

+ Nifty 50 AI Recommendations +

+
+ + {new Date(recommendation.date).toLocaleDateString('en-IN', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + })} +
+
+ + {/* Analyze All Button + Inline Stats */} +
+ {/* Analyze All Button */} + + +
setFilter('BUY')} title="Click to filter Buy stocks"> +
+
setFilter('HOLD')} title="Click to filter Hold stocks"> +
+
setFilter('SELL')} title="Click to filter Sell stocks"> +
+
+
+ + {/* Progress bar */} +
+
+
+
+
+
+
+ + {/* Analysis Progress Banner */} + {isAnalyzing && analysisProgress && ( +
+
+
+ + + Analyzing {analysisProgress.current_symbol || 'stocks'}... + +
+ + {analysisProgress.completed + analysisProgress.failed} / {analysisProgress.total} stocks + +
+
+
+
+ {analysisProgress.failed > 0 && ( +

+ {analysisProgress.failed} failed +

+ )} +
+ )} +
+ + {/* How It Works Section */} + + + {/* Top Picks and Avoid Section - Side by Side Compact */} +
+ + +
+ + {/* All Stocks Section with Integrated Filter */} +
+
+
+
+
+ +

All {total} Stocks

+
+
+ + + + +
+
+ {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-9 py-2 text-sm rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:border-transparent" + /> + {searchQuery && ( + + )} +
+
+
+ +
+ {filteredStocks.map((stock: StockAnalysis) => { + const backtest = getBacktestResult(stock.symbol); + const trend = stock.decision === 'BUY' ? 'up' : stock.decision === 'SELL' ? 'down' : 'flat'; + return ( + + {/* Background Chart */} + {backtest && ( +
+ +
+ )} + + {/* Content */} +
+
+ {stock.symbol} + +
+

{stock.company_name}

+
+ + ); + })} +
+ + {filteredStocks.length === 0 && ( +
+

No stocks match the selected filter.

+
+ )} +
+ + {/* Compact CTA */} + +
+
+
+ ); +} diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx new file mode 100644 index 00000000..f4e178ab --- /dev/null +++ b/frontend/src/pages/History.tsx @@ -0,0 +1,375 @@ +import { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { Calendar, TrendingUp, TrendingDown, Minus, ChevronRight, BarChart3, Target, HelpCircle, Activity, Calculator, LineChart, PieChart, Shield } from 'lucide-react'; +import { sampleRecommendations, getBacktestResult, calculateAccuracyMetrics, getDateStats, getOverallStats, getReturnBreakdown } from '../data/recommendations'; +import { DecisionBadge } from '../components/StockCard'; +import Sparkline from '../components/Sparkline'; +import AccuracyBadge from '../components/AccuracyBadge'; +import AccuracyExplainModal from '../components/AccuracyExplainModal'; +import ReturnExplainModal from '../components/ReturnExplainModal'; +import OverallReturnModal from '../components/OverallReturnModal'; +import AccuracyTrendChart from '../components/AccuracyTrendChart'; +import ReturnDistributionChart from '../components/ReturnDistributionChart'; +import RiskMetricsCard from '../components/RiskMetricsCard'; +import PortfolioSimulator from '../components/PortfolioSimulator'; +import IndexComparisonChart from '../components/IndexComparisonChart'; +import type { StockAnalysis } from '../types'; + +export default function History() { + const [selectedDate, setSelectedDate] = useState(null); + const [showAccuracyModal, setShowAccuracyModal] = useState(false); + const [showReturnModal, setShowReturnModal] = useState(false); + const [returnModalDate, setReturnModalDate] = useState(null); + const [showOverallModal, setShowOverallModal] = useState(false); + + const dates = sampleRecommendations.map(r => r.date); + const accuracyMetrics = calculateAccuracyMetrics(); + const overallStats = useMemo(() => getOverallStats(), []); + + // Pre-calculate date stats for all dates + const dateStatsMap = useMemo(() => { + const map: Record> = {}; + dates.forEach(date => { + map[date] = getDateStats(date); + }); + return map; + }, [dates]); + + const getRecommendation = (date: string) => { + return sampleRecommendations.find(r => r.date === date); + }; + + return ( +
+ {/* Compact Header */} +
+
+
+

+ Historical Recommendations +

+

Browse past AI recommendations with backtest results

+
+
+
+ + {dates.length} + days +
+
+
+
+ + {/* Accuracy Metrics */} +
+
+
+ +

Prediction Accuracy

+
+ +
+
+
+
+ {(accuracyMetrics.success_rate * 100).toFixed(0)}% +
+
Overall Accuracy
+
+
+
+ {(accuracyMetrics.buy_accuracy * 100).toFixed(0)}% +
+
Buy Accuracy
+
+
+
+ {(accuracyMetrics.sell_accuracy * 100).toFixed(0)}% +
+
Sell Accuracy
+
+
+
+ {(accuracyMetrics.hold_accuracy * 100).toFixed(0)}% +
+
Hold Accuracy
+
+
+

+ Based on {accuracyMetrics.total_predictions} predictions tracked over time +

+
+ + {/* Accuracy Trend Chart */} +
+
+ +

Accuracy Trend

+
+ +

+ Prediction accuracy over the past {dates.length} trading days +

+
+ + {/* Risk Metrics */} +
+
+ +

Risk Metrics

+
+ +

+ Risk-adjusted performance metrics for the AI trading strategy +

+
+ + {/* Portfolio Simulator */} + + + {/* Date Selector */} +
+
+ +

Select Date

+
+
+ {dates.map((date) => { + const rec = getRecommendation(date); + const stats = dateStatsMap[date]; + const avgReturn = stats?.avgReturn1d ?? 0; + const isPositive = avgReturn >= 0; + + return ( +
+ + {/* Help button for return explanation */} + +
+ ); + })} + + {/* Overall Summary Card */} +
+ + +
+
+
+ + {/* Selected Date Details */} + {selectedDate && ( +
+
+
+

+ {new Date(selectedDate).toLocaleDateString('en-IN', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
+ + + {getRecommendation(selectedDate)?.summary.buy} Buy + + + + {getRecommendation(selectedDate)?.summary.sell} Sell + + + + {getRecommendation(selectedDate)?.summary.hold} Hold + +
+
+
+ +
+ {Object.values(getRecommendation(selectedDate)?.analysis || {}).map((stock: StockAnalysis) => { + const backtest = getBacktestResult(stock.symbol); + // Use next-day return for the display + const nextDayReturn = backtest?.actual_return_1d ?? 0; + const isPositive = nextDayReturn >= 0; + + return ( + +
+ {stock.symbol} + {stock.company_name} +
+
+ {/* Sparkline */} + {backtest && ( + + )} + {/* Next-Day Return Badge */} + {backtest && ( + + )} + + +
+ + ); + })} +
+
+ )} + + {/* Performance Summary Cards */} +
+
+ +

Performance Summary

+
+
+
+
{overallStats.totalDays}
+
Days Tracked
+
+
+
= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}> + {overallStats.avgDailyReturn >= 0 ? '+' : ''}{overallStats.avgDailyReturn.toFixed(1)}% +
+
Avg Next-Day Return
+
+
+
+ {sampleRecommendations.reduce((acc, r) => acc + r.summary.buy, 0)} +
+
Buy Signals
+
+
+
+ {sampleRecommendations.reduce((acc, r) => acc + r.summary.sell, 0)} +
+
Sell Signals
+
+
+

+ Next-day return = Price change on the trading day after recommendation +

+
+ + {/* AI vs Nifty50 Index Comparison */} +
+
+ +

AI Strategy vs Nifty50 Index

+
+ +

+ Comparison of cumulative returns between AI strategy and Nifty50 index +

+
+ + {/* Return Distribution */} +
+
+ +

Return Distribution

+
+ +

+ Distribution of next-day returns across all predictions. Click bars to see stocks. +

+
+ + {/* Accuracy Explanation Modal */} + setShowAccuracyModal(false)} + metrics={accuracyMetrics} + /> + + {/* Return Calculation Modal */} + setShowReturnModal(false)} + breakdown={returnModalDate ? getReturnBreakdown(returnModalDate) : null} + date={returnModalDate || ''} + /> + + {/* Overall Return Modal */} + setShowOverallModal(false)} + /> +
+ ); +} diff --git a/frontend/src/pages/StockDetail.tsx b/frontend/src/pages/StockDetail.tsx new file mode 100644 index 00000000..cdd18f12 --- /dev/null +++ b/frontend/src/pages/StockDetail.tsx @@ -0,0 +1,575 @@ +import { useParams, Link } from 'react-router-dom'; +import { useMemo, useState, useEffect } from 'react'; +import { + ArrowLeft, Building2, TrendingUp, TrendingDown, Minus, AlertTriangle, + Calendar, Activity, LineChart, Database, MessageSquare, FileText, Layers, + RefreshCw, Play, Loader2 +} from 'lucide-react'; +import { NIFTY_50_STOCKS } from '../types'; +import { sampleRecommendations, getStockHistory, getExtendedPriceHistory, getPredictionPointsWithPrices, getRawAnalysis } from '../data/recommendations'; +import { DecisionBadge, ConfidenceBadge, RiskBadge } from '../components/StockCard'; +import AIAnalysisPanel from '../components/AIAnalysisPanel'; +import StockPriceChart from '../components/StockPriceChart'; +import { + PipelineOverview, + AgentReportCard, + DebateViewer, + RiskDebateViewer, + DataSourcesPanel +} from '../components/pipeline'; +import { api } from '../services/api'; +import { useSettings } from '../contexts/SettingsContext'; +import type { FullPipelineData, AgentType } from '../types/pipeline'; + +type TabType = 'overview' | 'pipeline' | 'debates' | 'data'; + +export default function StockDetail() { + const { symbol } = useParams<{ symbol: string }>(); + const [activeTab, setActiveTab] = useState('overview'); + const [pipelineData, setPipelineData] = useState(null); + const [isLoadingPipeline, setIsLoadingPipeline] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [lastRefresh, setLastRefresh] = useState(null); + const [refreshMessage, setRefreshMessage] = useState(null); + const { settings } = useSettings(); + + // Analysis state + const [isAnalysisRunning, setIsAnalysisRunning] = useState(false); + const [analysisStatus, setAnalysisStatus] = useState(null); + const [analysisProgress, setAnalysisProgress] = useState(null); + + const stock = NIFTY_50_STOCKS.find(s => s.symbol === symbol); + const latestRecommendation = sampleRecommendations[0]; + const analysis = latestRecommendation?.analysis[symbol || '']; + const history = symbol ? getStockHistory(symbol) : []; + + // Get price history and prediction points for the chart + const priceHistory = useMemo(() => { + return symbol ? getExtendedPriceHistory(symbol, 60) : []; + }, [symbol]); + + const predictionPoints = useMemo(() => { + return symbol && priceHistory.length > 0 + ? getPredictionPointsWithPrices(symbol, priceHistory) + : []; + }, [symbol, priceHistory]); + + // Function to fetch pipeline data + const fetchPipelineData = async (forceRefresh = false) => { + if (!symbol || !latestRecommendation?.date) return; + + if (forceRefresh) { + setIsRefreshing(true); + } else { + setIsLoadingPipeline(true); + } + + try { + const data = await api.getPipelineData(latestRecommendation.date, symbol, forceRefresh); + setPipelineData(data); + if (forceRefresh) { + setLastRefresh(new Date().toLocaleTimeString()); + const hasData = data.pipeline_steps?.length > 0 || Object.keys(data.agent_reports || {}).length > 0; + setRefreshMessage(hasData ? `✓ Data refreshed for ${symbol}` : `No pipeline data found for ${symbol}`); + setTimeout(() => setRefreshMessage(null), 3000); + } + console.log('Pipeline data fetched:', data); + } catch (error) { + console.error('Failed to fetch pipeline data:', error); + if (forceRefresh) { + setRefreshMessage(`✗ Failed to refresh: ${error}`); + setTimeout(() => setRefreshMessage(null), 3000); + } + // Set empty pipeline data structure + setPipelineData({ + date: latestRecommendation.date, + symbol: symbol, + agent_reports: {}, + debates: {}, + pipeline_steps: [], + data_sources: [], + status: 'no_data' + }); + } finally { + setIsLoadingPipeline(false); + setIsRefreshing(false); + } + }; + + // Fetch pipeline data when tab changes or symbol changes + useEffect(() => { + if (activeTab === 'overview') return; // Don't fetch for overview tab + fetchPipelineData(); + }, [symbol, latestRecommendation?.date, activeTab]); + + // Refresh handler + const handleRefresh = async () => { + console.log('Refresh button clicked - fetching fresh data...'); + await fetchPipelineData(true); + console.log('Refresh complete - data updated'); + }; + + // Run Analysis handler + const handleRunAnalysis = async () => { + if (!symbol || !latestRecommendation?.date) return; + + setIsAnalysisRunning(true); + setAnalysisStatus('starting'); + setAnalysisProgress('Starting analysis...'); + + try { + // Trigger analysis with settings from context + await api.runAnalysis(symbol, latestRecommendation.date, { + deep_think_model: settings.deepThinkModel, + quick_think_model: settings.quickThinkModel, + provider: settings.provider, + api_key: settings.provider === 'anthropic_api' ? settings.anthropicApiKey : undefined, + max_debate_rounds: settings.maxDebateRounds + }); + setAnalysisStatus('running'); + + // Poll for status + const pollInterval = setInterval(async () => { + try { + const status = await api.getAnalysisStatus(symbol); + setAnalysisProgress(status.progress || 'Processing...'); + + if (status.status === 'completed') { + clearInterval(pollInterval); + setIsAnalysisRunning(false); + setAnalysisStatus('completed'); + setAnalysisProgress(`✓ Analysis complete: ${status.decision || 'Done'}`); + // Refresh data to show results + await fetchPipelineData(true); + setTimeout(() => { + setAnalysisProgress(null); + setAnalysisStatus(null); + }, 5000); + } else if (status.status === 'error') { + clearInterval(pollInterval); + setIsAnalysisRunning(false); + setAnalysisStatus('error'); + setAnalysisProgress(`✗ Error: ${status.error}`); + } + } catch (err) { + console.error('Failed to poll analysis status:', err); + } + }, 2000); // Poll every 2 seconds + + // Cleanup after 10 minutes max + setTimeout(() => clearInterval(pollInterval), 600000); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Failed to start analysis:', errorMessage, error); + setIsAnalysisRunning(false); + setAnalysisStatus('error'); + // More helpful error message + if (errorMessage.includes('Failed to fetch') || errorMessage.includes('NetworkError')) { + setAnalysisProgress(`✗ Network error: Cannot connect to backend at localhost:8000. Please check if the server is running.`); + } else { + setAnalysisProgress(`✗ Failed to start analysis: ${errorMessage}`); + } + } + }; + + if (!stock) { + return ( +
+
+ +

Stock Not Found

+

The stock "{symbol}" was not found in Nifty 50.

+ + Back to Dashboard + +
+
+ ); + } + + const decisionIcon = { + BUY: TrendingUp, + SELL: TrendingDown, + HOLD: Minus, + }; + + const decisionColor = { + BUY: 'from-green-500 to-green-600', + SELL: 'from-red-500 to-red-600', + HOLD: 'from-amber-500 to-amber-600', + }; + + const DecisionIcon = analysis?.decision ? decisionIcon[analysis.decision] : Activity; + const bgGradient = analysis?.decision ? decisionColor[analysis.decision] : 'from-gray-500 to-gray-600'; + + const TABS = [ + { id: 'overview' as const, label: 'Overview', icon: LineChart }, + { id: 'pipeline' as const, label: 'Analysis Pipeline', icon: Layers }, + { id: 'debates' as const, label: 'Debates', icon: MessageSquare }, + { id: 'data' as const, label: 'Data Sources', icon: Database }, + ]; + + return ( +
+ {/* Back Button */} + + + Back to Dashboard + + + {/* Compact Stock Header */} +
+
+
+
+
+
+

{stock.symbol}

+ {analysis?.decision && ( + + + {analysis.decision} + + )} +
+

{stock.company_name}

+
+
+
+
+ + {stock.sector || 'N/A'} +
+
+ + {latestRecommendation?.date ? new Date(latestRecommendation.date).toLocaleDateString('en-IN', { + month: 'short', + day: 'numeric', + }) : 'N/A'} +
+
+
+
+ + {/* Analysis Details - Inline */} + {analysis && ( +
+
+ Decision: + +
+
+ Confidence: + +
+
+ Risk: + +
+
+ )} +
+ + {/* Tab Navigation */} +
+ {TABS.map(tab => { + const Icon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })} + + {/* Action Buttons - Show on non-overview tabs */} + {activeTab !== 'overview' && ( +
+ {lastRefresh && ( + + Updated: {lastRefresh} + + )} + + {/* Run Analysis Button */} + + + {/* Refresh Button */} + +
+ )} +
+ + {/* Analysis Progress Banner */} + {analysisProgress && ( +
+ {isAnalysisRunning && } + {analysisProgress} +
+ )} + + {/* Refresh Notification */} + {refreshMessage && !analysisProgress && ( +
+ {refreshMessage} +
+ )} + + {/* Tab Content */} + {activeTab === 'overview' && ( + <> + {/* Price Chart with Predictions */} + {priceHistory.length > 0 && ( +
+
+
+ +

Price History & AI Predictions

+
+
+
+ +
+
+ )} + + {/* AI Analysis Panel */} + {analysis && getRawAnalysis(symbol || '') && ( + + )} + + {/* Compact Stats Grid */} +
+
+
{history.length}
+
Analyses
+
+
+
+ {history.filter((h: { decision: string }) => h.decision === 'BUY').length} +
+
Buy
+
+
+
+ {history.filter((h: { decision: string }) => h.decision === 'HOLD').length} +
+
Hold
+
+
+
+ {history.filter((h: { decision: string }) => h.decision === 'SELL').length} +
+
Sell
+
+
+ + {/* Analysis History */} +
+
+

Recommendation History

+
+ + {history.length > 0 ? ( +
+ {history.map((entry, idx) => ( +
+
+ {new Date(entry.date).toLocaleDateString('en-IN', { + weekday: 'short', + month: 'short', + day: 'numeric', + })} +
+ +
+ ))} +
+ ) : ( +
+ +

No history yet

+
+ )} +
+ + )} + + {activeTab === 'pipeline' && ( +
+ {/* Pipeline Overview */} +
+
+ +

Analysis Pipeline

+
+ console.log('Step clicked:', step)} + /> +
+ + {/* Agent Reports Grid */} +
+
+ +

Agent Reports

+
+
+ {(['market', 'news', 'social_media', 'fundamentals'] as AgentType[]).map(agentType => ( + + ))} +
+
+
+ )} + + {activeTab === 'debates' && ( +
+ {/* Investment Debate */} + + + {/* Risk Debate */} + +
+ )} + + {activeTab === 'data' && ( +
+ + + {/* No data message */} + {!isLoadingPipeline && (!pipelineData?.data_sources || pipelineData.data_sources.length === 0) && ( +
+ +

+ No Data Source Logs Available +

+

+ Data source logs will appear here when the analysis pipeline runs. + This includes information about market data, news, and fundamental data fetched. +

+
+ )} +
+ )} + + {/* Top Pick / Avoid Status - Compact (visible on all tabs) */} + {latestRecommendation && ( + <> + {latestRecommendation.top_picks.some(p => p.symbol === symbol) && ( +
+
+ +
+ Top Pick: + + {latestRecommendation.top_picks.find(p => p.symbol === symbol)?.reason} + +
+
+
+ )} + + {latestRecommendation.stocks_to_avoid.some(s => s.symbol === symbol) && ( +
+
+ +
+ Avoid: + + {latestRecommendation.stocks_to_avoid.find(s => s.symbol === symbol)?.reason} + +
+
+
+ )} + + )} + + {/* Compact Disclaimer */} +

+ AI-generated recommendation for educational purposes only. Not financial advice. +

+
+ ); +} diff --git a/frontend/src/pages/Stocks.tsx b/frontend/src/pages/Stocks.tsx new file mode 100644 index 00000000..0b22f4c7 --- /dev/null +++ b/frontend/src/pages/Stocks.tsx @@ -0,0 +1,112 @@ +import { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { Search, Building2 } from 'lucide-react'; +import { NIFTY_50_STOCKS } from '../types'; +import { getLatestRecommendation } from '../data/recommendations'; +import { DecisionBadge, ConfidenceBadge } from '../components/StockCard'; + +export default function Stocks() { + const [search, setSearch] = useState(''); + const [sectorFilter, setSectorFilter] = useState('ALL'); + + const recommendation = getLatestRecommendation(); + + const sectors = useMemo(() => { + const sectorSet = new Set(NIFTY_50_STOCKS.map(s => s.sector).filter(Boolean)); + return ['ALL', ...Array.from(sectorSet).sort()]; + }, []); + + const filteredStocks = useMemo(() => { + return NIFTY_50_STOCKS.filter(stock => { + const matchesSearch = + stock.symbol.toLowerCase().includes(search.toLowerCase()) || + stock.company_name.toLowerCase().includes(search.toLowerCase()); + + const matchesSector = sectorFilter === 'ALL' || stock.sector === sectorFilter; + + return matchesSearch && matchesSector; + }); + }, [search, sectorFilter]); + + const getStockAnalysis = (symbol: string) => { + return recommendation?.analysis[symbol]; + }; + + return ( +
+ {/* Combined Header + Search */} +
+
+
+

+ All Nifty 50 Stocks +

+

+ {filteredStocks.length} of {NIFTY_50_STOCKS.length} stocks +

+
+
+ + {/* Search and Filter - inline */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-gray-200 focus:border-nifty-500 focus:ring-1 focus:ring-nifty-500/20 outline-none" + /> +
+ +
+
+ + {/* Compact Stocks Grid */} +
+ {filteredStocks.map((stock) => { + const analysis = getStockAnalysis(stock.symbol); + return ( + +
+

{stock.symbol}

+ {analysis && } +
+

{stock.company_name}

+
+
+ + {stock.sector} +
+ {analysis && } +
+ + ); + })} +
+ + {filteredStocks.length === 0 && ( +
+ +

No stocks found

+

Try adjusting your search.

+
+ )} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 00000000..086cce82 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,342 @@ +/** + * API service for fetching stock recommendations from the backend. + * Updated with cache-busting for refresh functionality. + */ + +import type { + FullPipelineData, + AgentReportsMap, + DebatesMap, + DataSourceLog, + PipelineSummary +} from '../types/pipeline'; + +// Use same hostname as the page, just different port for API +const getApiBaseUrl = () => { + // If env variable is set, use it + if (import.meta.env.VITE_API_URL) { + return import.meta.env.VITE_API_URL; + } + // Otherwise use the same host as the current page with port 8001 + const hostname = typeof window !== 'undefined' ? window.location.hostname : 'localhost'; + return `http://${hostname}:8001`; +}; + +const API_BASE_URL = getApiBaseUrl(); + +export interface StockAnalysis { + symbol: string; + company_name: string; + decision: 'BUY' | 'SELL' | 'HOLD' | null; + confidence?: 'HIGH' | 'MEDIUM' | 'LOW'; + risk?: 'HIGH' | 'MEDIUM' | 'LOW'; + raw_analysis?: string; +} + +export interface TopPick { + rank: number; + symbol: string; + company_name: string; + decision: string; + reason: string; + risk_level: string; +} + +export interface StockToAvoid { + symbol: string; + company_name: string; + reason: string; +} + +export interface Summary { + total: number; + buy: number; + sell: number; + hold: number; +} + +export interface DailyRecommendation { + date: string; + analysis: Record; + summary: Summary; + top_picks: TopPick[]; + stocks_to_avoid: StockToAvoid[]; +} + +export interface StockHistory { + date: string; + decision: string; + confidence?: string; + risk?: string; +} + +/** + * Analysis configuration options + */ +export interface AnalysisConfig { + deep_think_model?: string; + quick_think_model?: string; + provider?: string; + api_key?: string; + max_debate_rounds?: number; +} + +class ApiService { + private baseUrl: string; + + constructor() { + this.baseUrl = API_BASE_URL; + } + + private async fetch(endpoint: string, options?: RequestInit & { noCache?: boolean }): Promise { + let url = `${this.baseUrl}${endpoint}`; + + // Add cache-busting query param if noCache is true + const noCache = options?.noCache; + if (noCache) { + const separator = url.includes('?') ? '&' : '?'; + url = `${url}${separator}_t=${Date.now()}`; + } + + // Remove noCache from options before passing to fetch + const { noCache: _, ...fetchOptions } = options || {}; + + const response = await fetch(url, { + ...fetchOptions, + headers: { + 'Content-Type': 'application/json', + ...fetchOptions?.headers, + }, + cache: noCache ? 'no-store' : undefined, + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + /** + * Get all daily recommendations + */ + async getAllRecommendations(): Promise<{ recommendations: DailyRecommendation[]; count: number }> { + return this.fetch('/recommendations'); + } + + /** + * Get the latest recommendation + */ + async getLatestRecommendation(): Promise { + return this.fetch('/recommendations/latest'); + } + + /** + * Get recommendation for a specific date + */ + async getRecommendationByDate(date: string): Promise { + return this.fetch(`/recommendations/${date}`); + } + + /** + * Get historical recommendations for a stock + */ + async getStockHistory(symbol: string): Promise<{ symbol: string; history: StockHistory[]; count: number }> { + return this.fetch(`/stocks/${symbol}/history`); + } + + /** + * Get all available dates + */ + async getAvailableDates(): Promise<{ dates: string[]; count: number }> { + return this.fetch('/dates'); + } + + /** + * Health check + */ + async healthCheck(): Promise<{ status: string; database: string }> { + return this.fetch('/health'); + } + + /** + * Save a new recommendation (used by the analyzer) + */ + async saveRecommendation(recommendation: { + date: string; + analysis: Record; + summary: Summary; + top_picks: TopPick[]; + stocks_to_avoid: StockToAvoid[]; + }): Promise<{ message: string }> { + return this.fetch('/recommendations', { + method: 'POST', + body: JSON.stringify(recommendation), + }); + } + + // ============== Pipeline Data Methods ============== + + /** + * Get full pipeline data for a stock on a specific date + */ + async getPipelineData(date: string, symbol: string, refresh = false): Promise { + return this.fetch(`/recommendations/${date}/${symbol}/pipeline`, { noCache: refresh }); + } + + /** + * Get agent reports for a stock on a specific date + */ + async getAgentReports(date: string, symbol: string): Promise<{ + date: string; + symbol: string; + reports: AgentReportsMap; + count: number; + }> { + return this.fetch(`/recommendations/${date}/${symbol}/agents`); + } + + /** + * Get debate history for a stock on a specific date + */ + async getDebateHistory(date: string, symbol: string): Promise<{ + date: string; + symbol: string; + debates: DebatesMap; + }> { + return this.fetch(`/recommendations/${date}/${symbol}/debates`); + } + + /** + * Get data source logs for a stock on a specific date + */ + async getDataSources(date: string, symbol: string): Promise<{ + date: string; + symbol: string; + data_sources: DataSourceLog[]; + count: number; + }> { + return this.fetch(`/recommendations/${date}/${symbol}/data-sources`); + } + + /** + * Get pipeline summary for all stocks on a specific date + */ + async getPipelineSummary(date: string): Promise<{ + date: string; + stocks: PipelineSummary[]; + count: number; + }> { + return this.fetch(`/recommendations/${date}/pipeline-summary`); + } + + /** + * Save pipeline data for a stock (used by the analyzer) + */ + async savePipelineData(data: { + date: string; + symbol: string; + agent_reports?: Record; + investment_debate?: Record; + risk_debate?: Record; + pipeline_steps?: unknown[]; + data_sources?: unknown[]; + }): Promise<{ message: string }> { + return this.fetch('/pipeline', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + // ============== Analysis Trigger Methods ============== + + /** + * Start analysis for a stock + */ + async runAnalysis(symbol: string, date?: string, config?: AnalysisConfig): Promise<{ + message: string; + symbol: string; + date: string; + status: string; + }> { + const url = date ? `/analyze/${symbol}?date=${date}` : `/analyze/${symbol}`; + return this.fetch(url, { + method: 'POST', + body: JSON.stringify(config || {}), + noCache: true, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache' + } + }); + } + + /** + * Get analysis status for a stock + */ + async getAnalysisStatus(symbol: string): Promise<{ + symbol: string; + status: string; + progress?: string; + error?: string; + decision?: string; + started_at?: string; + completed_at?: string; + }> { + return this.fetch(`/analyze/${symbol}/status`, { noCache: true }); + } + + /** + * Get all running analyses + */ + async getRunningAnalyses(): Promise<{ + running: Record; + count: number; + }> { + return this.fetch('/analyze/running', { noCache: true }); + } + + /** + * Start bulk analysis for all Nifty 50 stocks + */ + async runBulkAnalysis(date?: string, config?: { + deep_think_model?: string; + quick_think_model?: string; + provider?: string; + api_key?: string; + max_debate_rounds?: number; + }): Promise<{ + message: string; + date: string; + total_stocks: number; + status: string; + }> { + const url = date ? `/analyze/all?date=${date}` : '/analyze/all'; + return this.fetch(url, { + method: 'POST', + body: JSON.stringify(config || {}), + noCache: true + }); + } + + /** + * Get bulk analysis status + */ + async getBulkAnalysisStatus(): Promise<{ + status: string; + total: number; + completed: number; + failed: number; + current_symbol: string | null; + started_at: string | null; + completed_at: string | null; + results: Record; + }> { + return this.fetch('/analyze/all/status', { noCache: true }); + } +} + +export const api = new ApiService(); + +// Export a hook-friendly version for React Query or SWR +export default api; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 00000000..20ad7dd4 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,222 @@ +export type Decision = 'BUY' | 'SELL' | 'HOLD'; +export type Confidence = 'HIGH' | 'MEDIUM' | 'LOW'; +export type Risk = 'HIGH' | 'MEDIUM' | 'LOW'; + +// Backtest Types +export interface PricePoint { + date: string; + price: number; +} + +export interface BacktestResult { + prediction_correct: boolean; + actual_return_1d: number; // next trading day percentage return + actual_return_1w: number; // percentage + actual_return_1m: number; // percentage + price_at_prediction: number; + current_price: number; + price_history: PricePoint[]; +} + +export interface AccuracyMetrics { + total_predictions: number; + correct_predictions: number; + success_rate: number; + buy_accuracy: number; + sell_accuracy: number; + hold_accuracy: number; +} + +// Date-level statistics for history page +export interface DateStats { + date: string; + avgReturn1d: number; // Average next-day return for all stocks + avgReturn1m: number; // Average 1-month return + totalStocks: number; + correctPredictions: number; + accuracy: number; + buyCount: number; + sellCount: number; + holdCount: number; +} + +export interface OverallStats { + totalDays: number; + totalPredictions: number; + avgDailyReturn: number; + avgMonthlyReturn: number; + overallAccuracy: number; + bestDay: { date: string; return: number } | null; + worstDay: { date: string; return: number } | null; +} + +export interface StockAnalysis { + symbol: string; + company_name: string; + decision: Decision | null; + confidence?: Confidence; + risk?: Risk; + raw_analysis?: string; + error?: string | null; +} + +export interface RankingResult { + ranking: string; + stocks_analyzed: number; + timestamp: string; + error?: string; +} + +export interface TopPick { + rank: number; + symbol: string; + company_name: string; + decision: string; + reason: string; + risk_level: Risk; +} + +export interface StockToAvoid { + symbol: string; + company_name: string; + reason: string; +} + +export interface DailyRecommendation { + date: string; + analysis: Record; + ranking: RankingResult; + summary: { + total: number; + buy: number; + sell: number; + hold: number; + }; + top_picks: TopPick[]; + stocks_to_avoid: StockToAvoid[]; +} + +export interface HistoricalEntry { + date: string; + symbol: string; + company_name: string; + decision: Decision; + confidence?: Confidence; + risk?: Risk; +} + +export interface StockHistory { + symbol: string; + company_name: string; + history: HistoricalEntry[]; + stats: { + total_recommendations: number; + buy_count: number; + sell_count: number; + hold_count: number; + accuracy?: number; + }; +} + +export interface NiftyStock { + symbol: string; + company_name: string; + sector?: string; +} + +// Nifty50 Index data point +export interface Nifty50IndexPoint { + date: string; + value: number; + return: number; // daily return % +} + +// Risk metrics for portfolio analysis +export interface RiskMetrics { + sharpeRatio: number; // (mean return - risk-free) / std dev + maxDrawdown: number; // peak-to-trough decline % + winLossRatio: number; // avg win / avg loss + winRate: number; // % of winning predictions + volatility: number; // std dev of returns + totalTrades: number; +} + +// Return distribution bucket +export interface ReturnBucket { + range: string; // e.g., "0% to 1%" + min: number; + max: number; + count: number; + stocks: string[]; // symbols in this bucket +} + +// Filter state for History page +export interface FilterState { + decision: 'ALL' | 'BUY' | 'SELL' | 'HOLD'; + confidence: 'ALL' | 'HIGH' | 'MEDIUM' | 'LOW'; + sector: string; + sortBy: 'symbol' | 'return' | 'accuracy'; + sortOrder: 'asc' | 'desc'; +} + +// Accuracy trend data point +export interface AccuracyTrendPoint { + date: string; + overall: number; + buy: number; + sell: number; + hold: number; +} + +export const NIFTY_50_STOCKS: NiftyStock[] = [ + { symbol: 'RELIANCE', company_name: 'Reliance Industries Ltd', sector: 'Energy' }, + { symbol: 'TCS', company_name: 'Tata Consultancy Services Ltd', sector: 'IT' }, + { symbol: 'HDFCBANK', company_name: 'HDFC Bank Ltd', sector: 'Banking' }, + { symbol: 'INFY', company_name: 'Infosys Ltd', sector: 'IT' }, + { symbol: 'ICICIBANK', company_name: 'ICICI Bank Ltd', sector: 'Banking' }, + { symbol: 'HINDUNILVR', company_name: 'Hindustan Unilever Ltd', sector: 'FMCG' }, + { symbol: 'ITC', company_name: 'ITC Ltd', sector: 'FMCG' }, + { symbol: 'SBIN', company_name: 'State Bank of India', sector: 'Banking' }, + { symbol: 'BHARTIARTL', company_name: 'Bharti Airtel Ltd', sector: 'Telecom' }, + { symbol: 'KOTAKBANK', company_name: 'Kotak Mahindra Bank Ltd', sector: 'Banking' }, + { symbol: 'LT', company_name: 'Larsen & Toubro Ltd', sector: 'Infrastructure' }, + { symbol: 'AXISBANK', company_name: 'Axis Bank Ltd', sector: 'Banking' }, + { symbol: 'ASIANPAINT', company_name: 'Asian Paints Ltd', sector: 'Consumer' }, + { symbol: 'MARUTI', company_name: 'Maruti Suzuki India Ltd', sector: 'Auto' }, + { symbol: 'HCLTECH', company_name: 'HCL Technologies Ltd', sector: 'IT' }, + { symbol: 'SUNPHARMA', company_name: 'Sun Pharmaceutical Industries Ltd', sector: 'Pharma' }, + { symbol: 'TITAN', company_name: 'Titan Company Ltd', sector: 'Consumer' }, + { symbol: 'BAJFINANCE', company_name: 'Bajaj Finance Ltd', sector: 'Finance' }, + { symbol: 'WIPRO', company_name: 'Wipro Ltd', sector: 'IT' }, + { symbol: 'ULTRACEMCO', company_name: 'UltraTech Cement Ltd', sector: 'Cement' }, + { symbol: 'NESTLEIND', company_name: 'Nestle India Ltd', sector: 'FMCG' }, + { symbol: 'NTPC', company_name: 'NTPC Ltd', sector: 'Power' }, + { symbol: 'POWERGRID', company_name: 'Power Grid Corporation of India Ltd', sector: 'Power' }, + { symbol: 'M&M', company_name: 'Mahindra & Mahindra Ltd', sector: 'Auto' }, + { symbol: 'TATAMOTORS', company_name: 'Tata Motors Ltd', sector: 'Auto' }, + { symbol: 'ONGC', company_name: 'Oil & Natural Gas Corporation Ltd', sector: 'Energy' }, + { symbol: 'JSWSTEEL', company_name: 'JSW Steel Ltd', sector: 'Metals' }, + { symbol: 'TATASTEEL', company_name: 'Tata Steel Ltd', sector: 'Metals' }, + { symbol: 'ADANIENT', company_name: 'Adani Enterprises Ltd', sector: 'Conglomerate' }, + { symbol: 'ADANIPORTS', company_name: 'Adani Ports and SEZ Ltd', sector: 'Infrastructure' }, + { symbol: 'COALINDIA', company_name: 'Coal India Ltd', sector: 'Mining' }, + { symbol: 'BAJAJFINSV', company_name: 'Bajaj Finserv Ltd', sector: 'Finance' }, + { symbol: 'TECHM', company_name: 'Tech Mahindra Ltd', sector: 'IT' }, + { symbol: 'HDFCLIFE', company_name: 'HDFC Life Insurance Company Ltd', sector: 'Insurance' }, + { symbol: 'SBILIFE', company_name: 'SBI Life Insurance Company Ltd', sector: 'Insurance' }, + { symbol: 'GRASIM', company_name: 'Grasim Industries Ltd', sector: 'Cement' }, + { symbol: 'DIVISLAB', company_name: "Divi's Laboratories Ltd", sector: 'Pharma' }, + { symbol: 'DRREDDY', company_name: "Dr. Reddy's Laboratories Ltd", sector: 'Pharma' }, + { symbol: 'CIPLA', company_name: 'Cipla Ltd', sector: 'Pharma' }, + { symbol: 'BRITANNIA', company_name: 'Britannia Industries Ltd', sector: 'FMCG' }, + { symbol: 'EICHERMOT', company_name: 'Eicher Motors Ltd', sector: 'Auto' }, + { symbol: 'APOLLOHOSP', company_name: 'Apollo Hospitals Enterprise Ltd', sector: 'Healthcare' }, + { symbol: 'INDUSINDBK', company_name: 'IndusInd Bank Ltd', sector: 'Banking' }, + { symbol: 'HEROMOTOCO', company_name: 'Hero MotoCorp Ltd', sector: 'Auto' }, + { symbol: 'TATACONSUM', company_name: 'Tata Consumer Products Ltd', sector: 'FMCG' }, + { symbol: 'BPCL', company_name: 'Bharat Petroleum Corporation Ltd', sector: 'Energy' }, + { symbol: 'UPL', company_name: 'UPL Ltd', sector: 'Chemicals' }, + { symbol: 'HINDALCO', company_name: 'Hindalco Industries Ltd', sector: 'Metals' }, + { symbol: 'BAJAJ-AUTO', company_name: 'Bajaj Auto Ltd', sector: 'Auto' }, + { symbol: 'LTIM', company_name: 'LTIMindtree Ltd', sector: 'IT' }, +]; diff --git a/frontend/src/types/pipeline.ts b/frontend/src/types/pipeline.ts new file mode 100644 index 00000000..d59727b6 --- /dev/null +++ b/frontend/src/types/pipeline.ts @@ -0,0 +1,199 @@ +/** + * TypeScript types for the analysis pipeline visualization + */ + +// Agent types that perform analysis +export type AgentType = 'market' | 'news' | 'social_media' | 'fundamentals'; + +// Debate types in the system +export type DebateType = 'investment' | 'risk'; + +// Pipeline step status +export type PipelineStepStatus = 'pending' | 'running' | 'completed' | 'error'; + +/** + * Individual agent's analysis report + */ +export interface AgentReport { + agent_type: AgentType; + report_content: string; + data_sources_used: string[]; + created_at?: string; +} + +/** + * Map of agent reports by type + */ +export interface AgentReportsMap { + market?: AgentReport; + news?: AgentReport; + social_media?: AgentReport; + fundamentals?: AgentReport; +} + +/** + * Debate history for investment or risk debates + */ +export interface DebateHistory { + debate_type: DebateType; + // Investment debate fields + bull_arguments?: string; + bear_arguments?: string; + // Risk debate fields + risky_arguments?: string; + safe_arguments?: string; + neutral_arguments?: string; + // Common fields + judge_decision?: string; + full_history?: string; + created_at?: string; +} + +/** + * Map of debates by type + */ +export interface DebatesMap { + investment?: DebateHistory; + risk?: DebateHistory; +} + +/** + * Single step in the analysis pipeline + */ +export interface PipelineStep { + step_number: number; + step_name: string; + status: PipelineStepStatus; + started_at?: string; + completed_at?: string; + duration_ms?: number; + output_summary?: string; +} + +/** + * Log entry for a data source fetch + */ +export interface DataSourceLog { + source_type: string; + source_name: string; + data_fetched?: Record | string; + fetch_timestamp?: string; + success: boolean; + error_message?: string; +} + +/** + * Complete pipeline data for a single stock analysis + */ +export interface FullPipelineData { + date: string; + symbol: string; + agent_reports: AgentReportsMap; + debates: DebatesMap; + pipeline_steps: PipelineStep[]; + data_sources: DataSourceLog[]; + status?: 'complete' | 'in_progress' | 'no_data'; +} + +/** + * Summary of pipeline for a single stock (used in list views) + */ +export interface PipelineSummary { + symbol: string; + pipeline_steps: { step_name: string; status: PipelineStepStatus }[]; + agent_reports_count: number; + has_debates: boolean; +} + +/** + * API response types + */ +export interface PipelineDataResponse extends FullPipelineData {} + +export interface AgentReportsResponse { + date: string; + symbol: string; + reports: AgentReportsMap; + count: number; +} + +export interface DebateHistoryResponse { + date: string; + symbol: string; + debates: DebatesMap; +} + +export interface DataSourcesResponse { + date: string; + symbol: string; + data_sources: DataSourceLog[]; + count: number; +} + +export interface PipelineSummaryResponse { + date: string; + stocks: PipelineSummary[]; + count: number; +} + +/** + * Pipeline step definitions (for UI rendering) + */ +export const PIPELINE_STEPS = [ + { number: 1, name: 'data_collection', label: 'Data Collection', icon: 'Database' }, + { number: 2, name: 'market_analysis', label: 'Market Analysis', icon: 'TrendingUp' }, + { number: 3, name: 'news_analysis', label: 'News Analysis', icon: 'Newspaper' }, + { number: 4, name: 'social_analysis', label: 'Social Analysis', icon: 'Users' }, + { number: 5, name: 'fundamentals_analysis', label: 'Fundamentals', icon: 'FileText' }, + { number: 6, name: 'investment_debate', label: 'Investment Debate', icon: 'MessageSquare' }, + { number: 7, name: 'trader_decision', label: 'Trader Decision', icon: 'Target' }, + { number: 8, name: 'risk_debate', label: 'Risk Assessment', icon: 'Shield' }, + { number: 9, name: 'final_decision', label: 'Final Decision', icon: 'CheckCircle' }, +] as const; + +/** + * Agent metadata for UI rendering + */ +export const AGENT_METADATA: Record = { + market: { + label: 'Market Analyst', + icon: 'TrendingUp', + color: 'blue', + description: 'Analyzes technical indicators, price trends, and market patterns' + }, + news: { + label: 'News Analyst', + icon: 'Newspaper', + color: 'purple', + description: 'Analyzes company news, macroeconomic trends, and market events' + }, + social_media: { + label: 'Social Media Analyst', + icon: 'Users', + color: 'pink', + description: 'Analyzes social sentiment, Reddit discussions, and public perception' + }, + fundamentals: { + label: 'Fundamentals Analyst', + icon: 'FileText', + color: 'green', + description: 'Analyzes financial statements, ratios, and company health' + } +}; + +/** + * Debate role metadata for UI rendering + */ +export const DEBATE_ROLES = { + investment: { + bull: { label: 'Bull Analyst', color: 'green', icon: 'TrendingUp' }, + bear: { label: 'Bear Analyst', color: 'red', icon: 'TrendingDown' }, + judge: { label: 'Research Manager', color: 'blue', icon: 'Scale' } + }, + risk: { + risky: { label: 'Aggressive Analyst', color: 'red', icon: 'Zap' }, + safe: { label: 'Conservative Analyst', color: 'green', icon: 'Shield' }, + neutral: { label: 'Neutral Analyst', color: 'gray', icon: 'Scale' }, + judge: { label: 'Risk Manager', color: 'blue', icon: 'ShieldCheck' } + } +} as const; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 00000000..c1e41357 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,45 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + 'nifty': { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + }, + 'bull': { + light: '#dcfce7', + DEFAULT: '#22c55e', + dark: '#15803d', + }, + 'bear': { + light: '#fee2e2', + DEFAULT: '#ef4444', + dark: '#b91c1c', + }, + 'hold': { + light: '#fef3c7', + DEFAULT: '#f59e0b', + dark: '#b45309', + } + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + display: ['Lexend', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [], +} diff --git a/frontend/test-ui.mjs b/frontend/test-ui.mjs new file mode 100644 index 00000000..78fc0edb --- /dev/null +++ b/frontend/test-ui.mjs @@ -0,0 +1,233 @@ +import puppeteer from 'puppeteer'; +import { writeFile } from 'fs/promises'; +import { join } from 'path'; + +const SESSION_ID = 'session-20260131-152418'; +const SCREENSHOT_DIR = `/home/hemang/Documents/GitHub/TradingAgents/.frontend-dev/screenshots/${SESSION_ID}`; +const BASE_URL = 'http://localhost:5173'; + +const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +// Session state +const sessionState = { + id: SESSION_ID, + startTime: new Date().toISOString(), + steps: [], + currentStep: 0, + issues: [], + consoleErrors: [], +}; + +let browser; +let page; + +// Helper to take screenshot +async function takeScreenshot(name, description) { + sessionState.currentStep++; + const stepNum = String(sessionState.currentStep).padStart(3, '0'); + const screenshotPath = join(SCREENSHOT_DIR, `step-${stepNum}-${name}.png`); + + await page.screenshot({ + path: screenshotPath, + fullPage: false + }); + + sessionState.steps.push({ + stepNumber: sessionState.currentStep, + name, + description, + screenshotPath, + timestamp: new Date().toISOString() + }); + + console.log(`Step ${stepNum}: ${description}`); + return screenshotPath; +} + +async function runTests() { + try { + // Launch browser + console.log('Launching browser...'); + browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + page = await browser.newPage(); + + // Listen to console errors + page.on('console', msg => { + if (msg.type() === 'error') { + sessionState.consoleErrors.push({ + text: msg.text(), + timestamp: new Date().toISOString() + }); + } + }); + + // Set viewport to desktop + await page.setViewport({ width: 1920, height: 1080 }); + + console.log('Starting iterative testing...\n'); + + // ===== SCENARIO 1: Dashboard Testing ===== + console.log('=== SCENARIO 1: Dashboard Testing ==='); + + // Step 1: Navigate to Dashboard + await page.goto(BASE_URL, { waitUntil: 'networkidle0' }); + await takeScreenshot('dashboard-initial', 'Initial dashboard load'); + + // Step 2: Test Buy filter + await page.evaluate(() => { + const buttons = Array.from(document.querySelectorAll('button')); + const buyButton = buttons.find(btn => btn.textContent.includes('Buy (')); + if (buyButton) buyButton.click(); + }); + await wait(500); + await takeScreenshot('dashboard-buy-filter', 'After clicking Buy filter'); + + // Step 3: Test Hold filter + await page.evaluate(() => { + const buttons = Array.from(document.querySelectorAll('button')); + const holdButton = buttons.find(btn => btn.textContent.includes('Hold (')); + if (holdButton) holdButton.click(); + }); + await wait(500); + await takeScreenshot('dashboard-hold-filter', 'After clicking Hold filter'); + + // Step 4: Test Sell filter + await page.evaluate(() => { + const buttons = Array.from(document.querySelectorAll('button')); + const sellButton = buttons.find(btn => btn.textContent.includes('Sell (')); + if (sellButton) sellButton.click(); + }); + await wait(500); + await takeScreenshot('dashboard-sell-filter', 'After clicking Sell filter'); + + // Step 5: Back to All filter + await page.evaluate(() => { + const buttons = Array.from(document.querySelectorAll('button')); + const allButton = buttons.find(btn => btn.textContent.includes('All (')); + if (allButton) allButton.click(); + }); + await wait(500); + await takeScreenshot('dashboard-all-filter', 'Back to All filter'); + + // ===== SCENARIO 2: Stock Detail ===== + console.log('\n=== SCENARIO 2: Stock Detail ==='); + + // Click on first stock + const firstStock = await page.$('a[href*="/stock/"]'); + if (firstStock) { + await firstStock.click(); + await wait(1000); + await takeScreenshot('stock-detail', 'Stock detail page loaded'); + } + + // ===== SCENARIO 3: History Page ===== + console.log('\n=== SCENARIO 3: History Page ==='); + + await page.goto(`${BASE_URL}/history`, { waitUntil: 'networkidle0' }); + await takeScreenshot('history-initial', 'History page loaded'); + + // Select a date + const dateButton = await page.$('button[class*="px-3 py-1.5"]'); + if (dateButton) { + await dateButton.click(); + await wait(500); + await takeScreenshot('history-date-selected', 'After selecting a date'); + } + + // ===== SCENARIO 4: All Stocks Page ===== + console.log('\n=== SCENARIO 4: All Stocks Page ==='); + + await page.goto(`${BASE_URL}/stocks`, { waitUntil: 'networkidle0' }); + await takeScreenshot('stocks-initial', 'All stocks page loaded'); + + // Test search + await page.type('input[type="text"]', 'RELIANCE'); + await wait(500); + await takeScreenshot('stocks-search', 'After searching for RELIANCE'); + + // Clear and test another search + await page.evaluate(() => { + const input = document.querySelector('input[type="text"]'); + if (input) input.value = ''; + }); + await page.type('input[type="text"]', 'HDFC'); + await wait(500); + await takeScreenshot('stocks-search-hdfc', 'After searching for HDFC'); + + // ===== SCENARIO 5: Mobile Testing ===== + console.log('\n=== SCENARIO 5: Mobile Testing ==='); + + await page.setViewport({ width: 375, height: 667 }); + await page.goto(BASE_URL, { waitUntil: 'networkidle0' }); + await takeScreenshot('mobile-dashboard', 'Mobile dashboard view'); + + // Test mobile menu + const menuButton = await page.$('button[class*="md:hidden"]'); + if (menuButton) { + await menuButton.click(); + await wait(500); + await takeScreenshot('mobile-menu-open', 'Mobile hamburger menu opened'); + + // Close menu + await menuButton.click(); + await wait(500); + await takeScreenshot('mobile-menu-closed', 'Mobile hamburger menu closed'); + } + + // Mobile history page + await page.goto(`${BASE_URL}/history`, { waitUntil: 'networkidle0' }); + await takeScreenshot('mobile-history', 'Mobile history page'); + + // Mobile stocks page + await page.goto(`${BASE_URL}/stocks`, { waitUntil: 'networkidle0' }); + await takeScreenshot('mobile-stocks', 'Mobile stocks page'); + + // ===== HOVER STATES TESTING ===== + console.log('\n=== Testing Hover States (Desktop) ==='); + + await page.setViewport({ width: 1920, height: 1080 }); + await page.goto(BASE_URL, { waitUntil: 'networkidle0' }); + + // Hover over stock card + const stockCard = await page.$('a[href*="/stock/"]'); + if (stockCard) { + await stockCard.hover(); + await wait(300); + await takeScreenshot('hover-stock-card', 'Hovering over stock card'); + } + + // Hover over navigation + const navLink = await page.$('a[href="/history"]'); + if (navLink) { + await navLink.hover(); + await wait(300); + await takeScreenshot('hover-nav-link', 'Hovering over navigation link'); + } + + // ===== FINAL STATE ===== + sessionState.endTime = new Date().toISOString(); + sessionState.testingComplete = true; + + // Save session state + const sessionPath = `/home/hemang/Documents/GitHub/TradingAgents/.frontend-dev/sessions/${SESSION_ID}.json`; + await writeFile(sessionPath, JSON.stringify(sessionState, null, 2)); + + console.log(`\n=== Testing Complete ===`); + console.log(`Steps completed: ${sessionState.currentStep}`); + console.log(`Console errors: ${sessionState.consoleErrors.length}`); + console.log(`Issues found: ${sessionState.issues.length}`); + console.log(`Session saved to: ${sessionPath}`); + + } catch (error) { + console.error('Test error:', error); + } finally { + if (browser) { + await browser.close(); + } + } +} + +runTests(); diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 00000000..a9b5a59c --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 00000000..8a67f62f --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..8c9413b9 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + host: '0.0.0.0', + port: 5173, + cors: true, + hmr: { + host: '192.168.3.200', + }, + }, +}) diff --git a/nifty50_recommend.py b/nifty50_recommend.py new file mode 100644 index 00000000..d466bddb --- /dev/null +++ b/nifty50_recommend.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Nifty 50 Stock Recommendation CLI. + +This script runs the Nifty 50 recommendation system to predict all Nifty 50 stocks +and select the ones with highest short-term growth potential using Claude Opus 4.5. + +Usage: + # Full run (all 50 stocks) + python nifty50_recommend.py --date 2025-01-30 + + # Test with specific stocks + python nifty50_recommend.py --stocks TCS,INFY,RELIANCE --date 2025-01-30 + + # Quiet mode (less output) + python nifty50_recommend.py --date 2025-01-30 --quiet +""" + +import argparse +import sys +from datetime import datetime + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Nifty 50 Stock Recommendation System using Claude Opus 4.5", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Analyze all 50 Nifty stocks + python nifty50_recommend.py --date 2025-01-30 + + # Analyze specific stocks only + python nifty50_recommend.py --stocks TCS,INFY,RELIANCE --date 2025-01-30 + + # Save results to custom directory + python nifty50_recommend.py --date 2025-01-30 --output ./my_results + + # Quick test with 3 stocks + python nifty50_recommend.py --stocks TCS,INFY,RELIANCE --date 2025-01-30 + """ + ) + + parser.add_argument( + "--date", + "-d", + type=str, + default=datetime.now().strftime("%Y-%m-%d"), + help="Analysis date in YYYY-MM-DD format (default: today)" + ) + + parser.add_argument( + "--stocks", + "-s", + type=str, + default=None, + help="Comma-separated list of stock symbols to analyze (default: all 50)" + ) + + parser.add_argument( + "--output", + "-o", + type=str, + default=None, + help="Directory to save results (default: ./results/nifty50_recommendations)" + ) + + parser.add_argument( + "--quiet", + "-q", + action="store_true", + help="Suppress progress output" + ) + + parser.add_argument( + "--no-save", + action="store_true", + help="Don't save results to disk" + ) + + parser.add_argument( + "--test-credentials", + action="store_true", + help="Only test Claude credentials and exit" + ) + + return parser.parse_args() + + +def test_credentials(): + """Test if Claude credentials are valid.""" + from tradingagents.nifty50_recommender import get_claude_credentials + + try: + token = get_claude_credentials() + print(f"✓ Claude credentials found") + print(f" Token prefix: {token[:20]}...") + return True + except FileNotFoundError as e: + print(f"✗ Credentials file not found: {e}") + return False + except KeyError as e: + print(f"✗ Invalid credentials format: {e}") + return False + except Exception as e: + print(f"✗ Error reading credentials: {e}") + return False + + +def main(): + """Main entry point.""" + args = parse_args() + + # Test credentials mode + if args.test_credentials: + success = test_credentials() + sys.exit(0 if success else 1) + + # Validate date format + try: + datetime.strptime(args.date, "%Y-%m-%d") + except ValueError: + print(f"Error: Invalid date format '{args.date}'. Use YYYY-MM-DD") + sys.exit(1) + + # Parse stock subset + stock_subset = None + if args.stocks: + stock_subset = [s.strip().upper() for s in args.stocks.split(",")] + print(f"Analyzing subset: {', '.join(stock_subset)}") + + # Import the simplified recommender (works with Claude Max subscription) + try: + from tradingagents.nifty50_simple_recommender import run_recommendation + except ImportError as e: + print(f"Error importing recommender module: {e}") + print("Make sure you're running from the TradingAgents directory") + sys.exit(1) + + # Run the recommendation + try: + predictions, ranking = run_recommendation( + trade_date=args.date, + stock_subset=stock_subset, + save_results=not args.no_save, + results_dir=args.output, + verbose=not args.quiet + ) + + # Print summary + if not args.quiet: + print("\n" + "="*60) + print("SUMMARY") + print("="*60) + + successful = sum(1 for p in predictions.values() if not p.get("error")) + print(f"Stocks analyzed: {successful}/{len(predictions)}") + + # Count decisions + decisions = {} + for p in predictions.values(): + if not p.get("error"): + decision = str(p.get("decision", "UNKNOWN")) + decisions[decision] = decisions.get(decision, 0) + 1 + + print("\nDecision breakdown:") + for decision, count in sorted(decisions.items()): + print(f" {decision}: {count}") + + except FileNotFoundError as e: + print(f"\nError: {e}") + print("\nTo authenticate with Claude, run:") + print(" claude auth login") + sys.exit(1) + except Exception as e: + print(f"\nError running recommendation: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/package.json b/package.json new file mode 100644 index 00000000..dc0ba8ba --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "tradingagents", + "version": "1.0.0", + "description": "

", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/TauricResearch/TradingAgents.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/TauricResearch/TradingAgents/issues" + }, + "homepage": "https://github.com/TauricResearch/TradingAgents#readme", + "dependencies": { + "playwright": "^1.58.1" + } +} diff --git a/requirements.txt b/requirements.txt index a6154cd2..b29d878a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,3 +24,5 @@ rich questionary langchain_anthropic langchain-google-genai +jugaad-data +sentence-transformers diff --git a/tradingagents/agents/utils/memory.py b/tradingagents/agents/utils/memory.py index 69b8ab8c..a1a7ba71 100644 --- a/tradingagents/agents/utils/memory.py +++ b/tradingagents/agents/utils/memory.py @@ -1,25 +1,32 @@ import chromadb from chromadb.config import Settings -from openai import OpenAI +from chromadb.utils import embedding_functions class FinancialSituationMemory: + """Memory system for storing and retrieving financial situations using local embeddings.""" + def __init__(self, name, config): - if config["backend_url"] == "http://localhost:11434/v1": - self.embedding = "nomic-embed-text" - else: - self.embedding = "text-embedding-3-small" - self.client = OpenAI(base_url=config["backend_url"]) + """ + Initialize the memory system with local embeddings. + + Args: + name: Name for the ChromaDB collection + config: Configuration dictionary (kept for compatibility) + """ + # Use ChromaDB's default embedding function (uses all-MiniLM-L6-v2 internally) + self.embedding_fn = embedding_functions.DefaultEmbeddingFunction() self.chroma_client = chromadb.Client(Settings(allow_reset=True)) - self.situation_collection = self.chroma_client.create_collection(name=name) + # Use get_or_create to avoid errors when collection already exists + self.situation_collection = self.chroma_client.get_or_create_collection( + name=name, + embedding_function=self.embedding_fn + ) def get_embedding(self, text): - """Get OpenAI embedding for a text""" - - response = self.client.embeddings.create( - model=self.embedding, input=text - ) - return response.data[0].embedding + """Get embedding for a text using the embedding function.""" + embeddings = self.embedding_fn([text]) + return embeddings[0] def add_situations(self, situations_and_advice): """Add financial situations and their corresponding advice. Parameter is a list of tuples (situation, rec)""" @@ -27,7 +34,6 @@ class FinancialSituationMemory: situations = [] advice = [] ids = [] - embeddings = [] offset = self.situation_collection.count() @@ -35,41 +41,39 @@ class FinancialSituationMemory: situations.append(situation) advice.append(recommendation) ids.append(str(offset + i)) - embeddings.append(self.get_embedding(situation)) + # Let ChromaDB handle embeddings automatically self.situation_collection.add( documents=situations, metadatas=[{"recommendation": rec} for rec in advice], - embeddings=embeddings, ids=ids, ) def get_memories(self, current_situation, n_matches=1): - """Find matching recommendations using OpenAI embeddings""" - query_embedding = self.get_embedding(current_situation) - + """Find matching recommendations using embeddings""" results = self.situation_collection.query( - query_embeddings=[query_embedding], + query_texts=[current_situation], n_results=n_matches, include=["metadatas", "documents", "distances"], ) matched_results = [] - for i in range(len(results["documents"][0])): - matched_results.append( - { - "matched_situation": results["documents"][0][i], - "recommendation": results["metadatas"][0][i]["recommendation"], - "similarity_score": 1 - results["distances"][0][i], - } - ) + if results["documents"] and results["documents"][0]: + for i in range(len(results["documents"][0])): + matched_results.append( + { + "matched_situation": results["documents"][0][i], + "recommendation": results["metadatas"][0][i]["recommendation"], + "similarity_score": 1 - results["distances"][0][i], + } + ) return matched_results if __name__ == "__main__": # Example usage - matcher = FinancialSituationMemory() + matcher = FinancialSituationMemory("test_memory", {}) # Example data example_data = [ @@ -96,7 +100,7 @@ if __name__ == "__main__": # Example query current_situation = """ - Market showing increased volatility in tech sector, with institutional investors + Market showing increased volatility in tech sector, with institutional investors reducing positions and rising interest rates affecting growth stock valuations """ diff --git a/tradingagents/agents/utils/news_data_tools.py b/tradingagents/agents/utils/news_data_tools.py index 0df9d047..8e9f2fb2 100644 --- a/tradingagents/agents/utils/news_data_tools.py +++ b/tradingagents/agents/utils/news_data_tools.py @@ -1,6 +1,7 @@ from langchain_core.tools import tool from typing import Annotated from tradingagents.dataflows.interface import route_to_vendor +from tradingagents.dataflows.markets import is_nifty_50_stock @tool def get_news( @@ -52,6 +53,12 @@ def get_insider_sentiment( Returns: str: A report of insider sentiment data """ + # Check if this is an NSE stock - insider sentiment from SEC sources is not available + if is_nifty_50_stock(ticker): + return (f"Note: SEC-style insider sentiment data is not available for Indian NSE stocks like {ticker}. " + f"For Indian stocks, insider trading disclosures are regulated by SEBI (Securities and Exchange Board of India) " + f"and can be found on NSE/BSE websites or through the company's regulatory filings.") + return route_to_vendor("get_insider_sentiment", ticker, curr_date) @tool diff --git a/tradingagents/claude_max_llm.py b/tradingagents/claude_max_llm.py new file mode 100644 index 00000000..2eed4d87 --- /dev/null +++ b/tradingagents/claude_max_llm.py @@ -0,0 +1,257 @@ +""" +Claude Max LLM Wrapper. + +This module provides a LangChain-compatible LLM that uses the Claude CLI +with Max subscription authentication instead of API keys. +""" + +import os +import subprocess +import json +import re +import copy +from typing import Any, Dict, List, Optional, Iterator, Sequence, Union + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import ( + AIMessage, + BaseMessage, + HumanMessage, + SystemMessage, + ToolMessage, +) +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.callbacks import CallbackManagerForLLMRun +from langchain_core.tools import BaseTool +from langchain_core.runnables import Runnable + + +class ClaudeMaxLLM(BaseChatModel): + """ + A LangChain-compatible chat model that uses Claude CLI with Max subscription. + + This bypasses API key requirements by using the Claude CLI which authenticates + via OAuth tokens from your Claude Max subscription. + """ + + model: str = "sonnet" # Use alias for Claude Max subscription + max_tokens: int = 4096 + temperature: float = 0.7 + claude_cli_path: str = "claude" + tools: List[Any] = [] # Bound tools + + class Config: + arbitrary_types_allowed = True + + @property + def _llm_type(self) -> str: + return "claude-max" + + @property + def _identifying_params(self) -> Dict[str, Any]: + return { + "model": self.model, + "max_tokens": self.max_tokens, + "temperature": self.temperature, + } + + def bind_tools( + self, + tools: Sequence[Union[Dict[str, Any], BaseTool, Any]], + **kwargs: Any, + ) -> "ClaudeMaxLLM": + """Bind tools to the model for function calling. + + Args: + tools: A list of tools to bind to the model. + **kwargs: Additional arguments (ignored for compatibility). + + Returns: + A new ClaudeMaxLLM instance with tools bound. + """ + # Create a copy with tools bound + new_instance = ClaudeMaxLLM( + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + claude_cli_path=self.claude_cli_path, + tools=list(tools), + ) + return new_instance + + def _format_tools_for_prompt(self) -> str: + """Format bound tools as a string for the prompt.""" + if not self.tools: + return "" + + tool_descriptions = [] + for tool in self.tools: + if hasattr(tool, 'name') and hasattr(tool, 'description'): + # LangChain BaseTool + name = tool.name + desc = tool.description + args = "" + if hasattr(tool, 'args_schema') and tool.args_schema: + schema = tool.args_schema.schema() if hasattr(tool.args_schema, 'schema') else {} + if 'properties' in schema: + args = ", ".join(f"{k}: {v.get('type', 'any')}" for k, v in schema['properties'].items()) + tool_descriptions.append(f"- {name}({args}): {desc}") + elif isinstance(tool, dict): + # Dict format + name = tool.get('name', 'unknown') + desc = tool.get('description', '') + tool_descriptions.append(f"- {name}: {desc}") + else: + # Try to get function info + name = getattr(tool, '__name__', str(tool)) + desc = getattr(tool, '__doc__', '') or '' + tool_descriptions.append(f"- {name}: {desc[:100]}") + + return "\n\nAvailable tools:\n" + "\n".join(tool_descriptions) + "\n\nTo use a tool, respond with: TOOL_CALL: tool_name(arguments)\n" + + def _format_messages_for_prompt(self, messages: List[BaseMessage]) -> str: + """Convert LangChain messages to a single prompt string.""" + formatted_parts = [] + + # Add tools description if tools are bound + tools_prompt = self._format_tools_for_prompt() + if tools_prompt: + formatted_parts.append(tools_prompt) + + for msg in messages: + # Handle dict messages (LangChain sometimes passes these) + if isinstance(msg, dict): + role = msg.get("role", msg.get("type", "human")) + content = msg.get("content", str(msg)) + if role in ("system",): + formatted_parts.append(f"\n{content}\n\n") + elif role in ("human", "user"): + formatted_parts.append(f"Human: {content}\n") + elif role in ("ai", "assistant"): + formatted_parts.append(f"Assistant: {content}\n") + else: + formatted_parts.append(f"{content}\n") + elif isinstance(msg, SystemMessage): + formatted_parts.append(f"\n{msg.content}\n\n") + elif isinstance(msg, HumanMessage): + formatted_parts.append(f"Human: {msg.content}\n") + elif isinstance(msg, AIMessage): + formatted_parts.append(f"Assistant: {msg.content}\n") + elif isinstance(msg, ToolMessage): + formatted_parts.append(f"Tool Result ({msg.name}): {msg.content}\n") + elif hasattr(msg, 'content'): + formatted_parts.append(f"{msg.content}\n") + else: + formatted_parts.append(f"{str(msg)}\n") + + return "\n".join(formatted_parts) + + def _call_claude_cli(self, prompt: str) -> str: + """Call the Claude CLI and return the response.""" + # Create environment without ANTHROPIC_API_KEY to force subscription auth + env = os.environ.copy() + env.pop("ANTHROPIC_API_KEY", None) + + # Build the command - use --prompt flag with stdin for long prompts + cmd = [ + self.claude_cli_path, + "--print", # Non-interactive mode + "--model", self.model, + "-p", prompt # Use -p flag for prompt + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + env=env, + timeout=300, # 5 minute timeout + ) + + if result.returncode != 0: + # Include both stdout and stderr for better debugging + error_info = result.stderr or result.stdout or "No output" + raise RuntimeError(f"Claude CLI error (code {result.returncode}): {error_info}") + + return result.stdout.strip() + + except subprocess.TimeoutExpired: + raise RuntimeError("Claude CLI timed out after 5 minutes") + except FileNotFoundError: + raise RuntimeError( + f"Claude CLI not found at '{self.claude_cli_path}'. " + "Make sure Claude Code is installed and in your PATH." + ) + + def _generate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Optional[CallbackManagerForLLMRun] = None, + **kwargs: Any, + ) -> ChatResult: + """Generate a response from the Claude CLI.""" + prompt = self._format_messages_for_prompt(messages) + response_text = self._call_claude_cli(prompt) + + # Apply stop sequences if provided + if stop: + for stop_seq in stop: + if stop_seq in response_text: + response_text = response_text.split(stop_seq)[0] + + message = AIMessage(content=response_text) + generation = ChatGeneration(message=message) + + return ChatResult(generations=[generation]) + + def invoke( + self, + input: Any, + config: Optional[Dict[str, Any]] = None, + *, + stop: Optional[List[str]] = None, + **kwargs: Any + ) -> AIMessage: + """Invoke the model with the given input.""" + if isinstance(input, str): + messages = [HumanMessage(content=input)] + elif isinstance(input, list): + messages = input + else: + messages = [HumanMessage(content=str(input))] + + result = self._generate(messages, stop=stop, **kwargs) + return result.generations[0].message + + +def get_claude_max_llm(model: str = "sonnet", **kwargs) -> ClaudeMaxLLM: + """ + Factory function to create a ClaudeMaxLLM instance. + + Args: + model: The Claude model to use (default: claude-sonnet-4-5-20250514) + **kwargs: Additional arguments passed to ClaudeMaxLLM + + Returns: + A configured ClaudeMaxLLM instance + """ + return ClaudeMaxLLM(model=model, **kwargs) + + +def test_claude_max(): + """Test the Claude Max LLM wrapper.""" + print("Testing Claude Max LLM wrapper...") + + llm = ClaudeMaxLLM(model="sonnet") + + # Test with a simple prompt + response = llm.invoke("Say 'Hello, I am using Claude Max subscription!' in exactly those words.") + print(f"Response: {response.content}") + + return response + + +if __name__ == "__main__": + test_claude_max() diff --git a/tradingagents/dataflows/alpha_vantage_fundamentals.py b/tradingagents/dataflows/alpha_vantage_fundamentals.py index 8b92faa6..f8148df3 100644 --- a/tradingagents/dataflows/alpha_vantage_fundamentals.py +++ b/tradingagents/dataflows/alpha_vantage_fundamentals.py @@ -1,13 +1,78 @@ +from datetime import datetime, timedelta from .alpha_vantage_common import _make_api_request +import json + + +def _filter_reports_by_date(data_str: str, curr_date: str, report_keys: list = None) -> str: + """ + Filter Alpha Vantage fundamentals data to only include reports available as of curr_date. + This ensures point-in-time accuracy for backtesting. + + Financial reports are typically published ~45 days after the fiscal date ending. + We filter to only include reports that would have been published by curr_date. + + Args: + data_str: JSON string from Alpha Vantage API + curr_date: The backtest date in yyyy-mm-dd format + report_keys: List of keys containing report arrays (e.g., ['quarterlyReports', 'annualReports']) + + Returns: + Filtered JSON string with only point-in-time available reports + """ + if curr_date is None: + return data_str + + if report_keys is None: + report_keys = ['quarterlyReports', 'annualReports'] + + try: + data = json.loads(data_str) + curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") + # Financial reports typically published ~45 days after fiscal date ending + publication_delay_days = 45 + + for key in report_keys: + if key in data and isinstance(data[key], list): + filtered_reports = [] + for report in data[key]: + fiscal_date = report.get('fiscalDateEnding') + if fiscal_date: + try: + fiscal_date_dt = datetime.strptime(fiscal_date, "%Y-%m-%d") + # Estimate when this report would have been published + estimated_publish_date = fiscal_date_dt + timedelta(days=publication_delay_days) + if estimated_publish_date <= curr_date_dt: + filtered_reports.append(report) + except ValueError: + # If date parsing fails, keep the report + filtered_reports.append(report) + else: + # If no fiscal date, keep the report + filtered_reports.append(report) + data[key] = filtered_reports + + # Add point-in-time metadata + data['_point_in_time_date'] = curr_date + data['_filtered_for_backtesting'] = True + + return json.dumps(data, indent=2) + + except (json.JSONDecodeError, Exception) as e: + # If parsing fails, return original data with warning + print(f"Warning: Could not filter Alpha Vantage data by date: {e}") + return data_str def get_fundamentals(ticker: str, curr_date: str = None) -> str: """ Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage. + Note: OVERVIEW endpoint returns current snapshot data only. For backtesting, + this may not reflect the exact fundamentals as of the historical date. + Args: ticker (str): Ticker symbol of the company - curr_date (str): Current date you are trading at, yyyy-mm-dd (not used for Alpha Vantage) + curr_date (str): Current date you are trading at, yyyy-mm-dd (used for documentation) Returns: str: Company overview data including financial ratios and key metrics @@ -16,62 +81,91 @@ def get_fundamentals(ticker: str, curr_date: str = None) -> str: "symbol": ticker, } - return _make_api_request("OVERVIEW", params) + result = _make_api_request("OVERVIEW", params) + + # Add warning about point-in-time accuracy for OVERVIEW data + if curr_date and result and not result.startswith("Error"): + try: + data = json.loads(result) + data['_warning'] = ( + "OVERVIEW data is current snapshot only. For accurate backtesting, " + "fundamental ratios may differ from actual values as of " + curr_date + ) + data['_requested_date'] = curr_date + return json.dumps(data, indent=2) + except: + pass + + return result def get_balance_sheet(ticker: str, freq: str = "quarterly", curr_date: str = None) -> str: """ Retrieve balance sheet data for a given ticker symbol using Alpha Vantage. + Filtered by curr_date for point-in-time backtesting accuracy. Args: ticker (str): Ticker symbol of the company - freq (str): Reporting frequency: annual/quarterly (default quarterly) - not used for Alpha Vantage - curr_date (str): Current date you are trading at, yyyy-mm-dd (not used for Alpha Vantage) + freq (str): Reporting frequency: annual/quarterly (default quarterly) + curr_date (str): Current date you are trading at, yyyy-mm-dd (used for point-in-time filtering) Returns: - str: Balance sheet data with normalized fields + str: Balance sheet data with normalized fields, filtered to only include + reports that would have been published by curr_date """ params = { "symbol": ticker, } - return _make_api_request("BALANCE_SHEET", params) + result = _make_api_request("BALANCE_SHEET", params) + + # Filter reports to only include those available as of curr_date + return _filter_reports_by_date(result, curr_date) def get_cashflow(ticker: str, freq: str = "quarterly", curr_date: str = None) -> str: """ Retrieve cash flow statement data for a given ticker symbol using Alpha Vantage. + Filtered by curr_date for point-in-time backtesting accuracy. Args: ticker (str): Ticker symbol of the company - freq (str): Reporting frequency: annual/quarterly (default quarterly) - not used for Alpha Vantage - curr_date (str): Current date you are trading at, yyyy-mm-dd (not used for Alpha Vantage) + freq (str): Reporting frequency: annual/quarterly (default quarterly) + curr_date (str): Current date you are trading at, yyyy-mm-dd (used for point-in-time filtering) Returns: - str: Cash flow statement data with normalized fields + str: Cash flow statement data with normalized fields, filtered to only include + reports that would have been published by curr_date """ params = { "symbol": ticker, } - return _make_api_request("CASH_FLOW", params) + result = _make_api_request("CASH_FLOW", params) + + # Filter reports to only include those available as of curr_date + return _filter_reports_by_date(result, curr_date) def get_income_statement(ticker: str, freq: str = "quarterly", curr_date: str = None) -> str: """ Retrieve income statement data for a given ticker symbol using Alpha Vantage. + Filtered by curr_date for point-in-time backtesting accuracy. Args: ticker (str): Ticker symbol of the company - freq (str): Reporting frequency: annual/quarterly (default quarterly) - not used for Alpha Vantage - curr_date (str): Current date you are trading at, yyyy-mm-dd (not used for Alpha Vantage) + freq (str): Reporting frequency: annual/quarterly (default quarterly) + curr_date (str): Current date you are trading at, yyyy-mm-dd (used for point-in-time filtering) Returns: - str: Income statement data with normalized fields + str: Income statement data with normalized fields, filtered to only include + reports that would have been published by curr_date """ params = { "symbol": ticker, } - return _make_api_request("INCOME_STATEMENT", params) + result = _make_api_request("INCOME_STATEMENT", params) + # Filter reports to only include those available as of curr_date + return _filter_reports_by_date(result, curr_date) diff --git a/tradingagents/dataflows/google.py b/tradingagents/dataflows/google.py index 3fe20f3c..bf424e71 100644 --- a/tradingagents/dataflows/google.py +++ b/tradingagents/dataflows/google.py @@ -1,14 +1,50 @@ -from typing import Annotated +from typing import Annotated, Union from datetime import datetime from dateutil.relativedelta import relativedelta from .googlenews_utils import getNewsData +from .markets import is_nifty_50_stock, get_nifty_50_company_name def get_google_news( query: Annotated[str, "Query to search with"], curr_date: Annotated[str, "Curr date in yyyy-mm-dd format"], - look_back_days: Annotated[int, "how many days to look back"], + look_back_days: Annotated[Union[int, str], "how many days to look back OR end_date string"], ) -> str: + """ + Fetch Google News for a query. + + Note: This function handles two calling conventions: + 1. Original: (query, curr_date, look_back_days: int) + 2. From get_news interface: (ticker, start_date, end_date) where end_date is a string + + When called with end_date string, it calculates look_back_days from the date difference. + """ + # Handle case where look_back_days is actually an end_date string (from get_news interface) + if isinstance(look_back_days, str): + try: + # Called as (ticker, start_date, end_date) - need to swap and calculate + start_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") + end_date_dt = datetime.strptime(look_back_days, "%Y-%m-%d") + # Swap: curr_date should be end_date, calculate days difference + actual_curr_date = look_back_days # end_date becomes curr_date + actual_look_back_days = (end_date_dt - start_date_dt).days + if actual_look_back_days < 0: + actual_look_back_days = abs(actual_look_back_days) + curr_date = actual_curr_date + look_back_days = actual_look_back_days + except ValueError: + # If parsing fails, default to 7 days + look_back_days = 7 + + # For NSE stocks, enhance query with company name for better news results + original_query = query + if is_nifty_50_stock(query): + company_name = get_nifty_50_company_name(query) + if company_name: + # Use company name for better news search results + # Add "NSE" and "stock" to filter for relevant financial news + query = f"{company_name} NSE stock" + query = query.replace(" ", "+") start_date = datetime.strptime(curr_date, "%Y-%m-%d") @@ -27,4 +63,6 @@ def get_google_news( if len(news_results) == 0: return "" - return f"## {query} Google News, from {before} to {curr_date}:\n\n{news_str}" \ No newline at end of file + # Use original query (symbol) in the header for clarity + display_query = original_query if is_nifty_50_stock(original_query) else query.replace("+", " ") + return f"## {display_query} Google News, from {before} to {curr_date}:\n\n{news_str}" \ No newline at end of file diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py index 4cd5ddef..67d0604a 100644 --- a/tradingagents/dataflows/interface.py +++ b/tradingagents/dataflows/interface.py @@ -16,6 +16,8 @@ from .alpha_vantage import ( get_news as get_alpha_vantage_news ) from .alpha_vantage_common import AlphaVantageRateLimitError +from .jugaad_data import get_jugaad_stock_data, get_jugaad_indicators +from .markets import detect_market, Market, is_nifty_50_stock # Configuration and routing logic from .config import get_config @@ -58,7 +60,8 @@ VENDOR_LIST = [ "local", "yfinance", "openai", - "google" + "google", + "jugaad_data" ] # Mapping of methods to their vendor-specific implementations @@ -68,12 +71,14 @@ VENDOR_METHODS = { "alpha_vantage": get_alpha_vantage_stock, "yfinance": get_YFin_data_online, "local": get_YFin_data, + "jugaad_data": get_jugaad_stock_data, }, # technical_indicators "get_indicators": { "alpha_vantage": get_alpha_vantage_indicator, "yfinance": get_stock_stats_indicators_window, - "local": get_stock_stats_indicators_window + "local": get_stock_stats_indicators_window, + "jugaad_data": get_jugaad_indicators, }, # fundamental_data "get_fundamentals": { @@ -123,9 +128,18 @@ def get_category_for_method(method: str) -> str: return category raise ValueError(f"Method '{method}' not found in any category") -def get_vendor(category: str, method: str = None) -> str: +def get_vendor(category: str, method: str = None, symbol: str = None) -> str: """Get the configured vendor for a data category or specific tool method. Tool-level configuration takes precedence over category-level. + For NSE stocks, automatically routes to jugaad_data for core_stock_apis and technical_indicators. + + Args: + category: Data category (e.g., "core_stock_apis", "technical_indicators") + method: Specific tool method name + symbol: Stock symbol (used for market detection) + + Returns: + Vendor name string """ config = get_config() @@ -135,13 +149,44 @@ def get_vendor(category: str, method: str = None) -> str: if method in tool_vendors: return tool_vendors[method] + # Market-aware vendor routing for NSE stocks + if symbol: + market_config = config.get("market", "auto") + market = detect_market(symbol, market_config) + + if market == Market.INDIA_NSE: + # Use yfinance as primary for NSE stocks (more reliable than jugaad_data from outside India) + # jugaad_data requires direct NSE access which may be blocked/slow + if category in ("core_stock_apis", "technical_indicators"): + return "yfinance" # yfinance handles .NS suffix automatically + # Use yfinance for fundamentals (with .NS suffix handled in y_finance.py) + elif category == "fundamental_data": + return "yfinance" + # Use google for news (handled in google.py with company name enhancement) + elif category == "news_data": + return "google" + # Fall back to category-level configuration return config.get("data_vendors", {}).get(category, "default") def route_to_vendor(method: str, *args, **kwargs): """Route method calls to appropriate vendor implementation with fallback support.""" category = get_category_for_method(method) - vendor_config = get_vendor(category, method) + + # Extract symbol from args/kwargs for market-aware routing + symbol = None + if args: + # First argument is typically the symbol/ticker + symbol = args[0] + elif "symbol" in kwargs: + symbol = kwargs["symbol"] + elif "ticker" in kwargs: + symbol = kwargs["ticker"] + elif "query" in kwargs: + # For news queries, the query might be the symbol + symbol = kwargs["query"] + + vendor_config = get_vendor(category, method, symbol) # Handle comma-separated vendors primary_vendors = [v.strip() for v in vendor_config.split(',')] diff --git a/tradingagents/dataflows/jugaad_data.py b/tradingagents/dataflows/jugaad_data.py new file mode 100644 index 00000000..e51835fc --- /dev/null +++ b/tradingagents/dataflows/jugaad_data.py @@ -0,0 +1,372 @@ +""" +Data vendor using jugaad-data library for Indian NSE stocks. +Provides historical OHLCV data, live quotes, and index data for NSE. + +Note: jugaad-data requires network access to NSE India website which may be +slow or blocked from some locations. The implementation includes timeouts +and will raise exceptions to trigger fallback to yfinance. +""" + +from typing import Annotated +from datetime import datetime, date +import pandas as pd +import signal + +from .markets import normalize_symbol, is_nifty_50_stock + + +class JugaadDataTimeoutError(Exception): + """Raised when jugaad-data request times out.""" + pass + + +def _timeout_handler(signum, frame): + raise JugaadDataTimeoutError("jugaad-data request timed out") + + +def get_jugaad_stock_data( + symbol: Annotated[str, "ticker symbol of the company"], + start_date: Annotated[str, "Start date in yyyy-mm-dd format"], + end_date: Annotated[str, "End date in yyyy-mm-dd format"], +) -> str: + """ + Fetch historical stock data from NSE using jugaad-data. + + Args: + symbol: NSE stock symbol (e.g., 'RELIANCE', 'TCS') + start_date: Start date in yyyy-mm-dd format + end_date: End date in yyyy-mm-dd format + + Returns: + CSV formatted string with OHLCV data + + Raises: + ImportError: If jugaad-data is not installed + JugaadDataTimeoutError: If request times out + Exception: For other errors (triggers fallback) + """ + try: + from jugaad_data.nse import stock_df + except ImportError: + raise ImportError("jugaad-data library not installed. Please install it with: pip install jugaad-data") + + # Normalize symbol for NSE (remove .NS suffix if present) + nse_symbol = normalize_symbol(symbol, target="nse") + + # Parse dates + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d").date() + end_dt = datetime.strptime(end_date, "%Y-%m-%d").date() + except ValueError as e: + raise ValueError(f"Error parsing dates: {e}. Please use yyyy-mm-dd format.") + + # Set a timeout for the request (15 seconds) + # This helps avoid hanging when NSE website is slow + timeout_seconds = 15 + old_handler = None + + try: + # Set timeout using signal (only works on Unix) + try: + old_handler = signal.signal(signal.SIGALRM, _timeout_handler) + signal.alarm(timeout_seconds) + except (AttributeError, ValueError): + # signal.SIGALRM not available on Windows + pass + + # Fetch data using jugaad-data + # series='EQ' for equity stocks + data = stock_df( + symbol=nse_symbol, + from_date=start_dt, + to_date=end_dt, + series="EQ" + ) + + # Cancel the alarm + try: + signal.alarm(0) + if old_handler: + signal.signal(signal.SIGALRM, old_handler) + except (AttributeError, ValueError): + pass + + if data.empty: + raise ValueError(f"No data found for symbol '{nse_symbol}' between {start_date} and {end_date}") + + # Rename columns to match yfinance format for consistency + column_mapping = { + "DATE": "Date", + "OPEN": "Open", + "HIGH": "High", + "LOW": "Low", + "CLOSE": "Close", + "LTP": "Last", + "VOLUME": "Volume", + "VALUE": "Value", + "NO OF TRADES": "Trades", + "PREV. CLOSE": "Prev Close", + } + + # Rename columns that exist + for old_name, new_name in column_mapping.items(): + if old_name in data.columns: + data = data.rename(columns={old_name: new_name}) + + # Select relevant columns (similar to yfinance output) + available_cols = ["Date", "Open", "High", "Low", "Close", "Volume"] + cols_to_use = [col for col in available_cols if col in data.columns] + data = data[cols_to_use] + + # Round numerical values + numeric_columns = ["Open", "High", "Low", "Close"] + for col in numeric_columns: + if col in data.columns: + data[col] = data[col].round(2) + + # Sort by date + if "Date" in data.columns: + data = data.sort_values("Date") + + # Convert to CSV string + csv_string = data.to_csv(index=False) + + # Add header information + header = f"# Stock data for {nse_symbol} (NSE) from {start_date} to {end_date}\n" + header += f"# Total records: {len(data)}\n" + header += f"# Data source: NSE India via jugaad-data\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + return header + csv_string + + except JugaadDataTimeoutError: + # Re-raise timeout errors to trigger fallback + raise + except Exception as e: + error_msg = str(e) + # Raise exceptions to trigger fallback to yfinance + if "No data" in error_msg or "empty" in error_msg.lower(): + raise ValueError(f"No data found for symbol '{nse_symbol}' between {start_date} and {end_date}. Please verify the symbol is listed on NSE.") + raise RuntimeError(f"Error fetching data for {nse_symbol} from jugaad-data: {error_msg}") + + +def get_jugaad_live_quote( + symbol: Annotated[str, "ticker symbol of the company"], +) -> str: + """ + Fetch live quote for an NSE stock. + + Args: + symbol: NSE stock symbol + + Returns: + Formatted string with current quote information + """ + try: + from jugaad_data.nse import NSELive + except ImportError: + return "Error: jugaad-data library not installed. Please install it with: pip install jugaad-data" + + nse_symbol = normalize_symbol(symbol, target="nse") + + try: + nse = NSELive() + quote = nse.stock_quote(nse_symbol) + + if not quote: + return f"No live quote available for '{nse_symbol}'" + + # Extract price info + price_info = quote.get("priceInfo", {}) + trade_info = quote.get("tradeInfo", {}) + security_info = quote.get("securityInfo", {}) + + result = f"# Live Quote for {nse_symbol} (NSE)\n" + result += f"# Retrieved: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + result += f"Last Price: {price_info.get('lastPrice', 'N/A')}\n" + result += f"Change: {price_info.get('change', 'N/A')}\n" + result += f"% Change: {price_info.get('pChange', 'N/A')}%\n" + result += f"Open: {price_info.get('open', 'N/A')}\n" + result += f"High: {price_info.get('intraDayHighLow', {}).get('max', 'N/A')}\n" + result += f"Low: {price_info.get('intraDayHighLow', {}).get('min', 'N/A')}\n" + result += f"Previous Close: {price_info.get('previousClose', 'N/A')}\n" + result += f"Volume: {trade_info.get('totalTradedVolume', 'N/A')}\n" + result += f"Value: {trade_info.get('totalTradedValue', 'N/A')}\n" + + return result + + except Exception as e: + return f"Error fetching live quote for {nse_symbol}: {str(e)}" + + +def get_jugaad_index_data( + index_name: Annotated[str, "Index name (e.g., 'NIFTY 50', 'NIFTY BANK')"], + start_date: Annotated[str, "Start date in yyyy-mm-dd format"], + end_date: Annotated[str, "End date in yyyy-mm-dd format"], +) -> str: + """ + Fetch historical index data from NSE. + + Args: + index_name: NSE index name (e.g., 'NIFTY 50', 'NIFTY BANK') + start_date: Start date in yyyy-mm-dd format + end_date: End date in yyyy-mm-dd format + + Returns: + CSV formatted string with index data + """ + try: + from jugaad_data.nse import index_df + except ImportError: + return "Error: jugaad-data library not installed. Please install it with: pip install jugaad-data" + + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d").date() + end_dt = datetime.strptime(end_date, "%Y-%m-%d").date() + except ValueError as e: + return f"Error parsing dates: {e}. Please use yyyy-mm-dd format." + + try: + data = index_df( + symbol=index_name.upper(), + from_date=start_dt, + to_date=end_dt + ) + + if data.empty: + return f"No data found for index '{index_name}' between {start_date} and {end_date}" + + # Sort by date + if "HistoricalDate" in data.columns: + data = data.sort_values("HistoricalDate") + + csv_string = data.to_csv(index=False) + + header = f"# Index data for {index_name} from {start_date} to {end_date}\n" + header += f"# Total records: {len(data)}\n" + header += f"# Data source: NSE India via jugaad-data\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + return header + csv_string + + except Exception as e: + return f"Error fetching index data for {index_name}: {str(e)}" + + +def get_jugaad_indicators( + symbol: Annotated[str, "ticker symbol of the company"], + indicator: Annotated[str, "technical indicator to calculate"], + curr_date: Annotated[str, "The current trading date, YYYY-mm-dd"], + look_back_days: Annotated[int, "how many days to look back"] = 30, +) -> str: + """ + Calculate technical indicators for NSE stocks using jugaad-data. + This fetches data and calculates indicators using stockstats. + + Args: + symbol: NSE stock symbol + indicator: Technical indicator name + curr_date: Current date for calculation + look_back_days: Number of days to look back + + Returns: + Formatted string with indicator values + + Raises: + ImportError: If required libraries not installed + Exception: For other errors (triggers fallback) + """ + try: + from jugaad_data.nse import stock_df + from stockstats import wrap + except ImportError as e: + raise ImportError(f"Required library not installed: {e}") + + nse_symbol = normalize_symbol(symbol, target="nse") + + # Set timeout for NSE request + timeout_seconds = 15 + old_handler = None + + try: + # Set timeout using signal (only works on Unix) + try: + old_handler = signal.signal(signal.SIGALRM, _timeout_handler) + signal.alarm(timeout_seconds) + except (AttributeError, ValueError): + pass + + # Calculate date range - need more history for indicator calculation + curr_dt = datetime.strptime(curr_date, "%Y-%m-%d").date() + # Fetch extra data for indicator calculation (e.g., 200-day SMA needs 200+ days) + start_dt = date(curr_dt.year - 1, curr_dt.month, curr_dt.day) # 1 year back + + data = stock_df( + symbol=nse_symbol, + from_date=start_dt, + to_date=curr_dt, + series="EQ" + ) + + # Cancel the alarm + try: + signal.alarm(0) + if old_handler: + signal.signal(signal.SIGALRM, old_handler) + except (AttributeError, ValueError): + pass + + if data.empty: + raise ValueError(f"No data found for symbol '{nse_symbol}' to calculate {indicator}") + + # Prepare data for stockstats + column_mapping = { + "DATE": "date", + "OPEN": "open", + "HIGH": "high", + "LOW": "low", + "CLOSE": "close", + "VOLUME": "volume", + } + + for old_name, new_name in column_mapping.items(): + if old_name in data.columns: + data = data.rename(columns={old_name: new_name}) + + # Wrap with stockstats + df = wrap(data) + + # Calculate the indicator + df[indicator] # This triggers stockstats calculation + + # Get the last N days of indicator values + from dateutil.relativedelta import relativedelta + result_data = [] + curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") + before = curr_date_dt - relativedelta(days=look_back_days) + + df["date_str"] = pd.to_datetime(df["date"]).dt.strftime("%Y-%m-%d") + + for _, row in df.iterrows(): + row_date = datetime.strptime(row["date_str"], "%Y-%m-%d") + if before <= row_date <= curr_date_dt: + ind_value = row[indicator] + if pd.isna(ind_value): + result_data.append((row["date_str"], "N/A")) + else: + result_data.append((row["date_str"], str(round(ind_value, 4)))) + + result_data.sort(reverse=True) # Most recent first + + result_str = f"## {indicator} values for {nse_symbol} (NSE) from {before.strftime('%Y-%m-%d')} to {curr_date}:\n\n" + for date_str, value in result_data: + result_str += f"{date_str}: {value}\n" + + return result_str + + except JugaadDataTimeoutError: + # Re-raise timeout to trigger fallback + raise + except Exception as e: + raise RuntimeError(f"Error calculating {indicator} for {nse_symbol}: {str(e)}") diff --git a/tradingagents/dataflows/markets.py b/tradingagents/dataflows/markets.py new file mode 100644 index 00000000..e5994bb1 --- /dev/null +++ b/tradingagents/dataflows/markets.py @@ -0,0 +1,172 @@ +""" +Market configuration and stock lists for different markets. +Supports US and Indian NSE (Nifty 50) stocks. +""" + +from enum import Enum +from typing import Optional + + +class Market(Enum): + """Supported markets.""" + US = "us" + INDIA_NSE = "india_nse" + + +# Nifty 50 stocks with company names +NIFTY_50_STOCKS = { + "RELIANCE": "Reliance Industries Ltd", + "TCS": "Tata Consultancy Services Ltd", + "HDFCBANK": "HDFC Bank Ltd", + "INFY": "Infosys Ltd", + "ICICIBANK": "ICICI Bank Ltd", + "HINDUNILVR": "Hindustan Unilever Ltd", + "ITC": "ITC Ltd", + "SBIN": "State Bank of India", + "BHARTIARTL": "Bharti Airtel Ltd", + "KOTAKBANK": "Kotak Mahindra Bank Ltd", + "LT": "Larsen & Toubro Ltd", + "AXISBANK": "Axis Bank Ltd", + "ASIANPAINT": "Asian Paints Ltd", + "MARUTI": "Maruti Suzuki India Ltd", + "HCLTECH": "HCL Technologies Ltd", + "SUNPHARMA": "Sun Pharmaceutical Industries Ltd", + "TITAN": "Titan Company Ltd", + "BAJFINANCE": "Bajaj Finance Ltd", + "WIPRO": "Wipro Ltd", + "ULTRACEMCO": "UltraTech Cement Ltd", + "NESTLEIND": "Nestle India Ltd", + "NTPC": "NTPC Ltd", + "POWERGRID": "Power Grid Corporation of India Ltd", + "M&M": "Mahindra & Mahindra Ltd", + "TATAMOTORS": "Tata Motors Ltd", + "ONGC": "Oil & Natural Gas Corporation Ltd", + "JSWSTEEL": "JSW Steel Ltd", + "TATASTEEL": "Tata Steel Ltd", + "ADANIENT": "Adani Enterprises Ltd", + "ADANIPORTS": "Adani Ports and SEZ Ltd", + "COALINDIA": "Coal India Ltd", + "BAJAJFINSV": "Bajaj Finserv Ltd", + "TECHM": "Tech Mahindra Ltd", + "HDFCLIFE": "HDFC Life Insurance Company Ltd", + "SBILIFE": "SBI Life Insurance Company Ltd", + "GRASIM": "Grasim Industries Ltd", + "DIVISLAB": "Divi's Laboratories Ltd", + "DRREDDY": "Dr. Reddy's Laboratories Ltd", + "CIPLA": "Cipla Ltd", + "BRITANNIA": "Britannia Industries Ltd", + "EICHERMOT": "Eicher Motors Ltd", + "APOLLOHOSP": "Apollo Hospitals Enterprise Ltd", + "INDUSINDBK": "IndusInd Bank Ltd", + "HEROMOTOCO": "Hero MotoCorp Ltd", + "TATACONSUM": "Tata Consumer Products Ltd", + "BPCL": "Bharat Petroleum Corporation Ltd", + "UPL": "UPL Ltd", + "HINDALCO": "Hindalco Industries Ltd", + "BAJAJ-AUTO": "Bajaj Auto Ltd", + "LTIM": "LTIMindtree Ltd", +} + + +def is_nifty_50_stock(symbol: str) -> bool: + """ + Check if a symbol is a Nifty 50 stock. + + Args: + symbol: Stock symbol (with or without .NS suffix) + + Returns: + True if the symbol is in the Nifty 50 list + """ + # Remove .NS suffix if present + clean_symbol = symbol.upper().replace(".NS", "") + return clean_symbol in NIFTY_50_STOCKS + + +def get_nifty_50_company_name(symbol: str) -> Optional[str]: + """ + Get the company name for a Nifty 50 stock symbol. + + Args: + symbol: Stock symbol (with or without .NS suffix) + + Returns: + Company name if found, None otherwise + """ + clean_symbol = symbol.upper().replace(".NS", "") + return NIFTY_50_STOCKS.get(clean_symbol) + + +def detect_market(symbol: str, config_market: str = "auto") -> Market: + """ + Detect the market for a given symbol. + + Args: + symbol: Stock symbol + config_market: Market setting from config ("auto", "us", "india_nse") + + Returns: + Market enum indicating the detected market + """ + if config_market == "india_nse": + return Market.INDIA_NSE + elif config_market == "us": + return Market.US + + # Auto-detection + # Check if symbol has .NS suffix (yfinance format for NSE) + if symbol.upper().endswith(".NS"): + return Market.INDIA_NSE + + # Check if symbol is in Nifty 50 list + if is_nifty_50_stock(symbol): + return Market.INDIA_NSE + + # Default to US market + return Market.US + + +def normalize_symbol(symbol: str, target: str = "yfinance") -> str: + """ + Normalize a symbol for a specific data source. + + Args: + symbol: Stock symbol + target: Target format ("yfinance", "jugaad", "nse") + + Returns: + Normalized symbol for the target + """ + clean_symbol = symbol.upper().replace(".NS", "") + + if target == "yfinance": + # yfinance requires .NS suffix for NSE stocks + if is_nifty_50_stock(clean_symbol): + return f"{clean_symbol}.NS" + return clean_symbol + + elif target in ("jugaad", "nse"): + # jugaad-data and NSE use symbols without suffix + return clean_symbol + + return symbol.upper() + + +def get_nifty_50_list() -> list: + """ + Get list of all Nifty 50 stock symbols. + + Returns: + List of Nifty 50 stock symbols + """ + return list(NIFTY_50_STOCKS.keys()) + + +def get_nifty_50_with_names() -> dict: + """ + Get dictionary of Nifty 50 stocks with company names. + + Returns: + Dictionary mapping symbols to company names + """ + return NIFTY_50_STOCKS.copy() diff --git a/tradingagents/dataflows/y_finance.py b/tradingagents/dataflows/y_finance.py index da7273d5..6e5b1d5e 100644 --- a/tradingagents/dataflows/y_finance.py +++ b/tradingagents/dataflows/y_finance.py @@ -4,6 +4,7 @@ from dateutil.relativedelta import relativedelta import yfinance as yf import os from .stockstats_utils import StockstatsUtils +from .markets import normalize_symbol, is_nifty_50_stock def get_YFin_data_online( symbol: Annotated[str, "ticker symbol of the company"], @@ -14,8 +15,11 @@ def get_YFin_data_online( datetime.strptime(start_date, "%Y-%m-%d") datetime.strptime(end_date, "%Y-%m-%d") + # Normalize symbol for yfinance (adds .NS suffix for NSE stocks) + normalized_symbol = normalize_symbol(symbol, target="yfinance") + # Create ticker object - ticker = yf.Ticker(symbol.upper()) + ticker = yf.Ticker(normalized_symbol) # Fetch historical data for the specified date range data = ticker.history(start=start_date, end=end_date) @@ -23,7 +27,7 @@ def get_YFin_data_online( # Check if data is empty if data.empty: return ( - f"No data found for symbol '{symbol}' between {start_date} and {end_date}" + f"No data found for symbol '{normalized_symbol}' between {start_date} and {end_date}" ) # Remove timezone info from index for cleaner output @@ -40,7 +44,7 @@ def get_YFin_data_online( csv_string = data.to_csv() # Add header information - header = f"# Stock data for {symbol.upper()} from {start_date} to {end_date}\n" + header = f"# Stock data for {normalized_symbol} from {start_date} to {end_date}\n" header += f"# Total records: {len(data)}\n" header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" @@ -216,11 +220,12 @@ def _get_stock_stats_bulk( raise Exception("Stockstats fail: Yahoo Finance data not fetched yet!") else: # Online data fetching with caching - today_date = pd.Timestamp.today() + # IMPORTANT: Use curr_date as end_date for backtesting accuracy + # This ensures we only use data available at the backtest date (point-in-time) curr_date_dt = pd.to_datetime(curr_date) - - end_date = today_date - start_date = today_date - pd.DateOffset(years=15) + + end_date = curr_date_dt # Use backtest date, NOT today's date + start_date = curr_date_dt - pd.DateOffset(years=15) start_date_str = start_date.strftime("%Y-%m-%d") end_date_str = end_date.strftime("%Y-%m-%d") @@ -293,94 +298,166 @@ def get_stockstats_indicator( return str(indicator_value) +def _filter_fundamentals_by_date(data, curr_date): + """ + Filter fundamentals data to only include reports available on or before curr_date. + This ensures point-in-time accuracy for backtesting. + + yfinance returns fundamentals with report dates as column headers. + Financial reports are typically published 30-45 days after quarter end. + We filter to only include columns (report dates) that are at least 45 days before curr_date. + """ + import pandas as pd + + if data.empty or curr_date is None: + return data + + try: + curr_date_dt = pd.to_datetime(curr_date) + # Financial reports are typically published ~45 days after the report date + # So for a report dated 2024-03-31, it would be available around mid-May + publication_delay_days = 45 + + # Filter columns (report dates) to only include those available at curr_date + valid_columns = [] + for col in data.columns: + try: + report_date = pd.to_datetime(col) + # Report would have been published ~45 days after report_date + estimated_publish_date = report_date + pd.Timedelta(days=publication_delay_days) + if estimated_publish_date <= curr_date_dt: + valid_columns.append(col) + except: + # If column can't be parsed as date, keep it (might be a label column) + valid_columns.append(col) + + if valid_columns: + return data[valid_columns] + else: + return data.iloc[:, :0] # Return empty dataframe with same index + except Exception as e: + print(f"Warning: Could not filter fundamentals by date: {e}") + return data + + def get_balance_sheet( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", - curr_date: Annotated[str, "current date (not used for yfinance)"] = None + curr_date: Annotated[str, "current date for point-in-time filtering"] = None ): - """Get balance sheet data from yfinance.""" + """Get balance sheet data from yfinance, filtered by curr_date for backtesting accuracy.""" try: - ticker_obj = yf.Ticker(ticker.upper()) - + # Normalize symbol for yfinance (adds .NS suffix for NSE stocks) + normalized_ticker = normalize_symbol(ticker, target="yfinance") + ticker_obj = yf.Ticker(normalized_ticker) + if freq.lower() == "quarterly": data = ticker_obj.quarterly_balance_sheet else: data = ticker_obj.balance_sheet - + if data.empty: - return f"No balance sheet data found for symbol '{ticker}'" - + return f"No balance sheet data found for symbol '{normalized_ticker}'" + + # Filter by curr_date for point-in-time accuracy in backtesting + data = _filter_fundamentals_by_date(data, curr_date) + + if data.empty: + return f"No balance sheet data available for {normalized_ticker} as of {curr_date}" + # Convert to CSV string for consistency with other functions csv_string = data.to_csv() - + # Add header information - header = f"# Balance Sheet data for {ticker.upper()} ({freq})\n" + header = f"# Balance Sheet data for {normalized_ticker} ({freq})\n" + if curr_date: + header += f"# Point-in-time data as of: {curr_date}\n" header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" - + return header + csv_string - + except Exception as e: - return f"Error retrieving balance sheet for {ticker}: {str(e)}" + return f"Error retrieving balance sheet for {normalized_ticker}: {str(e)}" def get_cashflow( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", - curr_date: Annotated[str, "current date (not used for yfinance)"] = None + curr_date: Annotated[str, "current date for point-in-time filtering"] = None ): - """Get cash flow data from yfinance.""" + """Get cash flow data from yfinance, filtered by curr_date for backtesting accuracy.""" try: - ticker_obj = yf.Ticker(ticker.upper()) - + # Normalize symbol for yfinance (adds .NS suffix for NSE stocks) + normalized_ticker = normalize_symbol(ticker, target="yfinance") + ticker_obj = yf.Ticker(normalized_ticker) + if freq.lower() == "quarterly": data = ticker_obj.quarterly_cashflow else: data = ticker_obj.cashflow - + if data.empty: - return f"No cash flow data found for symbol '{ticker}'" - + return f"No cash flow data found for symbol '{normalized_ticker}'" + + # Filter by curr_date for point-in-time accuracy in backtesting + data = _filter_fundamentals_by_date(data, curr_date) + + if data.empty: + return f"No cash flow data available for {normalized_ticker} as of {curr_date}" + # Convert to CSV string for consistency with other functions csv_string = data.to_csv() - + # Add header information - header = f"# Cash Flow data for {ticker.upper()} ({freq})\n" + header = f"# Cash Flow data for {normalized_ticker} ({freq})\n" + if curr_date: + header += f"# Point-in-time data as of: {curr_date}\n" header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" - + return header + csv_string - + except Exception as e: - return f"Error retrieving cash flow for {ticker}: {str(e)}" + return f"Error retrieving cash flow for {normalized_ticker}: {str(e)}" def get_income_statement( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", - curr_date: Annotated[str, "current date (not used for yfinance)"] = None + curr_date: Annotated[str, "current date for point-in-time filtering"] = None ): - """Get income statement data from yfinance.""" + """Get income statement data from yfinance, filtered by curr_date for backtesting accuracy.""" try: - ticker_obj = yf.Ticker(ticker.upper()) - + # Normalize symbol for yfinance (adds .NS suffix for NSE stocks) + normalized_ticker = normalize_symbol(ticker, target="yfinance") + ticker_obj = yf.Ticker(normalized_ticker) + if freq.lower() == "quarterly": data = ticker_obj.quarterly_income_stmt else: data = ticker_obj.income_stmt - + if data.empty: - return f"No income statement data found for symbol '{ticker}'" - + return f"No income statement data found for symbol '{normalized_ticker}'" + + # Filter by curr_date for point-in-time accuracy in backtesting + data = _filter_fundamentals_by_date(data, curr_date) + + if data.empty: + return f"No income statement data available for {normalized_ticker} as of {curr_date}" + # Convert to CSV string for consistency with other functions csv_string = data.to_csv() - + # Add header information - header = f"# Income Statement data for {ticker.upper()} ({freq})\n" + header = f"# Income Statement data for {normalized_ticker} ({freq})\n" + if curr_date: + header += f"# Point-in-time data as of: {curr_date}\n" header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" - + return header + csv_string - + except Exception as e: - return f"Error retrieving income statement for {ticker}: {str(e)}" + return f"Error retrieving income statement for {normalized_ticker}: {str(e)}" def get_insider_transactions( @@ -388,20 +465,26 @@ def get_insider_transactions( ): """Get insider transactions data from yfinance.""" try: - ticker_obj = yf.Ticker(ticker.upper()) + # Normalize symbol for yfinance (adds .NS suffix for NSE stocks) + normalized_ticker = normalize_symbol(ticker, target="yfinance") + ticker_obj = yf.Ticker(normalized_ticker) data = ticker_obj.insider_transactions - + if data is None or data.empty: - return f"No insider transactions data found for symbol '{ticker}'" - + # Check if this is an NSE stock - insider data may not be available + if is_nifty_50_stock(ticker): + return (f"Note: SEC-style insider transaction data is not available for Indian NSE stocks like {normalized_ticker}. " + f"For Indian stocks, insider trading disclosures are filed with SEBI and available through NSE/BSE websites.") + return f"No insider transactions data found for symbol '{normalized_ticker}'" + # Convert to CSV string for consistency with other functions csv_string = data.to_csv() - + # Add header information - header = f"# Insider Transactions data for {ticker.upper()}\n" + header = f"# Insider Transactions data for {normalized_ticker}\n" header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" - + return header + csv_string - + except Exception as e: - return f"Error retrieving insider transactions for {ticker}: {str(e)}" \ No newline at end of file + return f"Error retrieving insider transactions for {normalized_ticker}: {str(e)}" \ No newline at end of file diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 1f40a2a2..57adff38 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -13,16 +13,30 @@ DEFAULT_CONFIG = { "deep_think_llm": "o4-mini", "quick_think_llm": "gpt-4o-mini", "backend_url": "https://api.openai.com/v1", + # Anthropic-specific config for Claude models (using aliases for Claude Max subscription) + "anthropic_config": { + "deep_think_llm": "opus", # Claude Opus 4.5 for deep analysis + "quick_think_llm": "sonnet", # Claude Sonnet 4.5 for quick tasks + }, # Debate and discussion settings "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, "max_recur_limit": 100, + # Market configuration + # Options: "auto" (detect from symbol), "us", "india_nse" + # When set to "auto", Nifty 50 stocks are automatically detected and routed to NSE vendors + "market": "auto", # Data vendor configuration # Category-level configuration (default for all tools in category) + # For NSE stocks (when market is "auto" or "india_nse"): + # - core_stock_apis: jugaad_data (primary), yfinance (fallback) + # - technical_indicators: jugaad_data (primary), yfinance (fallback) + # - fundamental_data: yfinance (with .NS suffix) + # - news_data: google (with company name enhancement) "data_vendors": { - "core_stock_apis": "yfinance", # Options: yfinance, alpha_vantage, local - "technical_indicators": "yfinance", # Options: yfinance, alpha_vantage, local - "fundamental_data": "alpha_vantage", # Options: openai, alpha_vantage, local + "core_stock_apis": "yfinance", # Options: yfinance, alpha_vantage, local, jugaad_data + "technical_indicators": "yfinance", # Options: yfinance, alpha_vantage, local, jugaad_data + "fundamental_data": "alpha_vantage", # Options: openai, alpha_vantage, local, yfinance "news_data": "alpha_vantage", # Options: openai, alpha_vantage, google, local }, # Tool-level configuration (takes precedence over category-level) diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 40cdff75..2cdab4f6 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -1,14 +1,21 @@ # TradingAgents/graph/trading_graph.py import os +import sys from pathlib import Path import json -from datetime import date +from datetime import date, datetime from typing import Dict, Any, Tuple, List, Optional +# Add frontend backend to path for database access +FRONTEND_BACKEND_PATH = Path(__file__).parent.parent.parent / "frontend" / "backend" +if str(FRONTEND_BACKEND_PATH) not in sys.path: + sys.path.insert(0, str(FRONTEND_BACKEND_PATH)) + from langchain_openai import ChatOpenAI from langchain_anthropic import ChatAnthropic from langchain_google_genai import ChatGoogleGenerativeAI +from tradingagents.claude_max_llm import ClaudeMaxLLM from langgraph.prebuilt import ToolNode @@ -76,8 +83,9 @@ class TradingAgentsGraph: self.deep_thinking_llm = ChatOpenAI(model=self.config["deep_think_llm"], base_url=self.config["backend_url"]) self.quick_thinking_llm = ChatOpenAI(model=self.config["quick_think_llm"], base_url=self.config["backend_url"]) elif self.config["llm_provider"].lower() == "anthropic": - self.deep_thinking_llm = ChatAnthropic(model=self.config["deep_think_llm"], base_url=self.config["backend_url"]) - self.quick_thinking_llm = ChatAnthropic(model=self.config["quick_think_llm"], base_url=self.config["backend_url"]) + # Use ClaudeMaxLLM to leverage Claude Max subscription via CLI + self.deep_thinking_llm = ClaudeMaxLLM(model=self.config["deep_think_llm"]) + self.quick_thinking_llm = ClaudeMaxLLM(model=self.config["quick_think_llm"]) elif self.config["llm_provider"].lower() == "google": self.deep_thinking_llm = ChatGoogleGenerativeAI(model=self.config["deep_think_llm"]) self.quick_thinking_llm = ChatGoogleGenerativeAI(model=self.config["quick_think_llm"]) @@ -189,6 +197,9 @@ class TradingAgentsGraph: # Log state self._log_state(trade_date, final_state) + # Save to frontend database for UI display + self._save_to_frontend_db(trade_date, final_state) + # Return decision and processed signal return final_state, self.process_signal(final_state["final_trade_decision"]) @@ -234,6 +245,93 @@ class TradingAgentsGraph: ) as f: json.dump(self.log_states_dict, f, indent=4) + def _save_to_frontend_db(self, trade_date: str, final_state: Dict[str, Any]): + """Save pipeline data to the frontend database for UI display. + + Args: + trade_date: The date of the analysis + final_state: The final state from the graph execution + """ + try: + from database import ( + init_db, + save_agent_report, + save_debate_history, + save_pipeline_steps_bulk, + save_data_source_logs_bulk + ) + + # Initialize database if needed + init_db() + + symbol = final_state.get("company_of_interest", self.ticker) + now = datetime.now().isoformat() + + # 1. Save agent reports + agent_reports = [ + ("market", final_state.get("market_report", "")), + ("news", final_state.get("news_report", "")), + ("social_media", final_state.get("sentiment_report", "")), + ("fundamentals", final_state.get("fundamentals_report", "")), + ] + + for agent_type, content in agent_reports: + if content: + save_agent_report( + date=trade_date, + symbol=symbol, + agent_type=agent_type, + report_content=content, + data_sources_used=[] + ) + + # 2. Save investment debate + invest_debate = final_state.get("investment_debate_state", {}) + if invest_debate: + save_debate_history( + date=trade_date, + symbol=symbol, + debate_type="investment", + bull_arguments=invest_debate.get("bull_history", ""), + bear_arguments=invest_debate.get("bear_history", ""), + judge_decision=invest_debate.get("judge_decision", ""), + full_history=invest_debate.get("history", "") + ) + + # 3. Save risk debate + risk_debate = final_state.get("risk_debate_state", {}) + if risk_debate: + save_debate_history( + date=trade_date, + symbol=symbol, + debate_type="risk", + risky_arguments=risk_debate.get("risky_history", ""), + safe_arguments=risk_debate.get("safe_history", ""), + neutral_arguments=risk_debate.get("neutral_history", ""), + judge_decision=risk_debate.get("judge_decision", ""), + full_history=risk_debate.get("history", "") + ) + + # 4. Save pipeline steps (tracking the stages) + pipeline_steps = [ + {"step_number": 1, "step_name": "initialize", "status": "completed", "started_at": now, "completed_at": now, "output_summary": "Pipeline initialized"}, + {"step_number": 2, "step_name": "market_analysis", "status": "completed", "started_at": now, "completed_at": now, "output_summary": "Market analysis complete" if final_state.get("market_report") else "Skipped"}, + {"step_number": 3, "step_name": "news_analysis", "status": "completed", "started_at": now, "completed_at": now, "output_summary": "News analysis complete" if final_state.get("news_report") else "Skipped"}, + {"step_number": 4, "step_name": "social_analysis", "status": "completed", "started_at": now, "completed_at": now, "output_summary": "Social analysis complete" if final_state.get("sentiment_report") else "Skipped"}, + {"step_number": 5, "step_name": "fundamental_analysis", "status": "completed", "started_at": now, "completed_at": now, "output_summary": "Fundamental analysis complete" if final_state.get("fundamentals_report") else "Skipped"}, + {"step_number": 6, "step_name": "investment_debate", "status": "completed", "started_at": now, "completed_at": now, "output_summary": invest_debate.get("judge_decision", "")[:100] if invest_debate else "Skipped"}, + {"step_number": 7, "step_name": "trader_decision", "status": "completed", "started_at": now, "completed_at": now, "output_summary": final_state.get("trader_investment_plan", "")[:100] if final_state.get("trader_investment_plan") else "Skipped"}, + {"step_number": 8, "step_name": "risk_debate", "status": "completed", "started_at": now, "completed_at": now, "output_summary": risk_debate.get("judge_decision", "")[:100] if risk_debate else "Skipped"}, + {"step_number": 9, "step_name": "final_decision", "status": "completed", "started_at": now, "completed_at": now, "output_summary": final_state.get("final_trade_decision", "")[:100] if final_state.get("final_trade_decision") else "Pending"}, + ] + save_pipeline_steps_bulk(trade_date, symbol, pipeline_steps) + + print(f"[Frontend DB] Saved pipeline data for {symbol} on {trade_date}") + + except Exception as e: + print(f"[Frontend DB] Warning: Could not save to frontend database: {e}") + # Don't fail the main process if frontend DB save fails + def reflect_and_remember(self, returns_losses): """Reflect on decisions and update memory based on returns.""" self.reflector.reflect_bull_researcher( diff --git a/tradingagents/nifty50_recommender.py b/tradingagents/nifty50_recommender.py new file mode 100644 index 00000000..15711274 --- /dev/null +++ b/tradingagents/nifty50_recommender.py @@ -0,0 +1,448 @@ +""" +Nifty 50 Stock Recommendation System. + +This module predicts all 50 Nifty stocks and selects the ones with highest +short-term growth potential using Claude Opus 4.5 via Claude Max subscription. +""" + +import os +# Disable CUDA to avoid library issues with embeddings +os.environ["CUDA_VISIBLE_DEVICES"] = "" +# Remove API key to force Claude Max subscription auth +os.environ.pop("ANTHROPIC_API_KEY", None) + +import json +from pathlib import Path +from typing import Dict, Any, Optional, List, Tuple +from datetime import datetime + +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.default_config import DEFAULT_CONFIG +from tradingagents.dataflows.markets import NIFTY_50_STOCKS, get_nifty_50_list +from tradingagents.claude_max_llm import ClaudeMaxLLM + + +def verify_claude_cli() -> bool: + """ + Verify that Claude CLI is available and authenticated. + + Returns: + True if Claude CLI is available + + Raises: + RuntimeError: If Claude CLI is not available or not authenticated + """ + import subprocess + + try: + result = subprocess.run( + ["claude", "--version"], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + return True + except FileNotFoundError: + pass + + raise RuntimeError( + "Claude CLI is not available.\n\n" + "To use this recommendation system with Claude Max subscription:\n" + "1. Install Claude Code: npm install -g @anthropic-ai/claude-code\n" + "2. Authenticate: claude auth login\n" + ) + + +def create_claude_config() -> Dict[str, Any]: + """ + Create a configuration dictionary for using Claude models. + + Returns: + Configuration dictionary with Anthropic settings + """ + config = DEFAULT_CONFIG.copy() + config["llm_provider"] = "anthropic" + config["deep_think_llm"] = config["anthropic_config"]["deep_think_llm"] + config["quick_think_llm"] = config["anthropic_config"]["quick_think_llm"] + config["market"] = "india_nse" + + # Use jugaad_data for NSE stocks + config["data_vendors"] = { + "core_stock_apis": "jugaad_data", + "technical_indicators": "jugaad_data", + "fundamental_data": "yfinance", + "news_data": "google", + } + + return config + + +def predict_stock( + graph: TradingAgentsGraph, + symbol: str, + trade_date: str +) -> Dict[str, Any]: + """ + Run prediction for a single stock. + + Args: + graph: The TradingAgentsGraph instance + symbol: Stock symbol (e.g., 'RELIANCE', 'TCS') + trade_date: Date for the prediction (YYYY-MM-DD format) + + Returns: + Dictionary containing prediction results including decision, + market report, fundamentals report, news report, investment plan, + and final trade decision + """ + try: + final_state, decision = graph.propagate(symbol, trade_date) + + return { + "symbol": symbol, + "company_name": NIFTY_50_STOCKS.get(symbol, symbol), + "decision": decision, + "market_report": final_state.get("market_report", ""), + "fundamentals_report": final_state.get("fundamentals_report", ""), + "news_report": final_state.get("news_report", ""), + "sentiment_report": final_state.get("sentiment_report", ""), + "investment_plan": final_state.get("investment_plan", ""), + "final_trade_decision": final_state.get("final_trade_decision", ""), + "investment_debate": { + "bull_history": final_state.get("investment_debate_state", {}).get("bull_history", ""), + "bear_history": final_state.get("investment_debate_state", {}).get("bear_history", ""), + "judge_decision": final_state.get("investment_debate_state", {}).get("judge_decision", ""), + }, + "risk_debate": { + "risky_history": final_state.get("risk_debate_state", {}).get("risky_history", ""), + "safe_history": final_state.get("risk_debate_state", {}).get("safe_history", ""), + "neutral_history": final_state.get("risk_debate_state", {}).get("neutral_history", ""), + "judge_decision": final_state.get("risk_debate_state", {}).get("judge_decision", ""), + }, + "error": None, + } + except Exception as e: + return { + "symbol": symbol, + "company_name": NIFTY_50_STOCKS.get(symbol, symbol), + "decision": None, + "error": str(e), + } + + +def predict_all_nifty50( + trade_date: str, + config: Optional[Dict[str, Any]] = None, + stock_subset: Optional[List[str]] = None, + on_progress: Optional[callable] = None +) -> Dict[str, Dict[str, Any]]: + """ + Run predictions for all 50 Nifty stocks (or a subset). + + Args: + trade_date: Date for the predictions (YYYY-MM-DD format) + config: Optional configuration dictionary. If None, uses Claude config + stock_subset: Optional list of stock symbols to analyze. If None, analyzes all 50 + on_progress: Optional callback function(current_index, total, symbol, result) + + Returns: + Dictionary mapping stock symbols to their prediction results + """ + if config is None: + config = create_claude_config() + + # Verify Claude CLI is available for Max subscription + verify_claude_cli() + + # Initialize the graph + graph = TradingAgentsGraph( + selected_analysts=["market", "social", "news", "fundamentals"], + debug=False, + config=config + ) + + # Get list of stocks to analyze + stocks = stock_subset if stock_subset else get_nifty_50_list() + total = len(stocks) + + predictions = {} + + for i, symbol in enumerate(stocks, 1): + result = predict_stock(graph, symbol, trade_date) + predictions[symbol] = result + + if on_progress: + on_progress(i, total, symbol, result) + + return predictions + + +def format_predictions_for_prompt(predictions: Dict[str, Dict[str, Any]]) -> str: + """ + Format all predictions into a comprehensive prompt for Claude. + + Args: + predictions: Dictionary of prediction results + + Returns: + Formatted string containing all predictions + """ + formatted_parts = [] + + for symbol, pred in predictions.items(): + if pred.get("error"): + formatted_parts.append(f""" +=== {symbol} ({pred.get('company_name', symbol)}) === +ERROR: {pred['error']} +""") + continue + + formatted_parts.append(f""" +=== {symbol} ({pred.get('company_name', symbol)}) === + +DECISION: {pred.get('decision', 'N/A')} + +MARKET ANALYSIS: +{pred.get('market_report', 'N/A')[:1000]} + +FUNDAMENTALS: +{pred.get('fundamentals_report', 'N/A')[:1000]} + +NEWS & SENTIMENT: +{pred.get('news_report', 'N/A')[:500]} +{pred.get('sentiment_report', 'N/A')[:500]} + +INVESTMENT PLAN: +{pred.get('investment_plan', 'N/A')[:500]} + +FINAL TRADE DECISION: +{pred.get('final_trade_decision', 'N/A')[:500]} + +BULL/BEAR DEBATE SUMMARY: +Bull: {pred.get('investment_debate', {}).get('bull_history', 'N/A')[:300]} +Bear: {pred.get('investment_debate', {}).get('bear_history', 'N/A')[:300]} +Judge: {pred.get('investment_debate', {}).get('judge_decision', 'N/A')[:300]} + +RISK ASSESSMENT: +{pred.get('risk_debate', {}).get('judge_decision', 'N/A')[:300]} + +--- +""") + + return "\n".join(formatted_parts) + + +def parse_ranking_response(response_text: str) -> Dict[str, Any]: + """ + Parse the ranking response from Claude. + + Args: + response_text: Raw response text from Claude + + Returns: + Dictionary containing parsed ranking results + """ + return { + "raw_response": response_text, + "parsed": True, + } + + +def rank_stocks_for_growth( + predictions: Dict[str, Dict[str, Any]], +) -> Dict[str, Any]: + """ + Use Claude Opus 4.5 to rank stocks by short-term growth potential. + + Args: + predictions: Dictionary of prediction results for all stocks + + Returns: + Dictionary containing ranking results with top picks and stocks to avoid + """ + # Initialize Claude Opus via Max subscription + llm = ClaudeMaxLLM(model="opus") + + # Filter out stocks with errors + valid_predictions = { + k: v for k, v in predictions.items() + if not v.get("error") + } + + if not valid_predictions: + return { + "error": "No valid predictions to rank", + "top_picks": [], + "stocks_to_avoid": [], + } + + # Format predictions for prompt + formatted = format_predictions_for_prompt(valid_predictions) + + prompt = f"""You are an expert stock analyst specializing in the Indian equity market. +Analyze the following predictions for Nifty 50 stocks and select the TOP 3 stocks with +the highest short-term growth potential (1-2 weeks timeframe). + +For each stock, consider: +1. BUY/SELL/HOLD decision and the confidence level +2. Technical indicators and price momentum +3. Fundamental strength (earnings, revenue, valuations) +4. News sentiment and potential catalysts +5. Risk factors and volatility + +STOCK PREDICTIONS: +{formatted} + +Based on your comprehensive analysis, provide your recommendations in the following format: + +## TOP 3 PICKS FOR SHORT-TERM GROWTH + +### 1. TOP PICK: [SYMBOL] +**Company:** [Company Name] +**Recommendation:** [BUY/STRONG BUY] +**Target Upside:** [X%] +**Reasoning:** [2-3 sentences explaining why this is the top pick, citing specific data points] +**Key Catalysts:** [List 2-3 near-term catalysts] +**Risk Level:** [Low/Medium/High] + +### 2. SECOND PICK: [SYMBOL] +**Company:** [Company Name] +**Recommendation:** [BUY/STRONG BUY] +**Target Upside:** [X%] +**Reasoning:** [2-3 sentences] +**Key Catalysts:** [List 2-3 near-term catalysts] +**Risk Level:** [Low/Medium/High] + +### 3. THIRD PICK: [SYMBOL] +**Company:** [Company Name] +**Recommendation:** [BUY/STRONG BUY] +**Target Upside:** [X%] +**Reasoning:** [2-3 sentences] +**Key Catalysts:** [List 2-3 near-term catalysts] +**Risk Level:** [Low/Medium/High] + +## STOCKS TO AVOID + +List any stocks that show concerning signals and should be avoided: +- [SYMBOL]: [Brief reason - e.g., "Bearish technical setup, negative news flow"] +- [SYMBOL]: [Brief reason] + +## MARKET CONTEXT + +Provide a brief (2-3 sentences) overview of the current market conditions affecting these recommendations. + +## DISCLAIMER + +Include a brief investment disclaimer. +""" + + # Use Claude Opus 4.5's large context window + response = llm.invoke(prompt) + + return { + "ranking_analysis": response.content, + "total_stocks_analyzed": len(valid_predictions), + "stocks_with_errors": len(predictions) - len(valid_predictions), + "timestamp": datetime.now().isoformat(), + } + + +def run_recommendation( + trade_date: str, + stock_subset: Optional[List[str]] = None, + save_results: bool = True, + results_dir: Optional[str] = None, + verbose: bool = True +) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Any]]: + """ + Main entry point for running the Nifty 50 recommendation system. + + Args: + trade_date: Date for the predictions (YYYY-MM-DD format) + stock_subset: Optional list of stock symbols to analyze. If None, analyzes all 50 + save_results: Whether to save results to disk + results_dir: Directory to save results. If None, uses default + verbose: Whether to print progress updates + + Returns: + Tuple of (predictions dict, ranking results dict) + """ + def progress_callback(current, total, symbol, result): + if verbose: + status = "✓" if not result.get("error") else "✗" + decision = result.get("decision", "ERROR") if not result.get("error") else "ERROR" + print(f"[{current}/{total}] {symbol}: {status} {decision}") + + if verbose: + print(f"\n{'='*60}") + print(f"NIFTY 50 STOCK RECOMMENDATION SYSTEM") + print(f"{'='*60}") + print(f"Date: {trade_date}") + stocks = stock_subset if stock_subset else get_nifty_50_list() + print(f"Analyzing {len(stocks)} stocks...") + print(f"{'='*60}\n") + + # Run predictions for all stocks + predictions = predict_all_nifty50( + trade_date=trade_date, + stock_subset=stock_subset, + on_progress=progress_callback + ) + + if verbose: + successful = sum(1 for p in predictions.values() if not p.get("error")) + print(f"\n{'='*60}") + print(f"Predictions Complete: {successful}/{len(predictions)} successful") + print(f"{'='*60}\n") + print("Ranking stocks with Claude Opus 4.5...") + + # Rank stocks using Claude Opus 4.5 + ranking = rank_stocks_for_growth(predictions) + + if verbose: + print(f"\n{'='*60}") + print("RECOMMENDATION RESULTS") + print(f"{'='*60}\n") + print(ranking.get("ranking_analysis", "No ranking available")) + + # Save results if requested + if save_results: + if results_dir is None: + results_dir = Path(DEFAULT_CONFIG["results_dir"]) / "nifty50_recommendations" + else: + results_dir = Path(results_dir) + + results_dir.mkdir(parents=True, exist_ok=True) + + # Save predictions + predictions_file = results_dir / f"predictions_{trade_date}.json" + with open(predictions_file, "w") as f: + # Convert to serializable format + serializable_predictions = {} + for symbol, pred in predictions.items(): + serializable_predictions[symbol] = { + k: str(v) if not isinstance(v, (str, dict, list, type(None))) else v + for k, v in pred.items() + } + json.dump(serializable_predictions, f, indent=2) + + # Save ranking + ranking_file = results_dir / f"ranking_{trade_date}.json" + with open(ranking_file, "w") as f: + json.dump(ranking, f, indent=2) + + # Save readable report + report_file = results_dir / f"report_{trade_date}.md" + with open(report_file, "w") as f: + f.write(f"# Nifty 50 Stock Recommendation Report\n\n") + f.write(f"**Date:** {trade_date}\n\n") + f.write(f"**Stocks Analyzed:** {ranking.get('total_stocks_analyzed', 0)}\n\n") + f.write(f"**Generated:** {ranking.get('timestamp', 'N/A')}\n\n") + f.write("---\n\n") + f.write(ranking.get("ranking_analysis", "No ranking available")) + + if verbose: + print(f"\nResults saved to: {results_dir}") + + return predictions, ranking diff --git a/tradingagents/nifty50_simple_recommender.py b/tradingagents/nifty50_simple_recommender.py new file mode 100644 index 00000000..ba1fb28e --- /dev/null +++ b/tradingagents/nifty50_simple_recommender.py @@ -0,0 +1,364 @@ +""" +Simplified Nifty 50 Stock Recommendation System. + +This module uses Claude Max subscription (via CLI) to analyze pre-fetched stock data +and provide recommendations. It bypasses the complex agent framework to work with +Claude Max subscription without API keys. +""" + +import os +# Disable CUDA to avoid library issues with embeddings +os.environ["CUDA_VISIBLE_DEVICES"] = "" +# Remove API key to force Claude Max subscription auth +os.environ.pop("ANTHROPIC_API_KEY", None) + +import json +from pathlib import Path +from typing import Dict, Any, Optional, List, Tuple +from datetime import datetime, timedelta + +from tradingagents.dataflows.markets import NIFTY_50_STOCKS, get_nifty_50_list +from tradingagents.claude_max_llm import ClaudeMaxLLM + +# Import data fetching tools +from tradingagents.agents.utils.agent_utils import ( + get_stock_data, + get_indicators, + get_fundamentals, + get_news, +) + + +def fetch_stock_data(symbol: str, trade_date: str) -> Dict[str, str]: + """ + Fetch all relevant data for a stock. + + Args: + symbol: Stock symbol (e.g., 'TCS', 'RELIANCE') + trade_date: Date for analysis (YYYY-MM-DD) + + Returns: + Dictionary with stock data, indicators, fundamentals, and news + """ + # Calculate date range (10 days before trade date) + end_date = trade_date + start_date = (datetime.strptime(trade_date, "%Y-%m-%d") - timedelta(days=30)).strftime("%Y-%m-%d") + + data = {} + + # Fetch stock price data + try: + data["price_data"] = get_stock_data.invoke({ + "symbol": symbol, + "start_date": start_date, + "end_date": end_date + }) + except Exception as e: + data["price_data"] = f"Error fetching price data: {e}" + + # Fetch technical indicators + try: + data["indicators"] = get_indicators.invoke({ + "symbol": symbol, + "start_date": start_date, + "end_date": end_date + }) + except Exception as e: + data["indicators"] = f"Error fetching indicators: {e}" + + # Fetch fundamentals + try: + data["fundamentals"] = get_fundamentals.invoke({ + "symbol": symbol + }) + except Exception as e: + data["fundamentals"] = f"Error fetching fundamentals: {e}" + + # Fetch news + try: + company_name = NIFTY_50_STOCKS.get(symbol, symbol) + data["news"] = get_news.invoke({ + "symbol": symbol, + "company_name": company_name + }) + except Exception as e: + data["news"] = f"Error fetching news: {e}" + + return data + + +def analyze_stock(symbol: str, data: Dict[str, str], llm: ClaudeMaxLLM) -> Dict[str, Any]: + """ + Use Claude to analyze a stock based on pre-fetched data. + + Args: + symbol: Stock symbol + data: Pre-fetched stock data + llm: ClaudeMaxLLM instance + + Returns: + Analysis result with decision and reasoning + """ + company_name = NIFTY_50_STOCKS.get(symbol, symbol) + + prompt = f"""You are an expert stock analyst. Analyze the following data for {symbol} ({company_name}) and provide: +1. A trading decision: BUY, SELL, or HOLD +2. Confidence level: High, Medium, or Low +3. Key reasoning (2-3 sentences) +4. Risk assessment: High, Medium, or Low + +## Price Data (Last 30 days) +{data.get('price_data', 'Not available')[:2000]} + +## Technical Indicators +{data.get('indicators', 'Not available')[:2000]} + +## Fundamentals +{data.get('fundamentals', 'Not available')[:2000]} + +## Recent News +{data.get('news', 'Not available')[:1500]} + +Provide your analysis in this exact format: +DECISION: [BUY/SELL/HOLD] +CONFIDENCE: [High/Medium/Low] +REASONING: [Your 2-3 sentence reasoning] +RISK: [High/Medium/Low] +TARGET: [Expected price movement in next 1-2 weeks, e.g., "+5%" or "-3%"] +""" + + try: + response = llm.invoke(prompt) + analysis_text = response.content + + # Parse the response + result = { + "symbol": symbol, + "company_name": company_name, + "raw_analysis": analysis_text, + "error": None + } + + # Extract structured data (handle markdown formatting like **DECISION:**) + import re + text_upper = analysis_text.upper() + + # Look for DECISION + decision_match = re.search(r'\*?\*?DECISION:?\*?\*?\s*([A-Z]+)', text_upper) + if decision_match: + result["decision"] = decision_match.group(1).strip() + + # Look for CONFIDENCE + confidence_match = re.search(r'\*?\*?CONFIDENCE:?\*?\*?\s*([A-Z]+)', text_upper) + if confidence_match: + result["confidence"] = confidence_match.group(1).strip() + + # Look for RISK + risk_match = re.search(r'\*?\*?RISK:?\*?\*?\s*([A-Z]+)', text_upper) + if risk_match: + result["risk"] = risk_match.group(1).strip() + + return result + + except Exception as e: + return { + "symbol": symbol, + "company_name": company_name, + "decision": None, + "error": str(e) + } + + +def analyze_all_stocks( + trade_date: str, + stock_subset: Optional[List[str]] = None, + on_progress: Optional[callable] = None +) -> Dict[str, Dict[str, Any]]: + """ + Analyze all Nifty 50 stocks (or a subset). + + Args: + trade_date: Date for analysis + stock_subset: Optional list of symbols to analyze + on_progress: Optional callback(current, total, symbol, result) + + Returns: + Dictionary of analysis results + """ + # Initialize Claude LLM + llm = ClaudeMaxLLM(model="sonnet") + + stocks = stock_subset if stock_subset else get_nifty_50_list() + total = len(stocks) + results = {} + + for i, symbol in enumerate(stocks, 1): + # Fetch data + data = fetch_stock_data(symbol, trade_date) + + # Analyze with Claude + analysis = analyze_stock(symbol, data, llm) + results[symbol] = analysis + + if on_progress: + on_progress(i, total, symbol, analysis) + + return results + + +def rank_stocks(results: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: + """ + Rank stocks by growth potential using Claude Opus. + + Args: + results: Analysis results for all stocks + + Returns: + Ranking with top picks + """ + llm = ClaudeMaxLLM(model="opus") + + # Build summary of all analyses + summaries = [] + for symbol, analysis in results.items(): + if analysis.get("error"): + continue + summaries.append(f""" +{symbol} ({analysis.get('company_name', symbol)}): +- Decision: {analysis.get('decision', 'N/A')} +- Confidence: {analysis.get('confidence', 'N/A')} +- Risk: {analysis.get('risk', 'N/A')} +- Analysis: {analysis.get('raw_analysis', 'N/A')[:300]} +""") + + if not summaries: + return {"error": "No valid analyses to rank", "ranking": None} + + prompt = f"""You are an expert stock analyst. Based on the following analyses of Nifty 50 stocks, +identify the TOP 3 stocks with highest short-term growth potential (1-2 weeks). + +## Stock Analyses +{''.join(summaries)} + +Provide your ranking in this format: + +## TOP 3 PICKS + +### 1. [SYMBOL] - TOP PICK +**Decision:** [BUY/STRONG BUY] +**Reason:** [2-3 sentences explaining why this is the top pick] +**Risk Level:** [Low/Medium/High] + +### 2. [SYMBOL] - SECOND PICK +**Decision:** [BUY] +**Reason:** [2-3 sentences] +**Risk Level:** [Low/Medium/High] + +### 3. [SYMBOL] - THIRD PICK +**Decision:** [BUY] +**Reason:** [2-3 sentences] +**Risk Level:** [Low/Medium/High] + +## STOCKS TO AVOID +List 2-3 stocks that should be avoided with brief reasons. +""" + + try: + response = llm.invoke(prompt) + return { + "ranking": response.content, + "stocks_analyzed": len(summaries), + "timestamp": datetime.now().isoformat() + } + except Exception as e: + return {"error": str(e), "ranking": None} + + +def run_recommendation( + trade_date: str, + stock_subset: Optional[List[str]] = None, + save_results: bool = True, + results_dir: Optional[str] = None, + verbose: bool = True +) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Any]]: + """ + Main entry point for the recommendation system. + + Args: + trade_date: Date for analysis + stock_subset: Optional list of symbols + save_results: Whether to save results to disk + results_dir: Directory for results + verbose: Print progress + + Returns: + Tuple of (analysis results, ranking) + """ + def progress_callback(current, total, symbol, result): + if verbose: + status = "✓" if not result.get("error") else "✗" + decision = result.get("decision", "ERROR") if not result.get("error") else "ERROR" + print(f"[{current}/{total}] {symbol}: {status} {decision}") + + if verbose: + print(f"\n{'='*60}") + print("NIFTY 50 STOCK RECOMMENDATION SYSTEM (Simple)") + print(f"{'='*60}") + print(f"Date: {trade_date}") + stocks = stock_subset if stock_subset else get_nifty_50_list() + print(f"Analyzing {len(stocks)} stocks...") + print(f"Using Claude Max subscription via CLI") + print(f"{'='*60}\n") + + # Analyze all stocks + results = analyze_all_stocks(trade_date, stock_subset, progress_callback) + + if verbose: + successful = sum(1 for r in results.values() if not r.get("error")) + print(f"\n{'='*60}") + print(f"Analysis Complete: {successful}/{len(results)} successful") + print(f"{'='*60}\n") + print("Ranking stocks with Claude Opus...") + + # Rank stocks + ranking = rank_stocks(results) + + if verbose: + print(f"\n{'='*60}") + print("RECOMMENDATION RESULTS") + print(f"{'='*60}\n") + if ranking.get("ranking"): + print(ranking["ranking"]) + else: + print(f"Error: {ranking.get('error', 'Unknown error')}") + + # Save results if requested + if save_results: + from tradingagents.default_config import DEFAULT_CONFIG + if results_dir is None: + results_dir = Path(DEFAULT_CONFIG["results_dir"]) / "nifty50_simple_recommendations" + else: + results_dir = Path(results_dir) + + results_dir.mkdir(parents=True, exist_ok=True) + + # Save analysis results + with open(results_dir / f"analysis_{trade_date}.json", "w") as f: + json.dump(results, f, indent=2, default=str) + + # Save ranking + with open(results_dir / f"ranking_{trade_date}.json", "w") as f: + json.dump(ranking, f, indent=2, default=str) + + # Save readable report + with open(results_dir / f"report_{trade_date}.md", "w") as f: + f.write(f"# Nifty 50 Stock Recommendation Report\n\n") + f.write(f"**Date:** {trade_date}\n\n") + f.write(f"**Stocks Analyzed:** {ranking.get('stocks_analyzed', 0)}\n\n") + f.write("---\n\n") + f.write(ranking.get("ranking", "No ranking available")) + + if verbose: + print(f"\nResults saved to: {results_dir}") + + return results, ranking