""" Improved performance calculator with rate-limited market data fetching. This version uses the MarketDataService for efficient, cached price lookups. """ from sqlalchemy.orm import Session from sqlalchemy import and_ from typing import Dict, Optional from decimal import Decimal from datetime import datetime, timedelta import logging from app.models import Position, Transaction from app.models.position import PositionStatus from app.services.market_data_service import MarketDataService logger = logging.getLogger(__name__) class PerformanceCalculatorV2: """ Enhanced performance calculator with efficient market data handling. Features: - Database-backed price caching - Rate-limited API calls - Batch price fetching - Stale-while-revalidate pattern """ def __init__(self, db: Session, cache_ttl: int = 300): """ Initialize performance calculator. Args: db: Database session cache_ttl: Cache time-to-live in seconds (default: 5 minutes) """ self.db = db self.market_data = MarketDataService(db, cache_ttl_seconds=cache_ttl) def calculate_unrealized_pnl(self, position: Position, current_price: Optional[Decimal] = None) -> Optional[Decimal]: """ Calculate unrealized P&L for an open position. Args: position: Open position to calculate P&L for current_price: Optional pre-fetched current price (avoids API call) Returns: Unrealized P&L or None if market data unavailable """ if position.status != PositionStatus.OPEN: return None # Use provided price or fetch it if current_price is None: current_price = self.market_data.get_price(position.symbol, allow_stale=True) if current_price is None or position.avg_entry_price is None: return None # Calculate P&L based on position direction quantity = abs(position.total_quantity) is_short = position.total_quantity < 0 if is_short: # Short position: profit when price decreases pnl = (position.avg_entry_price - current_price) * quantity * 100 else: # Long position: profit when price increases pnl = (current_price - position.avg_entry_price) * quantity * 100 # Subtract fees and commissions from opening transactions total_fees = Decimal("0") for link in position.transaction_links: txn = link.transaction if txn.commission: total_fees += txn.commission if txn.fees: total_fees += txn.fees pnl -= total_fees return pnl def update_open_positions_pnl( self, account_id: int, max_api_calls: int = 10, allow_stale: bool = True ) -> Dict[str, int]: """ Update unrealized P&L for all open positions in an account. Uses batch fetching with rate limiting to avoid overwhelming Yahoo Finance API. Args: account_id: Account ID to update max_api_calls: Maximum number of Yahoo Finance API calls to make allow_stale: Allow using stale cached prices Returns: Dictionary with update statistics """ open_positions = ( self.db.query(Position) .filter( and_( Position.account_id == account_id, Position.status == PositionStatus.OPEN, ) ) .all() ) if not open_positions: return { "total": 0, "updated": 0, "cached": 0, "failed": 0 } # Get unique symbols symbols = list(set(p.symbol for p in open_positions)) logger.info(f"Updating P&L for {len(open_positions)} positions across {len(symbols)} symbols") # Fetch prices in batch prices = self.market_data.get_prices_batch( symbols, allow_stale=allow_stale, max_fetches=max_api_calls ) # Update P&L for each position updated = 0 cached = 0 failed = 0 for position in open_positions: price = prices.get(position.symbol) if price is not None: unrealized_pnl = self.calculate_unrealized_pnl(position, current_price=price) if unrealized_pnl is not None: position.unrealized_pnl = unrealized_pnl updated += 1 # Check if price was from cache (age > 0) or fresh fetch cached_info = self.market_data._get_cached_price(position.symbol) if cached_info: _, age = cached_info if age < self.market_data.cache_ttl: cached += 1 else: failed += 1 else: failed += 1 logger.warning(f"Could not get price for {position.symbol}") self.db.commit() logger.info( f"Updated {updated}/{len(open_positions)} positions " f"(cached: {cached}, failed: {failed})" ) return { "total": len(open_positions), "updated": updated, "cached": cached, "failed": failed } def calculate_account_stats( self, account_id: int, update_prices: bool = True, max_api_calls: int = 10, start_date = None, end_date = None ) -> Dict: """ Calculate aggregate statistics for an account. Args: account_id: Account ID update_prices: Whether to fetch fresh prices (if False, uses cached only) max_api_calls: Maximum number of Yahoo Finance API calls start_date: Filter positions opened on or after this date end_date: Filter positions opened on or before this date Returns: Dictionary with performance metrics """ # Get all positions with optional date filtering query = self.db.query(Position).filter(Position.account_id == account_id) if start_date: query = query.filter(Position.open_date >= start_date) if end_date: query = query.filter(Position.open_date <= end_date) positions = query.all() total_positions = len(positions) open_positions_count = sum( 1 for p in positions if p.status == PositionStatus.OPEN ) closed_positions_count = sum( 1 for p in positions if p.status == PositionStatus.CLOSED ) # Calculate realized P&L (doesn't need market data) total_realized_pnl = sum( (p.realized_pnl or Decimal("0")) for p in positions if p.status == PositionStatus.CLOSED ) # Update unrealized P&L for open positions update_stats = None if update_prices and open_positions_count > 0: update_stats = self.update_open_positions_pnl( account_id, max_api_calls=max_api_calls, allow_stale=True ) # Calculate total unrealized P&L total_unrealized_pnl = sum( (p.unrealized_pnl or Decimal("0")) for p in positions if p.status == PositionStatus.OPEN ) # Calculate win rate and average win/loss closed_with_pnl = [ p for p in positions if p.status == PositionStatus.CLOSED and p.realized_pnl is not None ] if closed_with_pnl: winning_trades = [p for p in closed_with_pnl if p.realized_pnl > 0] losing_trades = [p for p in closed_with_pnl if p.realized_pnl < 0] win_rate = (len(winning_trades) / len(closed_with_pnl)) * 100 avg_win = ( sum(p.realized_pnl for p in winning_trades) / len(winning_trades) if winning_trades else Decimal("0") ) avg_loss = ( sum(p.realized_pnl for p in losing_trades) / len(losing_trades) if losing_trades else Decimal("0") ) else: win_rate = 0.0 avg_win = Decimal("0") avg_loss = Decimal("0") # Get current account balance from latest transaction latest_txn = ( self.db.query(Transaction) .filter(Transaction.account_id == account_id) .order_by(Transaction.run_date.desc(), Transaction.id.desc()) .first() ) current_balance = ( latest_txn.cash_balance if latest_txn and latest_txn.cash_balance else Decimal("0") ) result = { "total_positions": total_positions, "open_positions": open_positions_count, "closed_positions": closed_positions_count, "total_realized_pnl": float(total_realized_pnl), "total_unrealized_pnl": float(total_unrealized_pnl), "total_pnl": float(total_realized_pnl + total_unrealized_pnl), "win_rate": float(win_rate), "avg_win": float(avg_win), "avg_loss": float(avg_loss), "current_balance": float(current_balance), } # Add update stats if prices were fetched if update_stats: result["price_update_stats"] = update_stats return result def get_balance_history( self, account_id: int, days: int = 30 ) -> list[Dict]: """ Get account balance history for charting. This doesn't need market data, just transaction history. Args: account_id: Account ID days: Number of days to retrieve Returns: List of {date, balance} dictionaries """ cutoff_date = datetime.now().date() - timedelta(days=days) transactions = ( self.db.query(Transaction.run_date, Transaction.cash_balance) .filter( and_( Transaction.account_id == account_id, Transaction.run_date >= cutoff_date, Transaction.cash_balance.isnot(None), ) ) .order_by(Transaction.run_date) .all() ) # Get one balance per day (use last transaction of the day) daily_balances = {} for txn in transactions: daily_balances[txn.run_date] = float(txn.cash_balance) return [ {"date": date.isoformat(), "balance": balance} for date, balance in sorted(daily_balances.items()) ] def get_top_trades( self, account_id: int, limit: int = 10, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> list[Dict]: """ Get top performing trades (by realized P&L). This doesn't need market data, just closed positions. Args: account_id: Account ID limit: Maximum number of trades to return start_date: Filter positions closed on or after this date end_date: Filter positions closed on or before this date Returns: List of trade dictionaries """ query = self.db.query(Position).filter( and_( Position.account_id == account_id, Position.status == PositionStatus.CLOSED, Position.realized_pnl.isnot(None), ) ) # Apply date filters if provided if start_date: query = query.filter(Position.close_date >= start_date) if end_date: query = query.filter(Position.close_date <= end_date) positions = query.order_by(Position.realized_pnl.desc()).limit(limit).all() return [ { "symbol": p.symbol, "option_symbol": p.option_symbol, "position_type": p.position_type.value, "open_date": p.open_date.isoformat(), "close_date": p.close_date.isoformat() if p.close_date else None, "quantity": float(p.total_quantity), "entry_price": float(p.avg_entry_price) if p.avg_entry_price else None, "exit_price": float(p.avg_exit_price) if p.avg_exit_price else None, "realized_pnl": float(p.realized_pnl), } for p in positions ] def get_worst_trades( self, account_id: int, limit: int = 10, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> list[Dict]: """ Get worst performing trades (by realized P&L). This doesn't need market data, just closed positions. Args: account_id: Account ID limit: Maximum number of trades to return start_date: Filter positions closed on or after this date end_date: Filter positions closed on or before this date Returns: List of trade dictionaries """ query = self.db.query(Position).filter( and_( Position.account_id == account_id, Position.status == PositionStatus.CLOSED, Position.realized_pnl.isnot(None), ) ) # Apply date filters if provided if start_date: query = query.filter(Position.close_date >= start_date) if end_date: query = query.filter(Position.close_date <= end_date) positions = query.order_by(Position.realized_pnl.asc()).limit(limit).all() return [ { "symbol": p.symbol, "option_symbol": p.option_symbol, "position_type": p.position_type.value, "open_date": p.open_date.isoformat(), "close_date": p.close_date.isoformat() if p.close_date else None, "quantity": float(p.total_quantity), "entry_price": float(p.avg_entry_price) if p.avg_entry_price else None, "exit_price": float(p.avg_exit_price) if p.avg_exit_price else None, "realized_pnl": float(p.realized_pnl), } for p in positions ]