Compare commits

...

59 Commits

Author SHA1 Message Date
hemangjoshi37a e6dbd11ba1 Remove Tauric Research branding and improve README SEO/UX
- Delete TauricResearch.png and wechat.png assets
- Fix clone URL to point to hemangjoshi37a/TradingAgents
- Fix broken anchor links in README navigation
- Fix license badge (Apache 2.0, not MIT)
- Add Analysis Pipeline and Investment Debates screenshots
- Improve image alt texts for SEO
- Replace Unicode box-drawing chars with ASCII in architecture diagram
- Update package.json with keywords, author, correct URLs
- Update pyproject.toml description
- Update setup.py URL
- Refresh screenshots after dark mode theme fix
- Update .gitignore for Playwright and test artifacts
2026-02-14 14:32:21 +11:00
hemangjoshi37a 7619a7f9bb ok 2026-02-14 14:24:18 +11:00
hemangjoshi37a b79179cea2 okok 2026-02-11 01:37:36 +11:00
hemangjoshi37a 473478a32d ok 2026-02-08 22:24:13 +11:00
hemangjoshi37a a556099d97 add 2026-02-08 12:59:10 +11:00
hemangjoshi37a bdc27679fa add 2026-02-08 12:33:09 +11:00
hemangjoshi37a 86381157d8 Address PR review feedback from Gemini Code Assist
Fixes:
- Remove duplicate get_running_analyses function (critical)
- Fix N+1 query in get_pipeline_summary_for_date with batch queries (high)
- Add thread-safety warning comment for running_analyses dict (high)
- Remove package-lock.json from .gitignore and track it (high)
- Config param in memory.py kept for backward compatibility (documented)
2026-02-01 08:24:35 +11:00
hemangjoshi37a d9e71b74cd Add Nifty50 AI Frontend documentation with screenshots to main README
- Added comprehensive frontend section with 10 feature screenshots
- Documented all key features: Dashboard, Settings, Pipeline, Debates, History
- Included Quick Start guide and tech stack information
- Added project structure overview
2026-02-01 08:10:21 +11:00
hemangjoshi37a f87ce2495e add all 2026-02-01 08:05:09 +11:00
hemangjoshi37a d1e99c7db9 Add Settings UI, Analysis Pipeline visualization, and comprehensive documentation
Features:
- API key management with secure browser localStorage
- Model selection for Deep Think (Opus) and Quick Think (Sonnet/Haiku)
- Configurable max debate rounds (1-5)
- Full analysis pipeline visualization with 9-step progress tracking
- Agent reports display (Market, News, Social, Fundamentals analysts)
- Investment debate viewer (Bull vs Bear with Research Manager decision)
- Risk debate viewer (Aggressive vs Conservative vs Neutral)
- Data sources tracking panel
- Dark mode support throughout
- Bulk "Analyze All" functionality for all 50 stocks

Backend:
- Added analysis config parameters to API endpoints
- Support for provider/model selection in analysis requests
- Indian market data integration improvements

Documentation:
- Comprehensive README with 10 feature screenshots
- API endpoint documentation
- Project structure guide
- Getting started instructions
2026-02-01 08:01:53 +11:00
hemangjoshi37a 9a292cde34 ok 2026-02-01 06:55:15 +11:00
Hemang Joshi 92ff07a2b1 add 2026-01-31 18:44:53 +05:30
hemangjoshi37a e43acb8247 Add Nifty50 AI Trading Dashboard frontend and Indian market support
- Add React + Vite + Tailwind CSS frontend for Nifty50 recommendations
- Add FastAPI backend for serving stock recommendations
- Add Indian market data sources (jugaad_data, markets API)
- Add Nifty50 stock recommender modules
- Update dataflows for Indian market support
- Fix various utility and configuration updates
2026-01-31 19:41:01 +11:00
Edward Sun 7902d249ca Merge pull request #245 from TauricResearch/feat/tooloptim
Y Finance Tools Optimizations
2025-10-09 00:34:10 -07:00
Edward Sun fabdde86e0 updated readme 2025-10-09 00:32:04 -07:00
Edward Sun 1cea7e837a update readme 2025-10-06 20:33:12 -07:00
Edward Sun 37f3bf7c8d optimized yfin fetching to be much faster 2025-10-06 19:58:01 -07:00
Yijia Xiao 341d49f560 Merge pull request #235 from luohy15/data_vendor
Add Alpha Vantage API Integration and Refactor Data Provider Architecture
2025-10-05 16:01:30 -07:00
Edward Sun c0f0415844 added fallbacks for tools 2025-10-03 22:40:09 -07:00
luohy15 e7d8305a25 minor fix 2025-09-30 13:27:48 +08:00
luohy15 01a12c945f Switch default data vendor 2025-09-30 12:43:27 +08:00
luohy15 5b8a917fff alpha vantage api key url 2025-09-29 18:22:31 +08:00
luohy15 1949ac7d75 minor fix 2025-09-27 00:04:59 +08:00
luohy15 8a61fe0cac Add environment variable configuration support
- Add .env.example file with API key placeholders
- Update README.md with .env file setup instructions
- Add dotenv loading in main.py for environment variables
2025-09-26 23:58:51 +08:00
luohy15 26edb71254 Update configuration documentation for Alpha Vantage data vendor
Add data vendor configuration examples in README and main.py showing how to configure Alpha Vantage as the primary data provider. Update documentation to reflect the current default behavior of using Alpha Vantage for real-time market data access.
2025-09-26 23:52:26 +08:00
luohy15 3e902d58fc Improve Alpha Vantage indicator column parsing with robust mapping
- Replace hardcoded column indices with column name lookup
- Add mapping for all supported indicators to their expected CSV column names
- Handle missing columns gracefully with descriptive error messages
- Strip whitespace from header parsing for reliability
2025-09-26 23:36:36 +08:00
luohy15 a880216c98 minor fix 2025-09-26 23:25:33 +08:00
luohy15 d63c5dcd46 Add Alpha Vantage API integration as primary data provider
- Replace FinnHub with Alpha Vantage API in README documentation
- Implement comprehensive Alpha Vantage modules:
  - Stock data (daily OHLCV with date filtering)
  - Technical indicators (SMA, EMA, MACD, RSI, Bollinger Bands, ATR)
  - Fundamental data (overview, balance sheet, cashflow, income statement)
  - News and sentiment data with insider transactions
- Update news analyst tools to use ticker-based news search
- Integrate Alpha Vantage vendor methods into interface routing
- Maintain backward compatibility with existing vendor system
2025-09-26 22:57:50 +08:00
luohy15 b1d1496ec0 WIP 2025-09-26 16:17:50 +08:00
Yijia Xiao 7e0fca554f Merge pull request #89 from Mirza-Samad-Ahmed-Baig/fixes
Enhancement: agent reflection, logging improvement
2025-07-03 10:15:39 -04:00
Yijia Xiao cedde45285 Update main.py 2025-07-03 10:14:06 -04:00
mirza-samad-ahmed-baig 109941af43 Fix: Prevent infinite loops, enable reflection, and improve logging 2025-07-03 17:43:40 +05:00
Edward Sun 76fd2f5aea Merge pull request #49 from Zhongyi-Lu/a
Exclude `.env` from Git.
2025-07-01 09:17:46 -07:00
Yijia Xiao db73eafd6f Merge pull request #29 from ZeroAct/save_results
Save results
2025-06-26 00:28:30 -04:00
Max Wong fdea6c9d87 Local Ollama (#53)
- Fix typo 'Start' 'End'
- Add llama3.1 selection
- Use 'quick_think_llm' model instead of hard-coding GPT
2025-06-26 00:27:01 -04:00
Yijia Xiao 9b5dbc166b Revert "Docker support and Ollama support (#47)" (#57)
This reverts commit 78ea029a0b.
2025-06-26 00:07:58 -04:00
Geeta Chauhan a8c031fe64 Docker support and Ollama support (#47)
- Added support for running CLI and Ollama server via Docker
- Introduced tests for local embeddings model and standalone Docker setup
- Enabled conditional Ollama server launch via LLM_PROVIDER
2025-06-25 23:57:05 -04:00
Huijae Lee d75ab7cf2e Merge branch 'TauricResearch:main' into save_results 2025-06-25 08:43:19 +09:00
Yijia Xiao 51da620c40 Merge pull request #46 from AtharvSabde/patch-2
Updated requirements.txt based on latest commit
2025-06-23 20:40:58 -04:00
Yijia Xiao bbf0ab24df Merge pull request #52 from TauricResearch/dev
Merge dev into main. Add support for Anthropic and OpenRouter.
2025-06-23 20:38:14 -04:00
Zhongyi Lu caaf399232 Exclude `.env` from Git 2025-06-21 23:29:26 -07:00
Edward Sun d9431181d6 fixed anthropic support. Anthropic has different format of response when it has tool calls. Explicit handling added 2025-06-21 12:51:34 -07:00
Atharv Sabde f2b48c9c85 Updated requirements.txt based on latest commit
PULL REQUEST: Add support for other backends, such as OpenRouter and Ollama

it had two requirments missing. added those
2025-06-20 15:58:22 +05:30
Yijia Xiao adf529a895 Merge pull request #40 from RealMyth21/main
Updated README.md: Swap Trader and Management order.
2025-06-19 15:10:36 -04:00
Yijia Xiao aaf0eb6412 Merge pull request #43 from AtharvSabde/patch-1
fundamentals_analyst.py (spelling mistake in instruction: Makrdown -> Markdown)
2025-06-19 15:05:08 -04:00
Yijia Xiao ed66256aa3 Merge pull request #44 from TauricResearch/dev
Merge dev into main branch
2025-06-19 15:00:07 -04:00
Atharv Sabde 2d2b574327 fundamentals_analyst.py(spelling mistake.markdown) 2025-06-19 21:48:16 +05:30
Mithil Srungarapu 7e2715d045 Updated README.md
The diagrams were switched, so I fixed it.
2025-06-18 19:08:10 -07:00
Edward Sun 71c1e6d08e update clear msg bc anthropic needs at least 1 msg in chat call 2025-06-15 23:14:47 -07:00
Edward Sun a2dfee5996 main works, cli bugs 2025-06-15 22:20:59 -07:00
Edward Sun fc9b0d7247 Merge pull request #25 from maxer137/main
Add support for other backends, such as OpenRouter and Ollama
2025-06-15 16:06:20 -07:00
ZeroAct 4fd8378a2b refactor 2025-06-12 13:53:28 +09:00
saksham0161 dd422893cf Fix ticker hardcoding in prompt (#28) 2025-06-11 19:43:39 -07:00
ZeroAct 88f18d6500 save reports & logs under results_dir 2025-06-12 11:25:07 +09:00
maxer137 1781ec5075 Add support for other backends, such as OpenRouter and olama
This aims to offer alternative OpenAI capable api's.
This offers people to experiment with running the application locally
2025-06-11 14:19:25 +02:00
neo f7a5920e22 docs: add links to other language versions of README (#13)
Added language selection links to the README for easier access to translated versions: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
2025-06-09 15:51:06 -07:00
Yijia-Xiao 887f5262fa Add star history 2025-06-09 15:14:41 -07:00
Edward Sun ff606dfb13 Fix default python usage config code 2025-06-08 13:16:10 -07:00
Edward Sun 8ea3c78dff Remove EODHD from readme 2025-06-07 15:04:43 -07:00
249 changed files with 35927 additions and 1795 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
ALPHA_VANTAGE_API_KEY=alpha_vantage_api_key_placeholder
OPENAI_API_KEY=openai_api_key_placeholder

21
.gitignore vendored
View File

@ -1,8 +1,27 @@
.venv
results
env/
__pycache__/
.DS_Store
*.csv
src/
/src/
eval_results/
eval_data/
*.egg-info/
.env
# Node.js
node_modules/
# Frontend dev artifacts
.frontend-dev/
# Runtime config
schedule_config.json
# Playwright MCP artifacts
.playwright-mcp/
# Test screenshots (root level)
test-*.png
prediction-accuracy-new.png

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.10

BIN
01-dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

BIN
02-settings-modal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

BIN
04-analysis-pipeline.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

BIN
05-debates-tab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

BIN
07-data-sources-tab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
08-dashboard-dark-mode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
09-how-it-works.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

BIN
10-history-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

396
README.md
View File

@ -1,190 +1,382 @@
<p align="center">
<img src="assets/TauricResearch.png" style="width: 60%; height: auto;">
</p>
<div align="center">
<img src="assets/schema.png" width="120" alt="TradingAgents Logo" />
# TradingAgents
### Multi-Agent LLM Financial Trading Framework
[![arXiv](https://img.shields.io/badge/arXiv-2412.20138-B31B1B?logo=arxiv)](https://arxiv.org/abs/2412.20138)
[![Python 3.13+](https://img.shields.io/badge/Python-3.13+-3776AB?logo=python&logoColor=white)](https://www.python.org/)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
[![React](https://img.shields.io/badge/React_18-TypeScript-61DAFB?logo=react&logoColor=white)](https://react.dev/)
[![FastAPI](https://img.shields.io/badge/FastAPI-Backend-009688?logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-4.0-06B6D4?logo=tailwindcss&logoColor=white)](https://tailwindcss.com/)
<br />
An open-source framework that deploys **specialized AI agents** &mdash; analysts, researchers, traders, and risk managers &mdash; to collaboratively analyze markets and generate investment recommendations through structured debate.
<br />
[Getting Started](#getting-started) &nbsp;&bull;&nbsp; [Web Dashboard](#nifty50-ai-web-dashboard) &nbsp;&bull;&nbsp; [Python API](#python-api) &nbsp;&bull;&nbsp; [Architecture](#architecture) &nbsp;&bull;&nbsp; [Contributing](#contributing)
<br />
<div align="center" style="line-height: 1;">
<a href="https://arxiv.org/abs/2412.20138" target="_blank"><img alt="arXiv" src="https://img.shields.io/badge/arXiv-2412.20138-B31B1B?logo=arxiv"/></a>
<a href="https://discord.com/invite/hk9PGKShPK" target="_blank"><img alt="Discord" src="https://img.shields.io/badge/Discord-TradingResearch-7289da?logo=discord&logoColor=white&color=7289da"/></a>
<a href="./assets/wechat.png" target="_blank"><img alt="WeChat" src="https://img.shields.io/badge/WeChat-TauricResearch-brightgreen?logo=wechat&logoColor=white"/></a>
<a href="https://x.com/TauricResearch" target="_blank"><img alt="X Follow" src="https://img.shields.io/badge/X-TauricResearch-white?logo=x&logoColor=white"/></a>
<br>
<a href="https://github.com/TauricResearch/" target="_blank"><img alt="Community" src="https://img.shields.io/badge/Join_GitHub_Community-TauricResearch-14C290?logo=discourse"/></a>
</div>
---
# TradingAgents: Multi-Agents LLM Financial Trading Framework
## Highlights
> 🎉 **TradingAgents** officially released! We have received numerous inquiries about the work, and we would like to express our thanks for the enthusiasm in our community.
>
> So we decided to fully open-source the framework. Looking forward to building impactful projects with you!
<table>
<tr>
<td width="50%">
<div align="center">
**Multi-Agent Collaboration** &mdash; Specialized AI agents (Technical, Fundamental, Sentiment, Risk) work together, each bringing domain expertise to stock analysis.
🚀 [TradingAgents](#tradingagents-framework) | ⚡ [Installation & CLI](#installation-and-cli) | 🎬 [Demo](https://www.youtube.com/watch?v=90gr5lwjIho) | 📦 [Package Usage](#tradingagents-package) | 🤝 [Contributing](#contributing) | 📄 [Citation](#citation)
**Structured Debate System** &mdash; Bull and bear researchers debate findings, challenge assumptions, and reach consensus through reasoned discussion.
</div>
</td>
<td width="50%">
## TradingAgents Framework
**Real-Time Web Dashboard** &mdash; Production-grade React frontend with live analysis pipeline visualization, backtesting, and portfolio simulation.
TradingAgents is a multi-agent trading framework that mirrors the dynamics of real-world trading firms. By deploying specialized LLM-powered agents: from fundamental analysts, sentiment experts, and technical analysts, to trader, risk management team, the platform collaboratively evaluates market conditions and informs trading decisions. Moreover, these agents engage in dynamic discussions to pinpoint the optimal strategy.
**Configurable & Extensible** &mdash; Swap LLM providers (OpenAI, Anthropic Claude), adjust debate rounds, configure data sources, and extend with custom agents.
</td>
</tr>
</table>
---
## Screenshots
<details open>
<summary><b>Dashboard &mdash; AI Recommendations at a Glance</b></summary>
<br />
<p align="center">
<img src="assets/schema.png" style="width: 100%; height: auto;">
<img src="frontend/docs/screenshots/01-dashboard.png" width="100%" alt="TradingAgents Dashboard showing all 50 Nifty stocks with AI-powered BUY, SELL, HOLD recommendations, rank badges, confidence levels, and decision filters" />
</p>
</details>
> TradingAgents framework is designed for research purposes. Trading performance may vary based on many factors, including the chosen backbone language models, model temperature, trading periods, the quality of data, and other non-deterministic factors. [It is not intended as financial, investment, or trading advice.](https://tauric.ai/disclaimer/)
Our framework decomposes complex trading tasks into specialized roles. This ensures the system achieves a robust, scalable approach to market analysis and decision-making.
### Analyst Team
- Fundamentals Analyst: Evaluates company financials and performance metrics, identifying intrinsic values and potential red flags.
- Sentiment Analyst: Analyzes social media and public sentiment using sentiment scoring algorithms to gauge short-term market mood.
- News Analyst: Monitors global news and macroeconomic indicators, interpreting the impact of events on market conditions.
- Technical Analyst: Utilizes technical indicators (like MACD and RSI) to detect trading patterns and forecast price movements.
<details>
<summary><b>History &mdash; Backtesting & Portfolio Simulation</b></summary>
<br />
<p align="center">
<img src="assets/analyst.png" width="100%" style="display: inline-block; margin: 0 2%;">
<img src="frontend/docs/screenshots/10-history-page.png" width="100%" alt="Historical backtesting page with prediction accuracy tracking, Sharpe ratio, max drawdown, win rate, portfolio simulator with Zerodha brokerage, and AI vs Nifty50 index comparison chart" />
</p>
</details>
### Researcher Team
- Comprises both bullish and bearish researchers who critically assess the insights provided by the Analyst Team. Through structured debates, they balance potential gains against inherent risks.
<details>
<summary><b>Stock Detail &mdash; Deep Analysis View</b></summary>
<br />
<p align="center">
<img src="assets/researcher.png" width="70%" style="display: inline-block; margin: 0 2%;">
<img src="frontend/docs/screenshots/03-stock-detail-overview.png" width="100%" alt="Individual stock analysis page showing AI recommendation with confidence level, risk assessment, recommendation history timeline, and detailed analysis summary" />
</p>
</details>
### Trader Agent
- Composes reports from the analysts and researchers to make informed trading decisions. It determines the timing and magnitude of trades based on comprehensive market insights.
<details>
<summary><b>Analysis Pipeline &mdash; 12-Step AI Decision Process</b></summary>
<br />
<p align="center">
<img src="assets/risk.png" width="70%" style="display: inline-block; margin: 0 2%;">
<img src="frontend/docs/screenshots/04-analysis-pipeline.png" width="100%" alt="12-step analysis pipeline visualization showing data collection, technical analysis, fundamental analysis, sentiment analysis, bull vs bear debate, and final trading decision" />
</p>
</details>
### Risk Management and Portfolio Manager
- Continuously evaluates portfolio risk by assessing market volatility, liquidity, and other risk factors. The risk management team evaluates and adjusts trading strategies, providing assessment reports to the Portfolio Manager for final decision.
- The Portfolio Manager approves/rejects the transaction proposal. If approved, the order will be sent to the simulated exchange and executed.
<details>
<summary><b>Investment Debates &mdash; Bull vs Bear AI Agents</b></summary>
<br />
<p align="center">
<img src="assets/trader.png" width="70%" style="display: inline-block; margin: 0 2%;">
<img src="frontend/docs/screenshots/05-debates-tab.png" width="100%" alt="AI-powered investment debate between bull and bear researcher agents with research manager synthesis and final judgment" />
</p>
</details>
## Installation and CLI
<details>
<summary><b>Historical Date View &mdash; Ranked Stock Lists</b></summary>
<br />
<p align="center">
<img src="frontend/docs/screenshots/11-history-stocks-expanded.png" width="100%" alt="History page with date cards showing buy/sell/hold breakdown, return percentages, and expanded ranked stock list with hold periods" />
</p>
</details>
<details>
<summary><b>How It Works &mdash; Multi-Agent AI System</b></summary>
<br />
<p align="center">
<img src="frontend/docs/screenshots/09-how-it-works.png" width="100%" alt="Educational page explaining the multi-agent AI system architecture with agent role cards and structured debate process flow" />
</p>
</details>
<details>
<summary><b>Settings &mdash; Configurable AI Models</b></summary>
<br />
<p align="center">
<img src="frontend/docs/screenshots/02-settings-modal.png" width="60%" alt="Settings panel for configuring LLM provider selection between Claude and OpenAI, model tier selection, API key management, and debate round configuration" />
</p>
</details>
<details>
<summary><b>Dark Mode</b></summary>
<br />
<p align="center">
<img src="frontend/docs/screenshots/08-dashboard-dark-mode.png" width="100%" alt="Dashboard in dark mode with glassmorphic card design, premium styling, and automatic system theme detection" />
</p>
</details>
---
## Architecture
TradingAgents mirrors the structure of real-world trading firms by decomposing complex trading tasks into specialized roles:
```
+-------------------------------------+
| Data Collection |
| (Market, News, Social, Financials) |
+-----------------+-------------------+
|
+-----------------v-------------------+
| Analyst Team |
| Technical | Fundamental | Sentiment |
| | News | |
+-----------------+-------------------+
|
+-----------------v-------------------+
| Researcher Team |
| Bull Researcher vs Bear Researcher |
| (Structured AI Debate) |
+-----------------+-------------------+
|
+-----------------v-------------------+
| Trader Agent |
| Synthesizes reports -> Decision |
+-----------------+-------------------+
|
+-----------------v-------------------+
| Risk Management & Portfolio Mgr |
| Evaluates risk -> Approves/Rejects |
+-------------------------------------+
```
<details>
<summary><b>Agent Details</b></summary>
| Agent | Role | Key Capabilities |
|-------|------|------------------|
| **Technical Analyst** | Chart & indicator analysis | RSI, MACD, Bollinger Bands, moving averages, volume patterns |
| **Fundamental Analyst** | Financial evaluation | P/E ratios, earnings, debt analysis, intrinsic value |
| **Sentiment Analyst** | Market mood assessment | Social media trends, analyst ratings, market psychology |
| **News Analyst** | Event impact analysis | Macro indicators, breaking news, sector developments |
| **Bull Researcher** | Bullish case builder | Identifies growth catalysts, upside potential |
| **Bear Researcher** | Risk challenger | Highlights risks, valuation concerns, downside scenarios |
| **Trader Agent** | Decision synthesis | Combines all reports into actionable BUY/SELL/HOLD |
| **Risk Manager** | Portfolio protection | Volatility assessment, position sizing, drawdown limits |
</details>
---
## Getting Started
### Prerequisites
- Python 3.13+
- Node.js 18+ (for web dashboard)
- API keys: OpenAI or Anthropic Claude, [Alpha Vantage](https://www.alphavantage.co/support/#api-key) (free)
### Installation
Clone TradingAgents:
```bash
git clone https://github.com/TauricResearch/TradingAgents.git
# Clone the repository
git clone https://github.com/hemangjoshi37a/TradingAgents.git
cd TradingAgents
```
Create a virtual environment in any of your favorite environment managers:
```bash
# Create virtual environment
conda create -n tradingagents python=3.13
conda activate tradingagents
```
Install dependencies:
```bash
# Install dependencies
pip install -r requirements.txt
```
### Required APIs
### API Keys
You will also need the FinnHub API and EODHD API for financial data. All of our code is implemented with the free tier.
```bash
export FINNHUB_API_KEY=$YOUR_FINNHUB_API_KEY
export OPENAI_API_KEY=your_openai_key
export ALPHA_VANTAGE_API_KEY=your_alpha_vantage_key
```
You will need the OpenAI API for all the agents.
Or create a `.env` file from the template:
```bash
export OPENAI_API_KEY=$YOUR_OPENAI_API_KEY
cp .env.example .env
```
> **Note:** Alpha Vantage provides a free API key with 60 requests/minute for TradingAgents-sourced requests. For offline experimentation, a local data vendor option is also available.
### CLI Usage
You can also try out the CLI directly by running:
```bash
python -m cli.main
```
You will see a screen where you can select your desired tickers, date, LLMs, research depth, etc.
Select your tickers, date, LLMs, and research depth from the interactive interface.
<p align="center">
<img src="assets/cli/cli_init.png" width="100%" style="display: inline-block; margin: 0 2%;">
<img src="assets/cli/cli_init.png" width="100%" alt="TradingAgents CLI interface showing ticker selection, date picker, and LLM configuration" />
</p>
An interface will appear showing results as they load, letting you track the agent's progress as it runs.
---
<p align="center">
<img src="assets/cli/cli_news.png" width="100%" style="display: inline-block; margin: 0 2%;">
</p>
## Nifty50 AI Web Dashboard
<p align="center">
<img src="assets/cli/cli_transaction.png" width="100%" style="display: inline-block; margin: 0 2%;">
</p>
A production-grade web dashboard built for **Indian Nifty 50 stocks** with full transparency into the multi-agent decision process.
## TradingAgents Package
### Quick Start
### Implementation Details
```bash
# Terminal 1: Start the backend
cd frontend/backend
pip install -r requirements.txt
python server.py # http://localhost:8001
We built TradingAgents with LangGraph to ensure flexibility and modularity. We utilize `o1-preview` and `gpt-4o` as our deep thinking and fast thinking LLMs for our experiments. However, for testing purposes, we recommend you use `o4-mini` and `gpt-4.1-mini` to save on costs as our framework makes **lots of** API calls.
### Python Usage
To use TradingAgents inside your code, you can import the `tradingagents` module and initialize a `TradingAgentsGraph()` object. The `.propagate()` function will return a decision. You can run `main.py`, here's also a quick example:
```python
from tradingagents.graph.trading_graph import TradingAgentsGraph
ta = TradingAgentsGraph(debug=True, config=config)
# forward propagate
_, decision = ta.propagate("NVDA", "2024-05-10")
print(decision)
# Terminal 2: Start the frontend
cd frontend
npm install
npm run dev # http://localhost:5173
```
You can also adjust the default configuration to set your own choice of LLMs, debate rounds, etc.
### Features
| Feature | Description |
|---------|-------------|
| **AI Recommendations** | BUY/SELL/HOLD decisions for all 50 Nifty stocks with confidence levels and risk ratings |
| **Stock Ranking (1-50)** | Composite scoring algorithm ranks stocks from best to worst investment opportunity |
| **Analysis Pipeline** | 12-step visualization showing data collection, agent analysis, debate, and decision |
| **Investment Debates** | Full bull vs bear debate transcripts with research manager synthesis |
| **Backtesting** | Prediction accuracy tracking, risk metrics (Sharpe, drawdown), win/loss ratios, date backtest runner with cancel support |
| **Portfolio Simulator** | Paper trading simulation with Zerodha-accurate brokerage charges and Nifty50 benchmarking |
| **Settings Panel** | Configure LLM provider (Claude/OpenAI), model tiers, debate rounds, parallel workers |
| **Dark Mode** | Automatic system theme detection with manual toggle |
### Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | React 18 + TypeScript, Vite, Tailwind CSS 4 |
| Charts | Recharts |
| Icons | Lucide React |
| Backend | FastAPI (Python) |
| Database | SQLite |
| Fonts | DM Sans + Plus Jakarta Sans |
### Project Structure
```
frontend/
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── pipeline/ # Analysis pipeline visualization
│ │ ├── StockCard.tsx # Stock cards with rank badges
│ │ ├── TopPicks.tsx # Top picks & stocks to avoid
│ │ └── Header.tsx # Navigation header
│ ├── contexts/ # React contexts (Settings, Theme)
│ ├── pages/
│ │ ├── Dashboard.tsx # Main stock grid with filters
│ │ ├── StockDetail.tsx # Individual stock analysis
│ │ ├── History.tsx # Backtesting & portfolio sim
│ │ └── About.tsx # How it works
│ ├── services/api.ts # API client
│ └── types/index.ts # TypeScript type definitions
├── backend/
│ ├── server.py # FastAPI server
│ ├── database.py # SQLite operations & ranking
│ └── backtest_service.py # Backtesting engine
└── docs/screenshots/ # Documentation screenshots
```
---
## Python API
Use TradingAgents programmatically in your own projects:
```python
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
# Create a custom config
config = DEFAULT_CONFIG.copy()
config["deep_think_llm"] = "gpt-4.1-nano" # Use a different model
config["quick_think_llm"] = "gpt-4.1-nano" # Use a different model
config["max_debate_rounds"] = 1 # Increase debate rounds
config["online_tools"] = True # Use online tools or cached data
ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy())
# Initialize with custom config
ta = TradingAgentsGraph(debug=True, config=config)
# forward propagate
# Analyze a stock
_, decision = ta.propagate("NVDA", "2024-05-10")
print(decision)
```
> For `online_tools`, we recommend enabling them for experimentation, as they provide access to real-time data. The agents' offline tools rely on cached data from our **Tauric TradingDB**, a curated dataset we use for backtesting. We're currently in the process of refining this dataset, and we plan to release it soon alongside our upcoming projects. Stay tuned!
### Custom Configuration
You can view the full list of configurations in `tradingagents/default_config.py`.
```python
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
config = DEFAULT_CONFIG.copy()
config["deep_think_llm"] = "gpt-4.1-nano"
config["quick_think_llm"] = "gpt-4.1-nano"
config["max_debate_rounds"] = 3
config["data_vendors"] = {
"core_stock_apis": "yfinance",
"technical_indicators": "yfinance",
"fundamental_data": "alpha_vantage",
"news_data": "alpha_vantage",
}
ta = TradingAgentsGraph(debug=True, config=config)
_, decision = ta.propagate("NVDA", "2024-05-10")
```
See `tradingagents/default_config.py` for the full list of configuration options.
---
## Contributing
We welcome contributions from the community! Whether it's fixing a bug, improving documentation, or suggesting a new feature, your input helps make this project better. If you are interested in this line of research, please consider joining our open-source financial AI research community [Tauric Research](https://tauric.ai/).
We welcome contributions! Whether it's fixing a bug, improving documentation, or suggesting a new feature &mdash; your input helps make this project better.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/your-feature`)
3. Commit your changes
4. Push to the branch and open a Pull Request
---
## Disclaimer
TradingAgents is designed for **research and educational purposes only**. Trading performance varies based on LLM selection, model temperature, data quality, and other non-deterministic factors. This software is **not financial, investment, or trading advice**. Always do your own research and consult a qualified financial advisor.
---
## Citation
Please reference our work if you find *TradingAgents* provides you with some help :)
If you find TradingAgents useful in your research, please cite:
```
```bibtex
@misc{xiao2025tradingagentsmultiagentsllmfinancial,
title={TradingAgents: Multi-Agents LLM Financial Trading Framework},
title={TradingAgents: Multi-Agents LLM Financial Trading Framework},
author={Yijia Xiao and Edward Sun and Di Luo and Wei Wang},
year={2025},
eprint={2412.20138},
archivePrefix={arXiv},
primaryClass={q-fin.TR},
url={https://arxiv.org/abs/2412.20138},
url={https://arxiv.org/abs/2412.20138},
}
```
---
<div align="center">
Built and maintained by **[hjlabs.in](https://hjlabs.in)**
<sub>Made with AI agents that actually debate before deciding.</sub>
</div>

BIN
analysis-cancelled.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
analysis-live-fullpage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
analysis-live-progress.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
analyze-all-skipped.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

View File

@ -1,7 +1,13 @@
from typing import Optional
import datetime
import typer
from pathlib import Path
from functools import wraps
from rich.console import Console
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
from rich.panel import Panel
from rich.spinner import Spinner
from rich.live import Live
@ -20,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 *
@ -97,7 +104,7 @@ class MessageBuffer:
if content is not None:
latest_section = section
latest_content = content
if latest_section and latest_content:
# Format the current section for display
section_titles = {
@ -189,7 +196,7 @@ def update_display(layout, spinner_text=None):
layout["header"].update(
Panel(
"[bold green]Welcome to TradingAgents CLI[/bold green]\n"
"[dim]© [Tauric Research](https://github.com/TauricResearch)[/dim]",
"[dim]© [hjlabs.in](https://hjlabs.in)[/dim]",
title="Welcome to TradingAgents",
border_style="green",
padding=(1, 2),
@ -295,10 +302,27 @@ def update_display(layout, spinner_text=None):
# Add regular messages
for timestamp, msg_type, content in message_buffer.messages:
# Convert content to string if it's not already
content_str = content
if isinstance(content, list):
# Handle list of content blocks (Anthropic format)
text_parts = []
for item in content:
if isinstance(item, dict):
if item.get('type') == 'text':
text_parts.append(item.get('text', ''))
elif item.get('type') == 'tool_use':
text_parts.append(f"[Tool: {item.get('name', 'unknown')}]")
else:
text_parts.append(str(item))
content_str = ' '.join(text_parts)
elif not isinstance(content_str, str):
content_str = str(content)
# Truncate message content if too long
if isinstance(content, str) and len(content) > 200:
content = content[:197] + "..."
all_messages.append((timestamp, msg_type, content))
if len(content_str) > 200:
content_str = content_str[:197] + "..."
all_messages.append((timestamp, msg_type, content_str))
# Sort by timestamp
all_messages.sort(key=lambda x: x[0])
@ -384,7 +408,7 @@ def get_user_selections():
welcome_content += "[bold]Workflow Steps:[/bold]\n"
welcome_content += "I. Analyst Team → II. Research Team → III. Trader → IV. Risk Management → V. Portfolio Management\n\n"
welcome_content += (
"[dim]Built by [Tauric Research](https://github.com/TauricResearch)[/dim]"
"[dim]Built by [hjlabs.in](https://hjlabs.in)[/dim]"
)
# Create and center the welcome box
@ -406,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()
@ -436,30 +473,41 @@ 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: Thinking agents
# Step 6: OpenAI backend
console.print(
create_question_box(
"Step 5: Thinking Agents", "Select your thinking agents for analysis"
"Step 6: LLM Provider", "Select which service to talk to"
)
)
selected_shallow_thinker = select_shallow_thinking_agent()
selected_deep_thinker = select_deep_thinking_agent()
selected_llm_provider, backend_url = select_llm_provider()
# Step 7: Thinking agents
console.print(
create_question_box(
"Step 7: Thinking Agents", "Select your thinking agents for analysis"
)
)
selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider)
selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider)
return {
"ticker": selected_ticker,
"analysis_date": analysis_date,
"analysts": selected_analysts,
"research_depth": selected_research_depth,
"llm_provider": selected_llm_provider.lower(),
"backend_url": backend_url,
"shallow_thinker": selected_shallow_thinker,
"deep_thinker": selected_deep_thinker,
"market": selected_market,
}
@ -683,6 +731,24 @@ def update_research_team_status(status):
for agent in research_team:
message_buffer.update_agent_status(agent, status)
def extract_content_string(content):
"""Extract string content from various message formats."""
if isinstance(content, str):
return content
elif isinstance(content, list):
# Handle Anthropic's list format
text_parts = []
for item in content:
if isinstance(item, dict):
if item.get('type') == 'text':
text_parts.append(item.get('text', ''))
elif item.get('type') == 'tool_use':
text_parts.append(f"[Tool: {item.get('name', 'unknown')}]")
else:
text_parts.append(str(item))
return ' '.join(text_parts)
else:
return str(content)
def run_analysis():
# First get all user selections
@ -694,12 +760,68 @@ def run_analysis():
config["max_risk_discuss_rounds"] = selections["research_depth"]
config["quick_think_llm"] = selections["shallow_thinker"]
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(
[analyst.value for analyst in selections["analysts"]], config=config, debug=True
)
# Create result directory
results_dir = Path(config["results_dir"]) / selections["ticker"] / selections["analysis_date"]
results_dir.mkdir(parents=True, exist_ok=True)
report_dir = results_dir / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
log_file = results_dir / "message_tool.log"
log_file.touch(exist_ok=True)
def save_message_decorator(obj, func_name):
func = getattr(obj, func_name)
@wraps(func)
def wrapper(*args, **kwargs):
func(*args, **kwargs)
timestamp, message_type, content = obj.messages[-1]
content = content.replace("\n", " ") # Replace newlines with spaces
with open(log_file, "a") as f:
f.write(f"{timestamp} [{message_type}] {content}\n")
return wrapper
def save_tool_call_decorator(obj, func_name):
func = getattr(obj, func_name)
@wraps(func)
def wrapper(*args, **kwargs):
func(*args, **kwargs)
timestamp, tool_name, args = obj.tool_calls[-1]
args_str = ", ".join(f"{k}={v}" for k, v in args.items())
with open(log_file, "a") as f:
f.write(f"{timestamp} [Tool Call] {tool_name}({args_str})\n")
return wrapper
def save_report_section_decorator(obj, func_name):
func = getattr(obj, func_name)
@wraps(func)
def wrapper(section_name, content):
func(section_name, content)
if section_name in obj.report_sections and obj.report_sections[section_name] is not None:
content = obj.report_sections[section_name]
if content:
file_name = f"{section_name}.md"
with open(report_dir / file_name, "w") as f:
f.write(content)
return wrapper
message_buffer.add_message = save_message_decorator(message_buffer, "add_message")
message_buffer.add_tool_call = save_tool_call_decorator(message_buffer, "add_tool_call")
message_buffer.update_report_section = save_report_section_decorator(message_buffer, "update_report_section")
# Now start the display layout
layout = create_layout()
@ -708,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'])}",
@ -754,14 +883,14 @@ def run_analysis():
# Extract message content and type
if hasattr(last_message, "content"):
content = last_message.content
content = extract_content_string(last_message.content) # Use the helper function
msg_type = "Reasoning"
else:
content = str(last_message)
msg_type = "System"
# Add message to buffer
message_buffer.add_message(msg_type, content)
message_buffer.add_message(msg_type, content)
# If it's a tool call, add it to tool calls
if hasattr(last_message, "tool_calls"):

View File

@ -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),
@ -122,22 +128,44 @@ def select_research_depth() -> int:
return choice
def select_shallow_thinking_agent() -> str:
def select_shallow_thinking_agent(provider) -> str:
"""Select shallow thinking llm engine using an interactive selection."""
# Define shallow thinking llm engine options with their corresponding model names
SHALLOW_AGENT_OPTIONS = [
("GPT-4o-mini - Fast and efficient for quick tasks", "gpt-4o-mini"),
("GPT-4.1-nano - Ultra-lightweight model for basic operations", "gpt-4.1-nano"),
("GPT-4.1-mini - Compact model with good performance", "gpt-4.1-mini"),
("GPT-4o - Standard model with solid capabilities", "gpt-4o"),
]
SHALLOW_AGENT_OPTIONS = {
"openai": [
("GPT-4o-mini - Fast and efficient for quick tasks", "gpt-4o-mini"),
("GPT-4.1-nano - Ultra-lightweight model for basic operations", "gpt-4.1-nano"),
("GPT-4.1-mini - Compact model with good performance", "gpt-4.1-mini"),
("GPT-4o - Standard model with solid capabilities", "gpt-4o"),
],
"anthropic": [
("Claude Haiku 3.5 - Fast inference and standard capabilities", "claude-3-5-haiku-latest"),
("Claude Sonnet 3.5 - Highly capable standard model", "claude-3-5-sonnet-latest"),
("Claude Sonnet 3.7 - Exceptional hybrid reasoning and agentic capabilities", "claude-3-7-sonnet-latest"),
("Claude Sonnet 4 - High performance and excellent reasoning", "claude-sonnet-4-0"),
],
"google": [
("Gemini 2.0 Flash-Lite - Cost efficiency and low latency", "gemini-2.0-flash-lite"),
("Gemini 2.0 Flash - Next generation features, speed, and thinking", "gemini-2.0-flash"),
("Gemini 2.5 Flash - Adaptive thinking, cost efficiency", "gemini-2.5-flash-preview-05-20"),
],
"openrouter": [
("Meta: Llama 4 Scout", "meta-llama/llama-4-scout:free"),
("Meta: Llama 3.3 8B Instruct - A lightweight and ultra-fast variant of Llama 3.3 70B", "meta-llama/llama-3.3-8b-instruct:free"),
("google/gemini-2.0-flash-exp:free - Gemini Flash 2.0 offers a significantly faster time to first token", "google/gemini-2.0-flash-exp:free"),
],
"ollama": [
("llama3.1 local", "llama3.1"),
("llama3.2 local", "llama3.2"),
]
}
choice = questionary.select(
"Select Your [Quick-Thinking LLM Engine]:",
choices=[
questionary.Choice(display, value=value)
for display, value in SHALLOW_AGENT_OPTIONS
for display, value in SHALLOW_AGENT_OPTIONS[provider.lower()]
],
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
@ -158,25 +186,48 @@ def select_shallow_thinking_agent() -> str:
return choice
def select_deep_thinking_agent() -> str:
def select_deep_thinking_agent(provider) -> str:
"""Select deep thinking llm engine using an interactive selection."""
# Define deep thinking llm engine options with their corresponding model names
DEEP_AGENT_OPTIONS = [
("GPT-4.1-nano - Ultra-lightweight model for basic operations", "gpt-4.1-nano"),
("GPT-4.1-mini - Compact model with good performance", "gpt-4.1-mini"),
("GPT-4o - Standard model with solid capabilities", "gpt-4o"),
("o4-mini - Specialized reasoning model (compact)", "o4-mini"),
("o3-mini - Advanced reasoning model (lightweight)", "o3-mini"),
("o3 - Full advanced reasoning model", "o3"),
("o1 - Premier reasoning and problem-solving model", "o1"),
]
DEEP_AGENT_OPTIONS = {
"openai": [
("GPT-4.1-nano - Ultra-lightweight model for basic operations", "gpt-4.1-nano"),
("GPT-4.1-mini - Compact model with good performance", "gpt-4.1-mini"),
("GPT-4o - Standard model with solid capabilities", "gpt-4o"),
("o4-mini - Specialized reasoning model (compact)", "o4-mini"),
("o3-mini - Advanced reasoning model (lightweight)", "o3-mini"),
("o3 - Full advanced reasoning model", "o3"),
("o1 - Premier reasoning and problem-solving model", "o1"),
],
"anthropic": [
("Claude Haiku 3.5 - Fast inference and standard capabilities", "claude-3-5-haiku-latest"),
("Claude Sonnet 3.5 - Highly capable standard model", "claude-3-5-sonnet-latest"),
("Claude Sonnet 3.7 - Exceptional hybrid reasoning and agentic capabilities", "claude-3-7-sonnet-latest"),
("Claude Sonnet 4 - High performance and excellent reasoning", "claude-sonnet-4-0"),
("Claude Opus 4 - Most powerful Anthropic model", " claude-opus-4-0"),
],
"google": [
("Gemini 2.0 Flash-Lite - Cost efficiency and low latency", "gemini-2.0-flash-lite"),
("Gemini 2.0 Flash - Next generation features, speed, and thinking", "gemini-2.0-flash"),
("Gemini 2.5 Flash - Adaptive thinking, cost efficiency", "gemini-2.5-flash-preview-05-20"),
("Gemini 2.5 Pro", "gemini-2.5-pro-preview-06-05"),
],
"openrouter": [
("DeepSeek V3 - a 685B-parameter, mixture-of-experts model", "deepseek/deepseek-chat-v3-0324:free"),
("Deepseek - latest iteration of the flagship chat model family from the DeepSeek team.", "deepseek/deepseek-chat-v3-0324:free"),
],
"ollama": [
("llama3.1 local", "llama3.1"),
("qwen3", "qwen3"),
]
}
choice = questionary.select(
"Select Your [Deep-Thinking LLM Engine]:",
choices=[
questionary.Choice(display, value=value)
for display, value in DEEP_AGENT_OPTIONS
for display, value in DEEP_AGENT_OPTIONS[provider.lower()]
],
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
@ -193,3 +244,151 @@ def select_deep_thinking_agent() -> str:
exit(1)
return choice
def select_llm_provider() -> tuple[str, str]:
"""Select the OpenAI api url using interactive selection."""
# Define OpenAI api options with their corresponding endpoints
BASE_URLS = [
("OpenAI", "https://api.openai.com/v1"),
("Anthropic", "https://api.anthropic.com/"),
("Google", "https://generativelanguage.googleapis.com/v1"),
("Openrouter", "https://openrouter.ai/api/v1"),
("Ollama", "http://localhost:11434/v1"),
]
choice = questionary.select(
"Select your LLM Provider:",
choices=[
questionary.Choice(display, value=(display, value))
for display, value in BASE_URLS
],
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
[
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]
),
).ask()
if choice is None:
console.print("\n[red]no OpenAI backend selected. Exiting...[/red]")
exit(1)
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

BIN
current-state.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
dashboard-hold-days.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
dashboard-polished-full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

BIN
dashboard-polished.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
dashboard-ranking-grid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
dashboard-ranking.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
data-source-raw-content.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
data-source-raw-viewer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
data-sources-all.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
data-sources-fixed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
debug-dark-after-fix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
debug-light-after-fix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
detail-drawer-bottom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
detail-drawer-test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
drawer-header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

24
frontend/.gitignore vendored Normal file
View File

@ -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?

217
frontend/README.md Normal file
View File

@ -0,0 +1,217 @@
# 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 with Zerodha-accurate brokerage charges
- **AI vs Nifty50**: Compare AI strategy performance against the index
- **Return Distribution**: Histogram of hold-period returns
- **Date Backtest Runner**: Run AI analysis for any date directly from the History page
- **Cancel Support**: Cancel in-progress bulk analysis
![History Page](docs/screenshots/10-history-page.png)
#### Date Selection & Stock List
Select any date to view all 50 ranked stocks with decisions, hold periods, and returns:
![History Stocks Expanded](docs/screenshots/11-history-stocks-expanded.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.

View File

@ -0,0 +1,255 @@
"""Backtest service for calculating real prediction accuracy."""
import yfinance as yf
import pandas as pd
from datetime import datetime, timedelta
from typing import Optional
import database as db
def get_trading_day_price(ticker: yf.Ticker, target_date: datetime,
direction: str = 'forward', max_days: int = 7) -> tuple[Optional[float], Optional[datetime]]:
"""
Get the closing price for a trading day near the target date.
Args:
ticker: yfinance Ticker object
target_date: The date we want price for
direction: 'forward' to look for next trading day, 'backward' for previous
max_days: Maximum days to search
Returns:
Tuple of (closing_price, actual_date) or (None, None) if not found
"""
for i in range(max_days):
if direction == 'forward':
check_date = target_date + timedelta(days=i)
else:
check_date = target_date - timedelta(days=i)
start = check_date
end = check_date + timedelta(days=1)
hist = ticker.history(start=start.strftime('%Y-%m-%d'),
end=end.strftime('%Y-%m-%d'))
if not hist.empty:
return hist['Close'].iloc[0], check_date
return None, None
def calculate_backtest_for_recommendation(date: str, symbol: str, decision: str,
hold_days: int = None) -> Optional[dict]:
"""
Calculate backtest results for a single recommendation.
Args:
date: Prediction date (YYYY-MM-DD)
symbol: Stock symbol (NSE format like RELIANCE.NS)
decision: BUY, SELL, or HOLD
hold_days: Recommended holding period in days (for BUY/HOLD)
Returns:
Dict with backtest results or None if calculation failed
"""
try:
# Convert date
pred_date = datetime.strptime(date, '%Y-%m-%d')
# For Indian stocks, append .NS suffix if not present
yf_symbol = symbol if '.' in symbol else f"{symbol}.NS"
ticker = yf.Ticker(yf_symbol)
# Get price at prediction date (or next trading day)
price_at_pred, actual_pred_date = get_trading_day_price(ticker, pred_date, 'forward')
if price_at_pred is None:
return None
# Get prices for 1 day, 1 week, 1 month later
date_1d = pred_date + timedelta(days=1)
date_1w = pred_date + timedelta(weeks=1)
date_1m = pred_date + timedelta(days=30)
price_1d, actual_1d_date = get_trading_day_price(ticker, date_1d, 'forward')
price_1w, actual_1w_date = get_trading_day_price(ticker, date_1w, 'forward')
price_1m, actual_1m_date = get_trading_day_price(ticker, date_1m, 'forward')
# Detect same-day resolution: if pred and 1d resolved to the same trading day,
# the 0% return is meaningless — treat as no data
if price_1d and actual_pred_date and actual_1d_date and actual_pred_date == actual_1d_date:
price_1d = None
# Calculate returns (only when we have a genuinely different trading day)
return_1d = ((price_1d - price_at_pred) / price_at_pred * 100) if price_1d else None
return_1w = ((price_1w - price_at_pred) / price_at_pred * 100) if price_1w else None
return_1m = ((price_1m - price_at_pred) / price_at_pred * 100) if price_1m else None
# Calculate return at hold_days horizon if specified
return_at_hold = None
if hold_days and hold_days > 0:
date_hold = pred_date + timedelta(days=hold_days)
price_at_hold, actual_hold_date = get_trading_day_price(ticker, date_hold, 'forward')
# Only count if we found a different day than the prediction date
if price_at_hold and actual_hold_date and actual_hold_date != actual_pred_date:
return_at_hold = round(((price_at_hold - price_at_pred) / price_at_pred * 100), 2)
# Skip if we have no usable return data at all
if return_1d is None and return_1w is None and return_at_hold is None:
return None
# Determine if prediction was correct
# Use hold_days return when available, fall back to 1-day return
prediction_correct = None
check_return = return_at_hold if return_at_hold is not None else return_1d
if check_return is not None:
if decision == 'BUY' or decision == 'HOLD':
prediction_correct = check_return > 0
elif decision == 'SELL':
prediction_correct = check_return < 0
# Sanitize the decision value before storing
clean_decision = decision.strip().upper()
if clean_decision not in ('BUY', 'SELL', 'HOLD'):
clean_decision = 'HOLD'
return {
'date': date,
'symbol': symbol,
'decision': clean_decision,
'price_at_prediction': round(price_at_pred, 2),
'price_1d_later': round(price_1d, 2) if price_1d else None,
'price_1w_later': round(price_1w, 2) if price_1w else None,
'price_1m_later': round(price_1m, 2) if price_1m else None,
'return_1d': round(return_1d, 2) if return_1d is not None else None,
'return_1w': round(return_1w, 2) if return_1w is not None else None,
'return_1m': round(return_1m, 2) if return_1m is not None else None,
'return_at_hold': return_at_hold,
'hold_days': hold_days,
'prediction_correct': prediction_correct
}
except Exception as e:
print(f"Error calculating backtest for {symbol} on {date}: {e}")
return None
def calculate_and_save_backtest(date: str, symbol: str, decision: str,
hold_days: int = None) -> Optional[dict]:
"""Calculate backtest and save to database."""
result = calculate_backtest_for_recommendation(date, symbol, decision, hold_days)
if result:
db.save_backtest_result(
date=result['date'],
symbol=result['symbol'],
decision=result['decision'],
price_at_prediction=result['price_at_prediction'],
price_1d_later=result['price_1d_later'],
price_1w_later=result['price_1w_later'],
price_1m_later=result['price_1m_later'],
return_1d=result['return_1d'],
return_1w=result['return_1w'],
return_1m=result['return_1m'],
prediction_correct=result['prediction_correct'],
hold_days=result.get('hold_days'),
return_at_hold=result.get('return_at_hold'),
)
return result
def backtest_all_recommendations_for_date(date: str) -> dict:
"""
Calculate backtest for all recommendations on a given date.
Returns summary statistics.
"""
rec = db.get_recommendation_by_date(date)
if not rec or 'analysis' not in rec:
return {'error': 'No recommendations found for date', 'date': date}
analysis = rec['analysis'] # Dict keyed by symbol
results = []
errors = []
for symbol, stock_data in analysis.items():
decision = stock_data['decision']
hold_days = stock_data.get('hold_days')
# Check if we already have a backtest result
existing = db.get_backtest_result(date, symbol)
if existing:
results.append(existing)
continue
# Calculate new backtest
result = calculate_and_save_backtest(date, symbol, decision, hold_days)
if result:
results.append(result)
else:
errors.append(symbol)
# Calculate summary
correct = sum(1 for r in results if r.get('prediction_correct'))
total_with_result = sum(1 for r in results if r.get('prediction_correct') is not None)
return {
'date': date,
'total_stocks': len(analysis),
'calculated': len(results),
'errors': errors,
'correct_predictions': correct,
'total_with_result': total_with_result,
'accuracy': round(correct / total_with_result * 100, 1) if total_with_result > 0 else 0
}
def get_backtest_data_for_frontend(date: str, symbol: str) -> dict:
"""
Get backtest data formatted for frontend display.
Includes price history for charts.
"""
result = db.get_backtest_result(date, symbol)
if not result:
# Try to calculate it
rec = db.get_recommendation_by_date(date)
if rec and 'analysis' in rec:
stock_data = rec['analysis'].get(symbol)
if stock_data:
result = calculate_and_save_backtest(date, symbol, stock_data['decision'], stock_data.get('hold_days'))
if not result:
return {'available': False, 'reason': 'Could not calculate backtest'}
# Get price history for chart
try:
pred_date = datetime.strptime(date, '%Y-%m-%d')
yf_symbol = symbol if '.' in symbol else f"{symbol}.NS"
ticker = yf.Ticker(yf_symbol)
# Get 30 days of history starting from prediction date
end_date = pred_date + timedelta(days=35)
hist = ticker.history(start=pred_date.strftime('%Y-%m-%d'),
end=end_date.strftime('%Y-%m-%d'))
price_history = [
{'date': idx.strftime('%Y-%m-%d'), 'price': round(row['Close'], 2)}
for idx, row in hist.iterrows()
][:30] # Limit to 30 data points
except Exception:
price_history = []
return {
'available': True,
'prediction_correct': result['prediction_correct'],
'actual_return_1d': result['return_1d'],
'actual_return_1w': result['return_1w'],
'actual_return_1m': result['return_1m'],
'return_at_hold': result.get('return_at_hold'),
'hold_days': result.get('hold_days'),
'price_at_prediction': result['price_at_prediction'],
'current_price': result.get('price_1m_later') or result.get('price_1w_later'),
'price_history': price_history
}

1545
frontend/backend/database.py Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,3 @@
fastapi>=0.109.0
uvicorn>=0.27.0
pydantic>=2.0.0

View File

@ -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()

1490
frontend/backend/server.py Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

23
frontend/eslint.config.js Normal file
View File

@ -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,
},
},
])

86
frontend/index.html Normal file
View File

@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Primary Meta Tags -->
<title>Nifty50 AI - Daily Stock Recommendations for Indian Markets</title>
<meta name="title" content="Nifty50 AI - Daily Stock Recommendations for Indian Markets" />
<meta name="description" content="AI-powered daily stock recommendations for all Nifty 50 stocks. Get actionable buy, sell, and hold signals based on technical analysis, fundamentals, and news sentiment." />
<meta name="keywords" content="Nifty 50, stock recommendations, AI stock analysis, Indian stock market, NSE, BSE, trading signals, buy sell hold, stock market India" />
<meta name="author" content="Nifty50 AI" />
<meta name="robots" content="index, follow" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://nifty50ai.com/" />
<meta property="og:title" content="Nifty50 AI - Daily Stock Recommendations for Indian Markets" />
<meta property="og:description" content="AI-powered daily stock recommendations for all Nifty 50 stocks. Get actionable buy, sell, and hold signals." />
<meta property="og:image" content="/og-image.png" />
<meta property="og:locale" content="en_IN" />
<meta property="og:site_name" content="Nifty50 AI" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://nifty50ai.com/" />
<meta property="twitter:title" content="Nifty50 AI - Daily Stock Recommendations for Indian Markets" />
<meta property="twitter:description" content="AI-powered daily stock recommendations for all Nifty 50 stocks. Get actionable buy, sell, and hold signals." />
<meta property="twitter:image" content="/og-image.png" />
<!-- Theme Color -->
<meta name="theme-color" content="#0284c7" />
<meta name="msapplication-TileColor" content="#0284c7" />
<!-- Canonical URL -->
<link rel="canonical" href="https://nifty50ai.com/" />
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Plus+Jakarta+Sans:wght@500;600;700;800&display=swap" rel="stylesheet">
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Nifty50 AI",
"description": "AI-powered daily stock recommendations for all Nifty 50 stocks",
"url": "https://nifty50ai.com/",
"potentialAction": {
"@type": "SearchAction",
"target": "https://nifty50ai.com/stock/{search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Nifty50 AI",
"url": "https://nifty50ai.com/",
"logo": "https://nifty50ai.com/logo.png",
"description": "AI-powered stock analysis and recommendations for Indian markets"
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- Noscript fallback -->
<noscript>
<div style="padding: 20px; text-align: center; font-family: system-ui, sans-serif;">
<h1>Nifty50 AI - Stock Recommendations</h1>
<p>Please enable JavaScript to view this website.</p>
</div>
</noscript>
</body>
</html>

5433
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0ea5e9"/>
<stop offset="100%" style="stop-color:#0369a1"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#bg)"/>
<path d="M16 44 L26 28 L36 36 L48 20" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="48" cy="20" r="4" fill="#22c55e"/>
</svg>

After

Width:  |  Height:  |  Size: 521 B

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@ -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;
}

39
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,39 @@
import { Routes, Route } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { SettingsProvider } from './contexts/SettingsContext';
import { NotificationProvider } from './contexts/NotificationContext';
import Header from './components/Header';
import Footer from './components/Footer';
import SettingsModal from './components/SettingsModal';
import ToastContainer from './components/Toast';
import Dashboard from './pages/Dashboard';
import History from './pages/History';
import StockDetail from './pages/StockDetail';
import About from './pages/About';
function App() {
return (
<ThemeProvider>
<SettingsProvider>
<NotificationProvider>
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-slate-900 transition-colors">
<Header />
<main className="flex-1 max-w-7xl mx-auto w-full px-3 sm:px-4 lg:px-6 py-4">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/history" element={<History />} />
<Route path="/stock/:symbol" element={<StockDetail />} />
<Route path="/about" element={<About />} />
</Routes>
</main>
<Footer />
<SettingsModal />
<ToastContainer />
</div>
</NotificationProvider>
</SettingsProvider>
</ThemeProvider>
);
}
export default App;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -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<string, typeof Brain> = {
'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 (
<div className="border-b border-gray-100 dark:border-slate-700 last:border-0">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors"
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">{section.title}</span>
</div>
{isOpen ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</button>
{isOpen && (
<div className="px-4 pb-3 text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
{section.content.split('\n').map((line, i) => {
// Handle bullet points
if (line.trim().startsWith('- ')) {
return (
<div key={i} className="flex gap-2 mt-1">
<span className="text-nifty-500"></span>
<span>{line.trim().substring(2)}</span>
</div>
);
}
return <p key={i} className={line.trim() ? 'mt-1' : 'mt-2'}>{line}</p>;
})}
</div>
)}
</div>
);
}
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 (
<section className="card overflow-hidden">
{/* Header with gradient */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`w-full bg-gradient-to-r ${gradient} p-3 text-white flex items-center justify-between`}
>
<div className="flex items-center gap-2">
<Brain className="w-5 h-5" />
<span className="font-semibold text-sm">AI Analysis</span>
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
{sections.length} sections
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-white/80">
{isExpanded ? 'Click to collapse' : 'Click to expand'}
</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</div>
</button>
{/* Content */}
{isExpanded && (
<div className="bg-white dark:bg-slate-800">
{sections.map((section, index) => (
<AnalysisSection
key={index}
section={section}
defaultOpen={index === 0}
/>
))}
</div>
)}
</section>
);
}

View File

@ -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 (
<span className={`inline-flex items-center rounded-full font-medium bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-gray-400 ${sizeClasses}`}>
<Minus className={iconSize} />
<span>Pending</span>
</span>
);
}
if (correct) {
return (
<span className={`inline-flex items-center rounded-full font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 ${sizeClasses}`}>
<Check className={iconSize} />
<span className={isPositiveReturn ? '' : 'text-green-600 dark:text-green-400'}>
{isPositiveReturn ? '+' : ''}{returnPercent.toFixed(1)}%
</span>
</span>
);
}
return (
<span className={`inline-flex items-center rounded-full font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 ${sizeClasses}`}>
<X className={iconSize} />
<span>
{isPositiveReturn ? '+' : ''}{returnPercent.toFixed(1)}%
</span>
</span>
);
}
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 (
<div className={`flex items-center gap-1.5 ${sizeClasses}`}>
<span className="text-gray-500 dark:text-gray-400">{label}:</span>
<span className={`font-semibold ${colorClass}`}>{percentage.toFixed(0)}%</span>
</div>
);
}

View File

@ -0,0 +1,179 @@
import { X, HelpCircle, TrendingUp, TrendingDown, Minus, CheckCircle } from 'lucide-react';
import { createPortal } from 'react-dom';
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 createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 flex items-center justify-between p-4 border-b border-gray-100 dark:border-slate-700 bg-white dark:bg-slate-800">
<div className="flex items-center gap-2">
<HelpCircle className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
How Accuracy is Calculated
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
>
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-5">
{/* Overview */}
<div className="p-4 rounded-lg bg-nifty-50 dark:bg-nifty-900/20 border border-nifty-100 dark:border-nifty-800">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Overall Accuracy</h3>
<div className="text-3xl font-bold text-nifty-600 dark:text-nifty-400 mb-1">
{(metrics.success_rate * 100).toFixed(1)}%
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
{metrics.correct_predictions} correct out of {metrics.total_predictions} predictions
</p>
</div>
{/* Formula */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Calculation Method</h3>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 font-mono text-sm">
<p className="text-gray-700 dark:text-gray-300">
Accuracy = (Correct Predictions / Total Predictions) × 100
</p>
<p className="text-gray-500 dark:text-gray-400 mt-2 text-xs">
= ({metrics.correct_predictions} / {metrics.total_predictions}) × 100 = {(metrics.success_rate * 100).toFixed(1)}%
</p>
</div>
</div>
{/* Decision Type Breakdown */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">Breakdown by Decision Type</h3>
<div className="space-y-3">
{/* BUY */}
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="font-medium text-green-800 dark:text-green-300">BUY Predictions</span>
</div>
<span className="text-lg font-bold text-green-600 dark:text-green-400">
{(metrics.buy_accuracy * 100).toFixed(0)}%
</span>
</div>
<p className="text-xs text-green-700 dark:text-green-400">
A BUY prediction is correct if the stock price <strong>increased</strong> after the recommendation
</p>
<div className="flex items-center gap-2 mt-2 text-xs text-green-600 dark:text-green-500">
<CheckCircle className="w-3 h-3" />
<span>~{buyCorrect} correct / {buyTotal} total BUY signals</span>
</div>
</div>
{/* SELL */}
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="font-medium text-red-800 dark:text-red-300">SELL Predictions</span>
</div>
<span className="text-lg font-bold text-red-600 dark:text-red-400">
{(metrics.sell_accuracy * 100).toFixed(0)}%
</span>
</div>
<p className="text-xs text-red-700 dark:text-red-400">
A SELL prediction is correct if the stock price <strong>decreased</strong> after the recommendation
</p>
<div className="flex items-center gap-2 mt-2 text-xs text-red-600 dark:text-red-500">
<CheckCircle className="w-3 h-3" />
<span>~{sellCorrect} correct / {sellTotal} total SELL signals</span>
</div>
</div>
{/* HOLD */}
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Minus className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="font-medium text-amber-800 dark:text-amber-300">HOLD Predictions</span>
</div>
<span className="text-lg font-bold text-amber-600 dark:text-amber-400">
{(metrics.hold_accuracy * 100).toFixed(0)}%
</span>
</div>
<p className="text-xs text-amber-700 dark:text-amber-400">
A HOLD prediction is correct if the stock price stayed <strong>relatively stable</strong> (±2% range)
</p>
<div className="flex items-center gap-2 mt-2 text-xs text-amber-600 dark:text-amber-500">
<CheckCircle className="w-3 h-3" />
<span>~{holdCorrect} correct / {holdTotal} total HOLD signals</span>
</div>
</div>
</div>
</div>
{/* Timeframe */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Evaluation Timeframe</h3>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li className="flex items-start gap-2">
<span className="text-nifty-600 dark:text-nifty-400"></span>
<span><strong>1-week return:</strong> Short-term price movement validation</span>
</li>
<li className="flex items-start gap-2">
<span className="text-nifty-600 dark:text-nifty-400"></span>
<span><strong>1-month return:</strong> Primary accuracy metric (shown in results)</span>
</li>
</ul>
</div>
</div>
{/* Disclaimer */}
<div className="p-3 rounded-lg bg-gray-100 dark:bg-slate-700/30 border border-gray-200 dark:border-slate-600">
<p className="text-xs text-gray-500 dark:text-gray-400">
<strong>Note:</strong> 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.
</p>
</div>
</div>
{/* Footer */}
<div className="sticky bottom-0 p-4 border-t border-gray-100 dark:border-slate-700 bg-white dark:bg-slate-800">
<button
onClick={onClose}
className="w-full btn-primary"
>
Got it
</button>
</div>
</div>
</div>,
document.body
);
}

View File

@ -0,0 +1,100 @@
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
export interface AccuracyTrendPoint {
date: string;
overall: number;
buy: number;
sell: number;
hold: number;
}
interface AccuracyTrendChartProps {
height?: number;
className?: string;
data?: AccuracyTrendPoint[];
}
export default function AccuracyTrendChart({ height = 200, className = '', data: propData }: AccuracyTrendChartProps) {
const data = propData || [];
if (data.length === 0) {
return (
<div className={`flex items-center justify-center text-gray-400 ${className}`} style={{ height }}>
No accuracy data available
</div>
);
}
// Format dates for display
const formattedData = data.map(d => ({
...d,
displayDate: new Date(d.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }),
}));
return (
<div className={className} style={{ height }}>
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<LineChart data={formattedData} margin={{ top: 5, right: 10, bottom: 5, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
<XAxis
dataKey="displayDate"
tick={{ fontSize: 11 }}
className="text-gray-500 dark:text-gray-400"
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 11 }}
tickFormatter={(v) => `${v}%`}
className="text-gray-500 dark:text-gray-400"
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg, #fff)',
border: '1px solid var(--tooltip-border, #e5e7eb)',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value) => [`${value}%`, '']}
labelFormatter={(label) => `Date: ${label}`}
/>
<Legend
wrapperStyle={{ fontSize: '11px' }}
formatter={(value) => value.charAt(0).toUpperCase() + value.slice(1)}
/>
<Line
type="monotone"
dataKey="overall"
stroke="#0ea5e9"
strokeWidth={2}
dot={{ fill: '#0ea5e9', r: 3 }}
activeDot={{ r: 5 }}
/>
<Line
type="monotone"
dataKey="buy"
stroke="#22c55e"
strokeWidth={1.5}
dot={{ fill: '#22c55e', r: 2 }}
strokeDasharray="5 5"
/>
<Line
type="monotone"
dataKey="sell"
stroke="#ef4444"
strokeWidth={1.5}
dot={{ fill: '#ef4444', r: 2 }}
strokeDasharray="5 5"
/>
<Line
type="monotone"
dataKey="hold"
stroke="#f59e0b"
strokeWidth={1.5}
dot={{ fill: '#f59e0b', r: 2 }}
strokeDasharray="5 5"
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -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 (
<div className={`w-full h-full ${className}`} style={{ filter: 'blur(1px)' }}>
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<AreaChart data={normalizedData} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<YAxis domain={[minPrice - padding, maxPrice + padding]} hide />
<defs>
<linearGradient id={`gradient-${trend}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={fill} stopOpacity={0.4} />
<stop offset="100%" stopColor={fill} stopOpacity={0.05} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="normalizedPrice"
stroke={stroke}
strokeWidth={1}
fill={`url(#gradient-${trend})`}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -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 (
<div style={{ width: '100%', height: '256px' }}>
<ResponsiveContainer width="100%" height={256} minWidth={0} minHeight={0}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
labelLine={false}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
formatter={(value) => [`${value} stocks`, '']}
/>
<Legend
verticalAlign="bottom"
height={36}
formatter={(value) => <span className="text-sm text-gray-600">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
}
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 (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<BarChart data={formattedData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="date"
tick={{ fontSize: 12, fill: '#6b7280' }}
tickLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#6b7280' }}
tickLine={{ stroke: '#e5e7eb' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
/>
<Legend
verticalAlign="top"
height={36}
formatter={(value) => <span className="text-sm text-gray-600 capitalize">{value}</span>}
/>
<Bar dataKey="buy" stackId="a" fill={COLORS.buy} radius={[4, 4, 0, 0]} name="Buy" />
<Bar dataKey="hold" stackId="a" fill={COLORS.hold} radius={[0, 0, 0, 0]} name="Hold" />
<Bar dataKey="sell" stackId="a" fill={COLORS.sell} radius={[0, 0, 4, 4]} name="Sell" />
</BarChart>
</ResponsiveContainer>
</div>
);
}
interface StockHistoryEntry {
date: string;
decision: string;
}
interface StockHistoryChartProps {
history: StockHistoryEntry[];
symbol: string;
}
export function StockHistoryTimeline({ history, symbol }: StockHistoryChartProps) {
if (history.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No historical data available for {symbol}
</div>
);
}
return (
<div className="space-y-3">
{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 (
<div key={idx} className="flex items-center gap-4">
<div className="w-24 text-sm text-gray-500">
{new Date(entry.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}
</div>
<div className={`w-3 h-3 rounded-full ${bgColor}`} />
<div className={`text-sm font-medium ${textColor}`}>
{entry.decision}
</div>
</div>
);
})}
</div>
);
}
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 (
<div className="space-y-4">
<div className="flex h-4 rounded-full overflow-hidden bg-gray-100">
<div
className="bg-green-500 transition-all duration-500"
style={{ width: `${(buy / total) * 100}%` }}
title={`Buy: ${buy} (${buyPercent}%)`}
/>
<div
className="bg-amber-500 transition-all duration-500"
style={{ width: `${(hold / total) * 100}%` }}
title={`Hold: ${hold} (${holdPercent}%)`}
/>
<div
className="bg-red-500 transition-all duration-500"
style={{ width: `${(sell / total) * 100}%` }}
title={`Sell: ${sell} (${sellPercent}%)`}
/>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="flex items-center justify-center gap-2 mb-1">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm font-medium text-gray-700">Buy</span>
</div>
<div className="text-2xl font-bold text-green-600">{buy}</div>
<div className="text-xs text-gray-500">{buyPercent}%</div>
</div>
<div>
<div className="flex items-center justify-center gap-2 mb-1">
<div className="w-3 h-3 rounded-full bg-amber-500" />
<span className="text-sm font-medium text-gray-700">Hold</span>
</div>
<div className="text-2xl font-bold text-amber-600">{hold}</div>
<div className="text-xs text-gray-500">{holdPercent}%</div>
</div>
<div>
<div className="flex items-center justify-center gap-2 mb-1">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-sm font-medium text-gray-700">Sell</span>
</div>
<div className="text-2xl font-bold text-red-600">{sell}</div>
<div className="text-xs text-gray-500">{sellPercent}%</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,74 @@
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
import type { CumulativeReturnPoint } from '../types';
interface CumulativeReturnChartProps {
height?: number;
className?: string;
data?: CumulativeReturnPoint[];
}
export default function CumulativeReturnChart({ height = 160, className = '', data: propData }: CumulativeReturnChartProps) {
const data = propData || [];
if (data.length === 0) {
return (
<div className={`flex items-center justify-center text-gray-400 ${className}`} style={{ height }}>
No data available
</div>
);
}
// 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 (
<div className={className} style={{ height }}>
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<AreaChart data={formattedData} margin={{ top: 5, right: 10, bottom: 5, left: 0 }}>
<defs>
<linearGradient id="cumulativeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={isPositive ? '#22c55e' : '#ef4444'} stopOpacity={0.3} />
<stop offset="95%" stopColor={isPositive ? '#22c55e' : '#ef4444'} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
<XAxis
dataKey="displayDate"
tick={{ fontSize: 10 }}
className="text-gray-500 dark:text-gray-400"
/>
<YAxis
tick={{ fontSize: 10 }}
tickFormatter={(v) => `${v}%`}
className="text-gray-500 dark:text-gray-400"
width={40}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg, #fff)',
border: '1px solid var(--tooltip-border, #e5e7eb)',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value) => [`${(value as number).toFixed(1)}%`, 'Return']}
labelFormatter={(label) => `Date: ${label}`}
/>
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
<Area
type="monotone"
dataKey="aiReturn"
stroke={isPositive ? '#22c55e' : '#ef4444'}
strokeWidth={2}
fill="url(#cumulativeGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,100 @@
import { SlidersHorizontal, ArrowUpDown } from 'lucide-react';
import { NIFTY_50_STOCKS } from '../types';
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 = ['All', ...Array.from(new Set(NIFTY_50_STOCKS.map(s => s.sector).filter(Boolean))).sort()];
const decisions: Array<FilterState['decision']> = ['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<HTMLSelectElement>) => {
onFilterChange({ ...filters, sector: e.target.value });
};
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onFilterChange({ ...filters, sortBy: e.target.value as FilterState['sortBy'] });
};
const toggleSortOrder = () => {
onFilterChange({ ...filters, sortOrder: filters.sortOrder === 'asc' ? 'desc' : 'asc' });
};
return (
<div className={`flex flex-wrap items-center gap-3 p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg ${className}`}>
<div className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<SlidersHorizontal className="w-4 h-4" />
<span className="text-xs font-medium">Filters:</span>
</div>
{/* Decision Toggle */}
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-slate-600">
{decisions.map(decision => (
<button
key={decision}
onClick={() => handleDecisionChange(decision)}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
filters.decision === decision
? decision === 'BUY'
? 'bg-green-500 text-white'
: decision === 'SELL'
? 'bg-red-500 text-white'
: decision === 'HOLD'
? 'bg-amber-500 text-white'
: 'bg-nifty-600 text-white'
: 'bg-white dark:bg-slate-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700'
}`}
>
{decision}
</button>
))}
</div>
{/* Sector Dropdown */}
<select
value={filters.sector}
onChange={handleSectorChange}
className="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-gray-700 dark:text-gray-300 focus:ring-2 focus:ring-nifty-500 focus:border-transparent"
>
<option value="">All Sectors</option>
{sectors.map(sector => (
<option key={sector} value={sector}>{sector}</option>
))}
</select>
{/* Sort */}
<div className="flex items-center gap-1 ml-auto">
<select
value={filters.sortBy}
onChange={handleSortChange}
className="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-gray-700 dark:text-gray-300 focus:ring-2 focus:ring-nifty-500 focus:border-transparent"
>
{sortOptions.map(opt => (
<option key={opt.value} value={opt.value}>Sort: {opt.label}</option>
))}
</select>
<button
onClick={toggleSortOrder}
className="p-1.5 rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700"
title={filters.sortOrder === 'asc' ? 'Ascending' : 'Descending'}
>
<ArrowUpDown className={`w-4 h-4 transition-transform ${filters.sortOrder === 'desc' ? 'rotate-180' : ''}`} />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { TrendingUp, Github, Twitter } from 'lucide-react';
import { Link } from 'react-router-dom';
export default function Footer() {
return (
<footer className="mt-auto border-t border-gray-200/50 dark:border-slate-700/30 bg-white/50 dark:bg-slate-900/50 transition-colors" style={{ backdropFilter: 'blur(8px)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-5">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
{/* Brand */}
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0ea5e9, #0369a1)' }}>
<TrendingUp className="w-3.5 h-3.5 text-white" />
</div>
<span className="font-display font-bold text-sm gradient-text">Nifty50 AI</span>
</div>
{/* Links */}
<div className="flex items-center gap-5 text-xs text-gray-500 dark:text-gray-400">
<Link to="/" className="hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Dashboard</Link>
<Link to="/history" className="hover:text-gray-900 dark:hover:text-gray-200 transition-colors">History</Link>
<Link to="/about" className="hover:text-gray-900 dark:hover:text-gray-200 transition-colors">How It Works</Link>
<span className="text-gray-200 dark:text-gray-700">|</span>
<a href="#" className="hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Disclaimer</a>
<a href="#" className="hover:text-gray-900 dark:hover:text-gray-200 transition-colors">Privacy</a>
</div>
{/* Social & Copyright */}
<div className="flex items-center gap-3">
<a href="#" className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<Github className="w-4 h-4" />
</a>
<a href="#" className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<Twitter className="w-4 h-4" />
</a>
<span className="text-xs text-gray-400 dark:text-gray-500">&copy; {new Date().getFullYear()}</span>
</div>
</div>
<p className="text-[11px] text-gray-400 dark:text-gray-500 text-center mt-4 leading-relaxed">
AI-generated recommendations for educational purposes only. Not financial advice. Do your own research.
</p>
</div>
</footer>
);
}

View File

@ -0,0 +1,103 @@
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 (
<header className="sticky top-0 z-50 transition-colors border-b border-gray-200/50 dark:border-slate-700/50 bg-white/70 dark:bg-slate-900/80" style={{ backdropFilter: 'blur(16px) saturate(180%)' }}>
<div className="max-w-7xl mx-auto px-3 sm:px-4 lg:px-6">
<div className="flex justify-between items-center h-14">
{/* Logo */}
<Link to="/" className="flex items-center gap-2.5 group">
<div className="w-8 h-8 rounded-lg flex items-center justify-center transition-transform group-hover:scale-105" style={{ background: 'linear-gradient(135deg, #0ea5e9, #0369a1)' }}>
<TrendingUp className="w-4 h-4 text-white" />
</div>
<span className="font-display font-bold gradient-text text-base tracking-tight">Nifty50 AI</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-1" aria-label="Main navigation">
{navItems.map(({ path, label, icon: Icon }) => (
<Link
key={path}
to={path}
aria-current={isActive(path) ? 'page' : undefined}
className={`relative flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
isActive(path)
? 'text-nifty-700 dark:text-nifty-400 bg-nifty-50/80 dark:bg-nifty-900/20'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-slate-800/50'
}`}
>
<Icon className="w-4 h-4" aria-hidden="true" />
{label}
</Link>
))}
</nav>
{/* Settings, Theme Toggle & Mobile Menu */}
<div className="flex items-center gap-1.5">
<button
onClick={openSettings}
className="p-2 rounded-lg hover:bg-gray-100/80 dark:hover:bg-slate-800/50 transition-colors text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
aria-label="Open settings"
title="Settings"
>
<Settings className="w-4 h-4" />
</button>
<div className="hidden md:block">
<ThemeToggle />
</div>
<div className="md:hidden">
<ThemeToggle compact />
</div>
<button
className="md:hidden p-2 rounded-lg hover:bg-gray-100/80 dark:hover:bg-slate-800/50 transition-colors text-gray-500 dark:text-gray-400"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
>
{mobileMenuOpen ? <X className="w-5 h-5" aria-hidden="true" /> : <Menu className="w-5 h-5" aria-hidden="true" />}
</button>
</div>
</div>
{/* Mobile Navigation */}
{mobileMenuOpen && (
<nav id="mobile-menu" className="md:hidden py-2 border-t border-gray-100 dark:border-slate-700/50 animate-in slide-in-from-top-2 duration-200" aria-label="Mobile navigation">
{navItems.map(({ path, label, icon: Icon }) => (
<Link
key={path}
to={path}
onClick={() => setMobileMenuOpen(false)}
aria-current={isActive(path) ? 'page' : undefined}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
isActive(path)
? 'bg-nifty-50/80 dark:bg-nifty-900/20 text-nifty-700 dark:text-nifty-400'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800/50'
}`}
>
<Icon className="w-4 h-4" aria-hidden="true" />
{label}
</Link>
))}
</nav>
)}
</div>
</header>
);
}

View File

@ -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 (
<section className="card overflow-hidden">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-3 bg-gradient-to-r from-indigo-500 to-purple-600 text-white"
>
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
<span className="font-semibold text-sm">Powered by AI Agents</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-white/80">
{isExpanded ? 'Hide details' : 'Learn how it works'}
</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</div>
</button>
{isExpanded && (
<div className="p-4 bg-white dark:bg-slate-800">
{/* Flow diagram */}
<div className="flex items-center justify-center gap-2 mb-4 text-xs text-gray-500 dark:text-gray-400">
<span className="px-2 py-1 bg-gray-100 dark:bg-slate-700 rounded">Data</span>
<span></span>
<span className="px-2 py-1 bg-gray-100 dark:bg-slate-700 rounded">Analysis</span>
<span></span>
<span className="px-2 py-1 bg-gray-100 dark:bg-slate-700 rounded">Debate</span>
<span></span>
<span className="px-2 py-1 bg-nifty-100 dark:bg-nifty-900/30 rounded text-nifty-700 dark:text-nifty-400 font-medium">Decision</span>
</div>
{/* Agents grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{agents.map((agent) => {
const Icon = agent.icon;
return (
<div
key={agent.name}
className="p-2.5 rounded-lg border border-gray-100 dark:border-slate-700 hover:border-gray-200 dark:hover:border-slate-600 transition-colors"
>
<div className="flex items-center gap-2 mb-1">
<div className={`p-1.5 rounded-md ${agent.color}`}>
<Icon className="w-3.5 h-3.5" />
</div>
<span className="font-medium text-xs text-gray-900 dark:text-gray-100">{agent.name}</span>
</div>
<p className="text-[10px] text-gray-500 dark:text-gray-400 leading-relaxed">
{agent.description}
</p>
</div>
);
})}
</div>
{/* Disclaimer */}
<p className="text-[10px] text-gray-400 dark:text-gray-500 text-center mt-3">
Multiple AI agents analyze each stock independently, then debate to reach a consensus recommendation.
</p>
</div>
)}
</section>
);
}
// 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 (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${color}`}>
<Icon className="w-3 h-3" />
{label}
</span>
);
}

View File

@ -0,0 +1,116 @@
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine } from 'recharts';
import { TrendingUp, TrendingDown } from 'lucide-react';
import type { CumulativeReturnPoint } from '../types';
export interface IndexComparisonChartProps {
height?: number;
className?: string;
data?: CumulativeReturnPoint[];
}
export default function IndexComparisonChart({ height = 220, className = '', data: propData }: IndexComparisonChartProps) {
const data = propData || [];
if (data.length === 0) {
return (
<div className={`flex items-center justify-center text-gray-400 ${className}`} style={{ height }}>
No comparison data available
</div>
);
}
// 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 (
<div className={className}>
{/* Summary Card */}
<div className="flex items-center justify-between p-3 mb-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
<div className="flex items-center gap-2">
{isOutperforming ? (
<TrendingUp className="w-5 h-5 text-green-500" />
) : (
<TrendingDown className="w-5 h-5 text-red-500" />
)}
<span className="text-sm text-gray-600 dark:text-gray-400">
AI Strategy {isOutperforming ? 'outperformed' : 'underperformed'} Nifty50 by{' '}
<span className={`font-bold ${isOutperforming ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{Math.abs(outperformance).toFixed(1)}%
</span>
</span>
</div>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1">
<div className="w-3 h-0.5 bg-nifty-600 rounded" />
<span className="text-gray-500 dark:text-gray-400">AI: {aiReturn >= 0 ? '+' : ''}{aiReturn.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-0.5 bg-amber-500 rounded" />
<span className="text-gray-500 dark:text-gray-400">Nifty: {indexReturn >= 0 ? '+' : ''}{indexReturn.toFixed(1)}%</span>
</div>
</div>
</div>
{/* Chart */}
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<LineChart data={formattedData} margin={{ top: 5, right: 10, bottom: 5, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
<XAxis
dataKey="displayDate"
tick={{ fontSize: 11 }}
className="text-gray-500 dark:text-gray-400"
/>
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => `${v}%`}
className="text-gray-500 dark:text-gray-400"
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg, #fff)',
border: '1px solid var(--tooltip-border, #e5e7eb)',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value) => [`${(value as number).toFixed(1)}%`, '']}
labelFormatter={(label) => `Date: ${label}`}
/>
<Legend
wrapperStyle={{ fontSize: '11px' }}
formatter={(value) => value === 'aiReturn' ? 'AI Strategy' : 'Nifty50 Index'}
/>
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
<Line
type="monotone"
dataKey="aiReturn"
name="aiReturn"
stroke="#0ea5e9"
strokeWidth={2}
dot={{ fill: '#0ea5e9', r: 3 }}
activeDot={{ r: 5 }}
/>
<Line
type="monotone"
dataKey="indexReturn"
name="indexReturn"
stroke="#f59e0b"
strokeWidth={2}
dot={{ fill: '#f59e0b', r: 3 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
import { X, Info } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { ReactNode } from 'react';
interface InfoModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
icon?: ReactNode;
}
export default function InfoModal({ isOpen, onClose, title, children, icon }: InfoModalProps) {
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
{/* Modal */}
<div className="relative min-h-screen flex items-center justify-center p-4">
<div className="relative w-full max-w-md bg-white dark:bg-slate-800 rounded-2xl shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-slate-700">
<div className="flex items-center gap-2">
{icon || <Info className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h3>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 max-h-[70vh] overflow-y-auto">
{children}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-100 dark:border-slate-700 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-nifty-600 text-white rounded-lg text-sm font-medium hover:bg-nifty-700 transition-colors"
>
Got it
</button>
</div>
</div>
</div>
</div>,
document.body
);
}
// Reusable info button component
interface InfoButtonProps {
onClick: () => void;
className?: string;
size?: 'sm' | 'md';
}
export function InfoButton({ onClick, className = '', size = 'sm' }: InfoButtonProps) {
const sizeClasses = size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4';
return (
<button
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={`inline-flex items-center justify-center p-0.5 rounded-full text-gray-400 hover:text-nifty-600 dark:hover:text-nifty-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors ${className}`}
title="Learn more"
>
<Info className={sizeClasses} />
</button>
);
}

View File

@ -0,0 +1,251 @@
import { X, Activity } from 'lucide-react';
import { createPortal } from 'react-dom';
import CumulativeReturnChart from './CumulativeReturnChart';
import type { CumulativeReturnPoint } from '../types';
export interface OverallReturnBreakdown {
dailyReturns: { date: string; return: number; multiplier: number; cumulative: number }[];
finalMultiplier: number;
finalReturn: number;
formula: string;
}
interface OverallReturnModalProps {
isOpen: boolean;
onClose: () => void;
breakdown?: OverallReturnBreakdown; // Optional prop for real data
cumulativeData?: CumulativeReturnPoint[]; // Optional prop for chart data
}
export default function OverallReturnModal({ isOpen, onClose, breakdown: propBreakdown, cumulativeData }: OverallReturnModalProps) {
if (!isOpen) return null;
const breakdown = propBreakdown || { dailyReturns: [], finalMultiplier: 1, finalReturn: 0, formula: '' };
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-[95vw] sm:max-w-lg w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 flex items-center justify-between p-4 border-b border-gray-100 dark:border-slate-700 bg-white dark:bg-slate-800">
<div className="flex items-center gap-2">
<Activity className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Overall Return Calculation
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
>
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-5">
{/* Final Result */}
<div className="p-4 rounded-lg bg-gradient-to-br from-nifty-500 to-nifty-700 text-white">
<div className="text-sm text-white/80 mb-1">Compound Return</div>
<div className="text-3xl font-bold">
{breakdown.finalReturn >= 0 ? '+' : ''}{breakdown.finalReturn.toFixed(1)}%
</div>
<div className="text-sm text-white/80 mt-1">
Multiplier: {breakdown.finalMultiplier.toFixed(4)}x
</div>
</div>
{/* Cumulative Return Chart */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Portfolio Growth</h3>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
<CumulativeReturnChart height={140} data={cumulativeData} />
</div>
</div>
{/* Method Explanation */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Why Compound Returns?</h3>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-sm space-y-2">
<p className="text-gray-700 dark:text-gray-300">
In real trading, gains and losses <strong>compound</strong> over time. If you start with 10,000:
</p>
<ul className="text-xs text-gray-600 dark:text-gray-400 ml-4 space-y-1">
<li> Day 1: +2% 10,000 × 1.02 = 10,200</li>
<li> Day 2: +1% 10,200 × 1.01 = 10,302</li>
<li> Day 3: -1% 10,302 × 0.99 = 10,199</li>
</ul>
<p className="text-gray-700 dark:text-gray-300 mt-2">
Simple average would give (2+1-1)/3 = 0.67%, but actual return is +1.99%
</p>
</div>
</div>
{/* Formula */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Formula</h3>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
<div className="font-mono text-sm text-gray-700 dark:text-gray-300 mb-2">
Overall = (1 + r) × (1 + r) × ... × (1 + rₙ) - 1
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Where r, r, ... rₙ are the daily weighted returns
</p>
</div>
</div>
{/* Daily Breakdown */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Daily Breakdown</h3>
{/* Desktop Table */}
<div className="hidden sm:block border border-gray-200 dark:border-slate-600 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-slate-700">
<tr>
<th className="px-2 sm:px-3 py-1.5 sm:py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Date</th>
<th className="px-2 sm:px-3 py-1.5 sm:py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Return</th>
<th className="px-2 sm:px-3 py-1.5 sm:py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Multiplier</th>
<th className="px-2 sm:px-3 py-1.5 sm:py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Cumulative</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
{breakdown.dailyReturns.map((day: { date: string; return: number; multiplier: number; cumulative: number }) => (
<tr key={day.date} className="hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td className="px-2 sm:px-3 py-1.5 sm:py-2 text-gray-700 dark:text-gray-300">
{new Date(day.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}
</td>
<td className={`px-2 sm:px-3 py-1.5 sm:py-2 text-right font-medium ${
day.return >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}%
</td>
<td className="px-2 sm:px-3 py-1.5 sm:py-2 text-right text-gray-600 dark:text-gray-400 font-mono text-xs">
×{day.multiplier.toFixed(4)}
</td>
<td className={`px-2 sm:px-3 py-1.5 sm:py-2 text-right font-medium ${
day.cumulative >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{day.cumulative >= 0 ? '+' : ''}{day.cumulative.toFixed(1)}%
</td>
</tr>
))}
</tbody>
<tfoot className="bg-nifty-50 dark:bg-nifty-900/20">
<tr>
<td className="px-2 sm:px-3 py-1.5 sm:py-2 font-semibold text-gray-900 dark:text-gray-100">Total</td>
<td className="px-2 sm:px-3 py-1.5 sm:py-2 text-right text-gray-500 dark:text-gray-400">-</td>
<td className="px-2 sm:px-3 py-1.5 sm:py-2 text-right font-mono text-xs font-semibold text-nifty-600 dark:text-nifty-400">
×{breakdown.finalMultiplier.toFixed(4)}
</td>
<td className={`px-2 sm:px-3 py-1.5 sm:py-2 text-right font-bold ${
breakdown.finalReturn >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{breakdown.finalReturn >= 0 ? '+' : ''}{breakdown.finalReturn.toFixed(1)}%
</td>
</tr>
</tfoot>
</table>
</div>
{/* Mobile Cards */}
<div className="sm:hidden space-y-2">
{breakdown.dailyReturns.map((day: { date: string; return: number; multiplier: number; cumulative: number }) => (
<div
key={day.date}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg"
>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{new Date(day.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
×{day.multiplier.toFixed(4)}
</div>
</div>
<div className="text-right">
<div className={`text-sm font-bold ${
day.return >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}%
</div>
<div className={`text-xs ${
day.cumulative >= 0 ? 'text-green-500 dark:text-green-500' : 'text-red-500 dark:text-red-500'
}`}>
{day.cumulative >= 0 ? '+' : ''}{day.cumulative.toFixed(1)}% total
</div>
</div>
</div>
))}
{/* Total Card */}
<div className="flex items-center justify-between p-3 bg-nifty-50 dark:bg-nifty-900/20 rounded-lg border border-nifty-200 dark:border-nifty-800">
<div className="font-semibold text-gray-900 dark:text-gray-100">Total</div>
<div className="text-right">
<div className={`text-lg font-bold ${
breakdown.finalReturn >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{breakdown.finalReturn >= 0 ? '+' : ''}{breakdown.finalReturn.toFixed(1)}%
</div>
<div className="text-xs text-nifty-600 dark:text-nifty-400 font-mono">
×{breakdown.finalMultiplier.toFixed(4)}
</div>
</div>
</div>
</div>
</div>
{/* Visual Formula */}
{breakdown.dailyReturns.length > 0 && (
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Calculation</h3>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 font-mono text-xs text-gray-600 dark:text-gray-400 break-all">
{breakdown.dailyReturns.map((d: { date: string; return: number }, i: number) => (
<span key={d.date}>
<span className={d.return >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
(1 {d.return >= 0 ? '+' : ''} {d.return.toFixed(1)}%)
</span>
{i < breakdown.dailyReturns.length - 1 && ' × '}
</span>
))}
{' = '}
<span className="font-bold text-nifty-600 dark:text-nifty-400">
{breakdown.finalMultiplier.toFixed(4)}
</span>
{' → '}
<span className={`font-bold ${breakdown.finalReturn >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{breakdown.finalReturn >= 0 ? '+' : ''}{breakdown.finalReturn.toFixed(1)}%
</span>
</div>
</div>
)}
{/* Disclaimer */}
<div className="p-3 rounded-lg bg-gray-100 dark:bg-slate-700/30 border border-gray-200 dark:border-slate-600">
<p className="text-xs text-gray-500 dark:text-gray-400">
<strong>Note:</strong> This compound return represents theoretical portfolio growth
if all recommendations were followed. Real trading results depend on execution,
position sizing, and market conditions.
</p>
</div>
</div>
{/* Footer */}
<div className="sticky bottom-0 p-4 border-t border-gray-100 dark:border-slate-700 bg-white dark:bg-slate-800">
<button
onClick={onClose}
className="w-full btn-primary"
>
Got it
</button>
</div>
</div>
</div>,
document.body
);
}

View File

@ -0,0 +1,834 @@
import { useState, useMemo } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend, BarChart, Bar, Cell, LabelList } from 'recharts';
import { Calculator, ChevronDown, ChevronUp, IndianRupee, Settings2, BarChart3, Info, TrendingUp, TrendingDown, ArrowRightLeft, Wallet, PiggyBank, Receipt, HelpCircle } from 'lucide-react';
import { calculateBrokerage, formatINR, type BrokerageBreakdown } from '../utils/brokerageCalculator';
import InfoModal, { InfoButton } from './InfoModal';
import type { Decision, DailyRecommendation } from '../types';
interface PortfolioSimulatorProps {
className?: string;
recommendations?: DailyRecommendation[];
nifty50Prices?: Record<string, number>;
allBacktestData?: Record<string, Record<string, number>>;
}
export type InvestmentMode = 'all50' | 'topPicks';
interface TradeRecord {
symbol: string;
entryDate: string;
entryPrice: number;
exitDate: string;
exitPrice: number;
quantity: number;
brokerage: BrokerageBreakdown;
profitLoss: number;
}
interface TradeStats {
totalTrades: number;
buyTrades: number;
sellTrades: number;
brokerageBreakdown: BrokerageBreakdown;
trades: TradeRecord[];
}
// Smart trade counting logic using Zerodha brokerage for Equity Delivery
function calculateSmartTrades(
recommendations: DailyRecommendation[],
mode: InvestmentMode,
startingAmount: number,
nifty50Prices?: Record<string, number>,
allBacktestData?: Record<string, Record<string, number>>
): {
portfolioData: Array<{ date: string; rawDate: string; value: number; niftyValue: number; return: number; cumulative: number }>;
stats: TradeStats;
openPositions: Record<string, { entryDate: string; entryPrice: number; decision: Decision }>;
} {
const hasRealNifty = nifty50Prices && Object.keys(nifty50Prices).length > 0;
const sortedRecs = [...recommendations].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
// Precompute real Nifty start price for comparison
const sortedNiftyDates = hasRealNifty ? Object.keys(nifty50Prices).sort() : [];
const niftyStartPrice = hasRealNifty && sortedNiftyDates.length > 0
? nifty50Prices[sortedNiftyDates[0]]
: null;
// Track open positions per stock
const openPositions: Record<string, { entryDate: string; entryPrice: number; decision: Decision }> = {};
const completedTrades: TradeRecord[] = [];
let buyTrades = 0;
let sellTrades = 0;
const getStocksToTrack = (rec: typeof recommendations[0]) => {
if (mode === 'topPicks') {
return rec.top_picks.map(p => p.symbol);
}
return Object.keys(rec.analysis);
};
const stockCount = mode === 'topPicks' ? 3 : 50;
const investmentPerStock = startingAmount / stockCount;
let portfolioValue = startingAmount;
let niftyValue = startingAmount;
const portfolioData = sortedRecs.map((rec) => {
const stocks = getStocksToTrack(rec);
let dayReturn = 0;
let stocksTracked = 0;
stocks.forEach(symbol => {
const analysis = rec.analysis[symbol];
if (!analysis || !analysis.decision) return;
const decision = analysis.decision;
const prevPosition = openPositions[symbol];
const currentPrice = 1000; // Nominal price for position sizing
const quantity = Math.floor(investmentPerStock / currentPrice);
if (decision === 'BUY') {
if (!prevPosition) {
openPositions[symbol] = { entryDate: rec.date, entryPrice: currentPrice, decision };
buyTrades++;
} else if (prevPosition.decision === 'SELL') {
buyTrades++;
openPositions[symbol] = { entryDate: rec.date, entryPrice: currentPrice, decision };
} else {
openPositions[symbol].decision = decision;
}
// Use real backtest return if available, otherwise 0 (neutral)
const realBuyReturn = allBacktestData?.[rec.date]?.[symbol];
dayReturn += realBuyReturn !== undefined ? realBuyReturn : 0;
stocksTracked++;
} else if (decision === 'HOLD') {
if (prevPosition) {
openPositions[symbol].decision = decision;
}
// Use real backtest return if available, otherwise 0 (neutral)
const realHoldReturn = allBacktestData?.[rec.date]?.[symbol];
dayReturn += realHoldReturn !== undefined ? realHoldReturn : 0;
stocksTracked++;
} else if (decision === 'SELL') {
if (prevPosition && (prevPosition.decision === 'BUY' || prevPosition.decision === 'HOLD')) {
sellTrades++;
// Use real backtest return for exit price if available, otherwise break-even
const realSellReturn = allBacktestData?.[rec.date]?.[symbol];
const exitPrice = realSellReturn !== undefined
? currentPrice * (1 + realSellReturn / 100)
: currentPrice;
const brokerage = calculateBrokerage({
buyPrice: prevPosition.entryPrice,
sellPrice: exitPrice,
quantity,
tradeType: 'delivery',
});
const grossProfit = (exitPrice - prevPosition.entryPrice) * quantity;
const profitLoss = grossProfit - brokerage.totalCharges;
completedTrades.push({
symbol,
entryDate: prevPosition.entryDate,
entryPrice: prevPosition.entryPrice,
exitDate: rec.date,
exitPrice,
quantity,
brokerage,
profitLoss,
});
delete openPositions[symbol];
}
stocksTracked++;
}
});
const avgDayReturn = stocksTracked > 0 ? dayReturn / stocksTracked : 0;
portfolioValue = portfolioValue * (1 + avgDayReturn / 100);
// Use real Nifty50 prices if available, otherwise use mock history
if (hasRealNifty && niftyStartPrice) {
const closestDate = sortedNiftyDates.find(d => d >= rec.date) || sortedNiftyDates[sortedNiftyDates.length - 1];
if (closestDate && nifty50Prices[closestDate]) {
niftyValue = startingAmount * (nifty50Prices[closestDate] / niftyStartPrice);
}
}
return {
date: new Date(rec.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }),
rawDate: rec.date,
value: Math.round(portfolioValue),
niftyValue: Math.round(niftyValue),
return: avgDayReturn,
cumulative: ((portfolioValue - startingAmount) / startingAmount) * 100,
};
});
const totalBrokerage = completedTrades.reduce<BrokerageBreakdown>(
(acc, trade) => ({
brokerage: acc.brokerage + trade.brokerage.brokerage,
stt: acc.stt + trade.brokerage.stt,
exchangeCharges: acc.exchangeCharges + trade.brokerage.exchangeCharges,
sebiCharges: acc.sebiCharges + trade.brokerage.sebiCharges,
gst: acc.gst + trade.brokerage.gst,
stampDuty: acc.stampDuty + trade.brokerage.stampDuty,
totalCharges: acc.totalCharges + trade.brokerage.totalCharges,
netProfit: acc.netProfit + trade.brokerage.netProfit,
turnover: acc.turnover + trade.brokerage.turnover,
}),
{ brokerage: 0, stt: 0, exchangeCharges: 0, sebiCharges: 0, gst: 0, stampDuty: 0, totalCharges: 0, netProfit: 0, turnover: 0 }
);
return {
portfolioData,
stats: {
totalTrades: buyTrades + sellTrades,
buyTrades,
sellTrades,
brokerageBreakdown: totalBrokerage,
trades: completedTrades,
},
openPositions,
};
}
// Helper for consistent positive/negative color classes
function getValueColorClass(value: number): string {
return value >= 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400';
}
export default function PortfolioSimulator({
className = '',
recommendations = [],
nifty50Prices,
allBacktestData,
}: PortfolioSimulatorProps) {
const [startingAmount, setStartingAmount] = useState(100000);
const [showBreakdown, setShowBreakdown] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showBrokerageDetails, setShowBrokerageDetails] = useState(false);
const [showTradeWaterfall, setShowTradeWaterfall] = useState(false);
const [investmentMode, setInvestmentMode] = useState<InvestmentMode>('all50');
const [includeBrokerage, setIncludeBrokerage] = useState(true);
// Modal state - single state for all modals instead of 7 separate booleans
type ModalType = 'totalTrades' | 'buyTrades' | 'sellTrades' | 'portfolioValue' | 'profitLoss' | 'comparison' | null;
const [activeModal, setActiveModal] = useState<ModalType>(null);
const { portfolioData, stats, openPositions } = useMemo(() => {
return calculateSmartTrades(
recommendations,
investmentMode,
startingAmount,
nifty50Prices,
allBacktestData
);
}, [recommendations, investmentMode, startingAmount, nifty50Prices, allBacktestData]);
const lastDataPoint = portfolioData[portfolioData.length - 1];
const currentValue = lastDataPoint?.value ?? startingAmount;
const niftyValue = lastDataPoint?.niftyValue ?? startingAmount;
const totalCharges = includeBrokerage ? stats.brokerageBreakdown.totalCharges : 0;
const finalValue = currentValue - totalCharges;
const totalReturn = ((finalValue - startingAmount) / startingAmount) * 100;
const profitLoss = finalValue - startingAmount;
const isPositive = profitLoss >= 0;
const niftyReturn = ((niftyValue - startingAmount) / startingAmount) * 100;
const outperformance = totalReturn - niftyReturn;
// Calculate Y-axis domain with padding
const yAxisDomain = useMemo(() => {
if (portfolioData.length === 0) return [0, startingAmount * 1.2];
const allValues = portfolioData.flatMap(d => [d.value, d.niftyValue]);
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
return [Math.floor((minValue - padding) / 1000) * 1000, Math.ceil((maxValue + padding) / 1000) * 1000];
}, [portfolioData, startingAmount]);
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value.replace(/,/g, ''), 10);
if (!isNaN(value) && value >= 0) {
setStartingAmount(value);
}
};
const openPositionsCount = Object.keys(openPositions).length;
return (
<div className={`card p-4 ${className}`}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Calculator className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />
<h2 className="font-semibold text-gray-900 dark:text-gray-100">Portfolio Simulator</h2>
</div>
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-1.5 rounded-lg transition-colors ${
showSettings
? 'bg-nifty-100 text-nifty-600 dark:bg-nifty-900/30 dark:text-nifty-400'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
title="Settings"
>
<Settings2 className="w-4 h-4" />
</button>
</div>
{/* Settings Panel */}
{showSettings && (
<div className="mb-4 p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg space-y-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
Investment Strategy
</label>
<div className="flex gap-2">
<button
onClick={() => setInvestmentMode('all50')}
className={`flex-1 px-3 py-2 text-xs font-medium rounded-lg transition-all ${
investmentMode === 'all50'
? 'bg-nifty-600 text-white'
: 'bg-white dark:bg-slate-600 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-slate-500 hover:bg-gray-50 dark:hover:bg-slate-500'
}`}
>
All 50 Stocks
</button>
<button
onClick={() => setInvestmentMode('topPicks')}
className={`flex-1 px-3 py-2 text-xs font-medium rounded-lg transition-all ${
investmentMode === 'topPicks'
? 'bg-nifty-600 text-white'
: 'bg-white dark:bg-slate-600 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-slate-500 hover:bg-gray-50 dark:hover:bg-slate-500'
}`}
>
Top Picks Only
</button>
</div>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeBrokerage}
onChange={(e) => setIncludeBrokerage(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-nifty-600 focus:ring-nifty-500"
/>
<span className="text-xs text-gray-600 dark:text-gray-400">Include Zerodha Equity Delivery Charges</span>
</label>
</div>
</div>
)}
{/* Input Section */}
<div className="mb-4">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Starting Investment
</label>
<div className="relative">
<IndianRupee className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={startingAmount.toLocaleString('en-IN')}
onChange={handleAmountChange}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-nifty-500 focus:border-transparent"
/>
</div>
<div className="flex gap-2 mt-2">
{[10000, 50000, 100000, 500000].map(amount => (
<button
key={amount}
onClick={() => setStartingAmount(amount)}
className={`px-2 py-1 text-xs rounded ${
startingAmount === amount
? 'bg-nifty-600 text-white'
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-slate-600'
}`}
>
{formatINR(amount, 0)}
</button>
))}
</div>
</div>
{/* Results Section */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 relative">
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-1">
<span>Final Portfolio Value</span>
<InfoButton onClick={() => setActiveModal('portfolioValue')} />
</div>
<div className={`text-xl font-bold ${getValueColorClass(profitLoss)}`}>
{formatINR(finalValue, 0)}
</div>
</div>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-1">
<span>Net Profit/Loss</span>
<InfoButton onClick={() => setActiveModal('profitLoss')} />
</div>
<div className={`text-xl font-bold ${getValueColorClass(profitLoss)}`}>
{isPositive ? '+' : ''}{formatINR(profitLoss, 0)}
<span className="text-sm ml-1">({isPositive ? '+' : ''}{totalReturn.toFixed(1)}%)</span>
</div>
</div>
</div>
{/* Trade Stats with Info Buttons */}
<div className="grid grid-cols-4 gap-2 mb-4">
<div
className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-center cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
onClick={() => setActiveModal('totalTrades')}
>
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{stats.totalTrades}</div>
<div className="text-[10px] text-blue-600/70 dark:text-blue-400/70 flex items-center justify-center gap-0.5">
Total Trades <HelpCircle className="w-2.5 h-2.5" />
</div>
</div>
<div
className="p-2 rounded-lg bg-green-50 dark:bg-green-900/20 text-center cursor-pointer hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors"
onClick={() => setActiveModal('buyTrades')}
>
<div className="text-lg font-bold text-green-600 dark:text-green-400">{stats.buyTrades}</div>
<div className="text-[10px] text-green-600/70 dark:text-green-400/70 flex items-center justify-center gap-0.5">
Buy Trades <HelpCircle className="w-2.5 h-2.5" />
</div>
</div>
<div
className="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-center cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
onClick={() => setActiveModal('sellTrades')}
>
<div className="text-lg font-bold text-red-600 dark:text-red-400">{stats.sellTrades}</div>
<div className="text-[10px] text-red-600/70 dark:text-red-400/70 flex items-center justify-center gap-0.5">
Sell Trades <HelpCircle className="w-2.5 h-2.5" />
</div>
</div>
<div
className="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-center cursor-pointer hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors"
onClick={() => setShowBrokerageDetails(!showBrokerageDetails)}
title="Click for detailed breakdown"
>
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{formatINR(totalCharges, 0)}</div>
<div className="text-[10px] text-amber-600/70 dark:text-amber-400/70 flex items-center justify-center gap-0.5">
Total Charges <Info className="w-2.5 h-2.5" />
</div>
</div>
</div>
{/* Open Positions Badge */}
{openPositionsCount > 0 && (
<div className="mb-4 p-2 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800/30">
<div className="flex items-center justify-between text-xs">
<span className="text-purple-700 dark:text-purple-300 flex items-center gap-1">
<Wallet className="w-3.5 h-3.5" />
Open Positions (not yet sold)
</span>
<span className="font-bold text-purple-600 dark:text-purple-400">{openPositionsCount} stocks</span>
</div>
</div>
)}
{/* Brokerage Breakdown */}
{showBrokerageDetails && includeBrokerage && (
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800/30">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-xs font-semibold text-amber-800 dark:text-amber-300">Zerodha Equity Delivery Charges</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Brokerage:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.brokerage)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">STT:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.stt)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Exchange Charges:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.exchangeCharges)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">SEBI Charges:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.sebiCharges)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">GST (18%):</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.gst)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Stamp Duty:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.stampDuty)}</span>
</div>
</div>
<div className="mt-2 pt-2 border-t border-amber-200 dark:border-amber-700 flex justify-between">
<span className="text-xs font-semibold text-amber-800 dark:text-amber-300">Total Turnover:</span>
<span className="text-xs font-bold text-amber-800 dark:text-amber-300">{formatINR(stats.brokerageBreakdown.turnover, 0)}</span>
</div>
</div>
)}
{/* Comparison with Nifty */}
<div
className="mb-4 p-3 rounded-lg bg-gradient-to-r from-nifty-50 to-blue-50 dark:from-nifty-900/20 dark:to-blue-900/20 border border-nifty-100 dark:border-nifty-800/30 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => setActiveModal('comparison')}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">vs Nifty 50 Index</span>
</div>
<HelpCircle className="w-3.5 h-3.5 text-gray-400" />
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className={`text-sm font-bold ${getValueColorClass(totalReturn)}`}>
{totalReturn >= 0 ? '+' : ''}{totalReturn.toFixed(1)}%
</div>
<div className="text-[10px] text-gray-500">AI Strategy</div>
</div>
<div>
<div className={`text-sm font-bold ${getValueColorClass(niftyReturn)}`}>
{niftyReturn >= 0 ? '+' : ''}{niftyReturn.toFixed(1)}%
</div>
<div className="text-[10px] text-gray-500">Nifty 50</div>
</div>
<div>
<div className={`text-sm font-bold ${outperformance >= 0 ? 'text-nifty-600 dark:text-nifty-400' : 'text-red-600 dark:text-red-400'}`}>
{outperformance >= 0 ? '+' : ''}{outperformance.toFixed(1)}%
</div>
<div className="text-[10px] text-gray-500">Outperformance</div>
</div>
</div>
</div>
{/* Chart with Nifty Comparison - Fixed Y-axis */}
{portfolioData.length > 0 && (
<div className="h-48 mb-4">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<LineChart data={portfolioData} margin={{ top: 5, right: 10, bottom: 5, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
<XAxis
dataKey="date"
tick={{ fontSize: 10 }}
className="text-gray-500 dark:text-gray-400"
/>
<YAxis
tick={{ fontSize: 10 }}
tickFormatter={(v) => formatINR(v, 0).replace('₹', '')}
className="text-gray-500 dark:text-gray-400"
width={60}
domain={yAxisDomain}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg, #fff)',
border: '1px solid var(--tooltip-border, #e5e7eb)',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value, name) => [
formatINR(Number(value) || 0, 0),
name === 'value' ? 'AI Strategy' : 'Nifty 50'
]}
/>
<Legend
wrapperStyle={{ fontSize: '10px' }}
formatter={(value) => value === 'value' ? 'AI Strategy' : 'Nifty 50'}
/>
<ReferenceLine
y={startingAmount}
stroke="#94a3b8"
strokeDasharray="5 5"
label={{ value: 'Start', fontSize: 10, fill: '#94a3b8' }}
/>
<Line
type="monotone"
dataKey="value"
name="value"
stroke={isPositive ? '#22c55e' : '#ef4444'}
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="niftyValue"
name="niftyValue"
stroke="#6366f1"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Trade Waterfall Toggle */}
<button
onClick={() => setShowTradeWaterfall(!showTradeWaterfall)}
className="flex items-center justify-between w-full px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700/50 rounded-lg transition-colors mb-2"
>
<span className="flex items-center gap-2">
<ArrowRightLeft className="w-4 h-4" />
Trade Timeline ({stats.trades.length} completed trades)
</span>
{showTradeWaterfall ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{/* Trade Waterfall Chart */}
{showTradeWaterfall && stats.trades.length > 0 && (
<div className="mb-4 p-3 bg-gray-50 dark:bg-slate-700/30 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Each bar represents a trade from buy to sell. Green = Profit, Red = Loss.
</div>
<div className="h-64 overflow-y-auto">
<div style={{ height: Math.max(200, stats.trades.length * 28) }}>
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<BarChart
data={stats.trades.map((t, i) => ({
...t,
idx: i,
displayName: `${t.symbol}`,
duration: `${new Date(t.entryDate).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })} → ${new Date(t.exitDate).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}`,
}))}
layout="vertical"
margin={{ top: 5, right: 60, bottom: 5, left: 70 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 9 }}
tickFormatter={(v) => formatINR(v, 0)}
domain={['dataMin', 'dataMax']}
/>
<YAxis
type="category"
dataKey="displayName"
tick={{ fontSize: 10 }}
width={65}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg, #fff)',
border: '1px solid var(--tooltip-border, #e5e7eb)',
borderRadius: '8px',
fontSize: '11px',
}}
formatter={(value) => [formatINR(Number(value) || 0, 2), 'P/L']}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const d = payload[0].payload;
return `${d.symbol}: ${d.duration}`;
}
return '';
}}
/>
<Bar dataKey="profitLoss" radius={[0, 4, 4, 0]}>
{stats.trades.map((trade, index) => (
<Cell
key={`cell-${index}`}
fill={trade.profitLoss >= 0 ? '#22c55e' : '#ef4444'}
/>
))}
<LabelList
dataKey="profitLoss"
position="right"
formatter={(v) => formatINR(Number(v) || 0, 0)}
style={{ fontSize: 9, fill: '#6b7280' }}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
{/* Daily Breakdown (Collapsible) */}
<button
onClick={() => setShowBreakdown(!showBreakdown)}
className="flex items-center justify-between w-full px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700/50 rounded-lg transition-colors"
>
<span>Daily Breakdown</span>
{showBreakdown ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showBreakdown && (
<div className="mt-2 border border-gray-200 dark:border-slate-600 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-slate-700">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Date</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Return</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">AI Value</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Nifty</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
{portfolioData.map((day, idx) => (
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{day.date}</td>
<td className={`px-3 py-2 text-right font-medium ${getValueColorClass(day.return)}`}>
{day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}%
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300">
{formatINR(day.value, 0)}
</td>
<td className="px-3 py-2 text-right text-indigo-600 dark:text-indigo-400">
{formatINR(day.niftyValue, 0)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-3 text-center">
Simulated using Zerodha Equity Delivery rates (0% brokerage, STT 0.1%, Exchange 0.00345%, SEBI 0.0001%, Stamp 0.015%).
{investmentMode === 'topPicks' ? ' Investing in Top Picks only.' : ' Investing in all 50 stocks.'}
{includeBrokerage ? ` Total Charges: ${formatINR(totalCharges, 0)}` : ''}
</p>
{/* Info Modals */}
<InfoModal
isOpen={activeModal === 'totalTrades'}
onClose={() => setActiveModal(null)}
title="Total Trades"
icon={<ArrowRightLeft className="w-5 h-5 text-blue-600 dark:text-blue-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Total Trades</strong> represents the sum of all buy and sell transactions executed during the simulation period.</p>
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="font-semibold text-blue-800 dark:text-blue-200 mb-1">Calculation:</div>
<code className="text-xs">Total Trades = Buy Trades + Sell Trades</code>
<div className="mt-2 text-xs">= {stats.buyTrades} + {stats.sellTrades} = <strong>{stats.totalTrades}</strong></div>
</div>
<p className="text-xs text-gray-500">Note: A complete round-trip trade (buy then sell) counts as 2 trades.</p>
</div>
</InfoModal>
<InfoModal
isOpen={activeModal === 'buyTrades'}
onClose={() => setActiveModal(null)}
title="Buy Trades"
icon={<TrendingUp className="w-5 h-5 text-green-600 dark:text-green-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Buy Trades</strong> counts when a new position is opened based on AI's BUY recommendation.</p>
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="font-semibold text-green-800 dark:text-green-200 mb-2">When is a Buy Trade counted?</div>
<ul className="text-xs space-y-1 list-disc list-inside">
<li>When AI recommends BUY and no position exists</li>
<li>When AI recommends BUY after a previous SELL</li>
</ul>
</div>
<p className="text-xs text-gray-500">Note: If AI recommends BUY while already holding (from previous BUY or HOLD), no new buy trade is counted - the position is simply carried forward.</p>
</div>
</InfoModal>
<InfoModal
isOpen={activeModal === 'sellTrades'}
onClose={() => setActiveModal(null)}
title="Sell Trades"
icon={<TrendingDown className="w-5 h-5 text-red-600 dark:text-red-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Sell Trades</strong> counts when a position is closed based on AI's SELL recommendation.</p>
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<div className="font-semibold text-red-800 dark:text-red-200 mb-2">When is a Sell Trade counted?</div>
<ul className="text-xs space-y-1 list-disc list-inside">
<li>When AI recommends SELL while holding a position</li>
<li>Position must have been opened via BUY or carried via HOLD</li>
</ul>
</div>
<p className="text-xs text-gray-500">Note: Brokerage is calculated when a sell trade completes a round-trip transaction.</p>
</div>
</InfoModal>
<InfoModal
isOpen={activeModal === 'portfolioValue'}
onClose={() => setActiveModal(null)}
title="Final Portfolio Value"
icon={<PiggyBank className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Final Portfolio Value</strong> is the total worth of your investments at the end of the simulation period.</p>
<div className="p-3 bg-nifty-50 dark:bg-nifty-900/20 rounded-lg">
<div className="font-semibold text-nifty-800 dark:text-nifty-200 mb-1">Calculation:</div>
<code className="text-xs">Final Value = Portfolio Value - Total Charges</code>
<div className="mt-2 text-xs">
= {formatINR(currentValue, 0)} - {formatINR(totalCharges, 0)} = <strong>{formatINR(finalValue, 0)}</strong>
</div>
</div>
<p className="text-xs text-gray-500">This includes all realized gains/losses from completed trades and deducts Zerodha brokerage charges.</p>
</div>
</InfoModal>
<InfoModal
isOpen={activeModal === 'profitLoss'}
onClose={() => setActiveModal(null)}
title="Net Profit/Loss"
icon={<Calculator className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Net Profit/Loss</strong> shows your actual earnings or losses after all charges.</p>
<div className="p-3 bg-gray-100 dark:bg-slate-700 rounded-lg">
<div className="font-semibold mb-1">Calculation:</div>
<code className="text-xs">Net P/L = Final Value - Starting Investment</code>
<div className="mt-2 text-xs">
= {formatINR(finalValue, 0)} - {formatINR(startingAmount, 0)} = <strong className={profitLoss >= 0 ? 'text-green-600' : 'text-red-600'}>{formatINR(profitLoss, 0)}</strong>
</div>
<div className="mt-2 text-xs">
Return = ({formatINR(profitLoss, 0)} / {formatINR(startingAmount, 0)}) × 100 = <strong>{totalReturn.toFixed(2)}%</strong>
</div>
</div>
</div>
</InfoModal>
<InfoModal
isOpen={activeModal === 'comparison'}
onClose={() => setActiveModal(null)}
title="vs Nifty 50 Index"
icon={<BarChart3 className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p>This compares the AI strategy's performance against simply investing in the Nifty 50 index.</p>
<div className="space-y-2">
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg flex justify-between items-center">
<span>AI Strategy Return:</span>
<strong className={totalReturn >= 0 ? 'text-green-600' : 'text-red-600'}>{totalReturn.toFixed(2)}%</strong>
</div>
<div className="p-2 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg flex justify-between items-center">
<span>Nifty 50 Return:</span>
<strong className={niftyReturn >= 0 ? 'text-green-600' : 'text-red-600'}>{niftyReturn.toFixed(2)}%</strong>
</div>
<div className="p-2 bg-nifty-50 dark:bg-nifty-900/20 rounded-lg flex justify-between items-center">
<span>Outperformance (Alpha):</span>
<strong className={outperformance >= 0 ? 'text-nifty-600' : 'text-red-600'}>{outperformance.toFixed(2)}%</strong>
</div>
</div>
<p className="text-xs text-gray-500">
{outperformance >= 0
? `The AI strategy beat the Nifty 50 index by ${outperformance.toFixed(2)} percentage points.`
: `The AI strategy underperformed the Nifty 50 index by ${Math.abs(outperformance).toFixed(2)} percentage points.`
}
</p>
</div>
</InfoModal>
</div>
);
}
// Export the type for use in other components
export { type InvestmentMode as PortfolioInvestmentMode };

View File

@ -0,0 +1,119 @@
import { useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { X } from 'lucide-react';
import type { ReturnBucket } from '../types';
export interface ReturnDistributionChartProps {
height?: number;
className?: string;
data?: ReturnBucket[];
}
export default function ReturnDistributionChart({ height = 200, className = '', data: propData }: ReturnDistributionChartProps) {
const [selectedBucket, setSelectedBucket] = useState<{ range: string; stocks: string[] } | null>(null);
const data = propData || [];
if (data.every(d => d.count === 0)) {
return (
<div className={`flex items-center justify-center text-gray-400 ${className}`} style={{ height }}>
No distribution data available
</div>
);
}
// 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 (
<div className={className}>
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<BarChart data={data} margin={{ top: 5, right: 10, bottom: 5, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
<XAxis
dataKey="range"
tick={{ fontSize: 10 }}
angle={-45}
textAnchor="end"
height={60}
className="text-gray-500 dark:text-gray-400"
/>
<YAxis
tick={{ fontSize: 11 }}
className="text-gray-500 dark:text-gray-400"
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg, #fff)',
border: '1px solid var(--tooltip-border, #e5e7eb)',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value) => [`${value} stocks`, 'Count']}
labelFormatter={(label) => `Return: ${label}`}
/>
<Bar
dataKey="count"
cursor="pointer"
onClick={(_data, index) => {
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 <rect x={x} y={y} width={width} height={height} fill={fill} rx={2} />;
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
{/* Selected bucket modal */}
{selectedBucket && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setSelectedBucket(null)} />
<div className="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-sm w-full p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
Stocks with {selectedBucket.range} return
</h3>
<button
onClick={() => setSelectedBucket(null)}
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700"
>
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
<div className="flex flex-wrap gap-2">
{selectedBucket.stocks.map(symbol => (
<span
key={symbol}
className="px-2 py-1 text-xs font-medium bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 rounded"
>
{symbol}
</span>
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,221 @@
import { X, CheckCircle, XCircle, Calculator } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { ReturnBreakdown } from '../types';
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 createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-[95vw] sm:max-w-lg w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 flex items-center justify-between p-4 border-b border-gray-100 dark:border-slate-700 bg-white dark:bg-slate-800">
<div className="flex items-center gap-2">
<Calculator className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Return Calculation
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
>
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-5">
{/* Date & Result */}
<div className="p-4 rounded-lg bg-nifty-50 dark:bg-nifty-900/20 border border-nifty-100 dark:border-nifty-800">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">{formattedDate}</div>
<div className={`text-3xl font-bold ${breakdown.weightedReturn >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{breakdown.weightedReturn >= 0 ? '+' : ''}{breakdown.weightedReturn.toFixed(1)}%
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Weighted Average Return
</p>
</div>
{/* Method Explanation */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Calculation Method</h3>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-sm space-y-2">
<p className="text-gray-700 dark:text-gray-300">
<strong>1. Correct Predictions</strong> Contribute <span className="text-green-600 dark:text-green-400">positively</span>
</p>
<ul className="text-xs text-gray-600 dark:text-gray-400 ml-4 space-y-1">
<li> BUY that went up add the gain</li>
<li> SELL that went down add the avoided loss</li>
<li> HOLD that stayed flat small positive</li>
</ul>
<p className="text-gray-700 dark:text-gray-300 mt-2">
<strong>2. Incorrect Predictions</strong> Contribute <span className="text-red-600 dark:text-red-400">negatively</span>
</p>
<ul className="text-xs text-gray-600 dark:text-gray-400 ml-4 space-y-1">
<li> BUY that went down subtract the loss</li>
<li> SELL that went up subtract missed gain</li>
<li> HOLD that moved subtract missed opportunity</li>
</ul>
<p className="text-gray-700 dark:text-gray-300 mt-2">
<strong>3. Weighted Average</strong>
</p>
<div className="p-2 bg-white dark:bg-slate-800 rounded border border-gray-200 dark:border-slate-600 font-mono text-xs">
(Correct Avg × Correct Weight) + (Incorrect Avg × Incorrect Weight)
</div>
</div>
</div>
{/* Correct Predictions Breakdown */}
<div>
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
<h3 className="font-semibold text-green-800 dark:text-green-300">Correct Predictions</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
({breakdown.correctPredictions.count} stocks)
</span>
</div>
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800">
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<div className="text-xs text-gray-500 dark:text-gray-400">Average Return</div>
<div className="text-lg font-bold text-green-600 dark:text-green-400">
+{breakdown.correctPredictions.avgReturn.toFixed(1)}%
</div>
</div>
<div>
<div className="text-xs text-gray-500 dark:text-gray-400">Weight</div>
<div className="text-lg font-bold text-green-600 dark:text-green-400">
{breakdown.correctPredictions.count}/{breakdown.correctPredictions.count + breakdown.incorrectPredictions.count}
</div>
</div>
</div>
{breakdown.correctPredictions.stocks.length > 0 && (
<div className="border-t border-green-200 dark:border-green-700 pt-2">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Top performers:</div>
<div className="space-y-1">
{breakdown.correctPredictions.stocks.map((stock: { symbol: string; decision: string; return1d: number }) => (
<div key={stock.symbol} className="flex items-center justify-between text-xs">
<span className="font-medium text-gray-700 dark:text-gray-300">
{stock.symbol}
<span className={`ml-1 ${
stock.decision === 'BUY' ? 'text-green-600 dark:text-green-400' :
stock.decision === 'SELL' ? 'text-red-600 dark:text-red-400' :
'text-amber-600 dark:text-amber-400'
}`}>
({stock.decision})
</span>
</span>
<span className="text-green-600 dark:text-green-400">+{stock.return1d.toFixed(1)}%</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Incorrect Predictions Breakdown */}
<div>
<div className="flex items-center gap-2 mb-2">
<XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
<h3 className="font-semibold text-red-800 dark:text-red-300">Incorrect Predictions</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
({breakdown.incorrectPredictions.count} stocks)
</span>
</div>
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800">
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<div className="text-xs text-gray-500 dark:text-gray-400">Average Return</div>
<div className="text-lg font-bold text-red-600 dark:text-red-400">
{breakdown.incorrectPredictions.avgReturn.toFixed(1)}%
</div>
</div>
<div>
<div className="text-xs text-gray-500 dark:text-gray-400">Weight</div>
<div className="text-lg font-bold text-red-600 dark:text-red-400">
{breakdown.incorrectPredictions.count}/{breakdown.correctPredictions.count + breakdown.incorrectPredictions.count}
</div>
</div>
</div>
{breakdown.incorrectPredictions.stocks.length > 0 && (
<div className="border-t border-red-200 dark:border-red-700 pt-2">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Worst performers:</div>
<div className="space-y-1">
{breakdown.incorrectPredictions.stocks.map((stock: { symbol: string; decision: string; return1d: number }) => (
<div key={stock.symbol} className="flex items-center justify-between text-xs">
<span className="font-medium text-gray-700 dark:text-gray-300">
{stock.symbol}
<span className={`ml-1 ${
stock.decision === 'BUY' ? 'text-green-600 dark:text-green-400' :
stock.decision === 'SELL' ? 'text-red-600 dark:text-red-400' :
'text-amber-600 dark:text-amber-400'
}`}>
({stock.decision})
</span>
</span>
<span className="text-red-600 dark:text-red-400">{stock.return1d.toFixed(1)}%</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Final Calculation */}
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Final Calculation</h3>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
<div className="font-mono text-xs text-gray-600 dark:text-gray-400 break-all">
{breakdown.formula}
</div>
</div>
</div>
{/* Disclaimer */}
<div className="p-3 rounded-lg bg-gray-100 dark:bg-slate-700/30 border border-gray-200 dark:border-slate-600">
<p className="text-xs text-gray-500 dark:text-gray-400">
<strong>Note:</strong> 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.
</p>
</div>
</div>
{/* Footer */}
<div className="sticky bottom-0 p-4 border-t border-gray-100 dark:border-slate-700 bg-white dark:bg-slate-800">
<button
onClick={onClose}
className="w-full btn-primary"
>
Got it
</button>
</div>
</div>
</div>,
document.body
);
}

View File

@ -0,0 +1,361 @@
import { TrendingUp, TrendingDown, Activity, Target } from 'lucide-react';
import { useState } from 'react';
import InfoModal, { InfoButton } from './InfoModal';
import type { RiskMetrics } from '../types';
export interface RiskMetricsCardProps {
className?: string;
metrics?: RiskMetrics;
}
type MetricModal = 'sharpe' | 'drawdown' | 'winloss' | 'winrate' | null;
const defaultMetrics: RiskMetrics = {
sharpeRatio: 0, maxDrawdown: 0, winLossRatio: 0, winRate: 0, volatility: 0, totalTrades: 0,
};
export default function RiskMetricsCard({ className = '', metrics: propMetrics }: RiskMetricsCardProps) {
const [activeModal, setActiveModal] = useState<MetricModal>(null);
const metrics = propMetrics || defaultMetrics;
// Color classes for metric values
const COLOR_GOOD = 'text-green-600 dark:text-green-400';
const COLOR_NEUTRAL = 'text-amber-600 dark:text-amber-400';
const COLOR_BAD = 'text-red-600 dark:text-red-400';
function getColor(metric: string, value: number): string {
// Thresholds for each metric: [good, neutral] - values below neutral are bad
const thresholds: Record<string, { good: number; neutral: number; inverted?: boolean }> = {
sharpe: { good: 1, neutral: 0 },
drawdown: { good: 5, neutral: 15, inverted: true }, // Lower is better
winloss: { good: 1.5, neutral: 1 },
winrate: { good: 70, neutral: 50 },
};
const config = thresholds[metric];
if (!config) return 'text-gray-700 dark:text-gray-300';
if (config.inverted) {
// For drawdown: lower is better
if (value <= config.good) return COLOR_GOOD;
if (value <= config.neutral) return COLOR_NEUTRAL;
return COLOR_BAD;
}
// For other metrics: higher is better
if (value >= config.good) return COLOR_GOOD;
if (value >= config.neutral) return COLOR_NEUTRAL;
return COLOR_BAD;
}
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 (
<>
<div className={`grid grid-cols-2 sm:grid-cols-4 gap-3 ${className}`}>
{cards.map((card) => {
const Icon = card.icon;
return (
<div
key={card.id}
className="relative p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center group"
>
<div className="flex items-center justify-center gap-1 mb-1">
<Icon className={`w-4 h-4 ${card.color}`} />
<span className={`text-xl font-bold ${card.color}`}>{card.value}</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="text-xs text-gray-500 dark:text-gray-400">{card.label}</span>
<InfoButton onClick={() => setActiveModal(card.id as MetricModal)} />
</div>
</div>
);
})}
</div>
{/* Sharpe Ratio Modal */}
<InfoModal
isOpen={activeModal === 'sharpe'}
onClose={() => setActiveModal(null)}
title="Sharpe Ratio"
icon={<Activity className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
The <strong className="text-gray-900 dark:text-gray-100">Sharpe Ratio</strong> measures risk-adjusted returns
by comparing the excess return of an investment to its standard deviation (volatility).
</p>
{/* Current Value Display */}
<div className={`p-3 rounded-lg ${getColor('sharpe', metrics.sharpeRatio).replace('text-', 'bg-').replace('-600', '-50').replace('-400', '-900/20')}`}>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Current Sharpe Ratio</div>
<div className={`text-2xl font-bold ${getColor('sharpe', metrics.sharpeRatio)}`}>{metrics.sharpeRatio.toFixed(2)}</div>
</div>
{/* Formula and Calculation */}
<div className="bg-gray-50 dark:bg-slate-700 p-3 rounded-lg space-y-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Formula:</p>
<div className="font-mono text-sm bg-white dark:bg-slate-800 p-2 rounded border border-gray-200 dark:border-slate-600">
Sharpe Ratio = ( Rf) / σ
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Where = Mean Return, Rf = Risk-Free Rate, σ = Standard Deviation
</p>
</div>
{metrics.meanReturn !== undefined && (
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Your Values:</p>
<div className="text-xs space-y-1 mb-3">
<p> Mean Daily Return () = <span className="text-nifty-600 dark:text-nifty-400 font-medium">{metrics.meanReturn}%</span></p>
<p> Risk-Free Rate (Rf) = <span className="text-gray-700 dark:text-gray-300 font-medium">{metrics.riskFreeRate}%</span> <span className="text-gray-400">(daily)</span></p>
<p> Volatility (σ) = <span className="text-amber-600 dark:text-amber-400 font-medium">{metrics.volatility}%</span></p>
</div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Calculation:</p>
<div className="font-mono text-xs bg-white dark:bg-slate-800 p-2 rounded border border-gray-200 dark:border-slate-600 space-y-1">
<p>= ({metrics.meanReturn} {metrics.riskFreeRate}) / {metrics.volatility}</p>
<p>= {(metrics.meanReturn - (metrics.riskFreeRate || 0)).toFixed(2)} / {metrics.volatility}</p>
<p className={`font-bold ${getColor('sharpe', metrics.sharpeRatio)}`}>= {metrics.sharpeRatio.toFixed(2)}</p>
</div>
</div>
)}
</div>
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-1">Interpretation:</p>
<ul className="space-y-1 ml-4 list-disc">
<li><span className="text-green-600 dark:text-green-400 font-medium">&gt; 1.0:</span> Good risk-adjusted returns</li>
<li><span className="text-green-600 dark:text-green-400 font-medium">&gt; 2.0:</span> Excellent performance</li>
<li><span className="text-amber-600 dark:text-amber-400 font-medium">0 - 1.0:</span> Acceptable but not optimal</li>
<li><span className="text-red-600 dark:text-red-400 font-medium">&lt; 0:</span> Returns below risk-free rate</li>
</ul>
</div>
<p className="text-xs italic">
Higher Sharpe Ratio indicates better compensation for the risk taken.
</p>
</div>
</InfoModal>
{/* Max Drawdown Modal */}
<InfoModal
isOpen={activeModal === 'drawdown'}
onClose={() => setActiveModal(null)}
title="Maximum Drawdown"
icon={<TrendingDown className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
<strong className="text-gray-900 dark:text-gray-100">Maximum Drawdown (MDD)</strong> measures the largest
peak-to-trough decline in portfolio value before a new peak is reached.
</p>
{/* Current Value Display */}
<div className={`p-3 rounded-lg ${getColor('drawdown', metrics.maxDrawdown).replace('text-', 'bg-').replace('-600', '-50').replace('-400', '-900/20')}`}>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Maximum Drawdown</div>
<div className={`text-2xl font-bold ${getColor('drawdown', metrics.maxDrawdown)}`}>{metrics.maxDrawdown.toFixed(1)}%</div>
</div>
{/* Formula and Calculation */}
<div className="bg-gray-50 dark:bg-slate-700 p-3 rounded-lg space-y-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Formula:</p>
<div className="font-mono text-sm bg-white dark:bg-slate-800 p-2 rounded border border-gray-200 dark:border-slate-600">
MDD = (Vpeak Vtrough) / Vpeak × 100%
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Where Vpeak = Peak Portfolio Value, Vtrough = Lowest Value after Peak
</p>
</div>
{metrics.peakValue !== undefined && metrics.troughValue !== undefined && (
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Your Values:</p>
<div className="text-xs space-y-1 mb-3">
<p> Peak Value (Vpeak) = <span className="text-green-600 dark:text-green-400 font-medium">{metrics.peakValue.toFixed(2)}</span> <span className="text-gray-400">(normalized from 100)</span></p>
<p> Trough Value (Vtrough) = <span className="text-red-600 dark:text-red-400 font-medium">{metrics.troughValue.toFixed(2)}</span></p>
</div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Calculation:</p>
<div className="font-mono text-xs bg-white dark:bg-slate-800 p-2 rounded border border-gray-200 dark:border-slate-600 space-y-1">
<p>= ({metrics.peakValue.toFixed(2)} {metrics.troughValue.toFixed(2)}) / {metrics.peakValue.toFixed(2)} × 100</p>
<p>= {(metrics.peakValue - metrics.troughValue).toFixed(2)} / {metrics.peakValue.toFixed(2)} × 100</p>
<p className={`font-bold ${getColor('drawdown', metrics.maxDrawdown)}`}>= {metrics.maxDrawdown.toFixed(1)}%</p>
</div>
</div>
)}
</div>
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-1">Interpretation:</p>
<ul className="space-y-1 ml-4 list-disc">
<li><span className="text-green-600 dark:text-green-400 font-medium">&lt; 5%:</span> Very low risk</li>
<li><span className="text-amber-600 dark:text-amber-400 font-medium">5% - 15%:</span> Moderate risk</li>
<li><span className="text-red-600 dark:text-red-400 font-medium">&gt; 15%:</span> Higher risk exposure</li>
</ul>
</div>
<p className="text-xs italic">
Lower drawdown indicates better capital preservation during market downturns.
</p>
</div>
</InfoModal>
{/* Win/Loss Ratio Modal */}
<InfoModal
isOpen={activeModal === 'winloss'}
onClose={() => setActiveModal(null)}
title="Win/Loss Ratio"
icon={<TrendingUp className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
The <strong className="text-gray-900 dark:text-gray-100">Win/Loss Ratio</strong> compares the average
profit from winning trades to the average loss from losing trades.
</p>
{/* Current Value Display */}
<div className={`p-3 rounded-lg ${getColor('winloss', metrics.winLossRatio).replace('text-', 'bg-').replace('-600', '-50').replace('-400', '-900/20')}`}>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Win/Loss Ratio</div>
<div className={`text-2xl font-bold ${getColor('winloss', metrics.winLossRatio)}`}>{metrics.winLossRatio.toFixed(2)}</div>
</div>
{/* Formula and Calculation */}
<div className="bg-gray-50 dark:bg-slate-700 p-3 rounded-lg space-y-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Formula:</p>
<div className="font-mono text-sm bg-white dark:bg-slate-800 p-2 rounded border border-gray-200 dark:border-slate-600">
Win/Loss Ratio = R̄w / |R̄l|
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Where R̄w = Avg Winning Return, R̄l = Avg Losing Return (absolute value)
</p>
</div>
{metrics.winningTrades !== undefined && metrics.losingTrades !== undefined && (
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Your Values:</p>
<div className="text-xs space-y-1 mb-3">
<p> Winning Predictions = <span className="text-green-600 dark:text-green-400 font-medium">{metrics.winningTrades}</span> days</p>
<p> Losing Predictions = <span className="text-red-600 dark:text-red-400 font-medium">{metrics.losingTrades}</span> days</p>
<p> Avg Winning Return (R̄w) = <span className="text-green-600 dark:text-green-400 font-medium">+{metrics.avgWinReturn?.toFixed(2)}%</span></p>
<p> Avg Losing Return (R̄l) = <span className="text-red-600 dark:text-red-400 font-medium">{metrics.avgLossReturn?.toFixed(2)}%</span></p>
</div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Calculation:</p>
<div className="font-mono text-xs bg-white dark:bg-slate-800 p-2 rounded border border-gray-200 dark:border-slate-600 space-y-1">
<p>= {metrics.avgWinReturn?.toFixed(2)} / {metrics.avgLossReturn?.toFixed(2)}</p>
<p className={`font-bold ${getColor('winloss', metrics.winLossRatio)}`}>= {metrics.winLossRatio.toFixed(2)}</p>
</div>
</div>
)}
</div>
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-1">Interpretation:</p>
<ul className="space-y-1 ml-4 list-disc">
<li><span className="text-green-600 dark:text-green-400 font-medium">&gt; 1.5:</span> Strong profit potential</li>
<li><span className="text-amber-600 dark:text-amber-400 font-medium">1.0 - 1.5:</span> Balanced trades</li>
<li><span className="text-red-600 dark:text-red-400 font-medium">&lt; 1.0:</span> Losses exceed wins on average</li>
</ul>
</div>
<p className="text-xs italic">
A ratio above 1.0 means your winning trades are larger than your losing ones on average.
</p>
</div>
</InfoModal>
{/* Win Rate Modal */}
<InfoModal
isOpen={activeModal === 'winrate'}
onClose={() => setActiveModal(null)}
title="Win Rate"
icon={<Target className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
<strong className="text-gray-900 dark:text-gray-100">Win Rate</strong> is the percentage of predictions
that were correct (BUY/HOLD with positive return, or SELL with negative return).
</p>
{/* Current Value Display */}
<div className={`p-3 rounded-lg ${getColor('winrate', metrics.winRate).replace('text-', 'bg-').replace('-600', '-50').replace('-400', '-900/20')}`}>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Win Rate</div>
<div className={`text-2xl font-bold ${getColor('winrate', metrics.winRate)}`}>{metrics.winRate}%</div>
</div>
{/* Formula and Calculation */}
<div className="bg-gray-50 dark:bg-slate-700 p-3 rounded-lg space-y-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Formula:</p>
<div className="font-mono text-sm bg-white dark:bg-slate-800 p-2 rounded border border-gray-200 dark:border-slate-600">
Win Rate = (Ncorrect / Ntotal) × 100%
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Where Ncorrect = Correct Predictions, Ntotal = Total Predictions
</p>
</div>
{metrics.winningTrades !== undefined && (
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Your Values:</p>
<div className="text-xs space-y-1 mb-3">
<p> Correct Predictions (Ncorrect) = <span className="text-green-600 dark:text-green-400 font-medium">{metrics.winningTrades}</span></p>
<p> Total Predictions (Ntotal) = <span className="text-gray-700 dark:text-gray-300 font-medium">{metrics.totalTrades}</span></p>
</div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Calculation:</p>
<div className="font-mono text-xs bg-white dark:bg-slate-800 p-2 rounded border border-gray-200 dark:border-slate-600 space-y-1">
<p>= ({metrics.winningTrades} / {metrics.totalTrades}) × 100</p>
<p>= {(metrics.winningTrades / metrics.totalTrades).toFixed(4)} × 100</p>
<p className={`font-bold ${getColor('winrate', metrics.winRate)}`}>= {metrics.winRate}%</p>
</div>
</div>
)}
</div>
<div>
<p className="font-medium text-gray-900 dark:text-gray-100 mb-1">Interpretation:</p>
<ul className="space-y-1 ml-4 list-disc">
<li><span className="text-green-600 dark:text-green-400 font-medium">&gt; 70%:</span> Excellent accuracy</li>
<li><span className="text-amber-600 dark:text-amber-400 font-medium">50% - 70%:</span> Above average</li>
<li><span className="text-red-600 dark:text-red-400 font-medium">&lt; 50%:</span> Below random chance</li>
</ul>
</div>
<p className="text-xs italic">
Note: Win rate alone doesn't determine profitability. A 40% win rate can still be profitable with a high Win/Loss ratio.
</p>
</div>
</InfoModal>
</>
);
}

View File

@ -0,0 +1,418 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import {
X, Settings, Cpu, Key, Zap, Brain, Sparkles,
Eye, EyeOff, Check, AlertCircle, RefreshCw, Clock
} from 'lucide-react';
import { useSettings, MODELS, PROVIDERS, TIMEZONES } from '../contexts/SettingsContext';
import type { ModelId, ProviderId, TimezoneId } 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 createPortal(
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={closeSettings}
/>
{/* Modal */}
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative w-full max-w-lg bg-white dark:bg-slate-900 rounded-2xl shadow-2xl transform transition-all">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-nifty-100 dark:bg-nifty-900/30 rounded-lg">
<Settings className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Settings</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">Configure AI models and API settings</p>
</div>
</div>
<button
onClick={closeSettings}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-6 max-h-[70vh] overflow-y-auto">
{/* Provider Selection */}
<section>
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
<Zap className="w-4 h-4 text-amber-500" />
LLM Provider
</h3>
<div className="grid gap-2">
{Object.values(PROVIDERS).map(provider => (
<button
key={provider.id}
onClick={() => handleProviderChange(provider.id as ProviderId)}
className={`
flex items-start gap-3 p-3 rounded-xl border-2 transition-all text-left
${settings.provider === provider.id
? 'border-nifty-500 bg-nifty-50 dark:bg-nifty-900/20'
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
}
`}
>
<div className={`
w-5 h-5 rounded-full border-2 flex items-center justify-center mt-0.5
${settings.provider === provider.id
? 'border-nifty-500 bg-nifty-500'
: 'border-gray-300 dark:border-slate-600'
}
`}>
{settings.provider === provider.id && (
<Check className="w-3 h-3 text-white" />
)}
</div>
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
{provider.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{provider.description}
</div>
</div>
</button>
))}
</div>
</section>
{/* API Key (only shown for API provider) */}
{selectedProvider.requiresApiKey && (
<section>
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
<Key className="w-4 h-4 text-purple-500" />
API Key
</h3>
<div className="space-y-2">
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={settings.anthropicApiKey}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={testApiKey}
disabled={isTesting || !settings.anthropicApiKey}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-gray-100 dark:bg-slate-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isTesting ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Check className="w-3 h-3" />
)}
Validate Key
</button>
{testResult && (
<span className={`flex items-center gap-1 text-xs ${testResult.success ? 'text-green-600' : 'text-red-600'}`}>
{testResult.success ? <Check className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />}
{testResult.message}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Your API key is stored locally in your browser and never sent to our servers.
</p>
</div>
</section>
)}
{/* Model Selection */}
<section>
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
<Cpu className="w-4 h-4 text-blue-500" />
Model Selection
</h3>
{/* Deep Think Model */}
<div className="mb-4">
<label className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
<Brain className="w-3 h-3" />
Deep Think Model (Complex Analysis)
</label>
<div className="grid grid-cols-3 gap-2">
{Object.values(MODELS).map(model => (
<button
key={model.id}
onClick={() => handleModelChange('deepThinkModel', model.id as ModelId)}
className={`
p-2 rounded-lg border-2 transition-all text-center
${settings.deepThinkModel === model.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
}
`}
>
<div className={`text-sm font-medium ${
settings.deepThinkModel === model.id
? 'text-blue-700 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300'
}`}>
{model.name.replace('Claude ', '')}
</div>
</button>
))}
</div>
</div>
{/* Quick Think Model */}
<div>
<label className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
<Sparkles className="w-3 h-3" />
Quick Think Model (Fast Operations)
</label>
<div className="grid grid-cols-3 gap-2">
{Object.values(MODELS).map(model => (
<button
key={model.id}
onClick={() => handleModelChange('quickThinkModel', model.id as ModelId)}
className={`
p-2 rounded-lg border-2 transition-all text-center
${settings.quickThinkModel === model.id
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
}
`}
>
<div className={`text-sm font-medium ${
settings.quickThinkModel === model.id
? 'text-green-700 dark:text-green-300'
: 'text-gray-700 dark:text-gray-300'
}`}>
{model.name.replace('Claude ', '')}
</div>
</button>
))}
</div>
</div>
</section>
{/* Analysis Settings */}
<section>
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
<Settings className="w-4 h-4 text-gray-500" />
Analysis Settings
</h3>
<div>
<label className="flex items-center justify-between text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
<span>Max Debate Rounds</span>
<span className="text-nifty-600 dark:text-nifty-400">{settings.maxDebateRounds}</span>
</label>
<input
type="range"
min="1"
max="5"
value={settings.maxDebateRounds}
onChange={(e) => 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"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>1 (Faster)</span>
<span>5 (More thorough)</span>
</div>
</div>
{/* Parallel Workers */}
<div className="mt-4">
<label className="flex items-center justify-between text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
<span>Parallel Workers (Analyze All)</span>
<span className="text-nifty-600 dark:text-nifty-400">{settings.parallelWorkers}</span>
</label>
<input
type="range"
min="1"
max="5"
value={settings.parallelWorkers}
onChange={(e) => updateSettings({ parallelWorkers: 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"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>1 (Conservative)</span>
<span>5 (Aggressive)</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Number of stocks to analyze simultaneously during Analyze All
</p>
</div>
</section>
{/* Auto-Analyze Schedule */}
<section>
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
<Clock className="w-4 h-4 text-indigo-500" />
Auto-Analyze Schedule
</h3>
{/* Enable Toggle */}
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
Daily Auto-Analyze
</div>
<div className="text-[10px] text-gray-500 dark:text-gray-400">
Automatically run Analyze All at the scheduled time
</div>
</div>
<button
onClick={() => updateSettings({ autoAnalyzeEnabled: !settings.autoAnalyzeEnabled })}
className={`relative w-10 h-5 rounded-full transition-colors ${
settings.autoAnalyzeEnabled
? 'bg-nifty-600'
: 'bg-gray-300 dark:bg-slate-600'
}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
settings.autoAnalyzeEnabled ? 'translate-x-5' : 'translate-x-0'
}`} />
</button>
</div>
{/* Timezone */}
<div className={`mb-3 ${!settings.autoAnalyzeEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<label className="text-[10px] font-medium text-gray-500 dark:text-gray-400 mb-1 block">Timezone</label>
<select
value={settings.autoAnalyzeTimezone}
onChange={(e) => updateSettings({ autoAnalyzeTimezone: e.target.value as TimezoneId })}
className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-nifty-500"
>
{TIMEZONES.map(tz => (
<option key={tz.id} value={tz.id}>
{tz.label} (UTC{tz.offset})
</option>
))}
</select>
</div>
{/* Time Picker */}
<div className={`flex items-center gap-3 ${!settings.autoAnalyzeEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<div className="flex-1">
<label className="text-[10px] font-medium text-gray-500 dark:text-gray-400 mb-1 block">Hour</label>
<select
value={settings.autoAnalyzeTime.split(':')[0]}
onChange={(e) => {
const minute = settings.autoAnalyzeTime.split(':')[1];
updateSettings({ autoAnalyzeTime: `${e.target.value}:${minute}` });
}}
className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-nifty-500"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={String(i).padStart(2, '0')}>
{String(i).padStart(2, '0')}
</option>
))}
</select>
</div>
<span className="text-lg font-bold text-gray-400 dark:text-gray-500 mt-4">:</span>
<div className="flex-1">
<label className="text-[10px] font-medium text-gray-500 dark:text-gray-400 mb-1 block">Minute</label>
<select
value={settings.autoAnalyzeTime.split(':')[1]}
onChange={(e) => {
const hour = settings.autoAnalyzeTime.split(':')[0];
updateSettings({ autoAnalyzeTime: `${hour}:${e.target.value}` });
}}
className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-nifty-500"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i} value={String(i * 5).padStart(2, '0')}>
{String(i * 5).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
{/* Preview */}
{settings.autoAnalyzeEnabled && (
<div className="mt-3 p-2.5 rounded-lg bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-100 dark:border-indigo-800">
<p className="text-xs text-indigo-700 dark:text-indigo-300 font-medium">
Runs daily at {settings.autoAnalyzeTime} {TIMEZONES.find(tz => tz.id === settings.autoAnalyzeTimezone)?.label || settings.autoAnalyzeTimezone} when the backend is running
</p>
</div>
)}
</section>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-gray-200 dark:border-slate-700">
<button
onClick={resetSettings}
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
Reset to Defaults
</button>
<button
onClick={closeSettings}
className="px-4 py-2 text-sm font-medium bg-nifty-600 text-white rounded-lg hover:bg-nifty-700 transition-colors"
>
Done
</button>
</div>
</div>
</div>
</div>,
document.body
);
}

View File

@ -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 (
<div
className={`flex items-center justify-center text-gray-300 dark:text-gray-600 ${className}`}
style={{ width, height }}
>
<span className="text-[10px]">No data</span>
</div>
);
}
// 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 (
<div className={className} style={{ width, height, minWidth: width, minHeight: height }}>
<ResponsiveContainer width="100%" height="100%" minWidth={width} minHeight={height}>
<LineChart data={normalizedData} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
<YAxis
domain={[minPrice - padding, maxPrice + padding]}
hide
/>
<Line
type="monotone"
dataKey="normalizedPrice"
stroke={color}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,210 @@
import { Link } from 'react-router-dom';
import { TrendingUp, TrendingDown, Minus, ChevronRight, Clock } 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-emerald-100 dark:bg-emerald-900/25',
text: 'text-emerald-700 dark:text-emerald-400',
border: 'border border-emerald-200/60 dark:border-emerald-800/40',
icon: TrendingUp,
},
SELL: {
bg: 'bg-red-100 dark:bg-red-900/25',
text: 'text-red-700 dark:text-red-400',
border: 'border border-red-200/60 dark:border-red-800/40',
icon: TrendingDown,
},
HOLD: {
bg: 'bg-amber-100 dark:bg-amber-900/25',
text: 'text-amber-700 dark:text-amber-400',
border: 'border border-amber-200/60 dark:border-amber-800/40',
icon: Minus,
},
};
const entry = config[decision];
if (!entry) return null;
const { bg, text, border, icon: Icon } = entry;
const sizeClasses = size === 'small'
? 'px-2 py-0.5 text-[11px] 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 (
<span className={`inline-flex items-center rounded-full font-semibold tracking-wide ${bg} ${text} ${border} ${sizeClasses}`}>
<Icon className={iconSize} />
{decision}
</span>
);
}
export function ConfidenceBadge({ confidence }: { confidence?: string }) {
if (!confidence) return null;
const colors = {
HIGH: 'bg-emerald-50 dark:bg-emerald-900/15 text-emerald-700 dark:text-emerald-400 border-emerald-200/60 dark:border-emerald-800/40',
MEDIUM: 'bg-amber-50 dark:bg-amber-900/15 text-amber-700 dark:text-amber-400 border-amber-200/60 dark:border-amber-800/40',
LOW: 'bg-gray-50 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400 border-gray-200/60 dark:border-gray-700/40',
};
return (
<span className={`text-[11px] font-medium px-2 py-0.5 rounded-md border ${colors[confidence as keyof typeof colors] || colors.MEDIUM}`}>
{confidence} Confidence
</span>
);
}
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-emerald-600 dark:text-emerald-400',
};
return (
<span className={`text-[11px] font-medium ${colors[risk as keyof typeof colors] || colors.MEDIUM}`}>
{risk} Risk
</span>
);
}
export function HoldDaysBadge({ holdDays, decision }: { holdDays?: number | null; decision?: Decision | null }) {
if (!holdDays || decision === 'SELL') return null;
const label = holdDays === 1 ? '1 day' : `${holdDays}d`;
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-md border bg-blue-50 dark:bg-blue-900/15 text-blue-700 dark:text-blue-400 border-blue-200/60 dark:border-blue-800/40">
<Clock className="w-3 h-3" />
Hold {label}
</span>
);
}
export function RankBadge({ rank, size = 'default' }: { rank?: number | null; size?: 'small' | 'default' }) {
if (!rank) return null;
let style: React.CSSProperties;
let textClass: string;
if (rank <= 10) {
style = {
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
boxShadow: '0 1px 3px rgba(245, 158, 11, 0.25)',
};
textClass = 'text-amber-900';
} else if (rank <= 30) {
style = {
background: 'rgba(148, 163, 184, 0.15)',
border: '1px solid rgba(148, 163, 184, 0.25)',
};
textClass = 'text-gray-600 dark:text-gray-300';
} else {
style = {
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
};
textClass = 'text-red-600 dark:text-red-400';
}
const sizeClasses = size === 'small'
? 'w-5 h-5 text-[10px]'
: 'w-6 h-6 text-xs';
return (
<span
className={`inline-flex items-center justify-center rounded-full font-bold ${textClass} ${sizeClasses} flex-shrink-0 tabular-nums`}
style={style}
title={`Rank #${rank} of analyzed stocks`}
>
{rank}
</span>
);
}
export default function StockCard({ stock, showDetails = true, compact = false }: StockCardProps) {
if (compact) {
return (
<Link
to={`/stock/${stock.symbol}`}
className="flex items-center justify-between px-3 py-2.5 hover:bg-gray-50/80 dark:hover:bg-slate-700/30 transition-all group focus:outline-none focus:bg-nifty-50 dark:focus:bg-nifty-900/30 rounded-lg"
role="listitem"
aria-label={`${stock.symbol} - ${stock.company_name} - ${stock.decision} recommendation`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<RankBadge rank={stock.rank} size="small" />
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
stock.decision === 'BUY' ? 'bg-emerald-500' :
stock.decision === 'SELL' ? 'bg-red-500' : 'bg-amber-500'
}`} aria-hidden="true" />
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{stock.symbol}</span>
<span className="text-gray-300 dark:text-gray-600 text-xs hidden sm:inline" aria-hidden="true">&middot;</span>
<span className="text-xs text-gray-500 dark:text-gray-400 truncate hidden sm:inline">{stock.company_name}</span>
</div>
<div className="flex items-center gap-2">
<DecisionBadge decision={stock.decision} />
<ChevronRight className="w-4 h-4 text-gray-300 dark:text-gray-600 group-hover:text-nifty-600 dark:group-hover:text-nifty-400 transition-colors" aria-hidden="true" />
</div>
</Link>
);
}
return (
<Link
to={`/stock/${stock.symbol}`}
className="card-hover p-3 flex items-center justify-between group"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<RankBadge rank={stock.rank} size="small" />
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm">{stock.symbol}</h3>
<DecisionBadge decision={stock.decision} />
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{stock.company_name}</p>
{showDetails && (
<div className="flex items-center gap-2 mt-1.5">
<ConfidenceBadge confidence={stock.confidence} />
<RiskBadge risk={stock.risk} />
<HoldDaysBadge holdDays={stock.hold_days} decision={stock.decision} />
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-400 dark:text-gray-500 group-hover:text-nifty-600 dark:group-hover:text-nifty-400 transition-colors flex-shrink-0" />
</Link>
);
}
export function StockCardCompact({ stock }: { stock: StockAnalysis }) {
return (
<Link
to={`/stock/${stock.symbol}`}
className="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50/80 dark:hover:bg-slate-700/30 transition-all"
>
<div className="flex items-center gap-3">
<RankBadge rank={stock.rank} size="small" />
<div className={`w-2 h-2 rounded-full ${
stock.decision === 'BUY' ? 'bg-emerald-500' :
stock.decision === 'SELL' ? 'bg-red-500' : 'bg-amber-500'
}`} />
<div>
<span className="font-semibold text-gray-900 dark:text-gray-100">{stock.symbol}</span>
<span className="text-gray-300 dark:text-gray-600 mx-2">&middot;</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{stock.company_name}</span>
</div>
</div>
<DecisionBadge decision={stock.decision} />
</Link>
);
}

View File

@ -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 (
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-600 rounded-lg shadow-lg p-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{new Date(label).toLocaleDateString('en-IN', {
weekday: 'short',
month: 'short',
day: 'numeric',
})}
</p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{data.price.toLocaleString('en-IN', { minimumFractionDigits: 2 })}
</p>
{data.prediction && (
<div className={`mt-1 text-xs font-medium flex items-center gap-1 ${
data.prediction === 'BUY' ? 'text-green-600 dark:text-green-400' :
data.prediction === 'SELL' ? 'text-red-600 dark:text-red-400' :
'text-amber-600 dark:text-amber-400'
}`}>
{data.prediction === 'BUY' && <TrendingUp className="w-3 h-3" />}
{data.prediction === 'SELL' && <TrendingDown className="w-3 h-3" />}
{data.prediction === 'HOLD' && <Minus className="w-3 h-3" />}
AI: {data.prediction}
</div>
)}
</div>
);
}
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 (
<g>
<circle cx={cx} cy={cy} r={10} fill={color.fill} fillOpacity={0.2} />
<path
d={`M ${cx} ${cy - 6} L ${cx + 5} ${cy + 2} L ${cx + 2} ${cy + 2} L ${cx + 2} ${cy + 6} L ${cx - 2} ${cy + 6} L ${cx - 2} ${cy + 2} L ${cx - 5} ${cy + 2} Z`}
fill={color.fill}
stroke={color.stroke}
strokeWidth={1}
/>
</g>
);
} else if (payload.prediction === 'SELL') {
// Down arrow
return (
<g>
<circle cx={cx} cy={cy} r={10} fill={color.fill} fillOpacity={0.2} />
<path
d={`M ${cx} ${cy + 6} L ${cx + 5} ${cy - 2} L ${cx + 2} ${cy - 2} L ${cx + 2} ${cy - 6} L ${cx - 2} ${cy - 6} L ${cx - 2} ${cy - 2} L ${cx - 5} ${cy - 2} Z`}
fill={color.fill}
stroke={color.stroke}
strokeWidth={1}
/>
</g>
);
} else {
// Equal/minus sign for HOLD
return (
<g>
<circle cx={cx} cy={cy} r={10} fill={color.fill} fillOpacity={0.2} />
<rect x={cx - 5} y={cy - 4} width={10} height={2.5} fill={color.fill} rx={1} />
<rect x={cx - 5} y={cy + 1.5} width={10} height={2.5} fill={color.fill} rx={1} />
</g>
);
}
};
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 (
<div className="h-64 flex items-center justify-center text-gray-400 dark:text-gray-500">
No price data available
</div>
);
}
// Background color based on theme
const chartBgColor = isDark ? '#1e293b' : '#ffffff';
return (
<div className="w-full" style={{ backgroundColor: chartBgColor }}>
<ResponsiveContainer width="100%" height={280} minWidth={200} minHeight={200}>
<ComposedChart
data={chartData}
margin={{ top: 20, right: 20, left: 10, bottom: 20 }}
style={{ backgroundColor: 'transparent' }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={trendColor} stopOpacity={0.3} />
<stop offset="95%" stopColor={trendColor} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke={gridColor}
strokeOpacity={0.5}
vertical={false}
/>
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: tickColor }}
tickLine={false}
axisLine={false}
tickFormatter={(date) => new Date(date).toLocaleDateString('en-IN', {
month: 'short',
day: 'numeric',
})}
interval="preserveStartEnd"
minTickGap={50}
/>
<YAxis
domain={[minPrice, maxPrice]}
tick={{ fontSize: 10, fill: tickColor }}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
{showArea && (
<Area
type="monotone"
dataKey="price"
stroke="transparent"
fill={`url(#${gradientId})`}
/>
)}
<Line
type="monotone"
dataKey="price"
stroke={trendColor}
strokeWidth={2}
dot={(props: any) => {
const { payload, cx, cy } = props;
if (payload?.prediction && cx !== undefined && cy !== undefined) {
return <PredictionMarker cx={cx} cy={cy} payload={payload} />;
}
return <g />; // Return empty group for non-prediction points
}}
activeDot={{ r: 4, fill: trendColor }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
{/* Legend */}
<div className="flex items-center justify-center gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<TrendingUp className="w-4 h-4 text-green-500" />
<span>BUY Signal</span>
</div>
<div className="flex items-center gap-1.5">
<Minus className="w-4 h-4 text-amber-500" />
<span>HOLD Signal</span>
</div>
<div className="flex items-center gap-1.5">
<TrendingDown className="w-4 h-4 text-red-500" />
<span>SELL Signal</span>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="card p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="section-title">Today's Summary</h2>
<span className="text-sm text-gray-500">
{new Date(date).toLocaleDateString('en-IN', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map(({ label, value, icon: Icon, color, bg, percentage }) => (
<div key={label} className={`${bg} rounded-xl p-4`}>
<div className="flex items-center justify-between mb-2">
<Icon className={`w-5 h-5 ${color}`} />
{percentage && (
<span className={`text-xs font-medium ${color}`}>{percentage}%</span>
)}
</div>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
<p className="text-sm text-gray-600">{label}</p>
</div>
))}
</div>
{/* Progress bar */}
<div className="mt-6">
<div className="flex h-3 rounded-full overflow-hidden">
<div
className="bg-green-500 transition-all duration-500"
style={{ width: `${(buy / total) * 100}%` }}
/>
<div
className="bg-amber-500 transition-all duration-500"
style={{ width: `${(hold / total) * 100}%` }}
/>
<div
className="bg-red-500 transition-all duration-500"
style={{ width: `${(sell / total) * 100}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-xs text-gray-500">
<span>Buy ({buy})</span>
<span>Hold ({hold})</span>
<span>Sell ({sell})</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,414 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { X, Terminal, Trash2, Download, Pause, Play, ChevronDown, Plus, Minus } from 'lucide-react';
interface LogEntry {
timestamp: string;
type: 'info' | 'success' | 'error' | 'warning' | 'llm' | 'agent' | 'data';
source: string;
message: string;
}
interface TerminalModalProps {
isOpen: boolean;
onClose: () => void;
isAnalyzing: boolean;
}
export default function TerminalModal({ isOpen, onClose, isAnalyzing }: TerminalModalProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isPaused, setIsPaused] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
const [filter, setFilter] = useState<string>('all');
const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
const [fontSize, setFontSize] = useState(12); // Font size in px
const terminalRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const isPausedRef = useRef(isPaused);
const firstLogTimeRef = useRef<number | null>(null);
// Keep isPausedRef in sync with isPaused state
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
// Connect to SSE stream when modal opens
useEffect(() => {
if (!isOpen) return;
setConnectionStatus('connecting');
// Connect to the backend SSE endpoint
const connectToStream = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
// Use the same hostname as the current page, but with the backend port
const backendHost = window.location.hostname;
const sseUrl = `http://${backendHost}:8001/stream/logs`;
console.log('[Terminal] Connecting to SSE stream at:', sseUrl);
const eventSource = new EventSource(sseUrl);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('[Terminal] SSE connection opened');
setConnectionStatus('connected');
};
eventSource.onmessage = (event) => {
if (isPausedRef.current) return;
try {
const data = JSON.parse(event.data);
if (data.type === 'heartbeat') return; // Ignore heartbeats
// Skip the initial "Connected to log stream" message - it's not a real log
if (data.message === 'Connected to log stream') return;
const logEntry: LogEntry = {
timestamp: data.timestamp || new Date().toISOString(),
type: data.type || 'info',
source: data.source || 'system',
message: data.message || ''
};
// Update the earliest timestamp reference for elapsed time
const logTime = new Date(logEntry.timestamp).getTime();
if (firstLogTimeRef.current === null || logTime < firstLogTimeRef.current) {
firstLogTimeRef.current = logTime;
}
setLogs(prev => [...prev.slice(-500), logEntry]); // Keep last 500 logs
} catch (e) {
// Handle non-JSON messages
console.log('[Terminal] Non-JSON message:', event.data);
setLogs(prev => [...prev.slice(-500), {
timestamp: new Date().toISOString(),
type: 'info',
source: 'stream',
message: event.data
}]);
}
};
eventSource.onerror = (err) => {
console.error('[Terminal] SSE connection error:', err);
setConnectionStatus('error');
// Reconnect after a delay
setTimeout(() => {
if (isOpen && eventSourceRef.current === eventSource) {
console.log('[Terminal] Attempting to reconnect...');
connectToStream();
}
}, 3000);
};
};
connectToStream();
return () => {
console.log('[Terminal] Closing SSE connection');
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [isOpen]);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
if (autoScroll && terminalRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}
}, [logs, autoScroll]);
// Handle scroll to detect manual scrolling
const handleScroll = useCallback(() => {
if (!terminalRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = terminalRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setAutoScroll(isAtBottom);
}, []);
const clearLogs = () => {
setLogs([]);
firstLogTimeRef.current = null;
};
const downloadLogs = () => {
const content = logs.map(log => {
const d = new Date(log.timestamp);
const dateStr = formatDate(d);
const timeStr = formatTime(d);
return `[${dateStr} ${timeStr}] [${log.type.toUpperCase()}] [${log.source}] ${log.message}`;
}).join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `analysis-logs-${new Date().toISOString().split('T')[0]}.txt`;
a.click();
URL.revokeObjectURL(url);
};
const scrollToBottom = () => {
if (terminalRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
setAutoScroll(true);
}
};
// Format date as DD/MM/YYYY
const formatDate = (d: Date) => {
const day = d.getDate().toString().padStart(2, '0');
const month = (d.getMonth() + 1).toString().padStart(2, '0');
const year = d.getFullYear();
return `${day}/${month}/${year}`;
};
// Format time as HH:MM:SS
const formatTime = (d: Date) => {
return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
};
// Calculate elapsed time from first log
const getElapsed = (timestamp: string) => {
if (!firstLogTimeRef.current) return '';
const logTime = new Date(timestamp).getTime();
const elapsed = Math.max(0, (logTime - firstLogTimeRef.current) / 1000);
if (elapsed < 60) return `+${elapsed.toFixed(0)}s`;
const mins = Math.floor(elapsed / 60);
const secs = Math.floor(elapsed % 60);
return `+${mins}m${secs.toString().padStart(2, '0')}s`;
};
const getTypeColor = (type: string) => {
switch (type) {
case 'success': return 'text-green-400';
case 'error': return 'text-red-400';
case 'warning': return 'text-yellow-400';
case 'llm': return 'text-purple-400';
case 'agent': return 'text-cyan-400';
case 'data': return 'text-blue-400';
default: return 'text-gray-300';
}
};
const getSourceBadge = (source: string) => {
const colors: Record<string, string> = {
'bull_researcher': 'bg-green-900/50 text-green-400 border-green-700',
'bear_researcher': 'bg-red-900/50 text-red-400 border-red-700',
'market_analyst': 'bg-blue-900/50 text-blue-400 border-blue-700',
'news_analyst': 'bg-teal-900/50 text-teal-400 border-teal-700',
'social_analyst': 'bg-pink-900/50 text-pink-400 border-pink-700',
'fundamentals': 'bg-emerald-900/50 text-emerald-400 border-emerald-700',
'risk_manager': 'bg-amber-900/50 text-amber-400 border-amber-700',
'research_mgr': 'bg-violet-900/50 text-violet-400 border-violet-700',
'trader': 'bg-purple-900/50 text-purple-400 border-purple-700',
'aggressive': 'bg-orange-900/50 text-orange-400 border-orange-700',
'conservative': 'bg-sky-900/50 text-sky-400 border-sky-700',
'neutral': 'bg-gray-700/50 text-gray-300 border-gray-500',
'debate': 'bg-cyan-900/50 text-cyan-400 border-cyan-700',
'data_fetch': 'bg-indigo-900/50 text-indigo-400 border-indigo-700',
'system': 'bg-gray-800/50 text-gray-400 border-gray-600',
};
return colors[source] || 'bg-gray-800/50 text-gray-400 border-gray-600';
};
const filteredLogs = filter === 'all'
? logs
: logs.filter(log => log.type === filter || log.source === filter);
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center sm:p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full sm:max-w-5xl h-[85vh] sm:h-[80vh] bg-slate-900 rounded-t-xl sm:rounded-xl shadow-2xl border border-slate-700 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-3 sm:px-4 py-2 sm:py-3 bg-slate-800 border-b border-slate-700 gap-2">
{/* Title row */}
<div className="flex items-center justify-between sm:justify-start gap-2 sm:gap-3">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 sm:w-5 sm:h-5 text-green-400" />
<h2 className="font-mono font-semibold text-white text-sm sm:text-base">Terminal</h2>
</div>
{isAnalyzing && (
<span className="flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 bg-green-900/50 text-green-400 text-xs font-mono rounded border border-green-700">
<span className="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-400 rounded-full animate-pulse" />
LIVE
</span>
)}
{/* Close button - visible on mobile in title row */}
<button
onClick={onClose}
className="sm:hidden p-1.5 bg-slate-700 text-gray-400 hover:text-white hover:bg-red-600 rounded transition-colors"
title="Close"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Controls row */}
<div className="flex items-center gap-1.5 sm:gap-2 overflow-x-auto pb-1 sm:pb-0 -mx-1 px-1 sm:mx-0 sm:px-0">
{/* Filter dropdown */}
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="px-1.5 sm:px-2 py-1 bg-slate-700 text-gray-300 text-xs font-mono rounded border border-slate-600 focus:outline-none focus:border-slate-500 min-w-0 flex-shrink-0"
>
<option value="all">All</option>
<option value="llm">LLM</option>
<option value="agent">Agent</option>
<option value="data">Data</option>
<option value="error">Errors</option>
<option value="success">Success</option>
</select>
{/* Font size controls */}
<div className="flex items-center gap-0.5 flex-shrink-0">
<button
onClick={() => setFontSize(s => Math.max(8, s - 1))}
className="p-1.5 bg-slate-700 text-gray-400 hover:text-white hover:bg-slate-600 rounded transition-colors"
title="Decrease font size"
>
<Minus className="w-3 h-3" />
</button>
<span className="text-gray-500 text-xs font-mono w-6 text-center">{fontSize}</span>
<button
onClick={() => setFontSize(s => Math.min(20, s + 1))}
className="p-1.5 bg-slate-700 text-gray-400 hover:text-white hover:bg-slate-600 rounded transition-colors"
title="Increase font size"
>
<Plus className="w-3 h-3" />
</button>
</div>
{/* Pause/Resume */}
<button
onClick={() => setIsPaused(!isPaused)}
className={`p-1.5 rounded transition-colors flex-shrink-0 ${
isPaused
? 'bg-amber-900/50 text-amber-400 hover:bg-amber-900'
: 'bg-slate-700 text-gray-400 hover:text-white hover:bg-slate-600'
}`}
title={isPaused ? 'Resume' : 'Pause'}
>
{isPaused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
</button>
{/* Download */}
<button
onClick={downloadLogs}
className="p-1.5 bg-slate-700 text-gray-400 hover:text-white hover:bg-slate-600 rounded transition-colors flex-shrink-0"
title="Download logs"
>
<Download className="w-4 h-4" />
</button>
{/* Clear */}
<button
onClick={clearLogs}
className="p-1.5 bg-slate-700 text-gray-400 hover:text-white hover:bg-slate-600 rounded transition-colors flex-shrink-0"
title="Clear logs"
>
<Trash2 className="w-4 h-4" />
</button>
{/* Close - hidden on mobile, shown on desktop */}
<button
onClick={onClose}
className="hidden sm:block p-1.5 bg-slate-700 text-gray-400 hover:text-white hover:bg-red-600 rounded transition-colors flex-shrink-0"
title="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Terminal Content */}
<div
ref={terminalRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-2 sm:p-4 font-mono bg-slate-950 scrollbar-thin scrollbar-track-slate-900 scrollbar-thumb-slate-700"
style={{ fontSize: `${fontSize}px`, lineHeight: '1.5' }}
>
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 px-4">
<Terminal className="w-10 h-10 sm:w-12 sm:h-12 mb-3 opacity-50" />
<p className="text-xs sm:text-sm text-center">
{connectionStatus === 'connecting' && 'Connecting to log stream...'}
{connectionStatus === 'error' && 'Connection error. Retrying...'}
{connectionStatus === 'connected' && (isAnalyzing
? 'Waiting for analysis logs...'
: 'Start an analysis to see live updates here')}
</p>
<p className="text-xs mt-1 text-gray-600 text-center">
{connectionStatus === 'connected'
? 'Logs will appear in real-time as the AI analyzes stocks'
: 'Establishing connection to backend...'}
</p>
</div>
) : (
<div className="space-y-0.5">
{filteredLogs.map((log, index) => {
const d = new Date(log.timestamp);
const dateStr = formatDate(d);
const timeStr = formatTime(d);
const elapsed = getElapsed(log.timestamp);
return (
<div key={index} className="flex flex-wrap sm:flex-nowrap items-start gap-1 sm:gap-2 hover:bg-slate-900/50 px-1 py-0.5 rounded">
{/* Date + Time */}
<span className="text-gray-600 whitespace-nowrap" style={{ fontSize: `${Math.max(fontSize - 1, 8)}px` }}>
{dateStr} {timeStr}
</span>
{/* Elapsed time */}
<span className="text-yellow-600/70 whitespace-nowrap font-semibold" style={{ fontSize: `${Math.max(fontSize - 1, 8)}px`, minWidth: '50px' }}>
{elapsed}
</span>
{/* Source badge */}
<span className={`px-1 sm:px-1.5 py-0.5 rounded border flex-shrink-0 ${getSourceBadge(log.source)}`} style={{ fontSize: `${Math.max(fontSize - 2, 7)}px` }}>
{log.source.length > 14 ? log.source.slice(0, 12) + '..' : log.source}
</span>
{/* Message */}
<span className={`w-full sm:w-auto sm:flex-1 ${getTypeColor(log.type)} break-words`}>
{log.message}
</span>
</div>
);
})}
</div>
)}
</div>
{/* Footer with scroll indicator */}
{!autoScroll && (
<button
onClick={scrollToBottom}
className="absolute bottom-14 sm:bottom-16 right-3 sm:right-6 flex items-center gap-1 px-2 sm:px-3 py-1 sm:py-1.5 bg-slate-700 text-gray-300 text-xs font-mono rounded-full shadow-lg hover:bg-slate-600 transition-colors"
>
<ChevronDown className="w-3 h-3" />
<span className="hidden sm:inline">Scroll to bottom</span>
<span className="sm:hidden">Bottom</span>
</button>
)}
{/* Status Bar */}
<div className="px-3 sm:px-4 py-2 bg-slate-800 border-t border-slate-700 flex items-center justify-between text-xs font-mono text-gray-500 gap-2">
<span className="truncate">{filteredLogs.length} logs | Font: {fontSize}px</span>
<span className="flex-shrink-0">
{isPaused ? 'PAUSED' : autoScroll ? 'AUTO' : 'MANUAL'}
</span>
</div>
</div>
</div>,
document.body
);
}

View File

@ -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 (
<button
onClick={() => setTheme(nextTheme.value)}
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
aria-label={`Current theme: ${theme}. Click to switch to ${nextTheme.label}`}
>
<CurrentIcon className="w-4 h-4" />
</button>
);
}
return (
<div
className="flex items-center gap-0.5 p-0.5 bg-gray-100 dark:bg-slate-700 rounded-lg"
role="radiogroup"
aria-label="Theme selection"
>
{themes.map(({ value, icon: Icon, label }) => {
const isActive = theme === value;
return (
<button
key={value}
onClick={() => setTheme(value)}
role="radio"
aria-checked={isActive}
aria-label={label}
className={`p-1.5 rounded-md transition-all ${
isActive
? 'bg-white dark:bg-slate-600 text-nifty-600 dark:text-nifty-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
<Icon className="w-3.5 h-3.5" />
</button>
);
})}
</div>
);
}

View File

@ -0,0 +1,85 @@
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react';
import { useNotification } from '../contexts/NotificationContext';
import type { NotificationType } from '../contexts/NotificationContext';
const iconMap: Record<NotificationType, typeof CheckCircle> = {
success: CheckCircle,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
};
const colorMap: Record<NotificationType, { bg: string; border: string; icon: string; title: string }> = {
success: {
bg: 'bg-green-50 dark:bg-green-900/30',
border: 'border-green-200 dark:border-green-800',
icon: 'text-green-500 dark:text-green-400',
title: 'text-green-800 dark:text-green-200',
},
error: {
bg: 'bg-red-50 dark:bg-red-900/30',
border: 'border-red-200 dark:border-red-800',
icon: 'text-red-500 dark:text-red-400',
title: 'text-red-800 dark:text-red-200',
},
warning: {
bg: 'bg-amber-50 dark:bg-amber-900/30',
border: 'border-amber-200 dark:border-amber-800',
icon: 'text-amber-500 dark:text-amber-400',
title: 'text-amber-800 dark:text-amber-200',
},
info: {
bg: 'bg-blue-50 dark:bg-blue-900/30',
border: 'border-blue-200 dark:border-blue-800',
icon: 'text-blue-500 dark:text-blue-400',
title: 'text-blue-800 dark:text-blue-200',
},
};
export default function ToastContainer() {
const { notifications, removeNotification } = useNotification();
if (notifications.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{notifications.map(notification => {
const Icon = iconMap[notification.type];
const colors = colorMap[notification.type];
return (
<div
key={notification.id}
className={`
pointer-events-auto
flex items-start gap-3 p-4 rounded-lg shadow-lg border
${colors.bg} ${colors.border}
animate-in slide-in-from-right-2
transform transition-all duration-300
`}
role="alert"
>
<Icon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${colors.icon}`} />
<div className="flex-1 min-w-0">
<p className={`font-semibold text-sm ${colors.title}`}>
{notification.title}
</p>
{notification.message && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{notification.message}
</p>
)}
</div>
<button
onClick={() => removeNotification(notification.id)}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Dismiss notification"
>
<X className="w-4 h-4" />
</button>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,110 @@
import { Link } from 'react-router-dom';
import { Trophy, AlertTriangle, TrendingUp, TrendingDown, ChevronRight } from 'lucide-react';
import type { TopPick, StockToAvoid } from '../types';
import { RankBadge } from './StockCard';
interface TopPicksProps {
picks: TopPick[];
}
export default function TopPicks({ picks }: TopPicksProps) {
return (
<div className="card p-4">
<div className="flex items-center gap-2.5 mb-3">
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #fbbf24, #f59e0b)', boxShadow: '0 2px 6px rgba(245,158,11,0.25)' }}>
<Trophy className="w-3.5 h-3.5 text-amber-900" />
</div>
<div>
<h2 className="font-display font-bold text-gray-900 dark:text-gray-100 text-sm tracking-tight">Top Picks</h2>
<p className="text-[11px] text-gray-500 dark:text-gray-400">Best ranked stocks today</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{picks.map((pick, index) => {
return (
<Link
key={pick.symbol}
to={`/stock/${pick.symbol}`}
className="group relative overflow-hidden rounded-xl border border-emerald-200/50 dark:border-emerald-800/30 p-3 transition-all hover:border-emerald-300 dark:hover:border-emerald-700/50"
style={{
background: index === 0
? 'linear-gradient(135deg, rgba(16,185,129,0.06), rgba(5,150,105,0.03))'
: 'linear-gradient(135deg, rgba(16,185,129,0.04), rgba(5,150,105,0.01))',
}}
>
<div className="relative z-10">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<RankBadge rank={pick.rank} size="small" />
<span className="font-bold text-gray-900 dark:text-gray-100 text-sm">{pick.symbol}</span>
</div>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold text-white" style={{ background: 'linear-gradient(135deg, #10b981, #059669)' }}>
<TrendingUp className="w-3 h-3" />
BUY
</span>
</div>
<p className="text-[11px] text-gray-600 dark:text-gray-400 line-clamp-2 mb-2 leading-relaxed">{pick.reason}</p>
<div className="flex items-center justify-between">
<span className={`text-[11px] px-2 py-0.5 rounded-md font-medium border ${
pick.risk_level === 'LOW' ? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border-emerald-200/50 dark:border-emerald-800/30' :
pick.risk_level === 'HIGH' ? 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border-red-200/50 dark:border-red-800/30' :
'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border-amber-200/50 dark:border-amber-800/30'
}`}>
{pick.risk_level} Risk
</span>
<ChevronRight className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors" />
</div>
</div>
</Link>
);
})}
</div>
</div>
);
}
interface StocksToAvoidProps {
stocks: StockToAvoid[];
}
export function StocksToAvoid({ stocks }: StocksToAvoidProps) {
return (
<div className="card p-4">
<div className="flex items-center gap-2.5 mb-3">
<div className="w-7 h-7 rounded-lg flex items-center justify-center bg-red-100 dark:bg-red-900/25">
<AlertTriangle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
</div>
<div>
<h2 className="font-display font-bold text-gray-900 dark:text-gray-100 text-sm tracking-tight">Stocks to Avoid</h2>
<p className="text-[11px] text-gray-500 dark:text-gray-400">Lowest ranked stocks today</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{stocks.map((stock) => {
return (
<Link
key={stock.symbol}
to={`/stock/${stock.symbol}`}
className="group relative overflow-hidden rounded-xl border border-red-200/40 dark:border-red-800/25 p-3 transition-all hover:border-red-300 dark:hover:border-red-700/40"
style={{ background: 'linear-gradient(135deg, rgba(239,68,68,0.04), rgba(220,38,38,0.01))' }}
>
<div className="relative z-10">
<div className="flex items-center justify-between mb-2">
<span className="font-bold text-gray-900 dark:text-gray-100 text-sm">{stock.symbol}</span>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold text-white" style={{ background: 'linear-gradient(135deg, #ef4444, #dc2626)' }}>
<TrendingDown className="w-3 h-3" />
SELL
</span>
</div>
<p className="text-[11px] text-gray-600 dark:text-gray-400 line-clamp-2 mb-2 leading-relaxed">{stock.reason}</p>
<ChevronRight className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 group-hover:text-red-600 dark:group-hover:text-red-400 transition-colors" />
</div>
</Link>
);
})}
</div>
</div>
);
}

View File

@ -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<AgentType, React.ElementType> = {
market: TrendingUp,
news: Newspaper,
social_media: Users,
fundamentals: FileText,
};
const AGENT_COLORS: Record<AgentType, { bg: string; border: string; text: string; accent: string }> = {
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 (
<div className={`rounded-xl border ${colors.border} ${colors.bg} overflow-hidden`}>
{/* Header */}
<div
className={`flex items-center justify-between p-4 cursor-pointer hover:opacity-90 transition-opacity`}
onClick={() => hasReport && setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${colors.accent} bg-opacity-20`}>
<Icon className={`w-5 h-5 ${colors.text}`} />
</div>
<div>
<h3 className={`font-semibold ${colors.text}`}>{metadata.label}</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
{metadata.description}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{hasReport ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : isLoading ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin opacity-50" />
) : (
<Clock className="w-4 h-4 text-slate-400" />
)}
{hasReport && (
isExpanded ? (
<ChevronUp className="w-5 h-5 text-slate-400" />
) : (
<ChevronDown className="w-5 h-5 text-slate-400" />
)
)}
</div>
</div>
{/* Preview (collapsed) */}
{!isExpanded && hasReport && (
<div className="px-4 pb-4">
<p className="text-sm text-slate-600 dark:text-slate-400 line-clamp-2">
{previewText}
</p>
</div>
)}
{/* Expanded content */}
{isExpanded && hasReport && (
<div className="border-t border-slate-200 dark:border-slate-700">
{/* Data sources */}
{report.data_sources_used && report.data_sources_used.length > 0 && (
<div className="px-4 py-2 bg-slate-100 dark:bg-slate-800/50 flex items-center gap-2 flex-wrap">
<Database className="w-3 h-3 text-slate-400" />
<span className="text-xs text-slate-500">Sources:</span>
{report.data_sources_used.map((source, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-slate-200 dark:bg-slate-700 rounded text-xs text-slate-600 dark:text-slate-300"
>
{source}
</span>
))}
</div>
)}
{/* Report content */}
<div className="p-4 space-y-4 max-h-96 overflow-y-auto">
{sections.length > 0 ? (
sections.map((section, idx) => (
<div key={idx} className="space-y-1">
<h4 className="font-medium text-sm text-slate-700 dark:text-slate-300">
{section.title}
</h4>
<div className="text-sm text-slate-600 dark:text-slate-400 space-y-1">
{section.content.map((line, lineIdx) => (
<p key={lineIdx}>{line}</p>
))}
</div>
</div>
))
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
<pre className="whitespace-pre-wrap text-sm text-slate-600 dark:text-slate-400">
{report.report_content}
</pre>
</div>
)}
</div>
{/* Timestamp */}
{report.created_at && (
<div className="px-4 py-2 border-t border-slate-200 dark:border-slate-700 flex items-center gap-2">
<Clock className="w-3 h-3 text-slate-400" />
<span className="text-xs text-slate-500">
Generated: {new Date(report.created_at).toLocaleString()}
</span>
</div>
)}
</div>
)}
</div>
);
}
export default AgentReportCard;

View File

@ -0,0 +1,254 @@
import { useState } from 'react';
import {
Database, ChevronDown, ChevronUp, CheckCircle,
XCircle, Clock, Server, Copy, Check, Maximize2, Minimize2
} from 'lucide-react';
import type { DataSourceLog } from '../../types/pipeline';
interface DataSourcesPanelProps {
dataSources: DataSourceLog[];
isLoading?: boolean;
}
const SOURCE_TYPE_COLORS: Record<string, { bg: string; text: string }> = {
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' }
};
// Raw data viewer with copy, expand/collapse, and formatted display
function RawDataViewer({ data, error }: { data: unknown; error?: string | null }) {
const [isFullHeight, setIsFullHeight] = useState(false);
const [copied, setCopied] = useState(false);
if (error) {
return (
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">
<strong>Error:</strong> {error}
</p>
</div>
</div>
);
}
if (!data) {
return (
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
<p className="mt-3 text-sm text-slate-500">No data details available</p>
</div>
);
}
const rawText = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const dataSize = rawText.length;
const handleCopy = () => {
navigator.clipboard.writeText(rawText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
{/* Toolbar */}
<div className="mt-3 flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
Raw Data
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
{dataSize > 1000 ? `${(dataSize / 1000).toFixed(1)}KB` : `${dataSize} chars`}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-[10px] rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 dark:text-slate-400 transition-colors"
title="Copy raw data"
>
{copied ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={() => setIsFullHeight(!isFullHeight)}
className="flex items-center gap-1 px-2 py-1 text-[10px] rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 dark:text-slate-400 transition-colors"
title={isFullHeight ? 'Collapse' : 'Expand'}
>
{isFullHeight ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
{isFullHeight ? 'Collapse' : 'Expand'}
</button>
</div>
</div>
{/* Raw data content */}
<div className={`bg-slate-900 dark:bg-slate-950 rounded-lg overflow-hidden ${isFullHeight ? '' : 'max-h-80'}`}>
<pre className={`p-3 text-xs text-green-400 font-mono whitespace-pre-wrap break-words overflow-auto ${isFullHeight ? 'max-h-[80vh]' : 'max-h-72'}`}>
{rawText}
</pre>
</div>
</div>
);
}
export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [expandedSources, setExpandedSources] = useState<Set<number>>(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 (
<div className="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
{/* Header */}
<div
className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800/50 cursor-pointer"
onClick={() => hasData && setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 dark:bg-slate-700 rounded-lg">
<Database className="w-5 h-5 text-slate-600 dark:text-slate-300" />
</div>
<div>
<h3 className="font-semibold text-slate-800 dark:text-slate-200">
Data Sources
</h3>
<p className="text-xs text-slate-500">
Raw data fetched for analysis
</p>
</div>
</div>
<div className="flex items-center gap-2">
{hasData ? (
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 px-2 py-1 bg-green-100 dark:bg-green-900/40 rounded text-xs text-green-700 dark:text-green-300">
<CheckCircle className="w-3 h-3" />
{successCount}
</span>
{errorCount > 0 && (
<span className="flex items-center gap-1 px-2 py-1 bg-red-100 dark:bg-red-900/40 rounded text-xs text-red-700 dark:text-red-300">
<XCircle className="w-3 h-3" />
{errorCount}
</span>
)}
</div>
) : isLoading ? (
<div className="w-4 h-4 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
) : (
<span className="px-2 py-1 bg-slate-100 dark:bg-slate-800 rounded text-xs text-slate-500">
No Data
</span>
)}
{hasData && (
isExpanded ? <ChevronUp className="w-5 h-5 text-slate-400" /> : <ChevronDown className="w-5 h-5 text-slate-400" />
)}
</div>
</div>
{/* Expanded content */}
{isExpanded && hasData && (
<div className="border-t border-slate-200 dark:border-slate-700">
<div className="divide-y divide-slate-200 dark:divide-slate-700">
{dataSources.map((source, index) => {
const colors = getSourceColors(source.source_type);
const isSourceExpanded = expandedSources.has(index);
return (
<div key={index} className="bg-white dark:bg-slate-900">
{/* Source header */}
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
onClick={() => toggleSourceExpanded(index)}
>
<div className="flex items-center gap-3">
<Server className="w-4 h-4 text-slate-400" />
<div>
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colors.bg} ${colors.text}`}>
{source.source_type}
</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{source.source_name}
</span>
</div>
{source.method && (
<div className="flex items-center gap-1.5 mt-1 text-xs">
<span className="font-mono font-semibold text-amber-600 dark:text-amber-400">
{source.method}()
</span>
{source.args && (
<span className="font-mono text-slate-500 dark:text-slate-400 truncate max-w-xs">
{source.args}
</span>
)}
</div>
)}
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
{formatTimestamp(source.fetch_timestamp)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{source.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
{isSourceExpanded ? (
<ChevronUp className="w-4 h-4 text-slate-400" />
) : (
<ChevronDown className="w-4 h-4 text-slate-400" />
)}
</div>
</div>
{/* Source details (expanded) — full raw data viewer */}
{isSourceExpanded && (
<RawDataViewer
data={source.data_fetched}
error={source.error_message}
/>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
}
export default DataSourcesPanel;

Some files were not shown because too many files have changed in this diff Show More