This commit is contained in:
hemangjoshi37a 2026-02-08 12:33:09 +11:00
parent 86381157d8
commit bdc27679fa
166 changed files with 7707 additions and 759 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

BIN
analysis-cancelled.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
analysis-live-fullpage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
analysis-live-progress.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
analyze-all-skipped.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
current-state.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
data-sources-all.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
data-sources-fixed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
detail-drawer-bottom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
detail-drawer-test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
drawer-header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

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

View File

@ -105,10 +105,17 @@ def init_db():
completed_at TEXT, completed_at TEXT,
duration_ms INTEGER, duration_ms INTEGER,
output_summary TEXT, output_summary TEXT,
step_details TEXT,
UNIQUE(date, symbol, step_number) UNIQUE(date, symbol, step_number)
) )
""") """)
# Add step_details column if it doesn't exist (migration for existing DBs)
try:
cursor.execute("ALTER TABLE pipeline_steps ADD COLUMN step_details TEXT")
except sqlite3.OperationalError:
pass # Column already exists
# Create data_source_logs table (stores what raw data was fetched) # Create data_source_logs table (stores what raw data was fetched)
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS data_source_logs ( CREATE TABLE IF NOT EXISTS data_source_logs (
@ -117,6 +124,8 @@ def init_db():
symbol TEXT NOT NULL, symbol TEXT NOT NULL,
source_type TEXT, source_type TEXT,
source_name TEXT, source_name TEXT,
method TEXT,
args TEXT,
data_fetched TEXT, data_fetched TEXT,
fetch_timestamp TEXT, fetch_timestamp TEXT,
success INTEGER DEFAULT 1, success INTEGER DEFAULT 1,
@ -124,6 +133,46 @@ def init_db():
) )
""") """)
# Migrate: add method/args columns if missing (existing databases)
try:
cursor.execute("ALTER TABLE data_source_logs ADD COLUMN method TEXT")
except Exception:
pass # Column already exists
try:
cursor.execute("ALTER TABLE data_source_logs ADD COLUMN args TEXT")
except Exception:
pass # Column already exists
# Create backtest_results table (stores calculated backtest accuracy)
cursor.execute("""
CREATE TABLE IF NOT EXISTS backtest_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
symbol TEXT NOT NULL,
decision TEXT,
price_at_prediction REAL,
price_1d_later REAL,
price_1w_later REAL,
price_1m_later REAL,
return_1d REAL,
return_1w REAL,
return_1m REAL,
prediction_correct INTEGER,
calculated_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date, symbol)
)
""")
# Add hold_days column if it doesn't exist (migration for existing DBs)
try:
cursor.execute("ALTER TABLE stock_analysis ADD COLUMN hold_days INTEGER")
except sqlite3.OperationalError:
pass # Column already exists
try:
cursor.execute("ALTER TABLE backtest_results ADD COLUMN hold_days INTEGER")
except sqlite3.OperationalError:
pass # Column already exists
# Create indexes for new tables # Create indexes for new tables
cursor.execute(""" cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_agent_reports_date_symbol ON agent_reports(date, symbol) CREATE INDEX IF NOT EXISTS idx_agent_reports_date_symbol ON agent_reports(date, symbol)
@ -137,6 +186,9 @@ def init_db():
cursor.execute(""" cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_data_source_logs_date_symbol ON data_source_logs(date, symbol) CREATE INDEX IF NOT EXISTS idx_data_source_logs_date_symbol ON data_source_logs(date, symbol)
""") """)
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_backtest_results_date ON backtest_results(date)
""")
conn.commit() conn.commit()
conn.close() conn.close()
@ -168,8 +220,8 @@ def save_recommendation(date: str, analysis_data: dict, summary: dict,
for symbol, analysis in analysis_data.items(): for symbol, analysis in analysis_data.items():
cursor.execute(""" cursor.execute("""
INSERT OR REPLACE INTO stock_analysis INSERT OR REPLACE INTO stock_analysis
(date, symbol, company_name, decision, confidence, risk, raw_analysis) (date, symbol, company_name, decision, confidence, risk, raw_analysis, hold_days)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
date, date,
symbol, symbol,
@ -177,7 +229,8 @@ def save_recommendation(date: str, analysis_data: dict, summary: dict,
analysis.get('decision'), analysis.get('decision'),
analysis.get('confidence'), analysis.get('confidence'),
analysis.get('risk'), analysis.get('risk'),
analysis.get('raw_analysis', '') analysis.get('raw_analysis', ''),
analysis.get('hold_days')
)) ))
conn.commit() conn.commit()
@ -185,6 +238,52 @@ def save_recommendation(date: str, analysis_data: dict, summary: dict,
conn.close() conn.close()
def save_single_stock_analysis(date: str, symbol: str, analysis: dict):
"""Save analysis for a single stock.
Args:
date: Date string (YYYY-MM-DD)
symbol: Stock symbol
analysis: Dict with keys: company_name, decision, confidence, risk, raw_analysis, hold_days
"""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
INSERT OR REPLACE INTO stock_analysis
(date, symbol, company_name, decision, confidence, risk, raw_analysis, hold_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
date,
symbol,
analysis.get('company_name', symbol),
analysis.get('decision', 'HOLD'),
analysis.get('confidence', 'MEDIUM'),
analysis.get('risk', 'MEDIUM'),
analysis.get('raw_analysis', ''),
analysis.get('hold_days')
))
conn.commit()
finally:
conn.close()
def get_analyzed_symbols_for_date(date: str) -> list:
"""Get list of symbols that already have analysis for a given date.
Used by bulk analysis to skip already-completed stocks when resuming.
"""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("SELECT symbol FROM stock_analysis WHERE date = ?", (date,))
return [row['symbol'] for row in cursor.fetchall()]
finally:
conn.close()
def get_recommendation_by_date(date: str) -> Optional[dict]: def get_recommendation_by_date(date: str) -> Optional[dict]:
"""Get recommendation for a specific date.""" """Get recommendation for a specific date."""
conn = get_connection() conn = get_connection()
@ -197,26 +296,32 @@ def get_recommendation_by_date(date: str) -> Optional[dict]:
""", (date,)) """, (date,))
row = cursor.fetchone() row = cursor.fetchone()
if not row:
return None
# Get stock analysis for this date # Get stock analysis for this date
cursor.execute(""" cursor.execute("""
SELECT * FROM stock_analysis WHERE date = ? SELECT * FROM stock_analysis WHERE date = ?
""", (date,)) """, (date,))
analysis_rows = cursor.fetchall() analysis_rows = cursor.fetchall()
# If no daily_recommendations AND no stock_analysis, return None
if not row and not analysis_rows:
return None
analysis = {} analysis = {}
for a in analysis_rows: for a in analysis_rows:
decision = (a['decision'] or '').strip().upper()
if decision not in ('BUY', 'SELL', 'HOLD'):
decision = 'HOLD'
analysis[a['symbol']] = { analysis[a['symbol']] = {
'symbol': a['symbol'], 'symbol': a['symbol'],
'company_name': a['company_name'], 'company_name': a['company_name'],
'decision': a['decision'], 'decision': decision,
'confidence': a['confidence'], 'confidence': a['confidence'] or 'MEDIUM',
'risk': a['risk'], 'risk': a['risk'] or 'MEDIUM',
'raw_analysis': a['raw_analysis'] 'raw_analysis': a['raw_analysis'],
'hold_days': a['hold_days'] if 'hold_days' in a.keys() else None
} }
if row:
return { return {
'date': row['date'], 'date': row['date'],
'analysis': analysis, 'analysis': analysis,
@ -229,6 +334,23 @@ def get_recommendation_by_date(date: str) -> Optional[dict]:
'top_picks': json.loads(row['top_picks']) if row['top_picks'] else [], '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 [] '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': date,
'analysis': analysis,
'summary': {
'total': len(analysis),
'buy': buy_count,
'sell': sell_count,
'hold': hold_count
},
'top_picks': [],
'stocks_to_avoid': []
}
finally: finally:
conn.close() conn.close()
@ -253,13 +375,17 @@ def get_latest_recommendation() -> Optional[dict]:
def get_all_dates() -> list: def get_all_dates() -> list:
"""Get all available dates.""" """Get all available dates (union of daily_recommendations and stock_analysis)."""
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
cursor.execute(""" cursor.execute("""
SELECT date FROM daily_recommendations ORDER BY date DESC SELECT DISTINCT date FROM (
SELECT date FROM daily_recommendations
UNION
SELECT date FROM stock_analysis
) ORDER BY date DESC
""") """)
return [row['date'] for row in cursor.fetchall()] return [row['date'] for row in cursor.fetchall()]
finally: finally:
@ -273,21 +399,26 @@ def get_stock_history(symbol: str) -> list:
try: try:
cursor.execute(""" cursor.execute("""
SELECT date, decision, confidence, risk SELECT date, decision, confidence, risk, hold_days
FROM stock_analysis FROM stock_analysis
WHERE symbol = ? WHERE symbol = ?
ORDER BY date DESC ORDER BY date DESC
""", (symbol,)) """, (symbol,))
return [ results = []
{ for row in cursor.fetchall():
decision = (row['decision'] or '').strip().upper()
# Sanitize: only allow BUY/SELL/HOLD
if decision not in ('BUY', 'SELL', 'HOLD'):
decision = 'HOLD'
results.append({
'date': row['date'], 'date': row['date'],
'decision': row['decision'], 'decision': decision,
'confidence': row['confidence'], 'confidence': row['confidence'] or 'MEDIUM',
'risk': row['risk'] 'risk': row['risk'] or 'MEDIUM',
} 'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None
for row in cursor.fetchall() })
] return results
finally: finally:
conn.close() conn.close()
@ -467,11 +598,14 @@ def save_pipeline_steps_bulk(date: str, symbol: str, steps: list):
try: try:
for step in steps: for step in steps:
step_details = step.get('step_details')
if step_details and not isinstance(step_details, str):
step_details = json.dumps(step_details)
cursor.execute(""" cursor.execute("""
INSERT OR REPLACE INTO pipeline_steps INSERT OR REPLACE INTO pipeline_steps
(date, symbol, step_number, step_name, status, (date, symbol, step_number, step_name, status,
started_at, completed_at, duration_ms, output_summary) started_at, completed_at, duration_ms, output_summary, step_details)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
date, symbol, date, symbol,
step.get('step_number'), step.get('step_number'),
@ -480,7 +614,8 @@ def save_pipeline_steps_bulk(date: str, symbol: str, steps: list):
step.get('started_at'), step.get('started_at'),
step.get('completed_at'), step.get('completed_at'),
step.get('duration_ms'), step.get('duration_ms'),
step.get('output_summary') step.get('output_summary'),
step_details
)) ))
conn.commit() conn.commit()
finally: finally:
@ -499,18 +634,26 @@ def get_pipeline_steps(date: str, symbol: str) -> list:
ORDER BY step_number ORDER BY step_number
""", (date, symbol)) """, (date, symbol))
return [ results = []
{ for row in cursor.fetchall():
step_details = None
raw_details = row['step_details'] if 'step_details' in row.keys() else None
if raw_details:
try:
step_details = json.loads(raw_details)
except (json.JSONDecodeError, TypeError):
step_details = None
results.append({
'step_number': row['step_number'], 'step_number': row['step_number'],
'step_name': row['step_name'], 'step_name': row['step_name'],
'status': row['status'], 'status': row['status'],
'started_at': row['started_at'], 'started_at': row['started_at'],
'completed_at': row['completed_at'], 'completed_at': row['completed_at'],
'duration_ms': row['duration_ms'], 'duration_ms': row['duration_ms'],
'output_summary': row['output_summary'] 'output_summary': row['output_summary'],
} 'step_details': step_details,
for row in cursor.fetchall() })
] return results
finally: finally:
conn.close() conn.close()
@ -550,13 +693,15 @@ def save_data_source_logs_bulk(date: str, symbol: str, logs: list):
for log in logs: for log in logs:
cursor.execute(""" cursor.execute("""
INSERT INTO data_source_logs INSERT INTO data_source_logs
(date, symbol, source_type, source_name, data_fetched, (date, symbol, source_type, source_name, method, args, data_fetched,
fetch_timestamp, success, error_message) fetch_timestamp, success, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
date, symbol, date, symbol,
log.get('source_type'), log.get('source_type'),
log.get('source_name'), log.get('source_name'),
log.get('method'),
log.get('args'),
json.dumps(log.get('data_fetched')) if log.get('data_fetched') else None, json.dumps(log.get('data_fetched')) if log.get('data_fetched') else None,
log.get('fetch_timestamp') or datetime.now().isoformat(), log.get('fetch_timestamp') or datetime.now().isoformat(),
1 if log.get('success', True) else 0, 1 if log.get('success', True) else 0,
@ -568,7 +713,8 @@ def save_data_source_logs_bulk(date: str, symbol: str, logs: list):
def get_data_source_logs(date: str, symbol: str) -> list: def get_data_source_logs(date: str, symbol: str) -> list:
"""Get all data source logs for a stock on a date.""" """Get all data source logs for a stock on a date.
Falls back to generating entries from agent_reports if no explicit logs exist."""
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -579,10 +725,12 @@ def get_data_source_logs(date: str, symbol: str) -> list:
ORDER BY fetch_timestamp ORDER BY fetch_timestamp
""", (date, symbol)) """, (date, symbol))
return [ logs = [
{ {
'source_type': row['source_type'], 'source_type': row['source_type'],
'source_name': row['source_name'], 'source_name': row['source_name'],
'method': row['method'] if 'method' in row.keys() else None,
'args': row['args'] if 'args' in row.keys() else None,
'data_fetched': json.loads(row['data_fetched']) if row['data_fetched'] else None, 'data_fetched': json.loads(row['data_fetched']) if row['data_fetched'] else None,
'fetch_timestamp': row['fetch_timestamp'], 'fetch_timestamp': row['fetch_timestamp'],
'success': bool(row['success']), 'success': bool(row['success']),
@ -590,6 +738,39 @@ def get_data_source_logs(date: str, symbol: str) -> list:
} }
for row in cursor.fetchall() for row in cursor.fetchall()
] ]
if logs:
return logs
# No explicit logs — generate from agent_reports with full raw content
AGENT_TO_SOURCE = {
'market': ('market_data', 'Yahoo Finance'),
'news': ('news', 'Google News'),
'social_media': ('social_media', 'Social Sentiment'),
'fundamentals': ('fundamentals', 'Financial Data'),
}
cursor.execute("""
SELECT agent_type, report_content, created_at
FROM agent_reports
WHERE date = ? AND symbol = ?
""", (date, symbol))
generated = []
for row in cursor.fetchall():
source_type, source_name = AGENT_TO_SOURCE.get(
row['agent_type'], ('other', row['agent_type'])
)
generated.append({
'source_type': source_type,
'source_name': source_name,
'data_fetched': row['report_content'],
'fetch_timestamp': row['created_at'],
'success': True,
'error_message': None
})
return generated
finally: finally:
conn.close() conn.close()
@ -698,5 +879,283 @@ def get_pipeline_summary_for_date(date: str) -> list:
conn.close() conn.close()
def save_backtest_result(date: str, symbol: str, decision: str,
price_at_prediction: float, price_1d_later: float = None,
price_1w_later: float = None, price_1m_later: float = None,
return_1d: float = None, return_1w: float = None,
return_1m: float = None, prediction_correct: bool = None,
hold_days: int = None):
"""Save a backtest result for a stock recommendation."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
INSERT OR REPLACE INTO backtest_results
(date, symbol, decision, price_at_prediction,
price_1d_later, price_1w_later, price_1m_later,
return_1d, return_1w, return_1m, prediction_correct, hold_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
date, symbol, decision, price_at_prediction,
price_1d_later, price_1w_later, price_1m_later,
return_1d, return_1w, return_1m,
1 if prediction_correct else 0 if prediction_correct is not None else None,
hold_days
))
conn.commit()
finally:
conn.close()
def get_backtest_result(date: str, symbol: str) -> Optional[dict]:
"""Get backtest result for a specific stock and date."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
SELECT * FROM backtest_results WHERE date = ? AND symbol = ?
""", (date, symbol))
row = cursor.fetchone()
if row:
return {
'date': row['date'],
'symbol': row['symbol'],
'decision': row['decision'],
'price_at_prediction': row['price_at_prediction'],
'price_1d_later': row['price_1d_later'],
'price_1w_later': row['price_1w_later'],
'price_1m_later': row['price_1m_later'],
'return_1d': row['return_1d'],
'return_1w': row['return_1w'],
'return_1m': row['return_1m'],
'prediction_correct': bool(row['prediction_correct']) if row['prediction_correct'] is not None else None,
'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None,
'calculated_at': row['calculated_at']
}
return None
finally:
conn.close()
def get_backtest_results_by_date(date: str) -> list:
"""Get all backtest results for a specific date."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
SELECT * FROM backtest_results WHERE date = ?
""", (date,))
return [
{
'symbol': row['symbol'],
'decision': row['decision'],
'price_at_prediction': row['price_at_prediction'],
'price_1d_later': row['price_1d_later'],
'price_1w_later': row['price_1w_later'],
'price_1m_later': row['price_1m_later'],
'return_1d': row['return_1d'],
'return_1w': row['return_1w'],
'return_1m': row['return_1m'],
'prediction_correct': bool(row['prediction_correct']) if row['prediction_correct'] is not None else None,
'hold_days': row['hold_days'] if 'hold_days' in row.keys() else None
}
for row in cursor.fetchall()
]
finally:
conn.close()
def get_all_backtest_results() -> list:
"""Get all backtest results for accuracy calculation."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
SELECT br.*, sa.confidence, sa.risk
FROM backtest_results br
LEFT JOIN stock_analysis sa ON br.date = sa.date AND br.symbol = sa.symbol
WHERE br.prediction_correct IS NOT NULL
ORDER BY br.date DESC
""")
return [
{
'date': row['date'],
'symbol': row['symbol'],
'decision': row['decision'],
'confidence': row['confidence'],
'risk': row['risk'],
'price_at_prediction': row['price_at_prediction'],
'return_1d': row['return_1d'],
'return_1w': row['return_1w'],
'return_1m': row['return_1m'],
'prediction_correct': bool(row['prediction_correct'])
}
for row in cursor.fetchall()
]
finally:
conn.close()
def calculate_accuracy_metrics() -> dict:
"""Calculate overall backtest accuracy metrics."""
results = get_all_backtest_results()
if not results:
return {
'overall_accuracy': 0,
'total_predictions': 0,
'correct_predictions': 0,
'by_decision': {'BUY': {'accuracy': 0, 'total': 0}, 'SELL': {'accuracy': 0, 'total': 0}, 'HOLD': {'accuracy': 0, 'total': 0}},
'by_confidence': {'High': {'accuracy': 0, 'total': 0}, 'Medium': {'accuracy': 0, 'total': 0}, 'Low': {'accuracy': 0, 'total': 0}}
}
total = len(results)
correct = sum(1 for r in results if r['prediction_correct'])
# By decision type
by_decision = {}
for decision in ['BUY', 'SELL', 'HOLD']:
decision_results = [r for r in results if r['decision'] == decision]
if decision_results:
decision_correct = sum(1 for r in decision_results if r['prediction_correct'])
by_decision[decision] = {
'accuracy': round(decision_correct / len(decision_results) * 100, 1),
'total': len(decision_results),
'correct': decision_correct
}
else:
by_decision[decision] = {'accuracy': 0, 'total': 0, 'correct': 0}
# By confidence level
by_confidence = {}
for conf in ['High', 'Medium', 'Low']:
conf_results = [r for r in results if r.get('confidence') == conf]
if conf_results:
conf_correct = sum(1 for r in conf_results if r['prediction_correct'])
by_confidence[conf] = {
'accuracy': round(conf_correct / len(conf_results) * 100, 1),
'total': len(conf_results),
'correct': conf_correct
}
else:
by_confidence[conf] = {'accuracy': 0, 'total': 0, 'correct': 0}
return {
'overall_accuracy': round(correct / total * 100, 1) if total > 0 else 0,
'total_predictions': total,
'correct_predictions': correct,
'by_decision': by_decision,
'by_confidence': by_confidence
}
def update_daily_recommendation_summary(date: str):
"""Auto-create/update daily_recommendations from stock_analysis for a date.
Counts BUY/SELL/HOLD decisions, generates top_picks and stocks_to_avoid,
and upserts the daily_recommendations row.
"""
conn = get_connection()
cursor = conn.cursor()
try:
# Get all stock analyses for this date
cursor.execute("""
SELECT symbol, company_name, decision, confidence, risk, raw_analysis
FROM stock_analysis WHERE date = ?
""", (date,))
rows = cursor.fetchall()
if not rows:
return
buy_count = 0
sell_count = 0
hold_count = 0
buy_stocks = []
sell_stocks = []
for row in rows:
decision = (row['decision'] or '').upper()
if decision == 'BUY':
buy_count += 1
buy_stocks.append({
'symbol': row['symbol'],
'company_name': row['company_name'] or row['symbol'],
'decision': 'BUY',
'confidence': row['confidence'] or 'MEDIUM',
'reason': (row['raw_analysis'] or '')[:200]
})
elif decision == 'SELL':
sell_count += 1
sell_stocks.append({
'symbol': row['symbol'],
'company_name': row['company_name'] or row['symbol'],
'decision': 'SELL',
'confidence': row['confidence'] or 'MEDIUM',
'reason': (row['raw_analysis'] or '')[:200]
})
else:
hold_count += 1
total = buy_count + sell_count + hold_count
# Top picks: up to 5 BUY stocks
top_picks = [
{'symbol': s['symbol'], 'company_name': s['company_name'],
'confidence': s['confidence'], 'reason': s['reason']}
for s in buy_stocks[:5]
]
# Stocks to avoid: up to 5 SELL stocks
stocks_to_avoid = [
{'symbol': s['symbol'], 'company_name': s['company_name'],
'confidence': s['confidence'], 'reason': s['reason']}
for s in sell_stocks[:5]
]
cursor.execute("""
INSERT OR REPLACE INTO daily_recommendations
(date, summary_total, summary_buy, summary_sell, summary_hold, top_picks, stocks_to_avoid)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
date, total, buy_count, sell_count, hold_count,
json.dumps(top_picks),
json.dumps(stocks_to_avoid)
))
conn.commit()
finally:
conn.close()
def rebuild_all_daily_recommendations():
"""Rebuild daily_recommendations for all dates that have stock_analysis data.
This ensures dates with stock_analysis but missing daily_recommendations
entries become visible to the API.
"""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("SELECT DISTINCT date FROM stock_analysis")
dates = [row['date'] for row in cursor.fetchall()]
finally:
conn.close()
for date in dates:
update_daily_recommendation_summary(date)
if dates:
print(f"[DB] Rebuilt daily_recommendations for {len(dates)} dates: {sorted(dates)}")
# Initialize database on module import # Initialize database on module import
init_db() init_db()

Binary file not shown.

View File

@ -1,6 +1,7 @@
"""FastAPI server for Nifty50 AI recommendations.""" """FastAPI server for Nifty50 AI recommendations."""
from fastapi import FastAPI, HTTPException, BackgroundTasks from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import database as db import database as db
@ -9,11 +10,18 @@ import os
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
import threading import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import asyncio
import json
import time
# Add parent directories to path for importing trading agents # Add parent directories to path for importing trading agents
PROJECT_ROOT = Path(__file__).parent.parent.parent PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
# Import shared logging system
from tradingagents.log_utils import add_log, analysis_logs, log_lock, log_subscribers
# Track running analyses # Track running analyses
# NOTE: This is not thread-safe for production multi-worker deployments. # NOTE: This is not thread-safe for production multi-worker deployments.
# For production, use Redis or a database-backed job queue instead. # For production, use Redis or a database-backed job queue instead.
@ -145,6 +153,11 @@ class RunAnalysisRequest(BaseModel):
config: Optional[AnalysisConfig] = None config: Optional[AnalysisConfig] = None
def _is_cancelled(symbol: str) -> bool:
"""Check if an analysis has been cancelled."""
return running_analyses.get(symbol, {}).get("cancelled", False)
def run_analysis_task(symbol: str, date: str, analysis_config: dict = None): def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
"""Background task to run trading analysis for a stock.""" """Background task to run trading analysis for a stock."""
global running_analyses global running_analyses
@ -163,14 +176,20 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
running_analyses[symbol] = { running_analyses[symbol] = {
"status": "initializing", "status": "initializing",
"started_at": datetime.now().isoformat(), "started_at": datetime.now().isoformat(),
"progress": "Loading trading agents..." "progress": "Loading trading agents...",
"cancelled": False,
} }
add_log("info", "system", f"🚀 Starting analysis for {symbol} on {date}")
add_log("info", "system", f"Config: deep_think={deep_think_model}, quick_think={quick_think_model}")
# Import trading agents # Import trading agents
add_log("info", "system", "Loading TradingAgentsGraph module...")
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
running_analyses[symbol]["progress"] = "Initializing analysis pipeline..." running_analyses[symbol]["progress"] = "Initializing analysis pipeline..."
add_log("info", "system", "Initializing analysis pipeline...")
# Create config from user settings # Create config from user settings
config = DEFAULT_CONFIG.copy() config = DEFAULT_CONFIG.copy()
@ -183,14 +202,77 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
if provider == "anthropic_api" and api_key: if provider == "anthropic_api" and api_key:
os.environ["ANTHROPIC_API_KEY"] = api_key os.environ["ANTHROPIC_API_KEY"] = api_key
# Check cancellation before starting
if _is_cancelled(symbol):
add_log("info", "system", f"Analysis for {symbol} was cancelled before starting")
running_analyses[symbol]["status"] = "cancelled"
running_analyses[symbol]["progress"] = "Analysis cancelled"
return
running_analyses[symbol]["status"] = "running" running_analyses[symbol]["status"] = "running"
running_analyses[symbol]["progress"] = f"Running market analysis (model: {deep_think_model})..." running_analyses[symbol]["progress"] = f"Running market analysis (model: {deep_think_model})..."
add_log("agent", "system", f"Creating TradingAgentsGraph for {symbol}...")
# Initialize and run # Initialize and run
ta = TradingAgentsGraph(debug=False, config=config) ta = TradingAgentsGraph(debug=False, config=config)
# Check cancellation before graph execution
if _is_cancelled(symbol):
add_log("info", "system", f"Analysis for {symbol} was cancelled before graph execution")
running_analyses[symbol]["status"] = "cancelled"
running_analyses[symbol]["progress"] = "Analysis cancelled"
return
running_analyses[symbol]["progress"] = f"Analyzing {symbol}..." running_analyses[symbol]["progress"] = f"Analyzing {symbol}..."
final_state, decision = ta.propagate(symbol, date) add_log("agent", "system", f"Starting propagation for {symbol}...")
add_log("data", "data_fetch", f"Fetching market data for {symbol}...")
final_state, decision, hold_days = ta.propagate(symbol, date)
# Check cancellation after graph execution (skip saving results)
if _is_cancelled(symbol):
add_log("info", "system", f"Analysis for {symbol} was cancelled after completion — results discarded")
running_analyses[symbol]["status"] = "cancelled"
running_analyses[symbol]["progress"] = "Analysis cancelled (results discarded)"
return
add_log("success", "system", f"✅ Analysis complete for {symbol}: {decision}")
# Extract raw analysis from final_state if available
raw_analysis = ""
if final_state:
if "final_trade_decision" in final_state:
raw_analysis = final_state.get("final_trade_decision", "")
elif "risk_debate_state" in final_state:
raw_analysis = final_state.get("risk_debate_state", {}).get("judge_decision", "")
# Save the analysis result to the database
analysis_data = {
"company_name": symbol,
"decision": decision.upper() if decision else "HOLD",
"confidence": "MEDIUM",
"risk": "MEDIUM",
"raw_analysis": raw_analysis,
"hold_days": hold_days
}
db.save_single_stock_analysis(date, symbol, analysis_data)
add_log("info", "system", f"💾 Saved analysis for {symbol} to database")
# Auto-update daily recommendation summary (counts, top_picks, stocks_to_avoid)
db.update_daily_recommendation_summary(date)
add_log("info", "system", f"📊 Updated daily recommendation summary for {date}")
# Auto-trigger backtest calculation for this stock
try:
import backtest_service as bt
bt_result = bt.calculate_and_save_backtest(date, symbol, analysis_data["decision"], analysis_data.get("hold_days"))
if bt_result:
add_log("info", "system", f"📈 Backtest calculated for {symbol}: correct={bt_result.get('prediction_correct')}")
else:
add_log("info", "system", f"📈 Backtest not available yet for {symbol} (future date or no price data)")
except Exception as bt_err:
add_log("warning", "system", f"⚠️ Backtest calculation skipped for {symbol}: {bt_err}")
running_analyses[symbol] = { running_analyses[symbol] = {
"status": "completed", "status": "completed",
@ -198,9 +280,16 @@ def run_analysis_task(symbol: str, date: str, analysis_config: dict = None):
"progress": f"Analysis complete: {decision}", "progress": f"Analysis complete: {decision}",
"decision": decision "decision": decision
} }
# Clear per-symbol step progress after completion
try:
from tradingagents.log_utils import symbol_progress
symbol_progress.clear(symbol)
except Exception:
pass
except Exception as e: except Exception as e:
error_msg = str(e) if str(e) else f"{type(e).__name__}: No details provided" error_msg = str(e) if str(e) else f"{type(e).__name__}: No details provided"
add_log("error", "system", f"❌ Error analyzing {symbol}: {error_msg}")
running_analyses[symbol] = { running_analyses[symbol] = {
"status": "error", "status": "error",
"error": error_msg, "error": error_msg,
@ -295,6 +384,60 @@ async def health_check():
return {"status": "healthy", "database": "connected"} return {"status": "healthy", "database": "connected"}
# ============== Live Log Streaming Endpoint ==============
@app.get("/stream/logs")
async def stream_logs():
"""Server-Sent Events endpoint for streaming analysis logs."""
import queue
# Create a queue for this subscriber
subscriber_queue = queue.Queue(maxsize=100)
with log_lock:
log_subscribers.append(subscriber_queue)
async def event_generator():
try:
# Send initial connection message
yield f"data: {json.dumps({'type': 'info', 'source': 'system', 'message': 'Connected to log stream', 'timestamp': datetime.now().isoformat()})}\n\n"
# Send any recent logs from buffer
with log_lock:
recent_logs = list(analysis_logs)[-50:] # Last 50 logs
for log in recent_logs:
yield f"data: {json.dumps(log)}\n\n"
# Stream new logs as they arrive
while True:
try:
# Check for new logs with timeout
log_entry = await asyncio.get_event_loop().run_in_executor(
None, lambda: subscriber_queue.get(timeout=5)
)
yield f"data: {json.dumps(log_entry)}\n\n"
except queue.Empty:
# Send heartbeat to keep connection alive
yield f"data: {json.dumps({'type': 'heartbeat', 'timestamp': datetime.now().isoformat()})}\n\n"
except Exception:
break
finally:
# Remove subscriber on disconnect
with log_lock:
if subscriber_queue in log_subscribers:
log_subscribers.remove(subscriber_queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
}
)
# ============== Pipeline Data Endpoints ============== # ============== Pipeline Data Endpoints ==============
@app.get("/recommendations/{date}/{symbol}/pipeline") @app.get("/recommendations/{date}/{symbol}/pipeline")
@ -395,14 +538,15 @@ async def save_pipeline_data(request: SavePipelineDataRequest):
# Track bulk analysis state # Track bulk analysis state
bulk_analysis_state = { bulk_analysis_state = {
"status": "idle", # idle, running, completed, error "status": "idle", # idle, running, completed, error, cancelled
"total": 0, "total": 0,
"completed": 0, "completed": 0,
"failed": 0, "failed": 0,
"current_symbol": None, "current_symbol": None,
"started_at": None, "started_at": None,
"completed_at": None, "completed_at": None,
"results": {} "results": {},
"cancelled": False # Flag to signal cancellation
} }
# List of Nifty 50 stocks # List of Nifty 50 stocks
@ -423,11 +567,12 @@ class BulkAnalysisRequest(BaseModel):
provider: Optional[str] = "claude_subscription" provider: Optional[str] = "claude_subscription"
api_key: Optional[str] = None api_key: Optional[str] = None
max_debate_rounds: Optional[int] = 1 max_debate_rounds: Optional[int] = 1
parallel_workers: Optional[int] = 3
@app.post("/analyze/all") @app.post("/analyze/all")
async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date: Optional[str] = None): async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date: Optional[str] = None):
"""Trigger analysis for all Nifty 50 stocks. Runs in background.""" """Trigger analysis for all Nifty 50 stocks. Runs in background with parallel processing."""
global bulk_analysis_state global bulk_analysis_state
# Check if bulk analysis is already running # Check if bulk analysis is already running
@ -443,6 +588,7 @@ async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date:
# Build analysis config from request # Build analysis config from request
analysis_config = {} analysis_config = {}
parallel_workers = 3
if request: if request:
analysis_config = { analysis_config = {
"deep_think_model": request.deep_think_model, "deep_think_model": request.deep_think_model,
@ -451,57 +597,129 @@ async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date:
"api_key": request.api_key, "api_key": request.api_key,
"max_debate_rounds": request.max_debate_rounds "max_debate_rounds": request.max_debate_rounds
} }
if request.parallel_workers is not None:
parallel_workers = max(1, min(5, request.parallel_workers))
# Resume support: skip stocks already analyzed for this date
already_analyzed = set(db.get_analyzed_symbols_for_date(date))
symbols_to_analyze = [s for s in NIFTY_50_SYMBOLS if s not in already_analyzed]
skipped_count = len(already_analyzed)
# If all stocks are already analyzed, return immediately
if not symbols_to_analyze:
bulk_analysis_state = {
"status": "completed",
"total": 0,
"total_all": len(NIFTY_50_SYMBOLS),
"skipped": skipped_count,
"completed": 0,
"failed": 0,
"current_symbols": [],
"started_at": datetime.now().isoformat(),
"completed_at": datetime.now().isoformat(),
"results": {},
"parallel_workers": parallel_workers,
"cancelled": False
}
return {
"message": f"All {skipped_count} stocks already analyzed for {date}",
"date": date,
"total_stocks": 0,
"skipped": skipped_count,
"parallel_workers": parallel_workers,
"status": "completed"
}
def analyze_single_stock(symbol: str, analysis_date: str, config: dict) -> tuple:
"""Analyze a single stock and return (symbol, status, error)."""
try:
# Check if cancelled before starting
if bulk_analysis_state.get("cancelled"):
return (symbol, "cancelled", "Bulk analysis was cancelled")
run_analysis_task(symbol, analysis_date, config)
# Wait for completion with timeout
import time
max_wait = 600 # 10 minute timeout per stock
waited = 0
while waited < max_wait:
# Check for cancellation during wait
if bulk_analysis_state.get("cancelled"):
return (symbol, "cancelled", "Bulk analysis was cancelled")
if symbol not in running_analyses:
return (symbol, "unknown", None)
status = running_analyses[symbol].get("status")
if status != "running" and status != "initializing":
return (symbol, status, None)
time.sleep(2)
waited += 2
return (symbol, "timeout", "Analysis timed out after 10 minutes")
except Exception as e:
return (symbol, "error", str(e))
# Start bulk analysis in background thread # Start bulk analysis in background thread
def run_bulk(): def run_bulk_parallel():
global bulk_analysis_state global bulk_analysis_state
bulk_analysis_state = { bulk_analysis_state = {
"status": "running", "status": "running",
"total": len(NIFTY_50_SYMBOLS), "total": len(symbols_to_analyze),
"total_all": len(NIFTY_50_SYMBOLS),
"skipped": skipped_count,
"completed": 0, "completed": 0,
"failed": 0, "failed": 0,
"current_symbol": None, "current_symbols": [],
"started_at": datetime.now().isoformat(), "started_at": datetime.now().isoformat(),
"completed_at": None, "completed_at": None,
"results": {} "results": {},
"parallel_workers": parallel_workers,
"cancelled": False
} }
for symbol in NIFTY_50_SYMBOLS: with ThreadPoolExecutor(max_workers=parallel_workers) as executor:
future_to_symbol = {
executor.submit(analyze_single_stock, symbol, date, analysis_config): symbol
for symbol in symbols_to_analyze
}
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: try:
bulk_analysis_state["current_symbol"] = symbol symbol, status, error = future.result()
run_analysis_task(symbol, date, analysis_config) bulk_analysis_state["results"][symbol] = status if not error else f"error: {error}"
# Wait for completion
import time
while symbol in running_analyses and running_analyses[symbol].get("status") == "running":
time.sleep(2)
if symbol in running_analyses:
status = running_analyses[symbol].get("status", "unknown")
bulk_analysis_state["results"][symbol] = status
if status == "completed": if status == "completed":
bulk_analysis_state["completed"] += 1 bulk_analysis_state["completed"] += 1
else: else:
bulk_analysis_state["failed"] += 1 bulk_analysis_state["failed"] += 1
else:
bulk_analysis_state["results"][symbol] = "unknown" remaining = [s for s in symbols_to_analyze
bulk_analysis_state["failed"] += 1 if s not in bulk_analysis_state["results"]]
bulk_analysis_state["current_symbols"] = remaining[:parallel_workers]
except Exception as e: except Exception as e:
bulk_analysis_state["results"][symbol] = f"error: {str(e)}" bulk_analysis_state["results"][symbol] = f"error: {str(e)}"
bulk_analysis_state["failed"] += 1 bulk_analysis_state["failed"] += 1
bulk_analysis_state["status"] = "completed" bulk_analysis_state["status"] = "completed"
bulk_analysis_state["current_symbol"] = None bulk_analysis_state["current_symbols"] = []
bulk_analysis_state["completed_at"] = datetime.now().isoformat() bulk_analysis_state["completed_at"] = datetime.now().isoformat()
thread = threading.Thread(target=run_bulk) thread = threading.Thread(target=run_bulk_parallel)
thread.start() thread.start()
skipped_msg = f", {skipped_count} already done" if skipped_count > 0 else ""
return { return {
"message": "Bulk analysis started for all Nifty 50 stocks", "message": f"Bulk analysis started for {len(symbols_to_analyze)} stocks ({parallel_workers} parallel workers{skipped_msg})",
"date": date, "date": date,
"total_stocks": len(NIFTY_50_SYMBOLS), "total_stocks": len(symbols_to_analyze),
"skipped": skipped_count,
"parallel_workers": parallel_workers,
"status": "started" "status": "started"
} }
@ -509,7 +727,47 @@ async def run_bulk_analysis(request: Optional[BulkAnalysisRequest] = None, date:
@app.get("/analyze/all/status") @app.get("/analyze/all/status")
async def get_bulk_analysis_status(): async def get_bulk_analysis_status():
"""Get the status of bulk analysis.""" """Get the status of bulk analysis."""
return bulk_analysis_state # Add backward compatibility for current_symbol (old format)
result = dict(bulk_analysis_state)
if "current_symbols" in result:
result["current_symbol"] = result["current_symbols"][0] if result["current_symbols"] else None
# Include per-stock step progress for currently-analyzing stocks
if result.get("status") == "running" and result.get("current_symbols"):
try:
from tradingagents.log_utils import symbol_progress
stock_progress = {}
for sym in result["current_symbols"]:
stock_progress[sym] = symbol_progress.get(sym)
result["stock_progress"] = stock_progress
except Exception:
pass
return result
@app.post("/analyze/all/cancel")
async def cancel_bulk_analysis():
"""Cancel the running bulk analysis."""
global bulk_analysis_state
if bulk_analysis_state.get("status") != "running":
return {
"message": "No bulk analysis is running",
"status": bulk_analysis_state.get("status")
}
# Set the cancelled flag
bulk_analysis_state["cancelled"] = True
bulk_analysis_state["status"] = "cancelled"
bulk_analysis_state["completed_at"] = datetime.now().isoformat()
return {
"message": "Bulk analysis cancellation requested",
"completed": bulk_analysis_state.get("completed", 0),
"total": bulk_analysis_state.get("total", 0),
"status": "cancelled"
}
@app.get("/analyze/running") @app.get("/analyze/running")
@ -571,7 +829,7 @@ async def run_analysis(symbol: str, background_tasks: BackgroundTasks, request:
@app.get("/analyze/{symbol}/status") @app.get("/analyze/{symbol}/status")
async def get_analysis_status(symbol: str): async def get_analysis_status(symbol: str):
"""Get the status of a running or completed analysis.""" """Get the status of a running or completed analysis, including live pipeline step progress."""
symbol = symbol.upper() symbol = symbol.upper()
if symbol not in running_analyses: if symbol not in running_analyses:
@ -581,11 +839,234 @@ async def get_analysis_status(symbol: str):
"message": "No analysis has been run for this stock" "message": "No analysis has been run for this stock"
} }
return { result = {
"symbol": symbol, "symbol": symbol,
**running_analyses[symbol] **running_analyses[symbol]
} }
# Include live pipeline step progress from step_timer when analysis is running
if running_analyses[symbol].get("status") == "running":
try:
from tradingagents.log_utils import step_timer
steps = step_timer.get_steps()
if steps:
# Build a live progress summary
STEP_NAMES = {
"market_analyst": "Market Analysis",
"social_media_analyst": "Social Media Analysis",
"news_analyst": "News Analysis",
"fundamentals_analyst": "Fundamental Analysis",
"bull_researcher": "Bull Research",
"bear_researcher": "Bear Research",
"research_manager": "Research Manager",
"trader": "Trader Decision",
"aggressive_analyst": "Aggressive Analysis",
"conservative_analyst": "Conservative Analysis",
"neutral_analyst": "Neutral Analysis",
"risk_manager": "Risk Manager",
}
completed = [k for k, v in steps.items() if v.get("status") == "completed"]
running = [k for k, v in steps.items() if v.get("status") == "running"]
total = 12
# Build progress message from live step data
if running:
current_step = STEP_NAMES.get(running[0], running[0])
result["progress"] = f"Step {len(completed)+1}/{total}: {current_step}..."
elif completed:
last_step = STEP_NAMES.get(completed[-1], completed[-1])
result["progress"] = f"Step {len(completed)}/{total}: {last_step} done"
result["steps_completed"] = len(completed)
result["steps_running"] = [STEP_NAMES.get(s, s) for s in running]
result["steps_total"] = total
result["pipeline_steps"] = {
k: {"status": v.get("status"), "duration_ms": v.get("duration_ms")}
for k, v in steps.items()
}
except Exception:
pass # Don't fail status endpoint if step_timer unavailable
return result
@app.post("/analyze/{symbol}/cancel")
async def cancel_analysis(symbol: str):
"""Cancel a running analysis for a stock."""
symbol = symbol.upper()
if symbol not in running_analyses:
return {"message": f"No analysis found for {symbol}", "status": "not_found"}
current_status = running_analyses[symbol].get("status")
if current_status not in ("running", "initializing"):
return {"message": f"Analysis for {symbol} is not running (status: {current_status})", "status": current_status}
# Set cancellation flag — the background thread checks this
running_analyses[symbol]["cancelled"] = True
running_analyses[symbol]["status"] = "cancelled"
running_analyses[symbol]["progress"] = "Cancellation requested..."
running_analyses[symbol]["completed_at"] = datetime.now().isoformat()
add_log("info", "system", f"🛑 Cancellation requested for {symbol}")
return {
"message": f"Cancellation requested for {symbol}",
"symbol": symbol,
"status": "cancelled"
}
# ============== Backtest Endpoints ==============
# NOTE: Static routes must come BEFORE parameterized routes to avoid
# "accuracy" being matched as a {date} parameter.
@app.get("/backtest/accuracy")
async def get_accuracy_metrics():
"""Get overall backtest accuracy metrics."""
metrics = db.calculate_accuracy_metrics()
return metrics
@app.get("/backtest/{date}/{symbol}")
async def get_backtest_result(date: str, symbol: str):
"""Get backtest result for a specific stock and date.
Returns pre-calculated results only (no on-demand yfinance fetching)
to avoid blocking the event loop.
"""
result = db.get_backtest_result(date, symbol.upper())
if not result:
return {'available': False, 'reason': 'Backtest not yet calculated'}
return {
'available': True,
'prediction_correct': result['prediction_correct'],
'actual_return_1d': result['return_1d'],
'actual_return_1w': result['return_1w'],
'actual_return_1m': result['return_1m'],
'price_at_prediction': result['price_at_prediction'],
'current_price': result.get('price_1m_later') or result.get('price_1w_later'),
'hold_days': result.get('hold_days'),
}
@app.get("/backtest/{date}")
async def get_backtest_results_for_date(date: str):
"""Get all backtest results for a specific date."""
results = db.get_backtest_results_by_date(date)
return {"date": date, "results": results}
@app.post("/backtest/{date}/calculate")
async def calculate_backtest_for_date(date: str):
"""Calculate backtest for all recommendations on a date (runs in background thread)."""
import backtest_service as bt
# Run calculation in a separate thread to avoid blocking the event loop
def run_backtest():
try:
bt.backtest_all_recommendations_for_date(date)
except Exception as e:
print(f"Backtest calculation error for {date}: {e}")
thread = threading.Thread(target=run_backtest)
thread.start()
return {"status": "started", "date": date, "message": "Backtest calculation started in background"}
# ============== Stock Price History Endpoint ==============
@app.get("/stocks/{symbol}/prices")
async def get_stock_price_history(symbol: str, days: int = 90):
"""Get real historical closing prices for a stock from yfinance."""
try:
import yfinance as yf
from datetime import timedelta
yf_symbol = symbol if '.' in symbol else f"{symbol}.NS"
ticker = yf.Ticker(yf_symbol)
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
hist = ticker.history(start=start_date.strftime('%Y-%m-%d'),
end=end_date.strftime('%Y-%m-%d'))
if hist.empty:
return {"symbol": symbol, "prices": [], "error": "No price data found"}
prices = [
{"date": idx.strftime('%Y-%m-%d'), "price": round(float(row['Close']), 2)}
for idx, row in hist.iterrows()
]
return {"symbol": symbol, "prices": prices}
except ImportError:
return {"symbol": symbol, "prices": [], "error": "yfinance not installed"}
except Exception as e:
return {"symbol": symbol, "prices": [], "error": str(e)}
# ============== Nifty50 Index Endpoint ==============
@app.get("/nifty50/history")
async def get_nifty50_history():
"""Get Nifty50 index closing prices for recommendation date range."""
try:
import yfinance as yf
from datetime import timedelta
# Get the date range from our recommendations
dates = db.get_all_dates()
if not dates:
return {"dates": [], "prices": {}}
# Get date range with buffer for daily return calculation
start_date = (datetime.strptime(min(dates), "%Y-%m-%d") - timedelta(days=7)).strftime("%Y-%m-%d")
end_date = (datetime.strptime(max(dates), "%Y-%m-%d") + timedelta(days=7)).strftime("%Y-%m-%d")
# Fetch ^NSEI data
nifty = yf.Ticker("^NSEI")
hist = nifty.history(start=start_date, end=end_date, interval="1d")
prices = {}
for idx, row in hist.iterrows():
date_str = idx.strftime("%Y-%m-%d")
prices[date_str] = round(float(row['Close']), 2)
return {"dates": sorted(prices.keys()), "prices": prices}
except ImportError:
return {"dates": [], "prices": {}, "error": "yfinance not installed"}
except Exception as e:
return {"dates": [], "prices": {}, "error": str(e)}
@app.on_event("startup")
async def startup_event():
"""Rebuild daily_recommendations and trigger backtest calculations at startup."""
db.rebuild_all_daily_recommendations()
# Trigger backtest calculation for all dates in background
def startup_backtest():
import backtest_service as bt
dates = db.get_all_dates()
for date in dates:
existing = db.get_backtest_results_by_date(date)
rec = db.get_recommendation_by_date(date)
expected_count = len(rec.get('analysis', {})) if rec else 0
if len(existing) < expected_count:
print(f"[Backtest] Calculating for {date} ({len(existing)}/{expected_count} done)...")
try:
bt.backtest_all_recommendations_for_date(date)
except Exception as e:
print(f"[Backtest] Error for {date}: {e}")
thread = threading.Thread(target=startup_backtest, daemon=True)
thread.start()
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

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

View File

@ -1,13 +1,23 @@
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { getAccuracyTrend } from '../data/recommendations'; import { getAccuracyTrend } from '../data/recommendations';
export interface AccuracyTrendPoint {
date: string;
overall: number;
buy: number;
sell: number;
hold: number;
}
interface AccuracyTrendChartProps { interface AccuracyTrendChartProps {
height?: number; height?: number;
className?: string; className?: string;
data?: AccuracyTrendPoint[]; // Optional prop for real data
} }
export default function AccuracyTrendChart({ height = 200, className = '' }: AccuracyTrendChartProps) { export default function AccuracyTrendChart({ height = 200, className = '', data: propData }: AccuracyTrendChartProps) {
const data = getAccuracyTrend(); // Use provided data or fall back to mock data
const data = propData || getAccuracyTrend();
if (data.length === 0) { if (data.length === 0) {
return ( return (

View File

@ -1,13 +1,16 @@
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
import { getCumulativeReturns } from '../data/recommendations'; import { getCumulativeReturns } from '../data/recommendations';
import type { CumulativeReturnPoint } from '../types';
interface CumulativeReturnChartProps { interface CumulativeReturnChartProps {
height?: number; height?: number;
className?: string; className?: string;
data?: CumulativeReturnPoint[]; // Optional prop for real data
} }
export default function CumulativeReturnChart({ height = 160, className = '' }: CumulativeReturnChartProps) { export default function CumulativeReturnChart({ height = 160, className = '', data: propData }: CumulativeReturnChartProps) {
const data = getCumulativeReturns(); // Use provided data or fall back to mock data
const data = propData || getCumulativeReturns();
if (data.length === 0) { if (data.length === 0) {
return ( return (

View File

@ -1,14 +1,17 @@
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine } from 'recharts'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine } from 'recharts';
import { TrendingUp, TrendingDown } from 'lucide-react'; import { TrendingUp, TrendingDown } from 'lucide-react';
import { getCumulativeReturns } from '../data/recommendations'; import { getCumulativeReturns } from '../data/recommendations';
import type { CumulativeReturnPoint } from '../types';
interface IndexComparisonChartProps { export interface IndexComparisonChartProps {
height?: number; height?: number;
className?: string; className?: string;
data?: CumulativeReturnPoint[]; // Optional prop for real data
} }
export default function IndexComparisonChart({ height = 220, className = '' }: IndexComparisonChartProps) { export default function IndexComparisonChart({ height = 220, className = '', data: propData }: IndexComparisonChartProps) {
const data = getCumulativeReturns(); // Use provided data or fall back to mock data
const data = propData || getCumulativeReturns();
if (data.length === 0) { if (data.length === 0) {
return ( return (

View File

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

View File

@ -1,16 +1,27 @@
import { X, Activity } from 'lucide-react'; import { X, Activity } from 'lucide-react';
import { getOverallReturnBreakdown } from '../data/recommendations'; import { getOverallReturnBreakdown } from '../data/recommendations';
import CumulativeReturnChart from './CumulativeReturnChart'; import CumulativeReturnChart from './CumulativeReturnChart';
import type { CumulativeReturnPoint } from '../types';
export interface OverallReturnBreakdown {
dailyReturns: { date: string; return: number; multiplier: number; cumulative: number }[];
finalMultiplier: number;
finalReturn: number;
formula: string;
}
interface OverallReturnModalProps { interface OverallReturnModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
breakdown?: OverallReturnBreakdown; // Optional prop for real data
cumulativeData?: CumulativeReturnPoint[]; // Optional prop for chart data
} }
export default function OverallReturnModal({ isOpen, onClose }: OverallReturnModalProps) { export default function OverallReturnModal({ isOpen, onClose, breakdown: propBreakdown, cumulativeData }: OverallReturnModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
const breakdown = getOverallReturnBreakdown(); // Use provided breakdown or fall back to mock data
const breakdown = propBreakdown || getOverallReturnBreakdown();
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
@ -55,7 +66,7 @@ export default function OverallReturnModal({ isOpen, onClose }: OverallReturnMod
<div> <div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Portfolio Growth</h3> <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Portfolio Growth</h3>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50"> <div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
<CumulativeReturnChart height={140} /> <CumulativeReturnChart height={140} data={cumulativeData} />
</div> </div>
</div> </div>

View File

@ -1,39 +1,271 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend, BarChart, Bar, Cell, LabelList } from 'recharts';
import { Calculator, ChevronDown, ChevronUp, IndianRupee } from 'lucide-react'; import { Calculator, ChevronDown, ChevronUp, IndianRupee, Settings2, BarChart3, Info, TrendingUp, TrendingDown, ArrowRightLeft, Wallet, PiggyBank, Receipt, HelpCircle, AlertCircle } from 'lucide-react';
import { getOverallReturnBreakdown } from '../data/recommendations'; import { sampleRecommendations, getNifty50IndexHistory, getBacktestResult } from '../data/recommendations';
import { calculateBrokerage, formatINR, type BrokerageBreakdown } from '../utils/brokerageCalculator';
import InfoModal, { InfoButton } from './InfoModal';
import type { Decision, DailyRecommendation } from '../types';
interface PortfolioSimulatorProps { interface PortfolioSimulatorProps {
className?: string; className?: string;
recommendations?: DailyRecommendation[];
isUsingMockData?: boolean;
nifty50Prices?: Record<string, number>;
allBacktestData?: Record<string, Record<string, number>>;
} }
export default function PortfolioSimulator({ className = '' }: PortfolioSimulatorProps) { export type InvestmentMode = 'all50' | 'topPicks';
const [startingAmount, setStartingAmount] = useState(100000);
const [showBreakdown, setShowBreakdown] = useState(false);
const breakdown = useMemo(() => getOverallReturnBreakdown(), []); 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);
}
}
// Calculate portfolio values over time
const portfolioData = useMemo(() => {
let value = startingAmount;
return breakdown.dailyReturns.map(day => {
value = value * day.multiplier;
return { return {
date: new Date(day.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }), date: new Date(rec.date).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' }),
value: Math.round(value), rawDate: rec.date,
return: day.return, value: Math.round(portfolioValue),
cumulative: day.cumulative, niftyValue: Math.round(niftyValue),
return: avgDayReturn,
cumulative: ((portfolioValue - startingAmount) / startingAmount) * 100,
}; };
}); });
}, [breakdown.dailyReturns, startingAmount]);
const currentValue = portfolioData.length > 0 const totalBrokerage = completedTrades.reduce<BrokerageBreakdown>(
? portfolioData[portfolioData.length - 1].value (acc, trade) => ({
: startingAmount; brokerage: acc.brokerage + trade.brokerage.brokerage,
const totalReturn = ((currentValue - startingAmount) / startingAmount) * 100; stt: acc.stt + trade.brokerage.stt,
const profitLoss = currentValue - startingAmount; 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);
// Modal state - single state for all modals instead of 7 separate booleans
type ModalType = 'totalTrades' | 'buyTrades' | 'sellTrades' | 'portfolioValue' | 'profitLoss' | 'comparison' | null;
const [activeModal, setActiveModal] = useState<ModalType>(null);
const { portfolioData, stats, openPositions } = useMemo(() => {
return calculateSmartTrades(
recommendations,
investmentMode,
startingAmount,
nifty50Prices,
allBacktestData
);
}, [recommendations, investmentMode, startingAmount, nifty50Prices, allBacktestData]);
const lastDataPoint = portfolioData[portfolioData.length - 1];
const currentValue = lastDataPoint?.value ?? startingAmount;
const niftyValue = lastDataPoint?.niftyValue ?? startingAmount;
const totalCharges = includeBrokerage ? stats.brokerageBreakdown.totalCharges : 0;
const finalValue = currentValue - totalCharges;
const totalReturn = ((finalValue - startingAmount) / startingAmount) * 100;
const profitLoss = finalValue - startingAmount;
const isPositive = profitLoss >= 0; const isPositive = profitLoss >= 0;
const niftyReturn = ((niftyValue - startingAmount) / startingAmount) * 100;
const outperformance = totalReturn - niftyReturn;
// Calculate Y-axis domain with padding
const yAxisDomain = useMemo(() => {
if (portfolioData.length === 0) return [0, startingAmount * 1.2];
const allValues = portfolioData.flatMap(d => [d.value, d.niftyValue]);
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
return [Math.floor((minValue - padding) / 1000) * 1000, Math.ceil((maxValue + padding) / 1000) * 1000];
}, [portfolioData, startingAmount]);
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value.replace(/,/g, ''), 10); const value = parseInt(e.target.value.replace(/,/g, ''), 10);
if (!isNaN(value) && value >= 0) { if (!isNaN(value) && value >= 0) {
@ -41,20 +273,72 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
} }
}; };
const formatCurrency = (value: number) => { const openPositionsCount = Object.keys(openPositions).length;
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
maximumFractionDigits: 0,
}).format(value);
};
return ( return (
<div className={`card p-4 ${className}`}> <div className={`card p-4 ${className}`}>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Calculator className="w-5 h-5 text-nifty-600 dark:text-nifty-400" /> <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> <h2 className="font-semibold text-gray-900 dark:text-gray-100">Portfolio Simulator</h2>
</div> </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 */} {/* Input Section */}
<div className="mb-4"> <div className="mb-4">
@ -81,7 +365,7 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-slate-600' : 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-slate-600'
}`} }`}
> >
{formatCurrency(amount)} {formatINR(amount, 0)}
</button> </button>
))} ))}
</div> </div>
@ -89,24 +373,158 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
{/* Results Section */} {/* Results Section */}
<div className="grid grid-cols-2 gap-3 mb-4"> <div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50"> <div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50 relative">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Current Value</div> <div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-1">
<div className={`text-xl font-bold ${isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}> <span>Final Portfolio Value</span>
{formatCurrency(currentValue)} <InfoButton onClick={() => setActiveModal('portfolioValue')} />
</div>
<div className={`text-xl font-bold ${getValueColorClass(profitLoss)}`}>
{formatINR(finalValue, 0)}
</div> </div>
</div> </div>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50"> <div className="p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Profit/Loss</div> <div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-1">
<div className={`text-xl font-bold ${isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}> <span>Net Profit/Loss</span>
{isPositive ? '+' : ''}{formatCurrency(profitLoss)} <InfoButton onClick={() => setActiveModal('profitLoss')} />
</div>
<div className={`text-xl font-bold ${getValueColorClass(profitLoss)}`}>
{isPositive ? '+' : ''}{formatINR(profitLoss, 0)}
<span className="text-sm ml-1">({isPositive ? '+' : ''}{totalReturn.toFixed(1)}%)</span> <span className="text-sm ml-1">({isPositive ? '+' : ''}{totalReturn.toFixed(1)}%)</span>
</div> </div>
</div> </div>
</div> </div>
{/* Chart */} {/* Trade Stats with Info Buttons */}
<div className="grid grid-cols-4 gap-2 mb-4">
<div
className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-center cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
onClick={() => setActiveModal('totalTrades')}
>
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{stats.totalTrades}</div>
<div className="text-[10px] text-blue-600/70 dark:text-blue-400/70 flex items-center justify-center gap-0.5">
Total Trades <HelpCircle className="w-2.5 h-2.5" />
</div>
</div>
<div
className="p-2 rounded-lg bg-green-50 dark:bg-green-900/20 text-center cursor-pointer hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors"
onClick={() => setActiveModal('buyTrades')}
>
<div className="text-lg font-bold text-green-600 dark:text-green-400">{stats.buyTrades}</div>
<div className="text-[10px] text-green-600/70 dark:text-green-400/70 flex items-center justify-center gap-0.5">
Buy Trades <HelpCircle className="w-2.5 h-2.5" />
</div>
</div>
<div
className="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-center cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
onClick={() => setActiveModal('sellTrades')}
>
<div className="text-lg font-bold text-red-600 dark:text-red-400">{stats.sellTrades}</div>
<div className="text-[10px] text-red-600/70 dark:text-red-400/70 flex items-center justify-center gap-0.5">
Sell Trades <HelpCircle className="w-2.5 h-2.5" />
</div>
</div>
<div
className="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-center cursor-pointer hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors"
onClick={() => setShowBrokerageDetails(!showBrokerageDetails)}
title="Click for detailed breakdown"
>
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{formatINR(totalCharges, 0)}</div>
<div className="text-[10px] text-amber-600/70 dark:text-amber-400/70 flex items-center justify-center gap-0.5">
Total Charges <Info className="w-2.5 h-2.5" />
</div>
</div>
</div>
{/* Open Positions Badge */}
{openPositionsCount > 0 && (
<div className="mb-4 p-2 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800/30">
<div className="flex items-center justify-between text-xs">
<span className="text-purple-700 dark:text-purple-300 flex items-center gap-1">
<Wallet className="w-3.5 h-3.5" />
Open Positions (not yet sold)
</span>
<span className="font-bold text-purple-600 dark:text-purple-400">{openPositionsCount} stocks</span>
</div>
</div>
)}
{/* Brokerage Breakdown */}
{showBrokerageDetails && includeBrokerage && (
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800/30">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-xs font-semibold text-amber-800 dark:text-amber-300">Zerodha Equity Delivery Charges</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Brokerage:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.brokerage)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">STT:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.stt)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Exchange Charges:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.exchangeCharges)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">SEBI Charges:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.sebiCharges)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">GST (18%):</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.gst)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Stamp Duty:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{formatINR(stats.brokerageBreakdown.stampDuty)}</span>
</div>
</div>
<div className="mt-2 pt-2 border-t border-amber-200 dark:border-amber-700 flex justify-between">
<span className="text-xs font-semibold text-amber-800 dark:text-amber-300">Total Turnover:</span>
<span className="text-xs font-bold text-amber-800 dark:text-amber-300">{formatINR(stats.brokerageBreakdown.turnover, 0)}</span>
</div>
</div>
)}
{/* Comparison with Nifty */}
<div
className="mb-4 p-3 rounded-lg bg-gradient-to-r from-nifty-50 to-blue-50 dark:from-nifty-900/20 dark:to-blue-900/20 border border-nifty-100 dark:border-nifty-800/30 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => setActiveModal('comparison')}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-nifty-600 dark:text-nifty-400" />
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">vs Nifty 50 Index</span>
</div>
<HelpCircle className="w-3.5 h-3.5 text-gray-400" />
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className={`text-sm font-bold ${getValueColorClass(totalReturn)}`}>
{totalReturn >= 0 ? '+' : ''}{totalReturn.toFixed(1)}%
</div>
<div className="text-[10px] text-gray-500">AI Strategy</div>
</div>
<div>
<div className={`text-sm font-bold ${getValueColorClass(niftyReturn)}`}>
{niftyReturn >= 0 ? '+' : ''}{niftyReturn.toFixed(1)}%
</div>
<div className="text-[10px] text-gray-500">Nifty 50</div>
</div>
<div>
<div className={`text-sm font-bold ${outperformance >= 0 ? 'text-nifty-600 dark:text-nifty-400' : 'text-red-600 dark:text-red-400'}`}>
{outperformance >= 0 ? '+' : ''}{outperformance.toFixed(1)}%
</div>
<div className="text-[10px] text-gray-500">Outperformance</div>
</div>
</div>
</div>
{/* Chart with Nifty Comparison - Fixed Y-axis */}
{portfolioData.length > 0 && ( {portfolioData.length > 0 && (
<div className="h-40 mb-4"> <div className="h-48 mb-4">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={portfolioData} margin={{ top: 5, right: 10, bottom: 5, left: 0 }}> <LineChart data={portfolioData} margin={{ top: 5, right: 10, bottom: 5, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" /> <CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
@ -117,9 +535,10 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
/> />
<YAxis <YAxis
tick={{ fontSize: 10 }} tick={{ fontSize: 10 }}
tickFormatter={(v) => formatCurrency(v).replace('₹', '')} tickFormatter={(v) => formatINR(v, 0).replace('₹', '')}
className="text-gray-500 dark:text-gray-400" className="text-gray-500 dark:text-gray-400"
width={60} width={60}
domain={yAxisDomain}
/> />
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
@ -128,7 +547,14 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
borderRadius: '8px', borderRadius: '8px',
fontSize: '12px', fontSize: '12px',
}} }}
formatter={(value) => [formatCurrency(value as number), 'Value']} formatter={(value, name) => [
formatINR(Number(value) || 0, 0),
name === 'value' ? 'AI Strategy' : 'Nifty 50'
]}
/>
<Legend
wrapperStyle={{ fontSize: '10px' }}
formatter={(value) => value === 'value' ? 'AI Strategy' : 'Nifty 50'}
/> />
<ReferenceLine <ReferenceLine
y={startingAmount} y={startingAmount}
@ -139,15 +565,106 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
<Line <Line
type="monotone" type="monotone"
dataKey="value" dataKey="value"
name="value"
stroke={isPositive ? '#22c55e' : '#ef4444'} stroke={isPositive ? '#22c55e' : '#ef4444'}
strokeWidth={2} strokeWidth={2}
dot={{ fill: isPositive ? '#22c55e' : '#ef4444', r: 3 }} dot={false}
/>
<Line
type="monotone"
dataKey="niftyValue"
name="niftyValue"
stroke="#6366f1"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/> />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)} )}
{/* Trade Waterfall Toggle */}
<button
onClick={() => setShowTradeWaterfall(!showTradeWaterfall)}
className="flex items-center justify-between w-full px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700/50 rounded-lg transition-colors mb-2"
>
<span className="flex items-center gap-2">
<ArrowRightLeft className="w-4 h-4" />
Trade Timeline ({stats.trades.length} completed trades)
</span>
{showTradeWaterfall ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{/* Trade Waterfall Chart */}
{showTradeWaterfall && stats.trades.length > 0 && (
<div className="mb-4 p-3 bg-gray-50 dark:bg-slate-700/30 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Each bar represents a trade from buy to sell. Green = Profit, Red = Loss.
</div>
<div className="h-64 overflow-y-auto">
<div style={{ height: Math.max(200, stats.trades.length * 28) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={stats.trades.map((t, i) => ({
...t,
idx: i,
displayName: `${t.symbol}`,
duration: `${new Date(t.entryDate).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })} → ${new Date(t.exitDate).toLocaleDateString('en-IN', { month: 'short', day: 'numeric' })}`,
}))}
layout="vertical"
margin={{ top: 5, right: 60, bottom: 5, left: 70 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 9 }}
tickFormatter={(v) => formatINR(v, 0)}
domain={['dataMin', 'dataMax']}
/>
<YAxis
type="category"
dataKey="displayName"
tick={{ fontSize: 10 }}
width={65}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--tooltip-bg, #fff)',
border: '1px solid var(--tooltip-border, #e5e7eb)',
borderRadius: '8px',
fontSize: '11px',
}}
formatter={(value) => [formatINR(Number(value) || 0, 2), 'P/L']}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const d = payload[0].payload;
return `${d.symbol}: ${d.duration}`;
}
return '';
}}
/>
<Bar dataKey="profitLoss" radius={[0, 4, 4, 0]}>
{stats.trades.map((trade, index) => (
<Cell
key={`cell-${index}`}
fill={trade.profitLoss >= 0 ? '#22c55e' : '#ef4444'}
/>
))}
<LabelList
dataKey="profitLoss"
position="right"
formatter={(v) => formatINR(Number(v) || 0, 0)}
style={{ fontSize: 9, fill: '#6b7280' }}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
{/* Daily Breakdown (Collapsible) */} {/* Daily Breakdown (Collapsible) */}
<button <button
onClick={() => setShowBreakdown(!showBreakdown)} onClick={() => setShowBreakdown(!showBreakdown)}
@ -164,20 +681,22 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
<tr> <tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Date</th> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Date</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Return</th> <th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Return</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Value</th> <th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">AI Value</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Nifty</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700"> <tbody className="divide-y divide-gray-100 dark:divide-slate-700">
{portfolioData.map((day, idx) => ( {portfolioData.map((day, idx) => (
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-slate-700/50"> <tr key={idx} className="hover:bg-gray-50 dark:hover:bg-slate-700/50">
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{day.date}</td> <td className="px-3 py-2 text-gray-700 dark:text-gray-300">{day.date}</td>
<td className={`px-3 py-2 text-right font-medium ${ <td className={`px-3 py-2 text-right font-medium ${getValueColorClass(day.return)}`}>
day.return >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}% {day.return >= 0 ? '+' : ''}{day.return.toFixed(1)}%
</td> </td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300"> <td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300">
{formatCurrency(day.value)} {formatINR(day.value, 0)}
</td>
<td className="px-3 py-2 text-right text-indigo-600 dark:text-indigo-400">
{formatINR(day.niftyValue, 0)}
</td> </td>
</tr> </tr>
))} ))}
@ -186,9 +705,151 @@ export default function PortfolioSimulator({ className = '' }: PortfolioSimulato
</div> </div>
)} )}
{/* Demo Data Notice */}
{isUsingMockData && (
<div className="flex items-center gap-2 px-3 py-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mt-3">
<AlertCircle className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<span className="text-[10px] text-amber-700 dark:text-amber-300">
Simulation uses demo data. Results are illustrative only.
</span>
</div>
)}
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-3 text-center"> <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-3 text-center">
Simulated returns based on AI recommendation performance. Past performance does not guarantee future results. Simulated using Zerodha Equity Delivery rates (0% brokerage, STT 0.1%, Exchange 0.00345%, SEBI 0.0001%, Stamp 0.015%).
{investmentMode === 'topPicks' ? ' Investing in Top Picks only.' : ' Investing in all 50 stocks.'}
{includeBrokerage ? ` Total Charges: ${formatINR(totalCharges, 0)}` : ''}
</p> </p>
{/* Info Modals */}
<InfoModal
isOpen={activeModal === 'totalTrades'}
onClose={() => setActiveModal(null)}
title="Total Trades"
icon={<ArrowRightLeft className="w-5 h-5 text-blue-600 dark:text-blue-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Total Trades</strong> represents the sum of all buy and sell transactions executed during the simulation period.</p>
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="font-semibold text-blue-800 dark:text-blue-200 mb-1">Calculation:</div>
<code className="text-xs">Total Trades = Buy Trades + Sell Trades</code>
<div className="mt-2 text-xs">= {stats.buyTrades} + {stats.sellTrades} = <strong>{stats.totalTrades}</strong></div>
</div>
<p className="text-xs text-gray-500">Note: A complete round-trip trade (buy then sell) counts as 2 trades.</p>
</div>
</InfoModal>
<InfoModal
isOpen={activeModal === 'buyTrades'}
onClose={() => setActiveModal(null)}
title="Buy Trades"
icon={<TrendingUp className="w-5 h-5 text-green-600 dark:text-green-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Buy Trades</strong> counts when a new position is opened based on AI's BUY recommendation.</p>
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="font-semibold text-green-800 dark:text-green-200 mb-2">When is a Buy Trade counted?</div>
<ul className="text-xs space-y-1 list-disc list-inside">
<li>When AI recommends BUY and no position exists</li>
<li>When AI recommends BUY after a previous SELL</li>
</ul>
</div>
<p className="text-xs text-gray-500">Note: If AI recommends BUY while already holding (from previous BUY or HOLD), no new buy trade is counted - the position is simply carried forward.</p>
</div>
</InfoModal>
<InfoModal
isOpen={activeModal === 'sellTrades'}
onClose={() => setActiveModal(null)}
title="Sell Trades"
icon={<TrendingDown className="w-5 h-5 text-red-600 dark:text-red-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Sell Trades</strong> counts when a position is closed based on AI's SELL recommendation.</p>
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<div className="font-semibold text-red-800 dark:text-red-200 mb-2">When is a Sell Trade counted?</div>
<ul className="text-xs space-y-1 list-disc list-inside">
<li>When AI recommends SELL while holding a position</li>
<li>Position must have been opened via BUY or carried via HOLD</li>
</ul>
</div>
<p className="text-xs text-gray-500">Note: Brokerage is calculated when a sell trade completes a round-trip transaction.</p>
</div>
</InfoModal>
<InfoModal
isOpen={activeModal === 'portfolioValue'}
onClose={() => setActiveModal(null)}
title="Final Portfolio Value"
icon={<PiggyBank className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Final Portfolio Value</strong> is the total worth of your investments at the end of the simulation period.</p>
<div className="p-3 bg-nifty-50 dark:bg-nifty-900/20 rounded-lg">
<div className="font-semibold text-nifty-800 dark:text-nifty-200 mb-1">Calculation:</div>
<code className="text-xs">Final Value = Portfolio Value - Total Charges</code>
<div className="mt-2 text-xs">
= {formatINR(currentValue, 0)} - {formatINR(totalCharges, 0)} = <strong>{formatINR(finalValue, 0)}</strong>
</div>
</div>
<p className="text-xs text-gray-500">This includes all realized gains/losses from completed trades and deducts Zerodha brokerage charges.</p>
</div>
</InfoModal>
<InfoModal
isOpen={activeModal === 'profitLoss'}
onClose={() => setActiveModal(null)}
title="Net Profit/Loss"
icon={<Calculator className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p><strong>Net Profit/Loss</strong> shows your actual earnings or losses after all charges.</p>
<div className="p-3 bg-gray-100 dark:bg-slate-700 rounded-lg">
<div className="font-semibold mb-1">Calculation:</div>
<code className="text-xs">Net P/L = Final Value - Starting Investment</code>
<div className="mt-2 text-xs">
= {formatINR(finalValue, 0)} - {formatINR(startingAmount, 0)} = <strong className={profitLoss >= 0 ? 'text-green-600' : 'text-red-600'}>{formatINR(profitLoss, 0)}</strong>
</div>
<div className="mt-2 text-xs">
Return = ({formatINR(profitLoss, 0)} / {formatINR(startingAmount, 0)}) × 100 = <strong>{totalReturn.toFixed(2)}%</strong>
</div>
</div>
</div>
</InfoModal>
<InfoModal
isOpen={activeModal === 'comparison'}
onClose={() => setActiveModal(null)}
title="vs Nifty 50 Index"
icon={<BarChart3 className="w-5 h-5 text-nifty-600 dark:text-nifty-400" />}
>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p>This compares the AI strategy's performance against simply investing in the Nifty 50 index.</p>
<div className="space-y-2">
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg flex justify-between items-center">
<span>AI Strategy Return:</span>
<strong className={totalReturn >= 0 ? 'text-green-600' : 'text-red-600'}>{totalReturn.toFixed(2)}%</strong>
</div>
<div className="p-2 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg flex justify-between items-center">
<span>Nifty 50 Return:</span>
<strong className={niftyReturn >= 0 ? 'text-green-600' : 'text-red-600'}>{niftyReturn.toFixed(2)}%</strong>
</div>
<div className="p-2 bg-nifty-50 dark:bg-nifty-900/20 rounded-lg flex justify-between items-center">
<span>Outperformance (Alpha):</span>
<strong className={outperformance >= 0 ? 'text-nifty-600' : 'text-red-600'}>{outperformance.toFixed(2)}%</strong>
</div>
</div>
<p className="text-xs text-gray-500">
{outperformance >= 0
? `The AI strategy beat the Nifty 50 index by ${outperformance.toFixed(2)} percentage points.`
: `The AI strategy underperformed the Nifty 50 index by ${Math.abs(outperformance).toFixed(2)} percentage points.`
}
</p>
</div>
</InfoModal>
</div> </div>
); );
} }
// Export the type for use in other components
export { type InvestmentMode as PortfolioInvestmentMode };

View File

@ -2,15 +2,18 @@ import { useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { getReturnDistribution } from '../data/recommendations'; import { getReturnDistribution } from '../data/recommendations';
import type { ReturnBucket } from '../types';
interface ReturnDistributionChartProps { export interface ReturnDistributionChartProps {
height?: number; height?: number;
className?: string; className?: string;
data?: ReturnBucket[]; // Optional prop for real data
} }
export default function ReturnDistributionChart({ height = 200, className = '' }: ReturnDistributionChartProps) { export default function ReturnDistributionChart({ height = 200, className = '', data: propData }: ReturnDistributionChartProps) {
const [selectedBucket, setSelectedBucket] = useState<{ range: string; stocks: string[] } | null>(null); const [selectedBucket, setSelectedBucket] = useState<{ range: string; stocks: string[] } | null>(null);
const data = getReturnDistribution(); // Use provided data or fall back to mock data
const data = propData || getReturnDistribution();
if (data.every(d => d.count === 0)) { if (data.every(d => d.count === 0)) {
return ( return (

View File

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

View File

@ -272,6 +272,29 @@ export default function SettingsModal() {
<span>5 (More thorough)</span> <span>5 (More thorough)</span>
</div> </div>
</div> </div>
{/* Parallel Workers */}
<div className="mt-4">
<label className="flex items-center justify-between text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
<span>Parallel Workers (Analyze All)</span>
<span className="text-nifty-600 dark:text-nifty-400">{settings.parallelWorkers}</span>
</label>
<input
type="range"
min="1"
max="5"
value={settings.parallelWorkers}
onChange={(e) => updateSettings({ parallelWorkers: parseInt(e.target.value) })}
className="w-full h-2 bg-gray-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-nifty-600"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>1 (Conservative)</span>
<span>5 (Aggressive)</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Number of stocks to analyze simultaneously during Analyze All
</p>
</div>
</section> </section>
</div> </div>

View File

@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { TrendingUp, TrendingDown, Minus, ChevronRight } from 'lucide-react'; import { TrendingUp, TrendingDown, Minus, ChevronRight, Clock } from 'lucide-react';
import type { StockAnalysis, Decision } from '../types'; import type { StockAnalysis, Decision } from '../types';
interface StockCardProps { interface StockCardProps {
@ -29,7 +29,9 @@ export function DecisionBadge({ decision, size = 'default' }: { decision: Decisi
}, },
}; };
const { bg, text, icon: Icon } = config[decision]; const entry = config[decision];
if (!entry) return null;
const { bg, text, icon: Icon } = entry;
const sizeClasses = size === 'small' const sizeClasses = size === 'small'
? 'px-2 py-0.5 text-xs gap-1' ? 'px-2 py-0.5 text-xs gap-1'
: 'px-2.5 py-0.5 text-xs gap-1'; : 'px-2.5 py-0.5 text-xs gap-1';
@ -75,6 +77,19 @@ export function RiskBadge({ risk }: { risk?: string }) {
); );
} }
export function HoldDaysBadge({ holdDays, decision }: { holdDays?: number | null; decision?: Decision | null }) {
if (!holdDays || decision === 'SELL') return null;
const label = holdDays === 1 ? '1 day' : `${holdDays}d`;
return (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800">
<Clock className="w-3 h-3" />
Hold {label}
</span>
);
}
export default function StockCard({ stock, showDetails = true, compact = false }: StockCardProps) { export default function StockCard({ stock, showDetails = true, compact = false }: StockCardProps) {
if (compact) { if (compact) {
return ( return (
@ -116,6 +131,7 @@ export default function StockCard({ stock, showDetails = true, compact = false }
<div className="flex items-center gap-2 mt-1.5"> <div className="flex items-center gap-2 mt-1.5">
<ConfidenceBadge confidence={stock.confidence} /> <ConfidenceBadge confidence={stock.confidence} />
<RiskBadge risk={stock.risk} /> <RiskBadge risk={stock.risk} />
<HoldDaysBadge holdDays={stock.hold_days} decision={stock.decision} />
</div> </div>
)} )}
</div> </div>

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Database, ChevronDown, ChevronUp, CheckCircle, Database, ChevronDown, ChevronUp, CheckCircle,
XCircle, Clock, Server XCircle, Clock, Server, Copy, Check, Maximize2, Minimize2
} from 'lucide-react'; } from 'lucide-react';
import type { DataSourceLog } from '../../types/pipeline'; import type { DataSourceLog } from '../../types/pipeline';
@ -19,6 +19,83 @@ const SOURCE_TYPE_COLORS: Record<string, { bg: string; text: string }> = {
default: { bg: 'bg-slate-100 dark:bg-slate-800', text: 'text-slate-700 dark:text-slate-300' } default: { bg: 'bg-slate-100 dark:bg-slate-800', text: 'text-slate-700 dark:text-slate-300' }
}; };
// Raw data viewer with copy, expand/collapse, and formatted display
function RawDataViewer({ data, error }: { data: unknown; error?: string | null }) {
const [isFullHeight, setIsFullHeight] = useState(false);
const [copied, setCopied] = useState(false);
if (error) {
return (
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">
<strong>Error:</strong> {error}
</p>
</div>
</div>
);
}
if (!data) {
return (
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
<p className="mt-3 text-sm text-slate-500">No data details available</p>
</div>
);
}
const rawText = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const dataSize = rawText.length;
const handleCopy = () => {
navigator.clipboard.writeText(rawText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800">
{/* Toolbar */}
<div className="mt-3 flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
Raw Data
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
{dataSize > 1000 ? `${(dataSize / 1000).toFixed(1)}KB` : `${dataSize} chars`}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-[10px] rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 dark:text-slate-400 transition-colors"
title="Copy raw data"
>
{copied ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={() => setIsFullHeight(!isFullHeight)}
className="flex items-center gap-1 px-2 py-1 text-[10px] rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 dark:text-slate-400 transition-colors"
title={isFullHeight ? 'Collapse' : 'Expand'}
>
{isFullHeight ? <Minimize2 className="w-3 h-3" /> : <Maximize2 className="w-3 h-3" />}
{isFullHeight ? 'Collapse' : 'Expand'}
</button>
</div>
</div>
{/* Raw data content */}
<div className={`bg-slate-900 dark:bg-slate-950 rounded-lg overflow-hidden ${isFullHeight ? '' : 'max-h-80'}`}>
<pre className={`p-3 text-xs text-green-400 font-mono whitespace-pre-wrap break-words overflow-auto ${isFullHeight ? 'max-h-[80vh]' : 'max-h-72'}`}>
{rawText}
</pre>
</div>
</div>
);
}
export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelProps) { export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [expandedSources, setExpandedSources] = useState<Set<number>>(new Set()); const [expandedSources, setExpandedSources] = useState<Set<number>>(new Set());
@ -124,6 +201,18 @@ export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelPro
{source.source_name} {source.source_name}
</span> </span>
</div> </div>
{source.method && (
<div className="flex items-center gap-1.5 mt-1 text-xs">
<span className="font-mono font-semibold text-amber-600 dark:text-amber-400">
{source.method}()
</span>
{source.args && (
<span className="font-mono text-slate-500 dark:text-slate-400 truncate max-w-xs">
{source.args}
</span>
)}
</div>
)}
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500"> <div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{formatTimestamp(source.fetch_timestamp)} {formatTimestamp(source.fetch_timestamp)}
@ -145,32 +234,12 @@ export function DataSourcesPanel({ dataSources, isLoading }: DataSourcesPanelPro
</div> </div>
</div> </div>
{/* Source details (expanded) */} {/* Source details (expanded) — full raw data viewer */}
{isSourceExpanded && ( {isSourceExpanded && (
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800"> <RawDataViewer
{source.error_message ? ( data={source.data_fetched}
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg"> error={source.error_message}
<p className="text-sm text-red-600 dark:text-red-400"> />
<strong>Error:</strong> {source.error_message}
</p>
</div>
) : source.data_fetched ? (
<div className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-xs text-slate-500 mb-2 font-medium">
Data Summary:
</p>
<pre className="text-xs text-slate-600 dark:text-slate-400 overflow-x-auto max-h-40">
{typeof source.data_fetched === 'string'
? source.data_fetched.slice(0, 500) + (source.data_fetched.length > 500 ? '...' : '')
: JSON.stringify(source.data_fetched, null, 2).slice(0, 500)}
</pre>
</div>
) : (
<p className="mt-3 text-sm text-slate-500">
No data details available
</p>
)}
</div>
)} )}
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -101,26 +101,44 @@ export function PipelineOverview({ steps, onStepClick, compact = false }: Pipeli
} }
return ( return (
<div className="space-y-4"> <div className="space-y-3">
{/* Progress bar */} {/* Compact Progress Header */}
<div className="flex items-center gap-3"> <div className="flex items-center justify-between">
<div className="flex-1 h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden"> <div className="flex items-center gap-2">
<div className="flex -space-x-0.5">
{displaySteps.map((step) => (
<div <div
className="h-full bg-gradient-to-r from-blue-500 to-green-500 transition-all duration-500" 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}%` }} style={{ width: `${progress}%` }}
/> />
</div> </div>
<span className="text-sm font-medium text-slate-600 dark:text-slate-400"> <span className="text-xs font-semibold text-slate-600 dark:text-slate-400">{progress}%</span>
{completedCount}/{totalSteps} </div>
</span>
</div> </div>
{/* Pipeline steps */} {/* Compact Pipeline Steps Grid */}
<div className="flex flex-wrap gap-2"> <div className="grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-9 gap-1.5">
{displaySteps.map((step) => { {displaySteps.map((step) => {
const StepIcon = STEP_ICONS[step.step_name] || Database; const StepIcon = STEP_ICONS[step.step_name] || Database;
const styles = STATUS_STYLES[step.status]; const styles = STATUS_STYLES[step.status];
const StatusIcon = styles.icon;
const label = STEP_LABELS[step.step_name] || step.step_name; const label = STEP_LABELS[step.step_name] || step.step_name;
return ( return (
@ -128,24 +146,22 @@ export function PipelineOverview({ steps, onStepClick, compact = false }: Pipeli
key={step.step_number} key={step.step_number}
onClick={() => onStepClick?.(step)} onClick={() => onStepClick?.(step)}
className={` className={`
flex items-center gap-2 px-3 py-2 rounded-lg border-2 transition-all relative flex flex-col items-center gap-1 p-2 rounded-lg border transition-all
${styles.bg} ${styles.border} ${styles.text} ${styles.bg} ${styles.border} ${styles.text}
hover:scale-105 hover:shadow-md hover:shadow-sm
${onStepClick ? 'cursor-pointer' : 'cursor-default'} ${onStepClick ? 'cursor-pointer' : 'cursor-default'}
`} `}
title={`${label}: ${step.status}${step.duration_ms ? ` (${(step.duration_ms / 1000).toFixed(1)}s)` : ''}`}
> >
<div className="relative"> <div className="relative">
<StepIcon className="w-4 h-4" /> <StepIcon className="w-4 h-4" />
{StatusIcon && step.status === 'running' && ( {step.status === 'running' && (
<Loader2 className="w-3 h-3 absolute -top-1 -right-1 animate-spin" /> <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
)} )}
</div> </div>
<span className="text-xs font-medium">{label}</span> <span className="text-[9px] font-medium leading-tight text-center line-clamp-1">
{step.duration_ms && ( {label.split(' ')[0]}
<span className="text-xs opacity-60">
{(step.duration_ms / 1000).toFixed(1)}s
</span> </span>
)}
</button> </button>
); );
})} })}

View File

@ -1,4 +1,5 @@
export { PipelineOverview } from './PipelineOverview'; export { PipelineOverview } from './PipelineOverview';
export { PipelineFlowchart } from './PipelineFlowchart';
export { AgentReportCard } from './AgentReportCard'; export { AgentReportCard } from './AgentReportCard';
export { DebateViewer } from './DebateViewer'; export { DebateViewer } from './DebateViewer';
export { RiskDebateViewer } from './RiskDebateViewer'; export { RiskDebateViewer } from './RiskDebateViewer';

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