Compare commits
59 Commits
572ef6c367
...
e6dbd11ba1
| Author | SHA1 | Date |
|---|---|---|
|
|
e6dbd11ba1 | |
|
|
7619a7f9bb | |
|
|
b79179cea2 | |
|
|
473478a32d | |
|
|
a556099d97 | |
|
|
bdc27679fa | |
|
|
86381157d8 | |
|
|
d9e71b74cd | |
|
|
f87ce2495e | |
|
|
d1e99c7db9 | |
|
|
9a292cde34 | |
|
|
92ff07a2b1 | |
|
|
e43acb8247 | |
|
|
7902d249ca | |
|
|
fabdde86e0 | |
|
|
1cea7e837a | |
|
|
37f3bf7c8d | |
|
|
341d49f560 | |
|
|
c0f0415844 | |
|
|
e7d8305a25 | |
|
|
01a12c945f | |
|
|
5b8a917fff | |
|
|
1949ac7d75 | |
|
|
8a61fe0cac | |
|
|
26edb71254 | |
|
|
3e902d58fc | |
|
|
a880216c98 | |
|
|
d63c5dcd46 | |
|
|
b1d1496ec0 | |
|
|
7e0fca554f | |
|
|
cedde45285 | |
|
|
109941af43 | |
|
|
76fd2f5aea | |
|
|
db73eafd6f | |
|
|
fdea6c9d87 | |
|
|
9b5dbc166b | |
|
|
a8c031fe64 | |
|
|
d75ab7cf2e | |
|
|
51da620c40 | |
|
|
bbf0ab24df | |
|
|
caaf399232 | |
|
|
d9431181d6 | |
|
|
f2b48c9c85 | |
|
|
adf529a895 | |
|
|
aaf0eb6412 | |
|
|
ed66256aa3 | |
|
|
2d2b574327 | |
|
|
7e2715d045 | |
|
|
71c1e6d08e | |
|
|
a2dfee5996 | |
|
|
fc9b0d7247 | |
|
|
4fd8378a2b | |
|
|
dd422893cf | |
|
|
88f18d6500 | |
|
|
1781ec5075 | |
|
|
f7a5920e22 | |
|
|
887f5262fa | |
|
|
ff606dfb13 | |
|
|
8ea3c78dff |
|
|
@ -0,0 +1,2 @@
|
|||
ALPHA_VANTAGE_API_KEY=alpha_vantage_api_key_placeholder
|
||||
OPENAI_API_KEY=openai_api_key_placeholder
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
3.10
|
||||
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 77 KiB |
396
README.md
|
|
@ -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
|
||||
|
||||
[](https://arxiv.org/abs/2412.20138)
|
||||
[](https://www.python.org/)
|
||||
[](LICENSE)
|
||||
[](https://react.dev/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
[](https://tailwindcss.com/)
|
||||
|
||||
<br />
|
||||
|
||||
An open-source framework that deploys **specialized AI agents** — analysts, researchers, traders, and risk managers — to collaboratively analyze markets and generate investment recommendations through structured debate.
|
||||
|
||||
<br />
|
||||
|
||||
[Getting Started](#getting-started) • [Web Dashboard](#nifty50-ai-web-dashboard) • [Python API](#python-api) • [Architecture](#architecture) • [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** — 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** — 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** — 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** — 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 216 KiB |
173
cli/main.py
|
|
@ -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"):
|
||||
|
|
|
|||
239
cli/utils.py
|
|
@ -1,7 +1,13 @@
|
|||
import questionary
|
||||
from typing import List, Optional, Tuple, Dict
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich import box
|
||||
|
||||
from cli.models import AnalystType
|
||||
from tradingagents.dataflows.markets import NIFTY_50_STOCKS, is_nifty_50_stock
|
||||
|
||||
console = Console()
|
||||
|
||||
ANALYST_ORDER = [
|
||||
("Market Analyst", AnalystType.MARKET),
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
### Dark Mode Support
|
||||
Full dark mode support with automatic system theme detection:
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
#### Expanded Debate View
|
||||
Full debate content with Bull and Bear arguments:
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
#### Date Selection & Stock List
|
||||
Select any date to view all 50 ranked stocks with decisions, hold periods, and returns:
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fastapi>=0.109.0
|
||||
uvicorn>=0.27.0
|
||||
pydantic>=2.0.0
|
||||
|
|
@ -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()
|
||||
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">© {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = (R̄ − Rf) / σ
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Where R̄ = 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 (R̄) = <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">> 1.0:</span> Good risk-adjusted returns</li>
|
||||
<li><span className="text-green-600 dark:text-green-400 font-medium">> 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">< 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">< 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">> 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">> 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">< 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">> 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">< 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">·</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">·</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{stock.company_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DecisionBadge decision={stock.decision} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||