add
|
Before Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 512 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
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 |
|
After Width: | Height: | Size: 64 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: 67 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
|
@ -0,0 +1,237 @@
|
||||||
|
"""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) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
Closing price or 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]
|
||||||
|
|
||||||
|
return 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 = 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 = get_trading_day_price(ticker, date_1d, 'forward')
|
||||||
|
price_1w = get_trading_day_price(ticker, date_1w, 'forward')
|
||||||
|
price_1m = get_trading_day_price(ticker, date_1m, 'forward')
|
||||||
|
|
||||||
|
# Calculate returns
|
||||||
|
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 = get_trading_day_price(ticker, date_hold, 'forward')
|
||||||
|
if price_at_hold:
|
||||||
|
return_at_hold = round(((price_at_hold - price_at_pred) / price_at_pred * 100), 2)
|
||||||
|
|
||||||
|
# Determine if prediction was correct
|
||||||
|
# Use hold_days return when available, fall back to 1-week return
|
||||||
|
prediction_correct = None
|
||||||
|
check_return = return_at_hold if return_at_hold is not None else return_1w
|
||||||
|
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
|
||||||
|
|
||||||
|
return {
|
||||||
|
'date': date,
|
||||||
|
'symbol': symbol,
|
||||||
|
'decision': 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 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'],
|
||||||
|
'price_at_prediction': result['price_at_prediction'],
|
||||||
|
'current_price': result.get('price_1m_later') or result.get('price_1w_later'),
|
||||||
|
'price_history': price_history
|
||||||
|
}
|
||||||
|
|
@ -105,10 +105,17 @@ def init_db():
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
duration_ms INTEGER,
|
duration_ms INTEGER,
|
||||||
output_summary TEXT,
|
output_summary TEXT,
|
||||||
|
step_details TEXT,
|
||||||
UNIQUE(date, symbol, step_number)
|
UNIQUE(date, symbol, step_number)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Add step_details column if it doesn't exist (migration for existing DBs)
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE pipeline_steps ADD COLUMN step_details TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
# Create data_source_logs table (stores what raw data was fetched)
|
# Create data_source_logs table (stores what raw data was fetched)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS data_source_logs (
|
CREATE TABLE IF NOT EXISTS data_source_logs (
|
||||||
|
|
@ -117,6 +124,8 @@ def init_db():
|
||||||
symbol TEXT NOT NULL,
|
symbol TEXT NOT NULL,
|
||||||
source_type TEXT,
|
source_type TEXT,
|
||||||
source_name TEXT,
|
source_name TEXT,
|
||||||
|
method TEXT,
|
||||||
|
args TEXT,
|
||||||
data_fetched TEXT,
|
data_fetched TEXT,
|
||||||
fetch_timestamp TEXT,
|
fetch_timestamp TEXT,
|
||||||
success INTEGER DEFAULT 1,
|
success INTEGER DEFAULT 1,
|
||||||
|
|
@ -124,6 +133,46 @@ def init_db():
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Migrate: add method/args columns if missing (existing databases)
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE data_source_logs ADD COLUMN method TEXT")
|
||||||
|
except Exception:
|
||||||
|
pass # Column already exists
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE data_source_logs ADD COLUMN args TEXT")
|
||||||
|
except Exception:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
|
# Create backtest_results table (stores calculated backtest accuracy)
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS backtest_results (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
decision TEXT,
|
||||||
|
price_at_prediction REAL,
|
||||||
|
price_1d_later REAL,
|
||||||
|
price_1w_later REAL,
|
||||||
|
price_1m_later REAL,
|
||||||
|
return_1d REAL,
|
||||||
|
return_1w REAL,
|
||||||
|
return_1m REAL,
|
||||||
|
prediction_correct INTEGER,
|
||||||
|
calculated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(date, symbol)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Add hold_days column if it doesn't exist (migration for existing DBs)
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE stock_analysis ADD COLUMN hold_days INTEGER")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE backtest_results ADD COLUMN hold_days INTEGER")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
# Create indexes for new tables
|
# Create indexes for new tables
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_agent_reports_date_symbol ON agent_reports(date, symbol)
|
CREATE INDEX IF NOT EXISTS idx_agent_reports_date_symbol ON agent_reports(date, symbol)
|
||||||
|
|
@ -137,6 +186,9 @@ def init_db():
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_data_source_logs_date_symbol ON data_source_logs(date, symbol)
|
CREATE INDEX IF NOT EXISTS idx_data_source_logs_date_symbol ON data_source_logs(date, symbol)
|
||||||
""")
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backtest_results_date ON backtest_results(date)
|
||||||
|
""")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -168,8 +220,8 @@ def save_recommendation(date: str, analysis_data: dict, summary: dict,
|
||||||
for symbol, analysis in analysis_data.items():
|
for symbol, analysis in analysis_data.items():
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT OR REPLACE INTO stock_analysis
|
INSERT OR REPLACE INTO stock_analysis
|
||||||
(date, symbol, company_name, decision, confidence, risk, raw_analysis)
|
(date, symbol, company_name, decision, confidence, risk, raw_analysis, hold_days)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
date,
|
date,
|
||||||
symbol,
|
symbol,
|
||||||
|
|
@ -177,7 +229,8 @@ def save_recommendation(date: str, analysis_data: dict, summary: dict,
|
||||||
analysis.get('decision'),
|
analysis.get('decision'),
|
||||||
analysis.get('confidence'),
|
analysis.get('confidence'),
|
||||||
analysis.get('risk'),
|
analysis.get('risk'),
|
||||||
analysis.get('raw_analysis', '')
|
analysis.get('raw_analysis', ''),
|
||||||
|
analysis.get('hold_days')
|
||||||
))
|
))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -185,6 +238,52 @@ def save_recommendation(date: str, analysis_data: dict, summary: dict,
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_single_stock_analysis(date: str, symbol: str, analysis: dict):
|
||||||
|
"""Save analysis for a single stock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: Date string (YYYY-MM-DD)
|
||||||
|
symbol: Stock symbol
|
||||||
|
analysis: Dict with keys: company_name, decision, confidence, risk, raw_analysis, hold_days
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO stock_analysis
|
||||||
|
(date, symbol, company_name, decision, confidence, risk, raw_analysis, hold_days)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
date,
|
||||||
|
symbol,
|
||||||
|
analysis.get('company_name', symbol),
|
||||||
|
analysis.get('decision', 'HOLD'),
|
||||||
|
analysis.get('confidence', 'MEDIUM'),
|
||||||
|
analysis.get('risk', 'MEDIUM'),
|
||||||
|
analysis.get('raw_analysis', ''),
|
||||||
|
analysis.get('hold_days')
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_analyzed_symbols_for_date(date: str) -> list:
|
||||||
|
"""Get list of symbols that already have analysis for a given date.
|
||||||
|
|
||||||
|
Used by bulk analysis to skip already-completed stocks when resuming.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT symbol FROM stock_analysis WHERE date = ?", (date,))
|
||||||
|
return [row['symbol'] for row in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def get_recommendation_by_date(date: str) -> Optional[dict]:
|
def get_recommendation_by_date(date: str) -> Optional[dict]:
|
||||||
"""Get recommendation for a specific date."""
|
"""Get recommendation for a specific date."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|
@ -197,37 +296,60 @@ def get_recommendation_by_date(date: str) -> Optional[dict]:
|
||||||
""", (date,))
|
""", (date,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Get stock analysis for this date
|
# Get stock analysis for this date
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT * FROM stock_analysis WHERE date = ?
|
SELECT * FROM stock_analysis WHERE date = ?
|
||||||
""", (date,))
|
""", (date,))
|
||||||
analysis_rows = cursor.fetchall()
|
analysis_rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# If no daily_recommendations AND no stock_analysis, return None
|
||||||
|
if not row and not analysis_rows:
|
||||||
|
return None
|
||||||
|
|
||||||
analysis = {}
|
analysis = {}
|
||||||
for a in analysis_rows:
|
for a in analysis_rows:
|
||||||
|
decision = (a['decision'] or '').strip().upper()
|
||||||
|
if decision not in ('BUY', 'SELL', 'HOLD'):
|
||||||
|
decision = 'HOLD'
|
||||||
analysis[a['symbol']] = {
|
analysis[a['symbol']] = {
|
||||||
'symbol': a['symbol'],
|
'symbol': a['symbol'],
|
||||||
'company_name': a['company_name'],
|
'company_name': a['company_name'],
|
||||||
'decision': a['decision'],
|
'decision': decision,
|
||||||
'confidence': a['confidence'],
|
'confidence': a['confidence'] or 'MEDIUM',
|
||||||
'risk': a['risk'],
|
'risk': a['risk'] or 'MEDIUM',
|
||||||
'raw_analysis': a['raw_analysis']
|
'raw_analysis': a['raw_analysis'],
|
||||||
|
'hold_days': a['hold_days'] if 'hold_days' in a.keys() else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
'date': row['date'],
|
||||||
|
'analysis': analysis,
|
||||||
|
'summary': {
|
||||||
|
'total': row['summary_total'],
|
||||||
|
'buy': row['summary_buy'],
|
||||||
|
'sell': row['summary_sell'],
|
||||||
|
'hold': row['summary_hold']
|
||||||
|
},
|
||||||
|
'top_picks': json.loads(row['top_picks']) if row['top_picks'] else [],
|
||||||
|
'stocks_to_avoid': json.loads(row['stocks_to_avoid']) if row['stocks_to_avoid'] else []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback: build summary from stock_analysis when daily_recommendations is missing
|
||||||
|
buy_count = sum(1 for a in analysis.values() if a['decision'] == 'BUY')
|
||||||
|
sell_count = sum(1 for a in analysis.values() if a['decision'] == 'SELL')
|
||||||
|
hold_count = sum(1 for a in analysis.values() if a['decision'] == 'HOLD')
|
||||||
return {
|
return {
|
||||||
'date': row['date'],
|
'date': date,
|
||||||
'analysis': analysis,
|
'analysis': analysis,
|
||||||
'summary': {
|
'summary': {
|
||||||
'total': row['summary_total'],
|
'total': len(analysis),
|
||||||
'buy': row['summary_buy'],
|
'buy': buy_count,
|
||||||
'sell': row['summary_sell'],
|
'sell': sell_count,
|
||||||
'hold': row['summary_hold']
|
'hold': hold_count
|
||||||
},
|
},
|
||||||
'top_picks': json.loads(row['top_picks']) if row['top_picks'] else [],
|
'top_picks': [],
|
||||||
'stocks_to_avoid': json.loads(row['stocks_to_avoid']) if row['stocks_to_avoid'] else []
|
'stocks_to_avoid': []
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -253,13 +375,17 @@ def get_latest_recommendation() -> Optional[dict]:
|
||||||
|
|
||||||
|
|
||||||
def get_all_dates() -> list:
|
def get_all_dates() -> list:
|
||||||
"""Get all available dates."""
|
"""Get all available dates (union of daily_recommendations and stock_analysis)."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT date FROM daily_recommendations ORDER BY date DESC
|
SELECT DISTINCT date FROM (
|
||||||
|
SELECT date FROM daily_recommendations
|
||||||
|
UNION
|
||||||
|
SELECT date FROM stock_analysis
|
||||||
|
) ORDER BY date DESC
|
||||||
""")
|
""")
|
||||||
return [row['date'] for row in cursor.fetchall()]
|
return [row['date'] for row in cursor.fetchall()]
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -273,21 +399,26 @@ def get_stock_history(symbol: str) -> list:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT date, decision, confidence, risk
|
SELECT date, decision, confidence, risk, hold_days
|
||||||
FROM stock_analysis
|
FROM stock_analysis
|
||||||
WHERE symbol = ?
|
WHERE symbol = ?
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
""", (symbol,))
|
""", (symbol,))
|
||||||
|
|
||||||
return [
|
results = []
|
||||||
{
|
for row in cursor.fetchall():
|
||||||
|
decision = (row['decision'] or '').strip().upper()
|
||||||
|
# Sanitize: only allow BUY/SELL/HOLD
|
||||||
|
if decision not in ('BUY', 'SELL', 'HOLD'):
|
||||||
|
decision = 'HOLD'
|
||||||
|
results.append({
|
||||||
'date': row['date'],
|
'date': row['date'],
|
||||||
'decision': row['decision'],
|
'decision': decision,
|
||||||
'confidence': row['confidence'],
|
'confidence': row['confidence'] or 'MEDIUM',
|
||||||
'risk': row['risk']
|
'risk': row['risk'] or 'MEDIUM',
|
||||||
}
|
'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None
|
||||||
for row in cursor.fetchall()
|
})
|
||||||
]
|
return results
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -467,11 +598,14 @@ def save_pipeline_steps_bulk(date: str, symbol: str, steps: list):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for step in steps:
|
for step in steps:
|
||||||
|
step_details = step.get('step_details')
|
||||||
|
if step_details and not isinstance(step_details, str):
|
||||||
|
step_details = json.dumps(step_details)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT OR REPLACE INTO pipeline_steps
|
INSERT OR REPLACE INTO pipeline_steps
|
||||||
(date, symbol, step_number, step_name, status,
|
(date, symbol, step_number, step_name, status,
|
||||||
started_at, completed_at, duration_ms, output_summary)
|
started_at, completed_at, duration_ms, output_summary, step_details)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
date, symbol,
|
date, symbol,
|
||||||
step.get('step_number'),
|
step.get('step_number'),
|
||||||
|
|
@ -480,7 +614,8 @@ def save_pipeline_steps_bulk(date: str, symbol: str, steps: list):
|
||||||
step.get('started_at'),
|
step.get('started_at'),
|
||||||
step.get('completed_at'),
|
step.get('completed_at'),
|
||||||
step.get('duration_ms'),
|
step.get('duration_ms'),
|
||||||
step.get('output_summary')
|
step.get('output_summary'),
|
||||||
|
step_details
|
||||||
))
|
))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -499,18 +634,26 @@ def get_pipeline_steps(date: str, symbol: str) -> list:
|
||||||
ORDER BY step_number
|
ORDER BY step_number
|
||||||
""", (date, symbol))
|
""", (date, symbol))
|
||||||
|
|
||||||
return [
|
results = []
|
||||||
{
|
for row in cursor.fetchall():
|
||||||
|
step_details = None
|
||||||
|
raw_details = row['step_details'] if 'step_details' in row.keys() else None
|
||||||
|
if raw_details:
|
||||||
|
try:
|
||||||
|
step_details = json.loads(raw_details)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
step_details = None
|
||||||
|
results.append({
|
||||||
'step_number': row['step_number'],
|
'step_number': row['step_number'],
|
||||||
'step_name': row['step_name'],
|
'step_name': row['step_name'],
|
||||||
'status': row['status'],
|
'status': row['status'],
|
||||||
'started_at': row['started_at'],
|
'started_at': row['started_at'],
|
||||||
'completed_at': row['completed_at'],
|
'completed_at': row['completed_at'],
|
||||||
'duration_ms': row['duration_ms'],
|
'duration_ms': row['duration_ms'],
|
||||||
'output_summary': row['output_summary']
|
'output_summary': row['output_summary'],
|
||||||
}
|
'step_details': step_details,
|
||||||
for row in cursor.fetchall()
|
})
|
||||||
]
|
return results
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -550,13 +693,15 @@ def save_data_source_logs_bulk(date: str, symbol: str, logs: list):
|
||||||
for log in logs:
|
for log in logs:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO data_source_logs
|
INSERT INTO data_source_logs
|
||||||
(date, symbol, source_type, source_name, data_fetched,
|
(date, symbol, source_type, source_name, method, args, data_fetched,
|
||||||
fetch_timestamp, success, error_message)
|
fetch_timestamp, success, error_message)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
date, symbol,
|
date, symbol,
|
||||||
log.get('source_type'),
|
log.get('source_type'),
|
||||||
log.get('source_name'),
|
log.get('source_name'),
|
||||||
|
log.get('method'),
|
||||||
|
log.get('args'),
|
||||||
json.dumps(log.get('data_fetched')) if log.get('data_fetched') else None,
|
json.dumps(log.get('data_fetched')) if log.get('data_fetched') else None,
|
||||||
log.get('fetch_timestamp') or datetime.now().isoformat(),
|
log.get('fetch_timestamp') or datetime.now().isoformat(),
|
||||||
1 if log.get('success', True) else 0,
|
1 if log.get('success', True) else 0,
|
||||||
|
|
@ -568,7 +713,8 @@ def save_data_source_logs_bulk(date: str, symbol: str, logs: list):
|
||||||
|
|
||||||
|
|
||||||
def get_data_source_logs(date: str, symbol: str) -> list:
|
def get_data_source_logs(date: str, symbol: str) -> list:
|
||||||
"""Get all data source logs for a stock on a date."""
|
"""Get all data source logs for a stock on a date.
|
||||||
|
Falls back to generating entries from agent_reports if no explicit logs exist."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
|
@ -579,10 +725,12 @@ def get_data_source_logs(date: str, symbol: str) -> list:
|
||||||
ORDER BY fetch_timestamp
|
ORDER BY fetch_timestamp
|
||||||
""", (date, symbol))
|
""", (date, symbol))
|
||||||
|
|
||||||
return [
|
logs = [
|
||||||
{
|
{
|
||||||
'source_type': row['source_type'],
|
'source_type': row['source_type'],
|
||||||
'source_name': row['source_name'],
|
'source_name': row['source_name'],
|
||||||
|
'method': row['method'] if 'method' in row.keys() else None,
|
||||||
|
'args': row['args'] if 'args' in row.keys() else None,
|
||||||
'data_fetched': json.loads(row['data_fetched']) if row['data_fetched'] else None,
|
'data_fetched': json.loads(row['data_fetched']) if row['data_fetched'] else None,
|
||||||
'fetch_timestamp': row['fetch_timestamp'],
|
'fetch_timestamp': row['fetch_timestamp'],
|
||||||
'success': bool(row['success']),
|
'success': bool(row['success']),
|
||||||
|
|
@ -590,6 +738,39 @@ def get_data_source_logs(date: str, symbol: str) -> list:
|
||||||
}
|
}
|
||||||
for row in cursor.fetchall()
|
for row in cursor.fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if logs:
|
||||||
|
return logs
|
||||||
|
|
||||||
|
# No explicit logs — generate from agent_reports with full raw content
|
||||||
|
AGENT_TO_SOURCE = {
|
||||||
|
'market': ('market_data', 'Yahoo Finance'),
|
||||||
|
'news': ('news', 'Google News'),
|
||||||
|
'social_media': ('social_media', 'Social Sentiment'),
|
||||||
|
'fundamentals': ('fundamentals', 'Financial Data'),
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT agent_type, report_content, created_at
|
||||||
|
FROM agent_reports
|
||||||
|
WHERE date = ? AND symbol = ?
|
||||||
|
""", (date, symbol))
|
||||||
|
|
||||||
|
generated = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
source_type, source_name = AGENT_TO_SOURCE.get(
|
||||||
|
row['agent_type'], ('other', row['agent_type'])
|
||||||
|
)
|
||||||
|
generated.append({
|
||||||
|
'source_type': source_type,
|
||||||
|
'source_name': source_name,
|
||||||
|
'data_fetched': row['report_content'],
|
||||||
|
'fetch_timestamp': row['created_at'],
|
||||||
|
'success': True,
|
||||||
|
'error_message': None
|
||||||
|
})
|
||||||
|
|
||||||
|
return generated
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -698,5 +879,283 @@ def get_pipeline_summary_for_date(date: str) -> list:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_backtest_result(date: str, symbol: str, decision: str,
|
||||||
|
price_at_prediction: float, price_1d_later: float = None,
|
||||||
|
price_1w_later: float = None, price_1m_later: float = None,
|
||||||
|
return_1d: float = None, return_1w: float = None,
|
||||||
|
return_1m: float = None, prediction_correct: bool = None,
|
||||||
|
hold_days: int = None):
|
||||||
|
"""Save a backtest result for a stock recommendation."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO backtest_results
|
||||||
|
(date, symbol, decision, price_at_prediction,
|
||||||
|
price_1d_later, price_1w_later, price_1m_later,
|
||||||
|
return_1d, return_1w, return_1m, prediction_correct, hold_days)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
date, symbol, decision, price_at_prediction,
|
||||||
|
price_1d_later, price_1w_later, price_1m_later,
|
||||||
|
return_1d, return_1w, return_1m,
|
||||||
|
1 if prediction_correct else 0 if prediction_correct is not None else None,
|
||||||
|
hold_days
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_backtest_result(date: str, symbol: str) -> Optional[dict]:
|
||||||
|
"""Get backtest result for a specific stock and date."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT * FROM backtest_results WHERE date = ? AND symbol = ?
|
||||||
|
""", (date, symbol))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
'date': row['date'],
|
||||||
|
'symbol': row['symbol'],
|
||||||
|
'decision': row['decision'],
|
||||||
|
'price_at_prediction': row['price_at_prediction'],
|
||||||
|
'price_1d_later': row['price_1d_later'],
|
||||||
|
'price_1w_later': row['price_1w_later'],
|
||||||
|
'price_1m_later': row['price_1m_later'],
|
||||||
|
'return_1d': row['return_1d'],
|
||||||
|
'return_1w': row['return_1w'],
|
||||||
|
'return_1m': row['return_1m'],
|
||||||
|
'prediction_correct': bool(row['prediction_correct']) if row['prediction_correct'] is not None else None,
|
||||||
|
'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None,
|
||||||
|
'calculated_at': row['calculated_at']
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_backtest_results_by_date(date: str) -> list:
|
||||||
|
"""Get all backtest results for a specific date."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT * FROM backtest_results WHERE date = ?
|
||||||
|
""", (date,))
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'symbol': row['symbol'],
|
||||||
|
'decision': row['decision'],
|
||||||
|
'price_at_prediction': row['price_at_prediction'],
|
||||||
|
'price_1d_later': row['price_1d_later'],
|
||||||
|
'price_1w_later': row['price_1w_later'],
|
||||||
|
'price_1m_later': row['price_1m_later'],
|
||||||
|
'return_1d': row['return_1d'],
|
||||||
|
'return_1w': row['return_1w'],
|
||||||
|
'return_1m': row['return_1m'],
|
||||||
|
'prediction_correct': bool(row['prediction_correct']) if row['prediction_correct'] is not None else None,
|
||||||
|
'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None
|
||||||
|
}
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_backtest_results() -> list:
|
||||||
|
"""Get all backtest results for accuracy calculation."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT br.*, sa.confidence, sa.risk
|
||||||
|
FROM backtest_results br
|
||||||
|
LEFT JOIN stock_analysis sa ON br.date = sa.date AND br.symbol = sa.symbol
|
||||||
|
WHERE br.prediction_correct IS NOT NULL
|
||||||
|
ORDER BY br.date DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'date': row['date'],
|
||||||
|
'symbol': row['symbol'],
|
||||||
|
'decision': row['decision'],
|
||||||
|
'confidence': row['confidence'],
|
||||||
|
'risk': row['risk'],
|
||||||
|
'price_at_prediction': row['price_at_prediction'],
|
||||||
|
'return_1d': row['return_1d'],
|
||||||
|
'return_1w': row['return_1w'],
|
||||||
|
'return_1m': row['return_1m'],
|
||||||
|
'prediction_correct': bool(row['prediction_correct'])
|
||||||
|
}
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_accuracy_metrics() -> dict:
|
||||||
|
"""Calculate overall backtest accuracy metrics."""
|
||||||
|
results = get_all_backtest_results()
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return {
|
||||||
|
'overall_accuracy': 0,
|
||||||
|
'total_predictions': 0,
|
||||||
|
'correct_predictions': 0,
|
||||||
|
'by_decision': {'BUY': {'accuracy': 0, 'total': 0}, 'SELL': {'accuracy': 0, 'total': 0}, 'HOLD': {'accuracy': 0, 'total': 0}},
|
||||||
|
'by_confidence': {'High': {'accuracy': 0, 'total': 0}, 'Medium': {'accuracy': 0, 'total': 0}, 'Low': {'accuracy': 0, 'total': 0}}
|
||||||
|
}
|
||||||
|
|
||||||
|
total = len(results)
|
||||||
|
correct = sum(1 for r in results if r['prediction_correct'])
|
||||||
|
|
||||||
|
# By decision type
|
||||||
|
by_decision = {}
|
||||||
|
for decision in ['BUY', 'SELL', 'HOLD']:
|
||||||
|
decision_results = [r for r in results if r['decision'] == decision]
|
||||||
|
if decision_results:
|
||||||
|
decision_correct = sum(1 for r in decision_results if r['prediction_correct'])
|
||||||
|
by_decision[decision] = {
|
||||||
|
'accuracy': round(decision_correct / len(decision_results) * 100, 1),
|
||||||
|
'total': len(decision_results),
|
||||||
|
'correct': decision_correct
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
by_decision[decision] = {'accuracy': 0, 'total': 0, 'correct': 0}
|
||||||
|
|
||||||
|
# By confidence level
|
||||||
|
by_confidence = {}
|
||||||
|
for conf in ['High', 'Medium', 'Low']:
|
||||||
|
conf_results = [r for r in results if r.get('confidence') == conf]
|
||||||
|
if conf_results:
|
||||||
|
conf_correct = sum(1 for r in conf_results if r['prediction_correct'])
|
||||||
|
by_confidence[conf] = {
|
||||||
|
'accuracy': round(conf_correct / len(conf_results) * 100, 1),
|
||||||
|
'total': len(conf_results),
|
||||||
|
'correct': conf_correct
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
by_confidence[conf] = {'accuracy': 0, 'total': 0, 'correct': 0}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'overall_accuracy': round(correct / total * 100, 1) if total > 0 else 0,
|
||||||
|
'total_predictions': total,
|
||||||
|
'correct_predictions': correct,
|
||||||
|
'by_decision': by_decision,
|
||||||
|
'by_confidence': by_confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_daily_recommendation_summary(date: str):
|
||||||
|
"""Auto-create/update daily_recommendations from stock_analysis for a date.
|
||||||
|
|
||||||
|
Counts BUY/SELL/HOLD decisions, generates top_picks and stocks_to_avoid,
|
||||||
|
and upserts the daily_recommendations row.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all stock analyses for this date
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT symbol, company_name, decision, confidence, risk, raw_analysis
|
||||||
|
FROM stock_analysis WHERE date = ?
|
||||||
|
""", (date,))
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
buy_count = 0
|
||||||
|
sell_count = 0
|
||||||
|
hold_count = 0
|
||||||
|
buy_stocks = []
|
||||||
|
sell_stocks = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
decision = (row['decision'] or '').upper()
|
||||||
|
if decision == 'BUY':
|
||||||
|
buy_count += 1
|
||||||
|
buy_stocks.append({
|
||||||
|
'symbol': row['symbol'],
|
||||||
|
'company_name': row['company_name'] or row['symbol'],
|
||||||
|
'decision': 'BUY',
|
||||||
|
'confidence': row['confidence'] or 'MEDIUM',
|
||||||
|
'reason': (row['raw_analysis'] or '')[:200]
|
||||||
|
})
|
||||||
|
elif decision == 'SELL':
|
||||||
|
sell_count += 1
|
||||||
|
sell_stocks.append({
|
||||||
|
'symbol': row['symbol'],
|
||||||
|
'company_name': row['company_name'] or row['symbol'],
|
||||||
|
'decision': 'SELL',
|
||||||
|
'confidence': row['confidence'] or 'MEDIUM',
|
||||||
|
'reason': (row['raw_analysis'] or '')[:200]
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
hold_count += 1
|
||||||
|
|
||||||
|
total = buy_count + sell_count + hold_count
|
||||||
|
|
||||||
|
# Top picks: up to 5 BUY stocks
|
||||||
|
top_picks = [
|
||||||
|
{'symbol': s['symbol'], 'company_name': s['company_name'],
|
||||||
|
'confidence': s['confidence'], 'reason': s['reason']}
|
||||||
|
for s in buy_stocks[:5]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Stocks to avoid: up to 5 SELL stocks
|
||||||
|
stocks_to_avoid = [
|
||||||
|
{'symbol': s['symbol'], 'company_name': s['company_name'],
|
||||||
|
'confidence': s['confidence'], 'reason': s['reason']}
|
||||||
|
for s in sell_stocks[:5]
|
||||||
|
]
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO daily_recommendations
|
||||||
|
(date, summary_total, summary_buy, summary_sell, summary_hold, top_picks, stocks_to_avoid)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
date, total, buy_count, sell_count, hold_count,
|
||||||
|
json.dumps(top_picks),
|
||||||
|
json.dumps(stocks_to_avoid)
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_all_daily_recommendations():
|
||||||
|
"""Rebuild daily_recommendations for all dates that have stock_analysis data.
|
||||||
|
|
||||||
|
This ensures dates with stock_analysis but missing daily_recommendations
|
||||||
|
entries become visible to the API.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT DISTINCT date FROM stock_analysis")
|
||||||
|
dates = [row['date'] for row in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
for date in dates:
|
||||||
|
update_daily_recommendation_summary(date)
|
||||||
|
|
||||||
|
if dates:
|
||||||
|
print(f"[DB] Rebuilt daily_recommendations for {len(dates)} dates: {sorted(dates)}")
|
||||||
|
|
||||||
|
|
||||||
# Initialize database on module import
|
# Initialize database on module import
|
||||||
init_db()
|
init_db()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""FastAPI server for Nifty50 AI recommendations."""
|
"""FastAPI server for Nifty50 AI recommendations."""
|
||||||
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import database as db
|
import database as db
|
||||||
|
|
@ -9,11 +10,18 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import threading
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
# Add parent directories to path for importing trading agents
|
# Add parent directories to path for importing trading agents
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
# Import shared logging system
|
||||||
|
from tradingagents.log_utils import add_log, analysis_logs, log_lock, log_subscribers
|
||||||
|
|
||||||
# Track running analyses
|
# Track running analyses
|
||||||
# NOTE: This is not thread-safe for production multi-worker deployments.
|
# NOTE: This is not thread-safe for production multi-worker deployments.
|
||||||
# For production, use Redis or a database-backed job queue instead.
|
# For production, use Redis or a database-backed job queue instead.
|
||||||
|
|
@ -145,6 +153,11 @@ class RunAnalysisRequest(BaseModel):
|
||||||
config: Optional[AnalysisConfig] = None
|
config: Optional[AnalysisConfig] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cancelled(symbol: str) -> bool:
|
||||||
|
"""Check if an analysis has been cancelled."""
|
||||||
|
return running_analyses.get(symbol, {}).get("cancelled", False)
|
||||||
|
|
||||||
|
|
||||||
def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
|
def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
|
||||||
"""Background task to run trading analysis for a stock."""
|
"""Background task to run trading analysis for a stock."""
|
||||||
global running_analyses
|
global running_analyses
|
||||||
|
|
@ -163,14 +176,20 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
|
||||||
running_analyses[symbol] = {
|
running_analyses[symbol] = {
|
||||||
"status": "initializing",
|
"status": "initializing",
|
||||||
"started_at": datetime.now().isoformat(),
|
"started_at": datetime.now().isoformat(),
|
||||||
"progress": "Loading trading agents..."
|
"progress": "Loading trading agents...",
|
||||||
|
"cancelled": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add_log("info", "system", f"🚀 Starting analysis for {symbol} on {date}")
|
||||||
|
add_log("info", "system", f"Config: deep_think={deep_think_model}, quick_think={quick_think_model}")
|
||||||
|
|
||||||
# Import trading agents
|
# Import trading agents
|
||||||
|
add_log("info", "system", "Loading TradingAgentsGraph module...")
|
||||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
from tradingagents.default_config import DEFAULT_CONFIG
|
from tradingagents.default_config import DEFAULT_CONFIG
|
||||||
|
|
||||||
running_analyses[symbol]["progress"] = "Initializing analysis pipeline..."
|
running_analyses[symbol]["progress"] = "Initializing analysis pipeline..."
|
||||||
|
add_log("info", "system", "Initializing analysis pipeline...")
|
||||||
|
|
||||||
# Create config from user settings
|
# Create config from user settings
|
||||||
config = DEFAULT_CONFIG.copy()
|
config = DEFAULT_CONFIG.copy()
|
||||||
|
|
@ -183,14 +202,77 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
|
||||||
if provider == "anthropic_api" and api_key:
|
if provider == "anthropic_api" and api_key:
|
||||||
os.environ["ANTHROPIC_API_KEY"] = api_key
|
os.environ["ANTHROPIC_API_KEY"] = api_key
|
||||||
|
|
||||||
|
# Check cancellation before starting
|
||||||
|
if _is_cancelled(symbol):
|
||||||
|
add_log("info", "system", f"Analysis for {symbol} was cancelled before starting")
|
||||||
|
running_analyses[symbol]["status"] = "cancelled"
|
||||||
|
running_analyses[symbol]["progress"] = "Analysis cancelled"
|
||||||
|
return
|
||||||
|
|
||||||
running_analyses[symbol]["status"] = "running"
|
running_analyses[symbol]["status"] = "running"
|
||||||
running_analyses[symbol]["progress"] = f"Running market analysis (model: {deep_think_model})..."
|
running_analyses[symbol]["progress"] = f"Running market analysis (model: {deep_think_model})..."
|
||||||
|
|
||||||
|
add_log("agent", "system", f"Creating TradingAgentsGraph for {symbol}...")
|
||||||
|
|
||||||
# Initialize and run
|
# Initialize and run
|
||||||
ta = TradingAgentsGraph(debug=False, config=config)
|
ta = TradingAgentsGraph(debug=False, config=config)
|
||||||
|
|
||||||
|
# Check cancellation before graph execution
|
||||||
|
if _is_cancelled(symbol):
|
||||||
|
add_log("info", "system", f"Analysis for {symbol} was cancelled before graph execution")
|
||||||
|
running_analyses[symbol]["status"] = "cancelled"
|
||||||
|
running_analyses[symbol]["progress"] = "Analysis cancelled"
|
||||||
|
return
|
||||||
|
|
||||||
running_analyses[symbol]["progress"] = f"Analyzing {symbol}..."
|
running_analyses[symbol]["progress"] = f"Analyzing {symbol}..."
|
||||||
final_state, decision = ta.propagate(symbol, date)
|
add_log("agent", "system", f"Starting propagation for {symbol}...")
|
||||||
|
add_log("data", "data_fetch", f"Fetching market data for {symbol}...")
|
||||||
|
|
||||||
|
final_state, decision, hold_days = ta.propagate(symbol, date)
|
||||||
|
|
||||||
|
# Check cancellation after graph execution (skip saving results)
|
||||||
|
if _is_cancelled(symbol):
|
||||||
|
add_log("info", "system", f"Analysis for {symbol} was cancelled after completion — results discarded")
|
||||||
|
running_analyses[symbol]["status"] = "cancelled"
|
||||||
|
running_analyses[symbol]["progress"] = "Analysis cancelled (results discarded)"
|
||||||
|
return
|
||||||
|
|
||||||
|
add_log("success", "system", f"✅ Analysis complete for {symbol}: {decision}")
|
||||||
|
|
||||||
|
# Extract raw analysis from final_state if available
|
||||||
|
raw_analysis = ""
|
||||||
|
if final_state:
|
||||||
|
if "final_trade_decision" in final_state:
|
||||||
|
raw_analysis = final_state.get("final_trade_decision", "")
|
||||||
|
elif "risk_debate_state" in final_state:
|
||||||
|
raw_analysis = final_state.get("risk_debate_state", {}).get("judge_decision", "")
|
||||||
|
|
||||||
|
# Save the analysis result to the database
|
||||||
|
analysis_data = {
|
||||||
|
"company_name": symbol,
|
||||||
|
"decision": decision.upper() if decision else "HOLD",
|
||||||
|
"confidence": "MEDIUM",
|
||||||
|
"risk": "MEDIUM",
|
||||||
|
"raw_analysis": raw_analysis,
|
||||||
|
"hold_days": hold_days
|
||||||
|
}
|
||||||
|
db.save_single_stock_analysis(date, symbol, analysis_data)
|
||||||
|
add_log("info", "system", f"💾 Saved analysis for {symbol} to database")
|
||||||
|
|
||||||
|
# Auto-update daily recommendation summary (counts, top_picks, stocks_to_avoid)
|
||||||
|
db.update_daily_recommendation_summary(date)
|
||||||
|
add_log("info", "system", f"📊 Updated daily recommendation summary for {date}")
|
||||||
|
|
||||||
|
# Auto-trigger backtest calculation for this stock
|
||||||
|
try:
|
||||||
|
import backtest_service as bt
|
||||||
|
bt_result = bt.calculate_and_save_backtest(date, symbol, analysis_data["decision"], analysis_data.get("hold_days"))
|
||||||
|
if bt_result:
|
||||||
|
add_log("info", "system", f"📈 Backtest calculated for {symbol}: correct={bt_result.get('prediction_correct')}")
|
||||||
|
else:
|
||||||
|
add_log("info", "system", f"📈 Backtest not available yet for {symbol} (future date or no price data)")
|
||||||
|
except Exception as bt_err:
|
||||||
|
add_log("warning", "system", f"⚠️ Backtest calculation skipped for {symbol}: {bt_err}")
|
||||||
|
|
||||||
running_analyses[symbol] = {
|
running_analyses[symbol] = {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
|
|
@ -198,9 +280,16 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
|
||||||
"progress": f"Analysis complete: {decision}",
|
"progress": f"Analysis complete: {decision}",
|
||||||
"decision": decision
|
"decision": decision
|
||||||
}
|
}
|
||||||
|
# Clear per-symbol step progress after completion
|
||||||
|
try:
|
||||||
|
from tradingagents.log_utils import symbol_progress
|
||||||
|
symbol_progress.clear(symbol)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e) if str(e) else f"{type(e).__name__}: No details provided"
|
error_msg = str(e) if str(e) else f"{type(e).__name__}: No details provided"
|
||||||
|
add_log("error", "system", f"❌ Error analyzing {symbol}: {error_msg}")
|
||||||
running_analyses[symbol] = {
|
running_analyses[symbol] = {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": error_msg,
|
"error": error_msg,
|
||||||
|
|
@ -295,6 +384,60 @@ async def health_check():
|
||||||
return {"status": "healthy", "database": "connected"}
|
return {"status": "healthy", "database": "connected"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Live Log Streaming Endpoint ==============
|
||||||
|
|
||||||
|
@app.get("/stream/logs")
|
||||||
|
async def stream_logs():
|
||||||
|
"""Server-Sent Events endpoint for streaming analysis logs."""
|
||||||
|
import queue
|
||||||
|
|
||||||
|
# Create a queue for this subscriber
|
||||||
|
subscriber_queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
with log_lock:
|
||||||
|
log_subscribers.append(subscriber_queue)
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
try:
|
||||||
|
# Send initial connection message
|
||||||
|
yield f"data: {json.dumps({'type': 'info', 'source': 'system', 'message': 'Connected to log stream', 'timestamp': datetime.now().isoformat()})}\n\n"
|
||||||
|
|
||||||
|
# Send any recent logs from buffer
|
||||||
|
with log_lock:
|
||||||
|
recent_logs = list(analysis_logs)[-50:] # Last 50 logs
|
||||||
|
for log in recent_logs:
|
||||||
|
yield f"data: {json.dumps(log)}\n\n"
|
||||||
|
|
||||||
|
# Stream new logs as they arrive
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Check for new logs with timeout
|
||||||
|
log_entry = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, lambda: subscriber_queue.get(timeout=5)
|
||||||
|
)
|
||||||
|
yield f"data: {json.dumps(log_entry)}\n\n"
|
||||||
|
except queue.Empty:
|
||||||
|
# Send heartbeat to keep connection alive
|
||||||
|
yield f"data: {json.dumps({'type': 'heartbeat', 'timestamp': datetime.now().isoformat()})}\n\n"
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
# Remove subscriber on disconnect
|
||||||
|
with log_lock:
|
||||||
|
if subscriber_queue in log_subscribers:
|
||||||
|
log_subscribers.remove(subscriber_queue)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============== Pipeline Data Endpoints ==============
|
# ============== Pipeline Data Endpoints ==============
|
||||||
|
|
||||||
@app.get("/recommendations/{date}/{symbol}/pipeline")
|
@app.get("/recommendations/{date}/{symbol}/pipeline")
|
||||||
|
|
@ -395,14 +538,15 @@ async def save_pipeline_data(request: SavePipelineDataRequest):
|
||||||
|
|
||||||
# Track bulk analysis state
|
# Track bulk analysis state
|
||||||
bulk_analysis_state = {
|
bulk_analysis_state = {
|
||||||
"status": "idle", # idle, running, completed, error
|
"status": "idle", # idle, running, completed, error, cancelled
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"completed": 0,
|
"completed": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"current_symbol": None,
|
"current_symbol": None,
|
||||||
"started_at": None,
|
"started_at": None,
|
||||||
"completed_at": None,
|
"completed_at": None,
|
||||||
"results": {}
|
"results": {},
|
||||||
|
"cancelled": False # Flag to signal cancellation
|
||||||
}
|
}
|
||||||
|
|
||||||
# List of Nifty 50 stocks
|
# List of Nifty 50 stocks
|
||||||
|
|
@ -423,11 +567,12 @@ class BulkAnalysisRequest(BaseModel):
|
||||||
provider: Optional[str] = "claude_subscription"
|
provider: Optional[str] = "claude_subscription"
|
||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
max_debate_rounds: Optional[int] = 1
|
max_debate_rounds: Optional[int] = 1
|
||||||
|
parallel_workers: Optional[int] = 3
|
||||||
|
|
||||||
|
|
||||||
@app.post("/analyze/all")
|
@app.post("/analyze/all")
|
||||||
async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date: Optional[str] = None):
|
async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date: Optional[str] = None):
|
||||||
"""Trigger analysis for all Nifty 50 stocks. Runs in background."""
|
"""Trigger analysis for all Nifty 50 stocks. Runs in background with parallel processing."""
|
||||||
global bulk_analysis_state
|
global bulk_analysis_state
|
||||||
|
|
||||||
# Check if bulk analysis is already running
|
# Check if bulk analysis is already running
|
||||||
|
|
@ -443,6 +588,7 @@ async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date:
|
||||||
|
|
||||||
# Build analysis config from request
|
# Build analysis config from request
|
||||||
analysis_config = {}
|
analysis_config = {}
|
||||||
|
parallel_workers = 3
|
||||||
if request:
|
if request:
|
||||||
analysis_config = {
|
analysis_config = {
|
||||||
"deep_think_model": request.deep_think_model,
|
"deep_think_model": request.deep_think_model,
|
||||||
|
|
@ -451,57 +597,129 @@ async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date:
|
||||||
"api_key": request.api_key,
|
"api_key": request.api_key,
|
||||||
"max_debate_rounds": request.max_debate_rounds
|
"max_debate_rounds": request.max_debate_rounds
|
||||||
}
|
}
|
||||||
|
if request.parallel_workers is not None:
|
||||||
|
parallel_workers = max(1, min(5, request.parallel_workers))
|
||||||
|
|
||||||
|
# Resume support: skip stocks already analyzed for this date
|
||||||
|
already_analyzed = set(db.get_analyzed_symbols_for_date(date))
|
||||||
|
symbols_to_analyze = [s for s in NIFTY_50_SYMBOLS if s not in already_analyzed]
|
||||||
|
skipped_count = len(already_analyzed)
|
||||||
|
|
||||||
|
# If all stocks are already analyzed, return immediately
|
||||||
|
if not symbols_to_analyze:
|
||||||
|
bulk_analysis_state = {
|
||||||
|
"status": "completed",
|
||||||
|
"total": 0,
|
||||||
|
"total_all": len(NIFTY_50_SYMBOLS),
|
||||||
|
"skipped": skipped_count,
|
||||||
|
"completed": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"current_symbols": [],
|
||||||
|
"started_at": datetime.now().isoformat(),
|
||||||
|
"completed_at": datetime.now().isoformat(),
|
||||||
|
"results": {},
|
||||||
|
"parallel_workers": parallel_workers,
|
||||||
|
"cancelled": False
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"message": f"All {skipped_count} stocks already analyzed for {date}",
|
||||||
|
"date": date,
|
||||||
|
"total_stocks": 0,
|
||||||
|
"skipped": skipped_count,
|
||||||
|
"parallel_workers": parallel_workers,
|
||||||
|
"status": "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
def analyze_single_stock(symbol: str, analysis_date: str, config: dict) -> tuple:
|
||||||
|
"""Analyze a single stock and return (symbol, status, error)."""
|
||||||
|
try:
|
||||||
|
# Check if cancelled before starting
|
||||||
|
if bulk_analysis_state.get("cancelled"):
|
||||||
|
return (symbol, "cancelled", "Bulk analysis was cancelled")
|
||||||
|
|
||||||
|
run_analysis_task(symbol, analysis_date, config)
|
||||||
|
|
||||||
|
# Wait for completion with timeout
|
||||||
|
import time
|
||||||
|
max_wait = 600 # 10 minute timeout per stock
|
||||||
|
waited = 0
|
||||||
|
while waited < max_wait:
|
||||||
|
# Check for cancellation during wait
|
||||||
|
if bulk_analysis_state.get("cancelled"):
|
||||||
|
return (symbol, "cancelled", "Bulk analysis was cancelled")
|
||||||
|
|
||||||
|
if symbol not in running_analyses:
|
||||||
|
return (symbol, "unknown", None)
|
||||||
|
status = running_analyses[symbol].get("status")
|
||||||
|
if status != "running" and status != "initializing":
|
||||||
|
return (symbol, status, None)
|
||||||
|
time.sleep(2)
|
||||||
|
waited += 2
|
||||||
|
|
||||||
|
return (symbol, "timeout", "Analysis timed out after 10 minutes")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return (symbol, "error", str(e))
|
||||||
|
|
||||||
# Start bulk analysis in background thread
|
# Start bulk analysis in background thread
|
||||||
def run_bulk():
|
def run_bulk_parallel():
|
||||||
global bulk_analysis_state
|
global bulk_analysis_state
|
||||||
bulk_analysis_state = {
|
bulk_analysis_state = {
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"total": len(NIFTY_50_SYMBOLS),
|
"total": len(symbols_to_analyze),
|
||||||
|
"total_all": len(NIFTY_50_SYMBOLS),
|
||||||
|
"skipped": skipped_count,
|
||||||
"completed": 0,
|
"completed": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"current_symbol": None,
|
"current_symbols": [],
|
||||||
"started_at": datetime.now().isoformat(),
|
"started_at": datetime.now().isoformat(),
|
||||||
"completed_at": None,
|
"completed_at": None,
|
||||||
"results": {}
|
"results": {},
|
||||||
|
"parallel_workers": parallel_workers,
|
||||||
|
"cancelled": False
|
||||||
}
|
}
|
||||||
|
|
||||||
for symbol in NIFTY_50_SYMBOLS:
|
with ThreadPoolExecutor(max_workers=parallel_workers) as executor:
|
||||||
try:
|
future_to_symbol = {
|
||||||
bulk_analysis_state["current_symbol"] = symbol
|
executor.submit(analyze_single_stock, symbol, date, analysis_config): symbol
|
||||||
run_analysis_task(symbol, date, analysis_config)
|
for symbol in symbols_to_analyze
|
||||||
|
}
|
||||||
|
|
||||||
# Wait for completion
|
bulk_analysis_state["current_symbols"] = list(symbols_to_analyze[:parallel_workers])
|
||||||
import time
|
|
||||||
while symbol in running_analyses and running_analyses[symbol].get("status") == "running":
|
for future in as_completed(future_to_symbol):
|
||||||
time.sleep(2)
|
symbol = future_to_symbol[future]
|
||||||
|
try:
|
||||||
|
symbol, status, error = future.result()
|
||||||
|
bulk_analysis_state["results"][symbol] = status if not error else f"error: {error}"
|
||||||
|
|
||||||
if symbol in running_analyses:
|
|
||||||
status = running_analyses[symbol].get("status", "unknown")
|
|
||||||
bulk_analysis_state["results"][symbol] = status
|
|
||||||
if status == "completed":
|
if status == "completed":
|
||||||
bulk_analysis_state["completed"] += 1
|
bulk_analysis_state["completed"] += 1
|
||||||
else:
|
else:
|
||||||
bulk_analysis_state["failed"] += 1
|
bulk_analysis_state["failed"] += 1
|
||||||
else:
|
|
||||||
bulk_analysis_state["results"][symbol] = "unknown"
|
remaining = [s for s in symbols_to_analyze
|
||||||
|
if s not in bulk_analysis_state["results"]]
|
||||||
|
bulk_analysis_state["current_symbols"] = remaining[:parallel_workers]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
bulk_analysis_state["results"][symbol] = f"error: {str(e)}"
|
||||||
bulk_analysis_state["failed"] += 1
|
bulk_analysis_state["failed"] += 1
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
bulk_analysis_state["results"][symbol] = f"error: {str(e)}"
|
|
||||||
bulk_analysis_state["failed"] += 1
|
|
||||||
|
|
||||||
bulk_analysis_state["status"] = "completed"
|
bulk_analysis_state["status"] = "completed"
|
||||||
bulk_analysis_state["current_symbol"] = None
|
bulk_analysis_state["current_symbols"] = []
|
||||||
bulk_analysis_state["completed_at"] = datetime.now().isoformat()
|
bulk_analysis_state["completed_at"] = datetime.now().isoformat()
|
||||||
|
|
||||||
thread = threading.Thread(target=run_bulk)
|
thread = threading.Thread(target=run_bulk_parallel)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
skipped_msg = f", {skipped_count} already done" if skipped_count > 0 else ""
|
||||||
return {
|
return {
|
||||||
"message": "Bulk analysis started for all Nifty 50 stocks",
|
"message": f"Bulk analysis started for {len(symbols_to_analyze)} stocks ({parallel_workers} parallel workers{skipped_msg})",
|
||||||
"date": date,
|
"date": date,
|
||||||
"total_stocks": len(NIFTY_50_SYMBOLS),
|
"total_stocks": len(symbols_to_analyze),
|
||||||
|
"skipped": skipped_count,
|
||||||
|
"parallel_workers": parallel_workers,
|
||||||
"status": "started"
|
"status": "started"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,7 +727,47 @@ async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date:
|
||||||
@app.get("/analyze/all/status")
|
@app.get("/analyze/all/status")
|
||||||
async def get_bulk_analysis_status():
|
async def get_bulk_analysis_status():
|
||||||
"""Get the status of bulk analysis."""
|
"""Get the status of bulk analysis."""
|
||||||
return bulk_analysis_state
|
# Add backward compatibility for current_symbol (old format)
|
||||||
|
result = dict(bulk_analysis_state)
|
||||||
|
if "current_symbols" in result:
|
||||||
|
result["current_symbol"] = result["current_symbols"][0] if result["current_symbols"] else None
|
||||||
|
|
||||||
|
# Include per-stock step progress for currently-analyzing stocks
|
||||||
|
if result.get("status") == "running" and result.get("current_symbols"):
|
||||||
|
try:
|
||||||
|
from tradingagents.log_utils import symbol_progress
|
||||||
|
stock_progress = {}
|
||||||
|
for sym in result["current_symbols"]:
|
||||||
|
stock_progress[sym] = symbol_progress.get(sym)
|
||||||
|
result["stock_progress"] = stock_progress
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/analyze/all/cancel")
|
||||||
|
async def cancel_bulk_analysis():
|
||||||
|
"""Cancel the running bulk analysis."""
|
||||||
|
global bulk_analysis_state
|
||||||
|
|
||||||
|
if bulk_analysis_state.get("status") != "running":
|
||||||
|
return {
|
||||||
|
"message": "No bulk analysis is running",
|
||||||
|
"status": bulk_analysis_state.get("status")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set the cancelled flag
|
||||||
|
bulk_analysis_state["cancelled"] = True
|
||||||
|
bulk_analysis_state["status"] = "cancelled"
|
||||||
|
bulk_analysis_state["completed_at"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Bulk analysis cancellation requested",
|
||||||
|
"completed": bulk_analysis_state.get("completed", 0),
|
||||||
|
"total": bulk_analysis_state.get("total", 0),
|
||||||
|
"status": "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/analyze/running")
|
@app.get("/analyze/running")
|
||||||
|
|
@ -571,7 +829,7 @@ async def run_analysis(symbol: str, background_tasks: BackgroundTasks, request:
|
||||||
|
|
||||||
@app.get("/analyze/{symbol}/status")
|
@app.get("/analyze/{symbol}/status")
|
||||||
async def get_analysis_status(symbol: str):
|
async def get_analysis_status(symbol: str):
|
||||||
"""Get the status of a running or completed analysis."""
|
"""Get the status of a running or completed analysis, including live pipeline step progress."""
|
||||||
symbol = symbol.upper()
|
symbol = symbol.upper()
|
||||||
|
|
||||||
if symbol not in running_analyses:
|
if symbol not in running_analyses:
|
||||||
|
|
@ -581,11 +839,234 @@ async def get_analysis_status(symbol: str):
|
||||||
"message": "No analysis has been run for this stock"
|
"message": "No analysis has been run for this stock"
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
**running_analyses[symbol]
|
**running_analyses[symbol]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Include live pipeline step progress from step_timer when analysis is running
|
||||||
|
if running_analyses[symbol].get("status") == "running":
|
||||||
|
try:
|
||||||
|
from tradingagents.log_utils import step_timer
|
||||||
|
|
||||||
|
steps = step_timer.get_steps()
|
||||||
|
if steps:
|
||||||
|
# Build a live progress summary
|
||||||
|
STEP_NAMES = {
|
||||||
|
"market_analyst": "Market Analysis",
|
||||||
|
"social_media_analyst": "Social Media Analysis",
|
||||||
|
"news_analyst": "News Analysis",
|
||||||
|
"fundamentals_analyst": "Fundamental Analysis",
|
||||||
|
"bull_researcher": "Bull Research",
|
||||||
|
"bear_researcher": "Bear Research",
|
||||||
|
"research_manager": "Research Manager",
|
||||||
|
"trader": "Trader Decision",
|
||||||
|
"aggressive_analyst": "Aggressive Analysis",
|
||||||
|
"conservative_analyst": "Conservative Analysis",
|
||||||
|
"neutral_analyst": "Neutral Analysis",
|
||||||
|
"risk_manager": "Risk Manager",
|
||||||
|
}
|
||||||
|
|
||||||
|
completed = [k for k, v in steps.items() if v.get("status") == "completed"]
|
||||||
|
running = [k for k, v in steps.items() if v.get("status") == "running"]
|
||||||
|
total = 12
|
||||||
|
|
||||||
|
# Build progress message from live step data
|
||||||
|
if running:
|
||||||
|
current_step = STEP_NAMES.get(running[0], running[0])
|
||||||
|
result["progress"] = f"Step {len(completed)+1}/{total}: {current_step}..."
|
||||||
|
elif completed:
|
||||||
|
last_step = STEP_NAMES.get(completed[-1], completed[-1])
|
||||||
|
result["progress"] = f"Step {len(completed)}/{total}: {last_step} done"
|
||||||
|
|
||||||
|
result["steps_completed"] = len(completed)
|
||||||
|
result["steps_running"] = [STEP_NAMES.get(s, s) for s in running]
|
||||||
|
result["steps_total"] = total
|
||||||
|
result["pipeline_steps"] = {
|
||||||
|
k: {"status": v.get("status"), "duration_ms": v.get("duration_ms")}
|
||||||
|
for k, v in steps.items()
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass # Don't fail status endpoint if step_timer unavailable
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/analyze/{symbol}/cancel")
|
||||||
|
async def cancel_analysis(symbol: str):
|
||||||
|
"""Cancel a running analysis for a stock."""
|
||||||
|
symbol = symbol.upper()
|
||||||
|
|
||||||
|
if symbol not in running_analyses:
|
||||||
|
return {"message": f"No analysis found for {symbol}", "status": "not_found"}
|
||||||
|
|
||||||
|
current_status = running_analyses[symbol].get("status")
|
||||||
|
if current_status not in ("running", "initializing"):
|
||||||
|
return {"message": f"Analysis for {symbol} is not running (status: {current_status})", "status": current_status}
|
||||||
|
|
||||||
|
# Set cancellation flag — the background thread checks this
|
||||||
|
running_analyses[symbol]["cancelled"] = True
|
||||||
|
running_analyses[symbol]["status"] = "cancelled"
|
||||||
|
running_analyses[symbol]["progress"] = "Cancellation requested..."
|
||||||
|
running_analyses[symbol]["completed_at"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
add_log("info", "system", f"🛑 Cancellation requested for {symbol}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Cancellation requested for {symbol}",
|
||||||
|
"symbol": symbol,
|
||||||
|
"status": "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Backtest Endpoints ==============
|
||||||
|
# NOTE: Static routes must come BEFORE parameterized routes to avoid
|
||||||
|
# "accuracy" being matched as a {date} parameter.
|
||||||
|
|
||||||
|
@app.get("/backtest/accuracy")
|
||||||
|
async def get_accuracy_metrics():
|
||||||
|
"""Get overall backtest accuracy metrics."""
|
||||||
|
metrics = db.calculate_accuracy_metrics()
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/backtest/{date}/{symbol}")
|
||||||
|
async def get_backtest_result(date: str, symbol: str):
|
||||||
|
"""Get backtest result for a specific stock and date.
|
||||||
|
|
||||||
|
Returns pre-calculated results only (no on-demand yfinance fetching)
|
||||||
|
to avoid blocking the event loop.
|
||||||
|
"""
|
||||||
|
result = db.get_backtest_result(date, symbol.upper())
|
||||||
|
if not result:
|
||||||
|
return {'available': False, 'reason': 'Backtest not yet calculated'}
|
||||||
|
|
||||||
|
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'],
|
||||||
|
'price_at_prediction': result['price_at_prediction'],
|
||||||
|
'current_price': result.get('price_1m_later') or result.get('price_1w_later'),
|
||||||
|
'hold_days': result.get('hold_days'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/backtest/{date}")
|
||||||
|
async def get_backtest_results_for_date(date: str):
|
||||||
|
"""Get all backtest results for a specific date."""
|
||||||
|
results = db.get_backtest_results_by_date(date)
|
||||||
|
return {"date": date, "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/backtest/{date}/calculate")
|
||||||
|
async def calculate_backtest_for_date(date: str):
|
||||||
|
"""Calculate backtest for all recommendations on a date (runs in background thread)."""
|
||||||
|
import backtest_service as bt
|
||||||
|
|
||||||
|
# Run calculation in a separate thread to avoid blocking the event loop
|
||||||
|
def run_backtest():
|
||||||
|
try:
|
||||||
|
bt.backtest_all_recommendations_for_date(date)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Backtest calculation error for {date}: {e}")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=run_backtest)
|
||||||
|
thread.start()
|
||||||
|
return {"status": "started", "date": date, "message": "Backtest calculation started in background"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Stock Price History Endpoint ==============
|
||||||
|
|
||||||
|
@app.get("/stocks/{symbol}/prices")
|
||||||
|
async def get_stock_price_history(symbol: str, days: int = 90):
|
||||||
|
"""Get real historical closing prices for a stock from yfinance."""
|
||||||
|
try:
|
||||||
|
import yfinance as yf
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
yf_symbol = symbol if '.' in symbol else f"{symbol}.NS"
|
||||||
|
ticker = yf.Ticker(yf_symbol)
|
||||||
|
|
||||||
|
end_date = datetime.now()
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
hist = ticker.history(start=start_date.strftime('%Y-%m-%d'),
|
||||||
|
end=end_date.strftime('%Y-%m-%d'))
|
||||||
|
|
||||||
|
if hist.empty:
|
||||||
|
return {"symbol": symbol, "prices": [], "error": "No price data found"}
|
||||||
|
|
||||||
|
prices = [
|
||||||
|
{"date": idx.strftime('%Y-%m-%d'), "price": round(float(row['Close']), 2)}
|
||||||
|
for idx, row in hist.iterrows()
|
||||||
|
]
|
||||||
|
|
||||||
|
return {"symbol": symbol, "prices": prices}
|
||||||
|
except ImportError:
|
||||||
|
return {"symbol": symbol, "prices": [], "error": "yfinance not installed"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"symbol": symbol, "prices": [], "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Nifty50 Index Endpoint ==============
|
||||||
|
|
||||||
|
@app.get("/nifty50/history")
|
||||||
|
async def get_nifty50_history():
|
||||||
|
"""Get Nifty50 index closing prices for recommendation date range."""
|
||||||
|
try:
|
||||||
|
import yfinance as yf
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# Get the date range from our recommendations
|
||||||
|
dates = db.get_all_dates()
|
||||||
|
if not dates:
|
||||||
|
return {"dates": [], "prices": {}}
|
||||||
|
|
||||||
|
# Get date range with buffer for daily return calculation
|
||||||
|
start_date = (datetime.strptime(min(dates), "%Y-%m-%d") - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||||
|
end_date = (datetime.strptime(max(dates), "%Y-%m-%d") + timedelta(days=7)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Fetch ^NSEI data
|
||||||
|
nifty = yf.Ticker("^NSEI")
|
||||||
|
hist = nifty.history(start=start_date, end=end_date, interval="1d")
|
||||||
|
|
||||||
|
prices = {}
|
||||||
|
for idx, row in hist.iterrows():
|
||||||
|
date_str = idx.strftime("%Y-%m-%d")
|
||||||
|
prices[date_str] = round(float(row['Close']), 2)
|
||||||
|
|
||||||
|
return {"dates": sorted(prices.keys()), "prices": prices}
|
||||||
|
except ImportError:
|
||||||
|
return {"dates": [], "prices": {}, "error": "yfinance not installed"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"dates": [], "prices": {}, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""Rebuild daily_recommendations and trigger backtest calculations at startup."""
|
||||||
|
db.rebuild_all_daily_recommendations()
|
||||||
|
|
||||||
|
# Trigger backtest calculation for all dates in background
|
||||||
|
def startup_backtest():
|
||||||
|
import backtest_service as bt
|
||||||
|
dates = db.get_all_dates()
|
||||||
|
for date in dates:
|
||||||
|
existing = db.get_backtest_results_by_date(date)
|
||||||
|
rec = db.get_recommendation_by_date(date)
|
||||||
|
expected_count = len(rec.get('analysis', {})) if rec else 0
|
||||||
|
if len(existing) < expected_count:
|
||||||
|
print(f"[Backtest] Calculating for {date} ({len(existing)}/{expected_count} done)...")
|
||||||
|
try:
|
||||||
|
bt.backtest_all_recommendations_for_date(date)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Backtest] Error for {date}: {e}")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=startup_backtest, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { SettingsProvider } from './contexts/SettingsContext';
|
import { SettingsProvider } from './contexts/SettingsContext';
|
||||||
|
import { NotificationProvider } from './contexts/NotificationContext';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
|
import ToastContainer from './components/Toast';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import History from './pages/History';
|
import History from './pages/History';
|
||||||
import StockDetail from './pages/StockDetail';
|
import StockDetail from './pages/StockDetail';
|
||||||
|
|
@ -13,19 +15,22 @@ function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-slate-900 transition-colors">
|
<NotificationProvider>
|
||||||
<Header />
|
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-slate-900 transition-colors">
|
||||||
<main className="flex-1 max-w-7xl mx-auto w-full px-3 sm:px-4 lg:px-6 py-4">
|
<Header />
|
||||||
<Routes>
|
<main className="flex-1 max-w-7xl mx-auto w-full px-3 sm:px-4 lg:px-6 py-4">
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Routes>
|
||||||
<Route path="/history" element={<History />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/stock/:symbol" element={<StockDetail />} />
|
<Route path="/history" element={<History />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/stock/:symbol" element={<StockDetail />} />
|
||||||
</Routes>
|
<Route path="/about" element={<About />} />
|
||||||
</main>
|
</Routes>
|
||||||
<Footer />
|
</main>
|
||||||
<SettingsModal />
|
<Footer />
|
||||||
</div>
|
<SettingsModal />
|
||||||
|
<ToastContainer />
|
||||||
|
</div>
|
||||||
|
</NotificationProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
import { getAccuracyTrend } from '../data/recommendations';
|
import { getAccuracyTrend } from '../data/recommendations';
|
||||||
|
|
||||||
|
export interface AccuracyTrendPoint {
|
||||||
|
date: string;
|
||||||
|
overall: number;
|
||||||
|
buy: number;
|
||||||
|
sell: number;
|
||||||
|
hold: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface AccuracyTrendChartProps {
|
interface AccuracyTrendChartProps {
|
||||||
height?: number;
|
height?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
data?: AccuracyTrendPoint[]; // Optional prop for real data
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccuracyTrendChart({ height = 200, className = '' }: AccuracyTrendChartProps) {
|
export default function AccuracyTrendChart({ height = 200, className = '', data: propData }: AccuracyTrendChartProps) {
|
||||||
const data = getAccuracyTrend();
|
// Use provided data or fall back to mock data
|
||||||
|
const data = propData || getAccuracyTrend();
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
|
||||||
import { getCumulativeReturns } from '../data/recommendations';
|
import { getCumulativeReturns } from '../data/recommendations';
|
||||||
|
import type { CumulativeReturnPoint } from '../types';
|
||||||
|
|
||||||
interface CumulativeReturnChartProps {
|
interface CumulativeReturnChartProps {
|
||||||
height?: number;
|
height?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
data?: CumulativeReturnPoint[]; // Optional prop for real data
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CumulativeReturnChart({ height = 160, className = '' }: CumulativeReturnChartProps) {
|
export default function CumulativeReturnChart({ height = 160, className = '', data: propData }: CumulativeReturnChartProps) {
|
||||||
const data = getCumulativeReturns();
|
// Use provided data or fall back to mock data
|
||||||
|
const data = propData || getCumulativeReturns();
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine } from 'recharts';
|
||||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
import { getCumulativeReturns } from '../data/recommendations';
|
import { getCumulativeReturns } from '../data/recommendations';
|
||||||
|
import type { CumulativeReturnPoint } from '../types';
|
||||||
|
|
||||||
interface IndexComparisonChartProps {
|
export interface IndexComparisonChartProps {
|
||||||
height?: number;
|
height?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
data?: CumulativeReturnPoint[]; // Optional prop for real data
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IndexComparisonChart({ height = 220, className = '' }: IndexComparisonChartProps) {
|
export default function IndexComparisonChart({ height = 220, className = '', data: propData }: IndexComparisonChartProps) {
|
||||||
const data = getCumulativeReturns();
|
// Use provided data or fall back to mock data
|
||||||
|
const data = propData || getCumulativeReturns();
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { X, Info } from 'lucide-react';
|
||||||
|
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 (
|
||||||
|
<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">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,27 @@
|
||||||
import { X, Activity } from 'lucide-react';
|
import { X, Activity } from 'lucide-react';
|
||||||
import { getOverallReturnBreakdown } from '../data/recommendations';
|
import { getOverallReturnBreakdown } from '../data/recommendations';
|
||||||
import CumulativeReturnChart from './CumulativeReturnChart';
|
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 {
|
interface OverallReturnModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
breakdown?: OverallReturnBreakdown; // Optional prop for real data
|
||||||
|
cumulativeData?: CumulativeReturnPoint[]; // Optional prop for chart data
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OverallReturnModal({ isOpen, onClose }: OverallReturnModalProps) {
|
export default function OverallReturnModal({ isOpen, onClose, breakdown: propBreakdown, cumulativeData }: OverallReturnModalProps) {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const breakdown = getOverallReturnBreakdown();
|
// Use provided breakdown or fall back to mock data
|
||||||
|
const breakdown = propBreakdown || getOverallReturnBreakdown();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
|
@ -55,7 +66,7 @@ export default function OverallReturnModal({ isOpen, onClose }: OverallReturnMod
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Portfolio Growth</h3>
|
<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">
|
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
|
||||||
<CumulativeReturnChart height={140} />
|
<CumulativeReturnChart height={140} data={cumulativeData} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,271 @@
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend, BarChart, Bar, Cell, LabelList } from 'recharts';
|
||||||
import { Calculator, ChevronDown, ChevronUp, IndianRupee } from 'lucide-react';
|
import { Calculator, ChevronDown, ChevronUp, IndianRupee, Settings2, BarChart3, Info, TrendingUp, TrendingDown, ArrowRightLeft, Wallet, PiggyBank, Receipt, HelpCircle, AlertCircle } from 'lucide-react';
|
||||||
import { getOverallReturnBreakdown } from '../data/recommendations';
|
import { sampleRecommendations, getNifty50IndexHistory, getBacktestResult } from '../data/recommendations';
|
||||||
|
import { calculateBrokerage, formatINR, type BrokerageBreakdown } from '../utils/brokerageCalculator';
|
||||||
|
import InfoModal, { InfoButton } from './InfoModal';
|
||||||
|
import type { Decision, DailyRecommendation } from '../types';
|
||||||
|
|
||||||
interface PortfolioSimulatorProps {
|
interface PortfolioSimulatorProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
recommendations?: DailyRecommendation[];
|
||||||
|
isUsingMockData?: boolean;
|
||||||
|
nifty50Prices?: Record<string, number>;
|
||||||
|
allBacktestData?: Record<string, Record<string, number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PortfolioSimulator({ className = '' }: PortfolioSimulatorProps) {
|
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: typeof sampleRecommendations,
|
||||||
|
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 niftyHistory = hasRealNifty ? null : getNifty50IndexHistory();
|
||||||
|
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 niftyStartValue = niftyHistory?.[0]?.value || 21500;
|
||||||
|
|
||||||
|
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 backtest = getBacktestResult(symbol);
|
||||||
|
const currentPrice = backtest?.current_price || 1000;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} else if (niftyHistory) {
|
||||||
|
const niftyPoint = niftyHistory.find(n => n.date === rec.date);
|
||||||
|
if (niftyPoint) {
|
||||||
|
niftyValue = startingAmount * (niftyPoint.value / niftyStartValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = sampleRecommendations,
|
||||||
|
isUsingMockData = true, // Default to true since this uses simulated returns
|
||||||
|
nifty50Prices,
|
||||||
|
allBacktestData,
|
||||||
|
}: PortfolioSimulatorProps) {
|
||||||
const [startingAmount, setStartingAmount] = useState(100000);
|
const [startingAmount, setStartingAmount] = useState(100000);
|
||||||
const [showBreakdown, setShowBreakdown] = useState(false);
|
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);
|
||||||
|
|
||||||
const breakdown = useMemo(() => getOverallReturnBreakdown(), []);
|
// 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);
|
||||||
|
|
||||||
// Calculate portfolio values over time
|
const { portfolioData, stats, openPositions } = useMemo(() => {
|
||||||
const portfolioData = useMemo(() => {
|
return calculateSmartTrades(
|
||||||
let value = startingAmount;
|
recommendations,
|
||||||
return breakdown.dailyReturns.map(day => {
|
investmentMode,
|
||||||
value = value * day.multiplier;
|
startingAmount,
|
||||||
return {
|
nifty50Prices,
|
||||||
date: new Date(day.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }),
|
allBacktestData
|
||||||
value: Math.round(value),
|
);
|
||||||
return: day.return,
|
}, [recommendations, investmentMode, startingAmount, nifty50Prices, allBacktestData]);
|
||||||
cumulative: day.cumulative,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [breakdown.dailyReturns, startingAmount]);
|
|
||||||
|
|
||||||
const currentValue = portfolioData.length > 0
|
const lastDataPoint = portfolioData[portfolioData.length - 1];
|
||||||
? portfolioData[portfolioData.length - 1].value
|
const currentValue = lastDataPoint?.value ?? startingAmount;
|
||||||
: startingAmount;
|
const niftyValue = lastDataPoint?.niftyValue ?? startingAmount;
|
||||||
const totalReturn = ((currentValue - startingAmount) / startingAmount) * 100;
|
|
||||||
const profitLoss = currentValue - 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 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 handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = parseInt(e.target.value.replace(/,/g, ''), 10);
|
const value = parseInt(e.target.value.replace(/,/g, ''), 10);
|
||||||
if (!isNaN(value) && value >= 0) {
|
if (!isNaN(value) && value >= 0) {
|
||||||
|
|
@ -41,21 +273,73 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const openPositionsCount = Object.keys(openPositions).length;
|
||||||
return new Intl.NumberFormat('en-IN', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'INR',
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`card p-4 ${className}`}>
|
<div className={`card p-4 ${className}`}>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<Calculator className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="font-semibold text-gray-900 dark:text-gray-100">Portfolio Simulator</h2>
|
<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>
|
</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 */}
|
{/* Input Section */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
|
@ -81,7 +365,7 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
||||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-slate-600'
|
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-slate-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatCurrency(amount)}
|
{formatINR(amount, 0)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,24 +373,158 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
||||||
|
|
||||||
{/* Results Section */}
|
{/* Results Section */}
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
|
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 relative">
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Current Value</div>
|
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
<div className={`text-xl font-bold ${isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
<span>Final Portfolio Value</span>
|
||||||
{formatCurrency(currentValue)}
|
<InfoButton onClick={() => setActiveModal('portfolioValue')} />
|
||||||
|
</div>
|
||||||
|
<div className={`text-xl font-bold ${getValueColorClass(profitLoss)}`}>
|
||||||
|
{formatINR(finalValue, 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
|
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Profit/Loss</div>
|
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
<div className={`text-xl font-bold ${isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
<span>Net Profit/Loss</span>
|
||||||
{isPositive ? '+' : ''}{formatCurrency(profitLoss)}
|
<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>
|
<span className="text-sm ml-1">({isPositive ? '+' : ''}{totalReturn.toFixed(1)}%)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart */}
|
{/* 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 && (
|
{portfolioData.length > 0 && (
|
||||||
<div className="h-40 mb-4">
|
<div className="h-48 mb-4">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={portfolioData} margin={{ top: 5, right: 10, bottom: 5, left: 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" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
|
||||||
|
|
@ -117,9 +535,10 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 10 }}
|
||||||
tickFormatter={(v) => formatCurrency(v).replace('₹', '')}
|
tickFormatter={(v) => formatINR(v, 0).replace('₹', '')}
|
||||||
className="text-gray-500 dark:text-gray-400"
|
className="text-gray-500 dark:text-gray-400"
|
||||||
width={60}
|
width={60}
|
||||||
|
domain={yAxisDomain}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
|
|
@ -128,7 +547,14 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
}}
|
}}
|
||||||
formatter={(value) => [formatCurrency(value as number), 'Value']}
|
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
|
<ReferenceLine
|
||||||
y={startingAmount}
|
y={startingAmount}
|
||||||
|
|
@ -139,15 +565,106 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
|
name="value"
|
||||||
stroke={isPositive ? '#22c55e' : '#ef4444'}
|
stroke={isPositive ? '#22c55e' : '#ef4444'}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ fill: isPositive ? '#22c55e' : '#ef4444', r: 3 }}
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="niftyValue"
|
||||||
|
name="niftyValue"
|
||||||
|
stroke="#6366f1"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</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%">
|
||||||
|
<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) */}
|
{/* Daily Breakdown (Collapsible) */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowBreakdown(!showBreakdown)}
|
onClick={() => setShowBreakdown(!showBreakdown)}
|
||||||
|
|
@ -164,20 +681,22 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
||||||
<tr>
|
<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-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">Return</th>
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Value</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||||
{portfolioData.map((day, idx) => (
|
{portfolioData.map((day, idx) => (
|
||||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
<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-gray-700 dark:text-gray-300">{day.date}</td>
|
||||||
<td className={`px-3 py-2 text-right font-medium ${
|
<td className={`px-3 py-2 text-right font-medium ${getValueColorClass(day.return)}`}>
|
||||||
day.return >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
|
||||||
}`}>
|
|
||||||
{day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}%
|
{day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}%
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300">
|
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300">
|
||||||
{formatCurrency(day.value)}
|
{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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -186,9 +705,151 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Demo Data Notice */}
|
||||||
|
{isUsingMockData && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mt-3">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||||
|
<span className="text-[10px] text-amber-700 dark:text-amber-300">
|
||||||
|
Simulation uses demo data. Results are illustrative only.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-3 text-center">
|
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-3 text-center">
|
||||||
Simulated returns based on AI recommendation performance. Past performance does not guarantee future results.
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the type for use in other components
|
||||||
|
export { type InvestmentMode as PortfolioInvestmentMode };
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,18 @@ import { useState } from 'react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { getReturnDistribution } from '../data/recommendations';
|
import { getReturnDistribution } from '../data/recommendations';
|
||||||
|
import type { ReturnBucket } from '../types';
|
||||||
|
|
||||||
interface ReturnDistributionChartProps {
|
export interface ReturnDistributionChartProps {
|
||||||
height?: number;
|
height?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
data?: ReturnBucket[]; // Optional prop for real data
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReturnDistributionChart({ height = 200, className = '' }: ReturnDistributionChartProps) {
|
export default function ReturnDistributionChart({ height = 200, className = '', data: propData }: ReturnDistributionChartProps) {
|
||||||
const [selectedBucket, setSelectedBucket] = useState<{ range: string; stocks: string[] } | null>(null);
|
const [selectedBucket, setSelectedBucket] = useState<{ range: string; stocks: string[] } | null>(null);
|
||||||
const data = getReturnDistribution();
|
// Use provided data or fall back to mock data
|
||||||
|
const data = propData || getReturnDistribution();
|
||||||
|
|
||||||
if (data.every(d => d.count === 0)) {
|
if (data.every(d => d.count === 0)) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,50 @@
|
||||||
import { HelpCircle, TrendingUp, TrendingDown, Activity, Target } from 'lucide-react';
|
import { TrendingUp, TrendingDown, Activity, Target } from 'lucide-react';
|
||||||
import { calculateRiskMetrics } from '../data/recommendations';
|
import { calculateRiskMetrics } from '../data/recommendations';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import InfoModal, { InfoButton } from './InfoModal';
|
||||||
|
import type { RiskMetrics } from '../types';
|
||||||
|
|
||||||
interface RiskMetricsCardProps {
|
export interface RiskMetricsCardProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
metrics?: RiskMetrics; // Optional prop for real data
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RiskMetricsCard({ className = '' }: RiskMetricsCardProps) {
|
type MetricModal = 'sharpe' | 'drawdown' | 'winloss' | 'winrate' | null;
|
||||||
const [showTooltip, setShowTooltip] = useState<string | null>(null);
|
|
||||||
const metrics = calculateRiskMetrics();
|
|
||||||
|
|
||||||
const tooltips: Record<string, string> = {
|
export default function RiskMetricsCard({ className = '', metrics: propMetrics }: RiskMetricsCardProps) {
|
||||||
sharpe: 'Sharpe Ratio measures risk-adjusted returns. Higher is better (>1 is good, >2 is excellent).',
|
const [activeModal, setActiveModal] = useState<MetricModal>(null);
|
||||||
drawdown: 'Maximum Drawdown shows the largest peak-to-trough decline. Lower is better.',
|
// Use provided metrics or fall back to mock data
|
||||||
winloss: 'Win/Loss Ratio compares average winning trade to average losing trade. Higher means bigger wins than losses.',
|
const metrics = propMetrics || calculateRiskMetrics();
|
||||||
winrate: 'Win Rate is the percentage of predictions that were correct.',
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColor = (metric: string, value: number) => {
|
// Color classes for metric values
|
||||||
switch (metric) {
|
const COLOR_GOOD = 'text-green-600 dark:text-green-400';
|
||||||
case 'sharpe':
|
const COLOR_NEUTRAL = 'text-amber-600 dark:text-amber-400';
|
||||||
return value >= 1 ? 'text-green-600 dark:text-green-400' : value >= 0 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400';
|
const COLOR_BAD = 'text-red-600 dark:text-red-400';
|
||||||
case 'drawdown':
|
|
||||||
return value <= 5 ? 'text-green-600 dark:text-green-400' : value <= 15 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400';
|
function getColor(metric: string, value: number): string {
|
||||||
case 'winloss':
|
// Thresholds for each metric: [good, neutral] - values below neutral are bad
|
||||||
return value >= 1.5 ? 'text-green-600 dark:text-green-400' : value >= 1 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400';
|
const thresholds: Record<string, { good: number; neutral: number; inverted?: boolean }> = {
|
||||||
case 'winrate':
|
sharpe: { good: 1, neutral: 0 },
|
||||||
return value >= 70 ? 'text-green-600 dark:text-green-400' : value >= 50 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400';
|
drawdown: { good: 5, neutral: 15, inverted: true }, // Lower is better
|
||||||
default:
|
winloss: { good: 1.5, neutral: 1 },
|
||||||
return 'text-gray-700 dark:text-gray-300';
|
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 = [
|
const cards = [
|
||||||
{
|
{
|
||||||
|
|
@ -64,38 +78,282 @@ export default function RiskMetricsCard({ className = '' }: RiskMetricsCardProps
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-2 sm:grid-cols-4 gap-3 ${className}`}>
|
<>
|
||||||
{cards.map((card) => {
|
<div className={`grid grid-cols-2 sm:grid-cols-4 gap-3 ${className}`}>
|
||||||
const Icon = card.icon;
|
{cards.map((card) => {
|
||||||
return (
|
const Icon = card.icon;
|
||||||
<div
|
return (
|
||||||
key={card.id}
|
<div
|
||||||
className="relative p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center group"
|
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}`} />
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
<span className={`text-xl font-bold ${card.color}`}>{card.value}</span>
|
<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>
|
||||||
<div className="flex items-center justify-center gap-1">
|
);
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">{card.label}</span>
|
})}
|
||||||
<button
|
</div>
|
||||||
onClick={() => setShowTooltip(showTooltip === card.id ? null : card.id)}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
{/* Sharpe Ratio Modal */}
|
||||||
>
|
<InfoModal
|
||||||
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" />
|
isOpen={activeModal === 'sharpe'}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{metrics.meanReturn !== undefined && (
|
||||||
{showTooltip === card.id && (
|
<div>
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded-lg shadow-lg z-10 w-48">
|
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Your Values:</p>
|
||||||
{tooltips[card.id]}
|
<div className="text-xs space-y-1 mb-3">
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-100" />
|
<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>
|
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,29 @@ export default function SettingsModal() {
|
||||||
<span>5 (More thorough)</span>
|
<span>5 (More thorough)</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { TrendingUp, TrendingDown, Minus, ChevronRight } from 'lucide-react';
|
import { TrendingUp, TrendingDown, Minus, ChevronRight, Clock } from 'lucide-react';
|
||||||
import type { StockAnalysis, Decision } from '../types';
|
import type { StockAnalysis, Decision } from '../types';
|
||||||
|
|
||||||
interface StockCardProps {
|
interface StockCardProps {
|
||||||
|
|
@ -29,7 +29,9 @@ export function DecisionBadge({ decision, size = 'default' }: { decision: Decisi
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { bg, text, icon: Icon } = config[decision];
|
const entry = config[decision];
|
||||||
|
if (!entry) return null;
|
||||||
|
const { bg, text, icon: Icon } = entry;
|
||||||
const sizeClasses = size === 'small'
|
const sizeClasses = size === 'small'
|
||||||
? 'px-2 py-0.5 text-xs gap-1'
|
? 'px-2 py-0.5 text-xs gap-1'
|
||||||
: 'px-2.5 py-0.5 text-xs gap-1';
|
: 'px-2.5 py-0.5 text-xs gap-1';
|
||||||
|
|
@ -75,6 +77,19 @@ export function RiskBadge({ risk }: { risk?: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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-xs px-2 py-0.5 rounded border bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Hold {label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function StockCard({ stock, showDetails = true, compact = false }: StockCardProps) {
|
export default function StockCard({ stock, showDetails = true, compact = false }: StockCardProps) {
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -116,6 +131,7 @@ export default function StockCard({ stock, showDetails = true, compact = false }
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<ConfidenceBadge confidence={stock.confidence} />
|
<ConfidenceBadge confidence={stock.confidence} />
|
||||||
<RiskBadge risk={stock.risk} />
|
<RiskBadge risk={stock.risk} />
|
||||||
|
<HoldDaysBadge holdDays={stock.hold_days} decision={stock.decision} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,412 @@
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Database, ChevronDown, ChevronUp, CheckCircle,
|
Database, ChevronDown, ChevronUp, CheckCircle,
|
||||||
XCircle, Clock, Server
|
XCircle, Clock, Server, Copy, Check, Maximize2, Minimize2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { DataSourceLog } from '../../types/pipeline';
|
import type { DataSourceLog } from '../../types/pipeline';
|
||||||
|
|
||||||
|
|
@ -19,6 +19,83 @@ const SOURCE_TYPE_COLORS: Record<string, { bg: string; text: string }> = {
|
||||||
default: { bg: 'bg-slate-100 dark:bg-slate-800', text: 'text-slate-700 dark:text-slate-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) {
|
export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [expandedSources, setExpandedSources] = useState<Set<number>>(new Set());
|
const [expandedSources, setExpandedSources] = useState<Set<number>>(new Set());
|
||||||
|
|
@ -124,6 +201,18 @@ export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelPro
|
||||||
{source.source_name}
|
{source.source_name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{formatTimestamp(source.fetch_timestamp)}
|
{formatTimestamp(source.fetch_timestamp)}
|
||||||
|
|
@ -145,32 +234,12 @@ export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelPro
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source details (expanded) */}
|
{/* Source details (expanded) — full raw data viewer */}
|
||||||
{isSourceExpanded && (
|
{isSourceExpanded && (
|
||||||
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
|
<RawDataViewer
|
||||||
{source.error_message ? (
|
data={source.data_fetched}
|
||||||
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
error={source.error_message}
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">
|
/>
|
||||||
<strong>Error:</strong> {source.error_message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : source.data_fetched ? (
|
|
||||||
<div className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
|
||||||
<p className="text-xs text-slate-500 mb-2 font-medium">
|
|
||||||
Data Summary:
|
|
||||||
</p>
|
|
||||||
<pre className="text-xs text-slate-600 dark:text-slate-400 overflow-x-auto max-h-40">
|
|
||||||
{typeof source.data_fetched === 'string'
|
|
||||||
? source.data_fetched.slice(0, 500) + (source.data_fetched.length > 500 ? '...' : '')
|
|
||||||
: JSON.stringify(source.data_fetched, null, 2).slice(0, 500)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-3 text-sm text-slate-500">
|
|
||||||
No data details available
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
interface FlowchartConnectorProps {
|
||||||
|
completed: boolean;
|
||||||
|
isPhase?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowchartConnector({ completed, isPhase = false }: FlowchartConnectorProps) {
|
||||||
|
const height = isPhase ? 32 : 20;
|
||||||
|
const color = completed ? '#22c55e' : '#475569';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex justify-center ${isPhase ? 'py-1' : 'py-0'}`}>
|
||||||
|
<svg width="20" height={height} viewBox={`0 0 20 ${height}`} className="flex-shrink-0">
|
||||||
|
<line
|
||||||
|
x1="10" y1="0" x2="10" y2={height - 6}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={isPhase ? 2.5 : 1.5}
|
||||||
|
strokeDasharray={completed ? 'none' : '3 3'}
|
||||||
|
strokeOpacity={completed ? 1 : 0.5}
|
||||||
|
/>
|
||||||
|
<polygon
|
||||||
|
points={`5,${height - 6} 10,${height} 15,${height - 6}`}
|
||||||
|
fill={color}
|
||||||
|
fillOpacity={completed ? 1 : 0.5}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
import {
|
||||||
|
TrendingUp, TrendingDown, Users, Newspaper, FileText,
|
||||||
|
Scale, Target, Zap, Shield, ShieldCheck,
|
||||||
|
Clock, Loader2, CheckCircle, AlertCircle, ChevronDown, ChevronUp
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { FlowchartNodeData, PipelineStepStatus } from '../../types/pipeline';
|
||||||
|
|
||||||
|
interface FlowchartNodeProps {
|
||||||
|
node: FlowchartNodeData;
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, React.ElementType> = {
|
||||||
|
TrendingUp, TrendingDown, Users, Newspaper, FileText,
|
||||||
|
Scale, Target, Zap, Shield, ShieldCheck,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<PipelineStepStatus, {
|
||||||
|
bg: string; border: string; text: string; badge: string; badgeText: string;
|
||||||
|
StatusIcon: React.ElementType; statusLabel: string;
|
||||||
|
}> = {
|
||||||
|
pending: {
|
||||||
|
bg: 'bg-slate-50 dark:bg-slate-800/60',
|
||||||
|
border: 'border-slate-200 dark:border-slate-700',
|
||||||
|
text: 'text-slate-400 dark:text-slate-500',
|
||||||
|
badge: 'bg-slate-100 dark:bg-slate-700',
|
||||||
|
badgeText: 'text-slate-500 dark:text-slate-400',
|
||||||
|
StatusIcon: Clock,
|
||||||
|
statusLabel: 'Pending',
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
bg: 'bg-blue-50/80 dark:bg-blue-900/20',
|
||||||
|
border: 'border-blue-300 dark:border-blue-600',
|
||||||
|
text: 'text-blue-600 dark:text-blue-400',
|
||||||
|
badge: 'bg-blue-100 dark:bg-blue-900/40',
|
||||||
|
badgeText: 'text-blue-600 dark:text-blue-400',
|
||||||
|
StatusIcon: Loader2,
|
||||||
|
statusLabel: 'Running',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
bg: 'bg-green-50/60 dark:bg-green-900/15',
|
||||||
|
border: 'border-green-300 dark:border-green-700',
|
||||||
|
text: 'text-green-600 dark:text-green-400',
|
||||||
|
badge: 'bg-green-100 dark:bg-green-900/30',
|
||||||
|
badgeText: 'text-green-600 dark:text-green-400',
|
||||||
|
StatusIcon: CheckCircle,
|
||||||
|
statusLabel: 'Completed',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: 'bg-red-50/60 dark:bg-red-900/15',
|
||||||
|
border: 'border-red-300 dark:border-red-700',
|
||||||
|
text: 'text-red-600 dark:text-red-400',
|
||||||
|
badge: 'bg-red-100 dark:bg-red-900/30',
|
||||||
|
badgeText: 'text-red-600 dark:text-red-400',
|
||||||
|
StatusIcon: AlertCircle,
|
||||||
|
statusLabel: 'Error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const NODE_COLORS: Record<string, { iconBg: string; iconText: string }> = {
|
||||||
|
blue: { iconBg: 'bg-blue-100 dark:bg-blue-900/40', iconText: 'text-blue-600 dark:text-blue-400' },
|
||||||
|
pink: { iconBg: 'bg-pink-100 dark:bg-pink-900/40', iconText: 'text-pink-600 dark:text-pink-400' },
|
||||||
|
purple: { iconBg: 'bg-purple-100 dark:bg-purple-900/40', iconText: 'text-purple-600 dark:text-purple-400' },
|
||||||
|
emerald: { iconBg: 'bg-emerald-100 dark:bg-emerald-900/40', iconText: 'text-emerald-600 dark:text-emerald-400' },
|
||||||
|
green: { iconBg: 'bg-green-100 dark:bg-green-900/40', iconText: 'text-green-600 dark:text-green-400' },
|
||||||
|
red: { iconBg: 'bg-red-100 dark:bg-red-900/40', iconText: 'text-red-600 dark:text-red-400' },
|
||||||
|
violet: { iconBg: 'bg-violet-100 dark:bg-violet-900/40', iconText: 'text-violet-600 dark:text-violet-400' },
|
||||||
|
amber: { iconBg: 'bg-amber-100 dark:bg-amber-900/40', iconText: 'text-amber-600 dark:text-amber-400' },
|
||||||
|
orange: { iconBg: 'bg-orange-100 dark:bg-orange-900/40', iconText: 'text-orange-600 dark:text-orange-400' },
|
||||||
|
sky: { iconBg: 'bg-sky-100 dark:bg-sky-900/40', iconText: 'text-sky-600 dark:text-sky-400' },
|
||||||
|
slate: { iconBg: 'bg-slate-200 dark:bg-slate-700', iconText: 'text-slate-600 dark:text-slate-400' },
|
||||||
|
indigo: { iconBg: 'bg-indigo-100 dark:bg-indigo-900/40', iconText: 'text-indigo-600 dark:text-indigo-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const secs = ms / 1000;
|
||||||
|
if (secs < 60) return `${secs.toFixed(1)}s`;
|
||||||
|
const mins = Math.floor(secs / 60);
|
||||||
|
const remSecs = Math.floor(secs % 60);
|
||||||
|
return `${mins}m ${remSecs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const day = d.getDate().toString().padStart(2, '0');
|
||||||
|
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const time = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
return `${day}/${month}/${year} ${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowchartNode({ node, isSelected, onClick }: FlowchartNodeProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const status = STATUS_CONFIG[node.status];
|
||||||
|
const colors = NODE_COLORS[node.color] || NODE_COLORS.blue;
|
||||||
|
const Icon = ICON_MAP[node.icon] || Target;
|
||||||
|
|
||||||
|
const hasPreview = !!(node.output_summary || node.agentReport?.report_content || node.debateContent);
|
||||||
|
const previewText = node.output_summary || node.agentReport?.report_content || node.debateContent || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-2 sm:gap-3 p-2.5 sm:p-3 rounded-xl border-2 transition-all text-left cursor-pointer
|
||||||
|
${status.bg} ${status.border}
|
||||||
|
${isSelected ? 'ring-2 ring-nifty-500 ring-offset-1 dark:ring-offset-slate-900 shadow-lg' : 'hover:shadow-md'}
|
||||||
|
${node.status === 'running' ? 'animate-pulse' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`p-1.5 sm:p-2 rounded-lg flex-shrink-0 ${colors.iconBg}`}>
|
||||||
|
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${colors.iconText}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name & Step # */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-medium text-xs sm:text-sm text-gray-800 dark:text-gray-200 truncate">
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] sm:text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||||
|
#{node.number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Timestamp */}
|
||||||
|
{node.completed_at && (
|
||||||
|
<div className="text-[9px] sm:text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
{formatTimestamp(node.completed_at)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.status === 'running' && node.started_at && (
|
||||||
|
<div className="text-[9px] sm:text-[10px] text-blue-500 dark:text-blue-400 mt-0.5">
|
||||||
|
Started {formatTimestamp(node.started_at)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status + Duration */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{node.duration_ms != null && node.status === 'completed' && (
|
||||||
|
<span className="text-[10px] sm:text-xs font-mono font-semibold text-gray-500 dark:text-gray-400">
|
||||||
|
{formatDuration(node.duration_ms)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center gap-1 px-1.5 sm:px-2 py-0.5 rounded-full text-[10px] sm:text-xs font-medium ${status.badge} ${status.badgeText}`}>
|
||||||
|
<status.StatusIcon className={`w-3 h-3 ${node.status === 'running' ? 'animate-spin' : ''}`} />
|
||||||
|
<span className="hidden sm:inline">{status.statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand toggle */}
|
||||||
|
{hasPreview && node.status === 'completed' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIsExpanded(!isExpanded); }}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline preview */}
|
||||||
|
{isExpanded && hasPreview && (
|
||||||
|
<div className="mt-1 mx-2 p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 text-xs text-gray-600 dark:text-gray-400 font-mono leading-relaxed max-h-32 overflow-y-auto">
|
||||||
|
{previewText.slice(0, 500)}{previewText.length > 500 ? '...' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { CheckCircle, Loader2, Clock } from 'lucide-react';
|
||||||
|
import type { FlowchartPhase } from '../../types/pipeline';
|
||||||
|
import { PHASE_META } from '../../types/pipeline';
|
||||||
|
|
||||||
|
interface FlowchartPhaseGroupProps {
|
||||||
|
phase: FlowchartPhase;
|
||||||
|
totalSteps: number;
|
||||||
|
completedSteps: number;
|
||||||
|
isActive: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowchartPhaseGroup({ phase, totalSteps, completedSteps, isActive, children }: FlowchartPhaseGroupProps) {
|
||||||
|
const meta = PHASE_META[phase];
|
||||||
|
const isComplete = completedSteps === totalSteps && totalSteps > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border-l-4 ${meta.borderColor} ${meta.bgColor} border border-slate-200/60 dark:border-slate-700/60 overflow-hidden`}>
|
||||||
|
{/* Phase header */}
|
||||||
|
<div className="flex items-center justify-between px-3 sm:px-4 py-2 bg-white/40 dark:bg-slate-800/40">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] sm:text-xs font-bold px-1.5 py-0.5 rounded ${meta.bgColor} ${meta.textColor}`}>
|
||||||
|
Phase {meta.number}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs sm:text-sm font-semibold ${meta.textColor}`}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{completedSteps}/{totalSteps}
|
||||||
|
</span>
|
||||||
|
{isComplete ? (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
|
||||||
|
) : isActive ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Clock className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase nodes */}
|
||||||
|
<div className="px-3 sm:px-4 py-2 space-y-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Clock, Timer, CheckCircle, AlertCircle, FileText, MessageSquare, ChevronDown, ChevronRight, Terminal, Bot, User, Wrench } from 'lucide-react';
|
||||||
|
import type { FlowchartNodeData, FullPipelineData, StepDetails } from '../../types/pipeline';
|
||||||
|
|
||||||
|
interface NodeDetailDrawerProps {
|
||||||
|
node: FlowchartNodeData;
|
||||||
|
pipelineData: FullPipelineData | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const day = d.getDate().toString().padStart(2, '0');
|
||||||
|
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const time = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
return `${day}/${month}/${year} ${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const secs = ms / 1000;
|
||||||
|
if (secs < 60) return `${secs.toFixed(1)}s`;
|
||||||
|
const mins = Math.floor(secs / 60);
|
||||||
|
const remSecs = Math.floor(secs % 60);
|
||||||
|
return `${mins}m ${remSecs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackContent(node: FlowchartNodeData, data: FullPipelineData | null): string {
|
||||||
|
if (node.agentReport?.report_content) return node.agentReport.report_content;
|
||||||
|
if (node.debateContent) return node.debateContent;
|
||||||
|
if (node.output_summary) return node.output_summary;
|
||||||
|
if (!data) return '';
|
||||||
|
|
||||||
|
if (node.debateType === 'investment' && data.debates?.investment) {
|
||||||
|
const d = data.debates.investment;
|
||||||
|
if (node.debateRole === 'bull') return d.bull_arguments || '';
|
||||||
|
if (node.debateRole === 'bear') return d.bear_arguments || '';
|
||||||
|
if (node.debateRole === 'judge') return d.judge_decision || '';
|
||||||
|
}
|
||||||
|
if (node.debateType === 'risk' && data.debates?.risk) {
|
||||||
|
const d = data.debates.risk;
|
||||||
|
if (node.debateRole === 'risky') return d.risky_arguments || '';
|
||||||
|
if (node.debateRole === 'safe') return d.safe_arguments || '';
|
||||||
|
if (node.debateRole === 'neutral') return d.neutral_arguments || '';
|
||||||
|
if (node.debateRole === 'judge') return d.judge_decision || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapsible section component */
|
||||||
|
function Section({ title, icon: Icon, iconColor, defaultOpen, children, badge }: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
iconColor: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
badge?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-800/60 transition-colors text-left"
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />}
|
||||||
|
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
|
||||||
|
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300 flex-1">{title}</span>
|
||||||
|
{badge && (
|
||||||
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-slate-200 dark:bg-slate-700 text-gray-500 dark:text-gray-400">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-slate-200 dark:border-slate-700">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Code block with monospace text */
|
||||||
|
function CodeBlock({ content, maxHeight = 'max-h-64' }: { content: string; maxHeight?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`${maxHeight} overflow-y-auto p-3 bg-slate-900 dark:bg-black/40`}>
|
||||||
|
<pre className="text-xs text-green-300 dark:text-green-400 font-mono whitespace-pre-wrap leading-relaxed">
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeDetailDrawer({ node, pipelineData, onClose }: NodeDetailDrawerProps) {
|
||||||
|
const details: StepDetails | undefined = node.step_details;
|
||||||
|
const fallbackContent = getFallbackContent(node, pipelineData);
|
||||||
|
|
||||||
|
// Determine if we have structured data or just fallback
|
||||||
|
const hasStructuredData = details && (details.system_prompt || details.user_prompt || details.response);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-lg overflow-hidden animate-in slide-in-from-top-2">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 bg-slate-50 dark:bg-slate-900/50 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="font-semibold text-sm text-gray-800 dark:text-gray-200">
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400">#{node.number}</span>
|
||||||
|
{node.status === 'completed' && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 font-medium">
|
||||||
|
completed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timing info bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 sm:gap-5 px-4 py-2 bg-slate-50/50 dark:bg-slate-900/30 border-b border-slate-100 dark:border-slate-800 text-xs">
|
||||||
|
{node.started_at && (
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>Started: {formatTimestamp(node.started_at)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.completed_at && (
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-500" />
|
||||||
|
<span>Completed: {formatTimestamp(node.completed_at)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.duration_ms != null && (
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
|
||||||
|
<Timer className="w-3 h-3" />
|
||||||
|
<span className="font-mono font-semibold">{formatDuration(node.duration_ms)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.status === 'error' && (
|
||||||
|
<div className="flex items-center gap-1.5 text-red-500">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
<span>Failed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content sections */}
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{hasStructuredData ? (
|
||||||
|
<>
|
||||||
|
{/* System Prompt */}
|
||||||
|
{details.system_prompt && (
|
||||||
|
<Section
|
||||||
|
title="System Prompt"
|
||||||
|
icon={Bot}
|
||||||
|
iconColor="text-violet-500"
|
||||||
|
badge={`${details.system_prompt.length} chars`}
|
||||||
|
>
|
||||||
|
<CodeBlock content={details.system_prompt} maxHeight="max-h-48" />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Prompt / Input */}
|
||||||
|
{details.user_prompt && (
|
||||||
|
<Section
|
||||||
|
title="User Prompt / Input"
|
||||||
|
icon={User}
|
||||||
|
iconColor="text-blue-500"
|
||||||
|
badge={`${details.user_prompt.length} chars`}
|
||||||
|
>
|
||||||
|
<CodeBlock content={details.user_prompt} maxHeight="max-h-48" />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tool Calls */}
|
||||||
|
{details.tool_calls && details.tool_calls.length > 0 && (
|
||||||
|
<Section
|
||||||
|
title="Tool Calls"
|
||||||
|
icon={Wrench}
|
||||||
|
iconColor="text-amber-500"
|
||||||
|
badge={`${details.tool_calls.length} calls`}
|
||||||
|
>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{details.tool_calls.map((tc, i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="flex items-start gap-2 text-xs">
|
||||||
|
<span className="font-mono font-semibold text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
||||||
|
{tc.name}()
|
||||||
|
</span>
|
||||||
|
{tc.args && (
|
||||||
|
<span className="font-mono text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{tc.args}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tc.result_preview && (
|
||||||
|
<div className="ml-4 p-2 bg-slate-900 dark:bg-black/40 rounded text-[11px] font-mono text-green-300 dark:text-green-400 max-h-32 overflow-auto whitespace-pre-wrap">
|
||||||
|
{tc.result_preview}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LLM Response */}
|
||||||
|
{details.response && (
|
||||||
|
<Section
|
||||||
|
title="LLM Response"
|
||||||
|
icon={MessageSquare}
|
||||||
|
iconColor="text-green-500"
|
||||||
|
defaultOpen={true}
|
||||||
|
badge={`${details.response.length} chars`}
|
||||||
|
>
|
||||||
|
<CodeBlock content={details.response} maxHeight="max-h-80" />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : fallbackContent ? (
|
||||||
|
/* Fallback: show the old-style content */
|
||||||
|
<>
|
||||||
|
<Section
|
||||||
|
title={node.agentType ? 'Agent Report' : node.debateRole === 'judge' ? 'Decision' : node.debateType ? 'Debate Argument' : 'Output'}
|
||||||
|
icon={node.agentType ? FileText : node.debateType ? MessageSquare : FileText}
|
||||||
|
iconColor="text-gray-500"
|
||||||
|
defaultOpen={true}
|
||||||
|
badge={`${fallbackContent.length} chars`}
|
||||||
|
>
|
||||||
|
<CodeBlock content={fallbackContent} maxHeight="max-h-80" />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
) : node.status === 'pending' ? (
|
||||||
|
<div className="text-center py-6 text-gray-400 dark:text-gray-500">
|
||||||
|
<Clock className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">This step hasn't run yet</p>
|
||||||
|
<p className="text-xs mt-1">Run an analysis to see results here</p>
|
||||||
|
</div>
|
||||||
|
) : node.status === 'running' ? (
|
||||||
|
<div className="text-center py-6 text-blue-500 dark:text-blue-400">
|
||||||
|
<div className="w-8 h-8 mx-auto mb-2 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<p className="text-sm">Processing...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 text-gray-400 dark:text-gray-500">
|
||||||
|
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">No output data available</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Layers, Loader2 } from 'lucide-react';
|
||||||
|
import type { FullPipelineData, FlowchartPhase, FlowchartNodeData } from '../../types/pipeline';
|
||||||
|
import { mapPipelineToFlowchart } from '../../types/pipeline';
|
||||||
|
import { FlowchartNode } from './FlowchartNode';
|
||||||
|
import { FlowchartConnector } from './FlowchartConnector';
|
||||||
|
import { FlowchartPhaseGroup } from './FlowchartPhaseGroup';
|
||||||
|
import { NodeDetailDrawer } from './NodeDetailDrawer';
|
||||||
|
|
||||||
|
interface PipelineFlowchartProps {
|
||||||
|
pipelineData: FullPipelineData | null;
|
||||||
|
isAnalyzing: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group flowchart steps by phase
|
||||||
|
const PHASE_ORDER: FlowchartPhase[] = ['data_analysis', 'investment_debate', 'trading', 'risk_debate'];
|
||||||
|
|
||||||
|
function groupByPhase(nodes: FlowchartNodeData[]): Record<FlowchartPhase, FlowchartNodeData[]> {
|
||||||
|
const groups: Record<FlowchartPhase, FlowchartNodeData[]> = {
|
||||||
|
data_analysis: [],
|
||||||
|
investment_debate: [],
|
||||||
|
trading: [],
|
||||||
|
risk_debate: [],
|
||||||
|
};
|
||||||
|
for (const node of nodes) {
|
||||||
|
groups[node.phase].push(node);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PipelineFlowchart({ pipelineData, isAnalyzing, isLoading }: PipelineFlowchartProps) {
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const nodes = useMemo(() => mapPipelineToFlowchart(pipelineData), [pipelineData]);
|
||||||
|
const groups = useMemo(() => groupByPhase(nodes), [nodes]);
|
||||||
|
|
||||||
|
const completedCount = nodes.filter(n => n.status === 'completed').length;
|
||||||
|
const totalSteps = nodes.length;
|
||||||
|
const progress = totalSteps > 0 ? Math.round((completedCount / totalSteps) * 100) : 0;
|
||||||
|
|
||||||
|
const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) : null;
|
||||||
|
|
||||||
|
const handleNodeClick = (nodeId: string) => {
|
||||||
|
setSelectedNodeId(prev => prev === nodeId ? null : nodeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="card p-12 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin mb-3" />
|
||||||
|
<p className="text-sm">Loading pipeline data...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header with progress */}
|
||||||
|
<div className="card p-3 sm:p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 sm:w-5 sm:h-5 text-nifty-600 dark:text-nifty-400" />
|
||||||
|
<h2 className="font-semibold text-sm sm:text-base text-gray-900 dark:text-gray-100">
|
||||||
|
Analysis Pipeline
|
||||||
|
</h2>
|
||||||
|
{isAnalyzing && (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 text-[10px] sm:text-xs font-medium rounded-full">
|
||||||
|
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{completedCount}/{totalSteps} steps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-700 ease-out ${
|
||||||
|
progress === 100
|
||||||
|
? 'bg-gradient-to-r from-green-500 to-emerald-500'
|
||||||
|
: 'bg-gradient-to-r from-nifty-500 to-blue-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-bold min-w-[36px] text-right ${
|
||||||
|
progress === 100 ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flowchart */}
|
||||||
|
<div className="space-y-0">
|
||||||
|
{PHASE_ORDER.map((phase, phaseIndex) => {
|
||||||
|
const phaseNodes = groups[phase];
|
||||||
|
const phaseCompleted = phaseNodes.filter(n => n.status === 'completed').length;
|
||||||
|
const phaseActive = phaseNodes.some(n => n.status === 'running');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={phase}>
|
||||||
|
{/* Phase connector (between phases) */}
|
||||||
|
{phaseIndex > 0 && (
|
||||||
|
<FlowchartConnector
|
||||||
|
completed={groups[PHASE_ORDER[phaseIndex - 1]].every(n => n.status === 'completed')}
|
||||||
|
isPhase
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlowchartPhaseGroup
|
||||||
|
phase={phase}
|
||||||
|
totalSteps={phaseNodes.length}
|
||||||
|
completedSteps={phaseCompleted}
|
||||||
|
isActive={phaseActive}
|
||||||
|
>
|
||||||
|
{phaseNodes.map((node, nodeIndex) => (
|
||||||
|
<div key={node.id}>
|
||||||
|
{/* Node connector (within phase) */}
|
||||||
|
{nodeIndex > 0 && (
|
||||||
|
<FlowchartConnector
|
||||||
|
completed={phaseNodes[nodeIndex - 1].status === 'completed'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FlowchartNode
|
||||||
|
node={node}
|
||||||
|
isSelected={selectedNodeId === node.id}
|
||||||
|
onClick={() => handleNodeClick(node.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</FlowchartPhaseGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail drawer */}
|
||||||
|
{selectedNode && (
|
||||||
|
<NodeDetailDrawer
|
||||||
|
node={selectedNode}
|
||||||
|
pipelineData={pipelineData}
|
||||||
|
onClose={() => setSelectedNodeId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -101,26 +101,44 @@ export function PipelineOverview({ steps, onStepClick, compact = false }: Pipeli
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{/* Progress bar */}
|
{/* Compact Progress Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1 h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div className="flex -space-x-0.5">
|
||||||
className="h-full bg-gradient-to-r from-blue-500 to-green-500 transition-all duration-500"
|
{displaySteps.map((step) => (
|
||||||
style={{ width: `${progress}%` }}
|
<div
|
||||||
/>
|
key={step.step_number}
|
||||||
|
className={`w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-800 ${
|
||||||
|
step.status === 'completed' ? 'bg-green-500' :
|
||||||
|
step.status === 'running' ? 'bg-blue-500' :
|
||||||
|
step.status === 'error' ? 'bg-red-500' :
|
||||||
|
'bg-slate-300 dark:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
title={`${STEP_LABELS[step.step_name]}: ${step.status}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||||
|
{completedCount}/{totalSteps} steps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-20 h-1.5 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-nifty-500 to-green-500 transition-all duration-500"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-slate-600 dark:text-slate-400">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
|
||||||
{completedCount}/{totalSteps}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pipeline steps */}
|
{/* Compact Pipeline Steps Grid */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-9 gap-1.5">
|
||||||
{displaySteps.map((step) => {
|
{displaySteps.map((step) => {
|
||||||
const StepIcon = STEP_ICONS[step.step_name] || Database;
|
const StepIcon = STEP_ICONS[step.step_name] || Database;
|
||||||
const styles = STATUS_STYLES[step.status];
|
const styles = STATUS_STYLES[step.status];
|
||||||
const StatusIcon = styles.icon;
|
|
||||||
const label = STEP_LABELS[step.step_name] || step.step_name;
|
const label = STEP_LABELS[step.step_name] || step.step_name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -128,24 +146,22 @@ export function PipelineOverview({ steps, onStepClick, compact = false }: Pipeli
|
||||||
key={step.step_number}
|
key={step.step_number}
|
||||||
onClick={() => onStepClick?.(step)}
|
onClick={() => onStepClick?.(step)}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-2 px-3 py-2 rounded-lg border-2 transition-all
|
relative flex flex-col items-center gap-1 p-2 rounded-lg border transition-all
|
||||||
${styles.bg} ${styles.border} ${styles.text}
|
${styles.bg} ${styles.border} ${styles.text}
|
||||||
hover:scale-105 hover:shadow-md
|
hover:shadow-sm
|
||||||
${onStepClick ? 'cursor-pointer' : 'cursor-default'}
|
${onStepClick ? 'cursor-pointer' : 'cursor-default'}
|
||||||
`}
|
`}
|
||||||
|
title={`${label}: ${step.status}${step.duration_ms ? ` (${(step.duration_ms / 1000).toFixed(1)}s)` : ''}`}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<StepIcon className="w-4 h-4" />
|
<StepIcon className="w-4 h-4" />
|
||||||
{StatusIcon && step.status === 'running' && (
|
{step.status === 'running' && (
|
||||||
<Loader2 className="w-3 h-3 absolute -top-1 -right-1 animate-spin" />
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium">{label}</span>
|
<span className="text-[9px] font-medium leading-tight text-center line-clamp-1">
|
||||||
{step.duration_ms && (
|
{label.split(' ')[0]}
|
||||||
<span className="text-xs opacity-60">
|
</span>
|
||||||
{(step.duration_ms / 1000).toFixed(1)}s
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export { PipelineOverview } from './PipelineOverview';
|
export { PipelineOverview } from './PipelineOverview';
|
||||||
|
export { PipelineFlowchart } from './PipelineFlowchart';
|
||||||
export { AgentReportCard } from './AgentReportCard';
|
export { AgentReportCard } from './AgentReportCard';
|
||||||
export { DebateViewer } from './DebateViewer';
|
export { DebateViewer } from './DebateViewer';
|
||||||
export { RiskDebateViewer } from './RiskDebateViewer';
|
export { RiskDebateViewer } from './RiskDebateViewer';
|
||||||
|
|
|
||||||