- 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>
434 lines
14 KiB
Python
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
|
|
]
|