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>
This commit is contained in:
433
backend/app/services/performance_calculator_v2.py
Normal file
433
backend/app/services/performance_calculator_v2.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
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
|
||||
]
|
||||
Reference in New Issue
Block a user