TradingAgents/frontend/backend/backtest_service.py

238 lines
8.7 KiB
Python

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