238 lines
8.7 KiB
Python
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
|
|
}
|