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,
|
||||
duration_ms INTEGER,
|
||||
output_summary TEXT,
|
||||
step_details TEXT,
|
||||
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)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS data_source_logs (
|
||||
|
|
@ -117,6 +124,8 @@ def init_db():
|
|||
symbol TEXT NOT NULL,
|
||||
source_type TEXT,
|
||||
source_name TEXT,
|
||||
method TEXT,
|
||||
args TEXT,
|
||||
data_fetched TEXT,
|
||||
fetch_timestamp TEXT,
|
||||
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
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_reports_date_symbol ON agent_reports(date, symbol)
|
||||
|
|
@ -137,6 +186,9 @@ def init_db():
|
|||
cursor.execute("""
|
||||
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.close()
|
||||
|
|
@ -168,8 +220,8 @@ def save_recommendation(date: str, analysis_data: dict, summary: dict,
|
|||
for symbol, analysis in analysis_data.items():
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO stock_analysis
|
||||
(date, symbol, company_name, decision, confidence, risk, raw_analysis)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
(date, symbol, company_name, decision, confidence, risk, raw_analysis, hold_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
date,
|
||||
symbol,
|
||||
|
|
@ -177,7 +229,8 @@ def save_recommendation(date: str, analysis_data: dict, summary: dict,
|
|||
analysis.get('decision'),
|
||||
analysis.get('confidence'),
|
||||
analysis.get('risk'),
|
||||
analysis.get('raw_analysis', '')
|
||||
analysis.get('raw_analysis', ''),
|
||||
analysis.get('hold_days')
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
|
@ -185,6 +238,52 @@ def save_recommendation(date: str, analysis_data: dict, summary: dict,
|
|||
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]:
|
||||
"""Get recommendation for a specific date."""
|
||||
conn = get_connection()
|
||||
|
|
@ -197,37 +296,60 @@ def get_recommendation_by_date(date: str) -> Optional[dict]:
|
|||
""", (date,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
# Get stock analysis for this date
|
||||
cursor.execute("""
|
||||
SELECT * FROM stock_analysis WHERE date = ?
|
||||
""", (date,))
|
||||
analysis_rows = cursor.fetchall()
|
||||
|
||||
# If no daily_recommendations AND no stock_analysis, return None
|
||||
if not row and not analysis_rows:
|
||||
return None
|
||||
|
||||
analysis = {}
|
||||
for a in analysis_rows:
|
||||
decision = (a['decision'] or '').strip().upper()
|
||||
if decision not in ('BUY', 'SELL', 'HOLD'):
|
||||
decision = 'HOLD'
|
||||
analysis[a['symbol']] = {
|
||||
'symbol': a['symbol'],
|
||||
'company_name': a['company_name'],
|
||||
'decision': a['decision'],
|
||||
'confidence': a['confidence'],
|
||||
'risk': a['risk'],
|
||||
'raw_analysis': a['raw_analysis']
|
||||
'decision': decision,
|
||||
'confidence': a['confidence'] or 'MEDIUM',
|
||||
'risk': a['risk'] or 'MEDIUM',
|
||||
'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 {
|
||||
'date': row['date'],
|
||||
'date': date,
|
||||
'analysis': analysis,
|
||||
'summary': {
|
||||
'total': row['summary_total'],
|
||||
'buy': row['summary_buy'],
|
||||
'sell': row['summary_sell'],
|
||||
'hold': row['summary_hold']
|
||||
'total': len(analysis),
|
||||
'buy': buy_count,
|
||||
'sell': sell_count,
|
||||
'hold': hold_count
|
||||
},
|
||||
'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 []
|
||||
'top_picks': [],
|
||||
'stocks_to_avoid': []
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
@ -253,13 +375,17 @@ def get_latest_recommendation() -> Optional[dict]:
|
|||
|
||||
|
||||
def get_all_dates() -> list:
|
||||
"""Get all available dates."""
|
||||
"""Get all available dates (union of daily_recommendations and stock_analysis)."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
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()]
|
||||
finally:
|
||||
|
|
@ -273,21 +399,26 @@ def get_stock_history(symbol: str) -> list:
|
|||
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT date, decision, confidence, risk
|
||||
SELECT date, decision, confidence, risk, hold_days
|
||||
FROM stock_analysis
|
||||
WHERE symbol = ?
|
||||
ORDER BY date DESC
|
||||
""", (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'],
|
||||
'decision': row['decision'],
|
||||
'confidence': row['confidence'],
|
||||
'risk': row['risk']
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
'decision': decision,
|
||||
'confidence': row['confidence'] or 'MEDIUM',
|
||||
'risk': row['risk'] or 'MEDIUM',
|
||||
'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None
|
||||
})
|
||||
return results
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -467,11 +598,14 @@ def save_pipeline_steps_bulk(date: str, symbol: str, steps: list):
|
|||
|
||||
try:
|
||||
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("""
|
||||
INSERT OR REPLACE INTO pipeline_steps
|
||||
(date, symbol, step_number, step_name, status,
|
||||
started_at, completed_at, duration_ms, output_summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
started_at, completed_at, duration_ms, output_summary, step_details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
date, symbol,
|
||||
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('completed_at'),
|
||||
step.get('duration_ms'),
|
||||
step.get('output_summary')
|
||||
step.get('output_summary'),
|
||||
step_details
|
||||
))
|
||||
conn.commit()
|
||||
finally:
|
||||
|
|
@ -499,18 +634,26 @@ def get_pipeline_steps(date: str, symbol: str) -> list:
|
|||
ORDER BY step_number
|
||||
""", (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_name': row['step_name'],
|
||||
'status': row['status'],
|
||||
'started_at': row['started_at'],
|
||||
'completed_at': row['completed_at'],
|
||||
'duration_ms': row['duration_ms'],
|
||||
'output_summary': row['output_summary']
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
'output_summary': row['output_summary'],
|
||||
'step_details': step_details,
|
||||
})
|
||||
return results
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -550,13 +693,15 @@ def save_data_source_logs_bulk(date: str, symbol: str, logs: list):
|
|||
for log in logs:
|
||||
cursor.execute("""
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
date, symbol,
|
||||
log.get('source_type'),
|
||||
log.get('source_name'),
|
||||
log.get('method'),
|
||||
log.get('args'),
|
||||
json.dumps(log.get('data_fetched')) if log.get('data_fetched') else None,
|
||||
log.get('fetch_timestamp') or datetime.now().isoformat(),
|
||||
1 if log.get('success', True) else 0,
|
||||
|
|
@ -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:
|
||||
"""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()
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
|
@ -579,10 +725,12 @@ def get_data_source_logs(date: str, symbol: str) -> list:
|
|||
ORDER BY fetch_timestamp
|
||||
""", (date, symbol))
|
||||
|
||||
return [
|
||||
logs = [
|
||||
{
|
||||
'source_type': row['source_type'],
|
||||
'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,
|
||||
'fetch_timestamp': row['fetch_timestamp'],
|
||||
'success': bool(row['success']),
|
||||
|
|
@ -590,6 +738,39 @@ def get_data_source_logs(date: str, symbol: str) -> list:
|
|||
}
|
||||
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:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -698,5 +879,283 @@ def get_pipeline_summary_for_date(date: str) -> list:
|
|||
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
|
||||
init_db()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""FastAPI server for Nifty50 AI recommendations."""
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import database as db
|
||||
|
|
@ -9,11 +10,18 @@ import os
|
|||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
# Add parent directories to path for importing trading agents
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
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
|
||||
# NOTE: This is not thread-safe for production multi-worker deployments.
|
||||
# For production, use Redis or a database-backed job queue instead.
|
||||
|
|
@ -145,6 +153,11 @@ class RunAnalysisRequest(BaseModel):
|
|||
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):
|
||||
"""Background task to run trading analysis for a stock."""
|
||||
global running_analyses
|
||||
|
|
@ -163,14 +176,20 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
|
|||
running_analyses[symbol] = {
|
||||
"status": "initializing",
|
||||
"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
|
||||
add_log("info", "system", "Loading TradingAgentsGraph module...")
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
|
||||
running_analyses[symbol]["progress"] = "Initializing analysis pipeline..."
|
||||
add_log("info", "system", "Initializing analysis pipeline...")
|
||||
|
||||
# Create config from user settings
|
||||
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:
|
||||
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]["progress"] = f"Running market analysis (model: {deep_think_model})..."
|
||||
|
||||
add_log("agent", "system", f"Creating TradingAgentsGraph for {symbol}...")
|
||||
|
||||
# Initialize and run
|
||||
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}..."
|
||||
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] = {
|
||||
"status": "completed",
|
||||
|
|
@ -198,9 +280,16 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
|
|||
"progress": f"Analysis complete: {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:
|
||||
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] = {
|
||||
"status": "error",
|
||||
"error": error_msg,
|
||||
|
|
@ -295,6 +384,60 @@ async def health_check():
|
|||
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 ==============
|
||||
|
||||
@app.get("/recommendations/{date}/{symbol}/pipeline")
|
||||
|
|
@ -395,14 +538,15 @@ async def save_pipeline_data(request: SavePipelineDataRequest):
|
|||
|
||||
# Track bulk analysis state
|
||||
bulk_analysis_state = {
|
||||
"status": "idle", # idle, running, completed, error
|
||||
"status": "idle", # idle, running, completed, error, cancelled
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"current_symbol": None,
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"results": {}
|
||||
"results": {},
|
||||
"cancelled": False # Flag to signal cancellation
|
||||
}
|
||||
|
||||
# List of Nifty 50 stocks
|
||||
|
|
@ -423,11 +567,12 @@ class BulkAnalysisRequest(BaseModel):
|
|||
provider: Optional[str] = "claude_subscription"
|
||||
api_key: Optional[str] = None
|
||||
max_debate_rounds: Optional[int] = 1
|
||||
parallel_workers: Optional[int] = 3
|
||||
|
||||
|
||||
@app.post("/analyze/all")
|
||||
async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date: Optional[str] = None):
|
||||
"""Trigger analysis for all Nifty 50 stocks. Runs in background."""
|
||||
"""Trigger analysis for all Nifty 50 stocks. Runs in background with parallel processing."""
|
||||
global bulk_analysis_state
|
||||
|
||||
# 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
|
||||
analysis_config = {}
|
||||
parallel_workers = 3
|
||||
if request:
|
||||
analysis_config = {
|
||||
"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,
|
||||
"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
|
||||
def run_bulk():
|
||||
def run_bulk_parallel():
|
||||
global bulk_analysis_state
|
||||
bulk_analysis_state = {
|
||||
"status": "running",
|
||||
"total": len(NIFTY_50_SYMBOLS),
|
||||
"total": len(symbols_to_analyze),
|
||||
"total_all": len(NIFTY_50_SYMBOLS),
|
||||
"skipped": skipped_count,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"current_symbol": None,
|
||||
"current_symbols": [],
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"completed_at": None,
|
||||
"results": {}
|
||||
"results": {},
|
||||
"parallel_workers": parallel_workers,
|
||||
"cancelled": False
|
||||
}
|
||||
|
||||
for symbol in NIFTY_50_SYMBOLS:
|
||||
try:
|
||||
bulk_analysis_state["current_symbol"] = symbol
|
||||
run_analysis_task(symbol, date, analysis_config)
|
||||
with ThreadPoolExecutor(max_workers=parallel_workers) as executor:
|
||||
future_to_symbol = {
|
||||
executor.submit(analyze_single_stock, symbol, date, analysis_config): symbol
|
||||
for symbol in symbols_to_analyze
|
||||
}
|
||||
|
||||
# Wait for completion
|
||||
import time
|
||||
while symbol in running_analyses and running_analyses[symbol].get("status") == "running":
|
||||
time.sleep(2)
|
||||
bulk_analysis_state["current_symbols"] = list(symbols_to_analyze[:parallel_workers])
|
||||
|
||||
for future in as_completed(future_to_symbol):
|
||||
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":
|
||||
bulk_analysis_state["completed"] += 1
|
||||
else:
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
bulk_analysis_state["results"][symbol] = f"error: {str(e)}"
|
||||
bulk_analysis_state["failed"] += 1
|
||||
|
||||
bulk_analysis_state["status"] = "completed"
|
||||
bulk_analysis_state["current_symbol"] = None
|
||||
bulk_analysis_state["current_symbols"] = []
|
||||
bulk_analysis_state["completed_at"] = datetime.now().isoformat()
|
||||
|
||||
thread = threading.Thread(target=run_bulk)
|
||||
thread = threading.Thread(target=run_bulk_parallel)
|
||||
thread.start()
|
||||
|
||||
skipped_msg = f", {skipped_count} already done" if skipped_count > 0 else ""
|
||||
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,
|
||||
"total_stocks": len(NIFTY_50_SYMBOLS),
|
||||
"total_stocks": len(symbols_to_analyze),
|
||||
"skipped": skipped_count,
|
||||
"parallel_workers": parallel_workers,
|
||||
"status": "started"
|
||||
}
|
||||
|
||||
|
|
@ -509,7 +727,47 @@ async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date:
|
|||
@app.get("/analyze/all/status")
|
||||
async def get_bulk_analysis_status():
|
||||
"""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")
|
||||
|
|
@ -571,7 +829,7 @@ async def run_analysis(symbol: str, background_tasks: BackgroundTasks, request:
|
|||
|
||||
@app.get("/analyze/{symbol}/status")
|
||||
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()
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
return {
|
||||
result = {
|
||||
"symbol": 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__":
|
||||
import uvicorn
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { Routes, Route } from 'react-router-dom';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { SettingsProvider } from './contexts/SettingsContext';
|
||||
import { NotificationProvider } from './contexts/NotificationContext';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
import ToastContainer from './components/Toast';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import History from './pages/History';
|
||||
import StockDetail from './pages/StockDetail';
|
||||
|
|
@ -13,19 +15,22 @@ function App() {
|
|||
return (
|
||||
<ThemeProvider>
|
||||
<SettingsProvider>
|
||||
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-slate-900 transition-colors">
|
||||
<Header />
|
||||
<main className="flex-1 max-w-7xl mx-auto w-full px-3 sm:px-4 lg:px-6 py-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/stock/:symbol" element={<StockDetail />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
<SettingsModal />
|
||||
</div>
|
||||
<NotificationProvider>
|
||||
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-slate-900 transition-colors">
|
||||
<Header />
|
||||
<main className="flex-1 max-w-7xl mx-auto w-full px-3 sm:px-4 lg:px-6 py-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/stock/:symbol" element={<StockDetail />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
<SettingsModal />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</NotificationProvider>
|
||||
</SettingsProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { getAccuracyTrend } from '../data/recommendations';
|
||||
|
||||
export interface AccuracyTrendPoint {
|
||||
date: string;
|
||||
overall: number;
|
||||
buy: number;
|
||||
sell: number;
|
||||
hold: number;
|
||||
}
|
||||
|
||||
interface AccuracyTrendChartProps {
|
||||
height?: number;
|
||||
className?: string;
|
||||
data?: AccuracyTrendPoint[]; // Optional prop for real data
|
||||
}
|
||||
|
||||
export default function AccuracyTrendChart({ height = 200, className = '' }: AccuracyTrendChartProps) {
|
||||
const data = getAccuracyTrend();
|
||||
export default function AccuracyTrendChart({ height = 200, className = '', data: propData }: AccuracyTrendChartProps) {
|
||||
// Use provided data or fall back to mock data
|
||||
const data = propData || getAccuracyTrend();
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
|
||||
import { getCumulativeReturns } from '../data/recommendations';
|
||||
import type { CumulativeReturnPoint } from '../types';
|
||||
|
||||
interface CumulativeReturnChartProps {
|
||||
height?: number;
|
||||
className?: string;
|
||||
data?: CumulativeReturnPoint[]; // Optional prop for real data
|
||||
}
|
||||
|
||||
export default function CumulativeReturnChart({ height = 160, className = '' }: CumulativeReturnChartProps) {
|
||||
const data = getCumulativeReturns();
|
||||
export default function CumulativeReturnChart({ height = 160, className = '', data: propData }: CumulativeReturnChartProps) {
|
||||
// Use provided data or fall back to mock data
|
||||
const data = propData || getCumulativeReturns();
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine } from 'recharts';
|
||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { getCumulativeReturns } from '../data/recommendations';
|
||||
import type { CumulativeReturnPoint } from '../types';
|
||||
|
||||
interface IndexComparisonChartProps {
|
||||
export interface IndexComparisonChartProps {
|
||||
height?: number;
|
||||
className?: string;
|
||||
data?: CumulativeReturnPoint[]; // Optional prop for real data
|
||||
}
|
||||
|
||||
export default function IndexComparisonChart({ height = 220, className = '' }: IndexComparisonChartProps) {
|
||||
const data = getCumulativeReturns();
|
||||
export default function IndexComparisonChart({ height = 220, className = '', data: propData }: IndexComparisonChartProps) {
|
||||
// Use provided data or fall back to mock data
|
||||
const data = propData || getCumulativeReturns();
|
||||
|
||||
if (data.length === 0) {
|
||||
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 { getOverallReturnBreakdown } from '../data/recommendations';
|
||||
import CumulativeReturnChart from './CumulativeReturnChart';
|
||||
import type { CumulativeReturnPoint } from '../types';
|
||||
|
||||
export interface OverallReturnBreakdown {
|
||||
dailyReturns: { date: string; return: number; multiplier: number; cumulative: number }[];
|
||||
finalMultiplier: number;
|
||||
finalReturn: number;
|
||||
formula: string;
|
||||
}
|
||||
|
||||
interface OverallReturnModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
breakdown?: OverallReturnBreakdown; // Optional prop for real data
|
||||
cumulativeData?: CumulativeReturnPoint[]; // Optional prop for chart data
|
||||
}
|
||||
|
||||
export default function OverallReturnModal({ isOpen, onClose }: OverallReturnModalProps) {
|
||||
export default function OverallReturnModal({ isOpen, onClose, breakdown: propBreakdown, cumulativeData }: OverallReturnModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const breakdown = getOverallReturnBreakdown();
|
||||
// Use provided breakdown or fall back to mock data
|
||||
const breakdown = propBreakdown || getOverallReturnBreakdown();
|
||||
|
||||
return (
|
||||
<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>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Portfolio Growth</h3>
|
||||
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
|
||||
<CumulativeReturnChart height={140} />
|
||||
<CumulativeReturnChart height={140} data={cumulativeData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,271 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
|
||||
import { Calculator, ChevronDown, ChevronUp, IndianRupee } from 'lucide-react';
|
||||
import { getOverallReturnBreakdown } from '../data/recommendations';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend, BarChart, Bar, Cell, LabelList } from 'recharts';
|
||||
import { Calculator, ChevronDown, ChevronUp, IndianRupee, Settings2, BarChart3, Info, TrendingUp, TrendingDown, ArrowRightLeft, Wallet, PiggyBank, Receipt, HelpCircle, AlertCircle } from 'lucide-react';
|
||||
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 {
|
||||
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 [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 = useMemo(() => {
|
||||
let value = startingAmount;
|
||||
return breakdown.dailyReturns.map(day => {
|
||||
value = value * day.multiplier;
|
||||
return {
|
||||
date: new Date(day.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }),
|
||||
value: Math.round(value),
|
||||
return: day.return,
|
||||
cumulative: day.cumulative,
|
||||
};
|
||||
});
|
||||
}, [breakdown.dailyReturns, startingAmount]);
|
||||
const { portfolioData, stats, openPositions } = useMemo(() => {
|
||||
return calculateSmartTrades(
|
||||
recommendations,
|
||||
investmentMode,
|
||||
startingAmount,
|
||||
nifty50Prices,
|
||||
allBacktestData
|
||||
);
|
||||
}, [recommendations, investmentMode, startingAmount, nifty50Prices, allBacktestData]);
|
||||
|
||||
const currentValue = portfolioData.length > 0
|
||||
? portfolioData[portfolioData.length - 1].value
|
||||
: startingAmount;
|
||||
const totalReturn = ((currentValue - startingAmount) / startingAmount) * 100;
|
||||
const profitLoss = currentValue - startingAmount;
|
||||
const lastDataPoint = portfolioData[portfolioData.length - 1];
|
||||
const currentValue = lastDataPoint?.value ?? startingAmount;
|
||||
const niftyValue = lastDataPoint?.niftyValue ?? startingAmount;
|
||||
|
||||
const totalCharges = includeBrokerage ? stats.brokerageBreakdown.totalCharges : 0;
|
||||
const finalValue = currentValue - totalCharges;
|
||||
const totalReturn = ((finalValue - startingAmount) / startingAmount) * 100;
|
||||
const profitLoss = finalValue - startingAmount;
|
||||
const isPositive = profitLoss >= 0;
|
||||
|
||||
const niftyReturn = ((niftyValue - startingAmount) / startingAmount) * 100;
|
||||
const outperformance = totalReturn - niftyReturn;
|
||||
|
||||
// Calculate Y-axis domain with padding
|
||||
const yAxisDomain = useMemo(() => {
|
||||
if (portfolioData.length === 0) return [0, startingAmount * 1.2];
|
||||
|
||||
const allValues = portfolioData.flatMap(d => [d.value, d.niftyValue]);
|
||||
const minValue = Math.min(...allValues);
|
||||
const maxValue = Math.max(...allValues);
|
||||
const padding = (maxValue - minValue) * 0.1;
|
||||
|
||||
return [Math.floor((minValue - padding) / 1000) * 1000, Math.ceil((maxValue + padding) / 1000) * 1000];
|
||||
}, [portfolioData, startingAmount]);
|
||||
|
||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value.replace(/,/g, ''), 10);
|
||||
if (!isNaN(value) && value >= 0) {
|
||||
|
|
@ -41,21 +273,73 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
|||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
const openPositionsCount = Object.keys(openPositions).length;
|
||||
|
||||
return (
|
||||
<div className={`card p-4 ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<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 className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />
|
||||
<h2 className="font-semibold text-gray-900 dark:text-gray-100">Portfolio Simulator</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
showSettings
|
||||
? 'bg-nifty-100 text-nifty-600 dark:bg-nifty-900/30 dark:text-nifty-400'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
{showSettings && (
|
||||
<div className="mb-4 p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||
Investment Strategy
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setInvestmentMode('all50')}
|
||||
className={`flex-1 px-3 py-2 text-xs font-medium rounded-lg transition-all ${
|
||||
investmentMode === 'all50'
|
||||
? 'bg-nifty-600 text-white'
|
||||
: 'bg-white dark:bg-slate-600 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-slate-500 hover:bg-gray-50 dark:hover:bg-slate-500'
|
||||
}`}
|
||||
>
|
||||
All 50 Stocks
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInvestmentMode('topPicks')}
|
||||
className={`flex-1 px-3 py-2 text-xs font-medium rounded-lg transition-all ${
|
||||
investmentMode === 'topPicks'
|
||||
? 'bg-nifty-600 text-white'
|
||||
: 'bg-white dark:bg-slate-600 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-slate-500 hover:bg-gray-50 dark:hover:bg-slate-500'
|
||||
}`}
|
||||
>
|
||||
Top Picks Only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeBrokerage}
|
||||
onChange={(e) => setIncludeBrokerage(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-nifty-600 focus:ring-nifty-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Include Zerodha Equity Delivery Charges</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Section */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
|
|
@ -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'
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(amount)}
|
||||
{formatINR(amount, 0)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -89,24 +373,158 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
|||
|
||||
{/* Results Section */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Current Value</div>
|
||||
<div className={`text-xl font-bold ${isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{formatCurrency(currentValue)}
|
||||
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 relative">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<span>Final Portfolio Value</span>
|
||||
<InfoButton onClick={() => setActiveModal('portfolioValue')} />
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${getValueColorClass(profitLoss)}`}>
|
||||
{formatINR(finalValue, 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Profit/Loss</div>
|
||||
<div className={`text-xl font-bold ${isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{isPositive ? '+' : ''}{formatCurrency(profitLoss)}
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<span>Net Profit/Loss</span>
|
||||
<InfoButton onClick={() => setActiveModal('profitLoss')} />
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${getValueColorClass(profitLoss)}`}>
|
||||
{isPositive ? '+' : ''}{formatINR(profitLoss, 0)}
|
||||
<span className="text-sm ml-1">({isPositive ? '+' : ''}{totalReturn.toFixed(1)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="h-40 mb-4">
|
||||
<div className="h-48 mb-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={portfolioData} margin={{ top: 5, right: 10, bottom: 5, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
|
||||
|
|
@ -117,9 +535,10 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
|||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v) => formatCurrency(v).replace('₹', '')}
|
||||
tickFormatter={(v) => formatINR(v, 0).replace('₹', '')}
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
width={60}
|
||||
domain={yAxisDomain}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
|
|
@ -128,7 +547,14 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
|||
borderRadius: '8px',
|
||||
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
|
||||
y={startingAmount}
|
||||
|
|
@ -139,15 +565,106 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
|||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
name="value"
|
||||
stroke={isPositive ? '#22c55e' : '#ef4444'}
|
||||
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>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trade Waterfall Toggle */}
|
||||
<button
|
||||
onClick={() => setShowTradeWaterfall(!showTradeWaterfall)}
|
||||
className="flex items-center justify-between w-full px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700/50 rounded-lg transition-colors mb-2"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ArrowRightLeft className="w-4 h-4" />
|
||||
Trade Timeline ({stats.trades.length} completed trades)
|
||||
</span>
|
||||
{showTradeWaterfall ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Trade Waterfall Chart */}
|
||||
{showTradeWaterfall && stats.trades.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-gray-50 dark:bg-slate-700/30 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
Each bar represents a trade from buy to sell. Green = Profit, Red = Loss.
|
||||
</div>
|
||||
<div className="h-64 overflow-y-auto">
|
||||
<div style={{ height: Math.max(200, stats.trades.length * 28) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={stats.trades.map((t, i) => ({
|
||||
...t,
|
||||
idx: i,
|
||||
displayName: `${t.symbol}`,
|
||||
duration: `${new Date(t.entryDate).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })} → ${new Date(t.exitDate).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}`,
|
||||
}))}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 60, bottom: 5, left: 70 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 9 }}
|
||||
tickFormatter={(v) => formatINR(v, 0)}
|
||||
domain={['dataMin', 'dataMax']}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="displayName"
|
||||
tick={{ fontSize: 10 }}
|
||||
width={65}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--tooltip-bg, #fff)',
|
||||
border: '1px solid var(--tooltip-border, #e5e7eb)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
formatter={(value) => [formatINR(Number(value) || 0, 2), 'P/L']}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const d = payload[0].payload;
|
||||
return `${d.symbol}: ${d.duration}`;
|
||||
}
|
||||
return '';
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="profitLoss" radius={[0, 4, 4, 0]}>
|
||||
{stats.trades.map((trade, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={trade.profitLoss >= 0 ? '#22c55e' : '#ef4444'}
|
||||
/>
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="profitLoss"
|
||||
position="right"
|
||||
formatter={(v) => formatINR(Number(v) || 0, 0)}
|
||||
style={{ fontSize: 9, fill: '#6b7280' }}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily Breakdown (Collapsible) */}
|
||||
<button
|
||||
onClick={() => setShowBreakdown(!showBreakdown)}
|
||||
|
|
@ -164,20 +681,22 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
|||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Date</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Return</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{portfolioData.map((day, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{day.date}</td>
|
||||
<td className={`px-3 py-2 text-right font-medium ${
|
||||
day.return >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
<td className={`px-3 py-2 text-right font-medium ${getValueColorClass(day.return)}`}>
|
||||
{day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}%
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300">
|
||||
{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>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -186,9 +705,151 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
|
|||
</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">
|
||||
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>
|
||||
|
||||
{/* Info Modals */}
|
||||
<InfoModal
|
||||
isOpen={activeModal === 'totalTrades'}
|
||||
onClose={() => setActiveModal(null)}
|
||||
title="Total Trades"
|
||||
icon={<ArrowRightLeft className="w-5 h-5 text-blue-600 dark:text-blue-400" />}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p><strong>Total Trades</strong> represents the sum of all buy and sell transactions executed during the simulation period.</p>
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="font-semibold text-blue-800 dark:text-blue-200 mb-1">Calculation:</div>
|
||||
<code className="text-xs">Total Trades = Buy Trades + Sell Trades</code>
|
||||
<div className="mt-2 text-xs">= {stats.buyTrades} + {stats.sellTrades} = <strong>{stats.totalTrades}</strong></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Note: A complete round-trip trade (buy then sell) counts as 2 trades.</p>
|
||||
</div>
|
||||
</InfoModal>
|
||||
|
||||
<InfoModal
|
||||
isOpen={activeModal === 'buyTrades'}
|
||||
onClose={() => setActiveModal(null)}
|
||||
title="Buy Trades"
|
||||
icon={<TrendingUp className="w-5 h-5 text-green-600 dark:text-green-400" />}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p><strong>Buy Trades</strong> counts when a new position is opened based on AI's BUY recommendation.</p>
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="font-semibold text-green-800 dark:text-green-200 mb-2">When is a Buy Trade counted?</div>
|
||||
<ul className="text-xs space-y-1 list-disc list-inside">
|
||||
<li>When AI recommends BUY and no position exists</li>
|
||||
<li>When AI recommends BUY after a previous SELL</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Note: If AI recommends BUY while already holding (from previous BUY or HOLD), no new buy trade is counted - the position is simply carried forward.</p>
|
||||
</div>
|
||||
</InfoModal>
|
||||
|
||||
<InfoModal
|
||||
isOpen={activeModal === 'sellTrades'}
|
||||
onClose={() => setActiveModal(null)}
|
||||
title="Sell Trades"
|
||||
icon={<TrendingDown className="w-5 h-5 text-red-600 dark:text-red-400" />}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p><strong>Sell Trades</strong> counts when a position is closed based on AI's SELL recommendation.</p>
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<div className="font-semibold text-red-800 dark:text-red-200 mb-2">When is a Sell Trade counted?</div>
|
||||
<ul className="text-xs space-y-1 list-disc list-inside">
|
||||
<li>When AI recommends SELL while holding a position</li>
|
||||
<li>Position must have been opened via BUY or carried via HOLD</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Note: Brokerage is calculated when a sell trade completes a round-trip transaction.</p>
|
||||
</div>
|
||||
</InfoModal>
|
||||
|
||||
<InfoModal
|
||||
isOpen={activeModal === 'portfolioValue'}
|
||||
onClose={() => setActiveModal(null)}
|
||||
title="Final Portfolio Value"
|
||||
icon={<PiggyBank className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p><strong>Final Portfolio Value</strong> is the total worth of your investments at the end of the simulation period.</p>
|
||||
<div className="p-3 bg-nifty-50 dark:bg-nifty-900/20 rounded-lg">
|
||||
<div className="font-semibold text-nifty-800 dark:text-nifty-200 mb-1">Calculation:</div>
|
||||
<code className="text-xs">Final Value = Portfolio Value - Total Charges</code>
|
||||
<div className="mt-2 text-xs">
|
||||
= {formatINR(currentValue, 0)} - {formatINR(totalCharges, 0)} = <strong>{formatINR(finalValue, 0)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">This includes all realized gains/losses from completed trades and deducts Zerodha brokerage charges.</p>
|
||||
</div>
|
||||
</InfoModal>
|
||||
|
||||
<InfoModal
|
||||
isOpen={activeModal === 'profitLoss'}
|
||||
onClose={() => setActiveModal(null)}
|
||||
title="Net Profit/Loss"
|
||||
icon={<Calculator className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p><strong>Net Profit/Loss</strong> shows your actual earnings or losses after all charges.</p>
|
||||
<div className="p-3 bg-gray-100 dark:bg-slate-700 rounded-lg">
|
||||
<div className="font-semibold mb-1">Calculation:</div>
|
||||
<code className="text-xs">Net P/L = Final Value - Starting Investment</code>
|
||||
<div className="mt-2 text-xs">
|
||||
= {formatINR(finalValue, 0)} - {formatINR(startingAmount, 0)} = <strong className={profitLoss >= 0 ? 'text-green-600' : 'text-red-600'}>{formatINR(profitLoss, 0)}</strong>
|
||||
</div>
|
||||
<div className="mt-2 text-xs">
|
||||
Return = ({formatINR(profitLoss, 0)} / {formatINR(startingAmount, 0)}) × 100 = <strong>{totalReturn.toFixed(2)}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InfoModal>
|
||||
|
||||
<InfoModal
|
||||
isOpen={activeModal === 'comparison'}
|
||||
onClose={() => setActiveModal(null)}
|
||||
title="vs Nifty 50 Index"
|
||||
icon={<BarChart3 className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p>This compares the AI strategy's performance against simply investing in the Nifty 50 index.</p>
|
||||
<div className="space-y-2">
|
||||
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg flex justify-between items-center">
|
||||
<span>AI Strategy Return:</span>
|
||||
<strong className={totalReturn >= 0 ? 'text-green-600' : 'text-red-600'}>{totalReturn.toFixed(2)}%</strong>
|
||||
</div>
|
||||
<div className="p-2 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg flex justify-between items-center">
|
||||
<span>Nifty 50 Return:</span>
|
||||
<strong className={niftyReturn >= 0 ? 'text-green-600' : 'text-red-600'}>{niftyReturn.toFixed(2)}%</strong>
|
||||
</div>
|
||||
<div className="p-2 bg-nifty-50 dark:bg-nifty-900/20 rounded-lg flex justify-between items-center">
|
||||
<span>Outperformance (Alpha):</span>
|
||||
<strong className={outperformance >= 0 ? 'text-nifty-600' : 'text-red-600'}>{outperformance.toFixed(2)}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{outperformance >= 0
|
||||
? `The AI strategy beat the Nifty 50 index by ${outperformance.toFixed(2)} percentage points.`
|
||||
: `The AI strategy underperformed the Nifty 50 index by ${Math.abs(outperformance).toFixed(2)} percentage points.`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</InfoModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export the type for use in other components
|
||||
export { type InvestmentMode as PortfolioInvestmentMode };
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@ import { useState } from 'react';
|
|||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { X } from 'lucide-react';
|
||||
import { getReturnDistribution } from '../data/recommendations';
|
||||
import type { ReturnBucket } from '../types';
|
||||
|
||||
interface ReturnDistributionChartProps {
|
||||
export interface ReturnDistributionChartProps {
|
||||
height?: number;
|
||||
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 data = getReturnDistribution();
|
||||
// Use provided data or fall back to mock data
|
||||
const data = propData || getReturnDistribution();
|
||||
|
||||
if (data.every(d => d.count === 0)) {
|
||||
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 { useState } from 'react';
|
||||
import InfoModal, { InfoButton } from './InfoModal';
|
||||
import type { RiskMetrics } from '../types';
|
||||
|
||||
interface RiskMetricsCardProps {
|
||||
export interface RiskMetricsCardProps {
|
||||
className?: string;
|
||||
metrics?: RiskMetrics; // Optional prop for real data
|
||||
}
|
||||
|
||||
export default function RiskMetricsCard({ className = '' }: RiskMetricsCardProps) {
|
||||
const [showTooltip, setShowTooltip] = useState<string | null>(null);
|
||||
const metrics = calculateRiskMetrics();
|
||||
type MetricModal = 'sharpe' | 'drawdown' | 'winloss' | 'winrate' | null;
|
||||
|
||||
const tooltips: Record<string, string> = {
|
||||
sharpe: 'Sharpe Ratio measures risk-adjusted returns. Higher is better (>1 is good, >2 is excellent).',
|
||||
drawdown: 'Maximum Drawdown shows the largest peak-to-trough decline. Lower is better.',
|
||||
winloss: 'Win/Loss Ratio compares average winning trade to average losing trade. Higher means bigger wins than losses.',
|
||||
winrate: 'Win Rate is the percentage of predictions that were correct.',
|
||||
};
|
||||
export default function RiskMetricsCard({ className = '', metrics: propMetrics }: RiskMetricsCardProps) {
|
||||
const [activeModal, setActiveModal] = useState<MetricModal>(null);
|
||||
// Use provided metrics or fall back to mock data
|
||||
const metrics = propMetrics || calculateRiskMetrics();
|
||||
|
||||
const getColor = (metric: string, value: number) => {
|
||||
switch (metric) {
|
||||
case 'sharpe':
|
||||
return value >= 1 ? 'text-green-600 dark:text-green-400' : value >= 0 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400';
|
||||
case 'drawdown':
|
||||
return value <= 5 ? 'text-green-600 dark:text-green-400' : value <= 15 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400';
|
||||
case 'winloss':
|
||||
return value >= 1.5 ? 'text-green-600 dark:text-green-400' : value >= 1 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400';
|
||||
case 'winrate':
|
||||
return value >= 70 ? 'text-green-600 dark:text-green-400' : value >= 50 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400';
|
||||
default:
|
||||
return 'text-gray-700 dark:text-gray-300';
|
||||
// Color classes for metric values
|
||||
const COLOR_GOOD = 'text-green-600 dark:text-green-400';
|
||||
const COLOR_NEUTRAL = 'text-amber-600 dark:text-amber-400';
|
||||
const COLOR_BAD = 'text-red-600 dark:text-red-400';
|
||||
|
||||
function getColor(metric: string, value: number): string {
|
||||
// Thresholds for each metric: [good, neutral] - values below neutral are bad
|
||||
const thresholds: Record<string, { good: number; neutral: number; inverted?: boolean }> = {
|
||||
sharpe: { good: 1, neutral: 0 },
|
||||
drawdown: { good: 5, neutral: 15, inverted: true }, // Lower is better
|
||||
winloss: { good: 1.5, neutral: 1 },
|
||||
winrate: { good: 70, neutral: 50 },
|
||||
};
|
||||
|
||||
const config = thresholds[metric];
|
||||
if (!config) return 'text-gray-700 dark:text-gray-300';
|
||||
|
||||
if (config.inverted) {
|
||||
// For drawdown: lower is better
|
||||
if (value <= config.good) return COLOR_GOOD;
|
||||
if (value <= config.neutral) return COLOR_NEUTRAL;
|
||||
return COLOR_BAD;
|
||||
}
|
||||
};
|
||||
|
||||
// For other metrics: higher is better
|
||||
if (value >= config.good) return COLOR_GOOD;
|
||||
if (value >= config.neutral) return COLOR_NEUTRAL;
|
||||
return COLOR_BAD;
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
|
|
@ -64,38 +78,282 @@ export default function RiskMetricsCard({ className = '' }: RiskMetricsCardProps
|
|||
];
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 sm:grid-cols-4 gap-3 ${className}`}>
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="relative p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center group"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<Icon className={`w-4 h-4 ${card.color}`} />
|
||||
<span className={`text-xl font-bold ${card.color}`}>{card.value}</span>
|
||||
<>
|
||||
<div className={`grid grid-cols-2 sm:grid-cols-4 gap-3 ${className}`}>
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="relative p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 text-center group"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<Icon className={`w-4 h-4 ${card.color}`} />
|
||||
<span className={`text-xl font-bold ${card.color}`}>{card.value}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{card.label}</span>
|
||||
<InfoButton onClick={() => setActiveModal(card.id as MetricModal)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{card.label}</span>
|
||||
<button
|
||||
onClick={() => setShowTooltip(showTooltip === card.id ? null : card.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Sharpe Ratio Modal */}
|
||||
<InfoModal
|
||||
isOpen={activeModal === 'sharpe'}
|
||||
onClose={() => setActiveModal(null)}
|
||||
title="Sharpe Ratio"
|
||||
icon={<Activity className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
The <strong className="text-gray-900 dark:text-gray-100">Sharpe Ratio</strong> measures risk-adjusted returns
|
||||
by comparing the excess return of an investment to its standard deviation (volatility).
|
||||
</p>
|
||||
|
||||
{/* Current Value Display */}
|
||||
<div className={`p-3 rounded-lg ${getColor('sharpe', metrics.sharpeRatio).replace('text-', 'bg-').replace('-600', '-50').replace('-400', '-900/20')}`}>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Current Sharpe Ratio</div>
|
||||
<div className={`text-2xl font-bold ${getColor('sharpe', metrics.sharpeRatio)}`}>{metrics.sharpeRatio.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
{/* Formula and Calculation */}
|
||||
<div className="bg-gray-50 dark:bg-slate-700 p-3 rounded-lg space-y-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Formula:</p>
|
||||
<div className="font-mono text-sm bg-white dark:bg-slate-800 p-2 rounded border border-gray-200 dark:border-slate-600">
|
||||
Sharpe Ratio = (R̄ − Rf) / σ
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Where R̄ = Mean Return, Rf = Risk-Free Rate, σ = Standard Deviation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{showTooltip === card.id && (
|
||||
<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">
|
||||
{tooltips[card.id]}
|
||||
<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" />
|
||||
{metrics.meanReturn !== undefined && (
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Your Values:</p>
|
||||
<div className="text-xs space-y-1 mb-3">
|
||||
<p>• Mean Daily Return (R̄) = <span className="text-nifty-600 dark:text-nifty-400 font-medium">{metrics.meanReturn}%</span></p>
|
||||
<p>• Risk-Free Rate (Rf) = <span className="text-gray-700 dark:text-gray-300 font-medium">{metrics.riskFreeRate}%</span> <span className="text-gray-400">(daily)</span></p>
|
||||
<p>• Volatility (σ) = <span className="text-amber-600 dark:text-amber-400 font-medium">{metrics.volatility}%</span></p>
|
||||
</div>
|
||||
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100 mb-2">Calculation:</p>
|
||||
<div className="font-mono text-xs bg-white dark:bg-slate-800 p-2 rounded border border-gray-200 dark:border-slate-600 space-y-1">
|
||||
<p>= ({metrics.meanReturn} − {metrics.riskFreeRate}) / {metrics.volatility}</p>
|
||||
<p>= {(metrics.meanReturn - (metrics.riskFreeRate || 0)).toFixed(2)} / {metrics.volatility}</p>
|
||||
<p className={`font-bold ${getColor('sharpe', metrics.sharpeRatio)}`}>= {metrics.sharpeRatio.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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';
|
||||
|
||||
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'
|
||||
? 'px-2 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) {
|
||||
if (compact) {
|
||||
return (
|
||||
|
|
@ -116,6 +131,7 @@ export default function StockCard({ stock, showDetails = true, compact = false }
|
|||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<ConfidenceBadge confidence={stock.confidence} />
|
||||
<RiskBadge risk={stock.risk} />
|
||||
<HoldDaysBadge holdDays={stock.hold_days} decision={stock.decision} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
Database, ChevronDown, ChevronUp, CheckCircle,
|
||||
XCircle, Clock, Server
|
||||
XCircle, Clock, Server, Copy, Check, Maximize2, Minimize2
|
||||
} from 'lucide-react';
|
||||
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' }
|
||||
};
|
||||
|
||||
// Raw data viewer with copy, expand/collapse, and formatted display
|
||||
function RawDataViewer({ data, error }: { data: unknown; error?: string | null }) {
|
||||
const [isFullHeight, setIsFullHeight] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
|
||||
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
|
||||
<p className="mt-3 text-sm text-slate-500">No data details available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rawText = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
const dataSize = rawText.length;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(rawText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
|
||||
{/* Toolbar */}
|
||||
<div className="mt-3 flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||
Raw Data
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
|
||||
{dataSize > 1000 ? `${(dataSize / 1000).toFixed(1)}KB` : `${dataSize} chars`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 dark:text-slate-400 transition-colors"
|
||||
title="Copy raw data"
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullHeight(!isFullHeight)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 dark:text-slate-400 transition-colors"
|
||||
title={isFullHeight ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isFullHeight ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
|
||||
{isFullHeight ? 'Collapse' : 'Expand'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Raw data content */}
|
||||
<div className={`bg-slate-900 dark:bg-slate-950 rounded-lg overflow-hidden ${isFullHeight ? '' : 'max-h-80'}`}>
|
||||
<pre className={`p-3 text-xs text-green-400 font-mono whitespace-pre-wrap break-words overflow-auto ${isFullHeight ? 'max-h-[80vh]' : 'max-h-72'}`}>
|
||||
{rawText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [expandedSources, setExpandedSources] = useState<Set<number>>(new Set());
|
||||
|
|
@ -124,6 +201,18 @@ export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelPro
|
|||
{source.source_name}
|
||||
</span>
|
||||
</div>
|
||||
{source.method && (
|
||||
<div className="flex items-center gap-1.5 mt-1 text-xs">
|
||||
<span className="font-mono font-semibold text-amber-600 dark:text-amber-400">
|
||||
{source.method}()
|
||||
</span>
|
||||
{source.args && (
|
||||
<span className="font-mono text-slate-500 dark:text-slate-400 truncate max-w-xs">
|
||||
{source.args}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTimestamp(source.fetch_timestamp)}
|
||||
|
|
@ -145,32 +234,12 @@ export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelPro
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source details (expanded) */}
|
||||
{/* Source details (expanded) — full raw data viewer */}
|
||||
{isSourceExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
|
||||
{source.error_message ? (
|
||||
<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> {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>
|
||||
<RawDataViewer
|
||||
data={source.data_fetched}
|
||||
error={source.error_message}
|
||||
/>
|
||||
)}
|
||||
</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 (
|
||||
<div className="space-y-4">
|
||||
{/* 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 bg-gradient-to-r from-blue-500 to-green-500 transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{/* Compact Progress Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-0.5">
|
||||
{displaySteps.map((step) => (
|
||||
<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>
|
||||
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||
{completedCount}/{totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pipeline steps */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Compact Pipeline Steps Grid */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-9 gap-1.5">
|
||||
{displaySteps.map((step) => {
|
||||
const StepIcon = STEP_ICONS[step.step_name] || Database;
|
||||
const styles = STATUS_STYLES[step.status];
|
||||
const StatusIcon = styles.icon;
|
||||
const label = STEP_LABELS[step.step_name] || step.step_name;
|
||||
|
||||
return (
|
||||
|
|
@ -128,24 +146,22 @@ export function PipelineOverview({ steps, onStepClick, compact = false }: Pipeli
|
|||
key={step.step_number}
|
||||
onClick={() => onStepClick?.(step)}
|
||||
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}
|
||||
hover:scale-105 hover:shadow-md
|
||||
hover:shadow-sm
|
||||
${onStepClick ? 'cursor-pointer' : 'cursor-default'}
|
||||
`}
|
||||
title={`${label}: ${step.status}${step.duration_ms ? ` (${(step.duration_ms / 1000).toFixed(1)}s)` : ''}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<StepIcon className="w-4 h-4" />
|
||||
{StatusIcon && step.status === 'running' && (
|
||||
<Loader2 className="w-3 h-3 absolute -top-1 -right-1 animate-spin" />
|
||||
{step.status === 'running' && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
{step.duration_ms && (
|
||||
<span className="text-xs opacity-60">
|
||||
{(step.duration_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] font-medium leading-tight text-center line-clamp-1">
|
||||
{label.split(' ')[0]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { PipelineOverview } from './PipelineOverview';
|
||||
export { PipelineFlowchart } from './PipelineFlowchart';
|
||||
export { AgentReportCard } from './AgentReportCard';
|
||||
export { DebateViewer } from './DebateViewer';
|
||||
export { RiskDebateViewer } from './RiskDebateViewer';
|
||||
|
|
|
|||