Files
myTradeTracker/backend/app/services/performance_calculator_v2.py
Chris eea4469095 Initial release v1.1.0
- Complete MVP for tracking Fidelity brokerage account performance
- Transaction import from CSV with deduplication
- Automatic FIFO position tracking with options support
- Real-time P&L calculations with market data caching
- Dashboard with timeframe filtering (30/90/180 days, 1 year, YTD, all time)
- Docker-based deployment with PostgreSQL backend
- React/TypeScript frontend with TailwindCSS
- FastAPI backend with SQLAlchemy ORM

Features:
- Multi-account support
- Import via CSV upload or filesystem
- Open and closed position tracking
- Balance history charting
- Performance analytics and metrics
- Top trades analysis
- Responsive UI design

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 14:27:43 -05:00

434 lines
14 KiB
Python

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