From 5c19410f716841bb05689298f8e329958c3b5a12 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 3 Nov 2025 23:12:49 -0500 Subject: [PATCH] feat: add daily P&L calculator with weekend gap handling Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- agent/pnl_calculator.py | 124 ++++++++++++++++++++++++ tests/unit/test_pnl_calculator.py | 152 ++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 agent/pnl_calculator.py create mode 100644 tests/unit/test_pnl_calculator.py diff --git a/agent/pnl_calculator.py b/agent/pnl_calculator.py new file mode 100644 index 0000000..715959e --- /dev/null +++ b/agent/pnl_calculator.py @@ -0,0 +1,124 @@ +"""Daily P&L calculation logic.""" + +from datetime import datetime +from typing import Optional, Dict, List + + +class DailyPnLCalculator: + """Calculate daily profit/loss for trading portfolios.""" + + def __init__(self, initial_cash: float): + """Initialize calculator. + + Args: + initial_cash: Starting cash amount for first day + """ + self.initial_cash = initial_cash + + def calculate( + self, + previous_day: Optional[Dict], + current_date: str, + current_prices: Dict[str, float] + ) -> Dict: + """Calculate daily P&L by valuing holdings at current prices. + + Args: + previous_day: Previous trading day data with keys: + - date: str + - ending_cash: float + - ending_portfolio_value: float + - holdings: List[Dict] with symbol and quantity + None if first trading day + current_date: Current trading date (YYYY-MM-DD) + current_prices: Dict mapping symbol to current price + + Returns: + Dict with keys: + - daily_profit: float + - daily_return_pct: float + - starting_portfolio_value: float + - days_since_last_trading: int + + Raises: + ValueError: If price data missing for a holding + """ + if previous_day is None: + # First trading day - no P&L + return { + "daily_profit": 0.0, + "daily_return_pct": 0.0, + "starting_portfolio_value": self.initial_cash, + "days_since_last_trading": 0 + } + + # Calculate days since last trading + days_gap = self._calculate_day_gap( + previous_day["date"], + current_date + ) + + # Value previous holdings at current prices + current_value = self._calculate_portfolio_value( + holdings=previous_day["holdings"], + prices=current_prices, + cash=previous_day["ending_cash"] + ) + + # Calculate P&L + previous_value = previous_day["ending_portfolio_value"] + daily_profit = current_value - previous_value + daily_return_pct = (daily_profit / previous_value * 100) if previous_value > 0 else 0.0 + + return { + "daily_profit": daily_profit, + "daily_return_pct": daily_return_pct, + "starting_portfolio_value": current_value, + "days_since_last_trading": days_gap + } + + def _calculate_portfolio_value( + self, + holdings: List[Dict], + prices: Dict[str, float], + cash: float + ) -> float: + """Calculate total portfolio value. + + Args: + holdings: List of dicts with symbol and quantity + prices: Dict mapping symbol to price + cash: Cash balance + + Returns: + Total portfolio value + + Raises: + ValueError: If price missing for a holding + """ + total_value = cash + + for holding in holdings: + symbol = holding["symbol"] + quantity = holding["quantity"] + + if symbol not in prices: + raise ValueError(f"Missing price data for {symbol}") + + total_value += quantity * prices[symbol] + + return total_value + + def _calculate_day_gap(self, date1: str, date2: str) -> int: + """Calculate number of days between two dates. + + Args: + date1: Earlier date (YYYY-MM-DD) + date2: Later date (YYYY-MM-DD) + + Returns: + Number of days between dates + """ + d1 = datetime.strptime(date1, "%Y-%m-%d") + d2 = datetime.strptime(date2, "%Y-%m-%d") + return (d2 - d1).days diff --git a/tests/unit/test_pnl_calculator.py b/tests/unit/test_pnl_calculator.py new file mode 100644 index 0000000..b92b27f --- /dev/null +++ b/tests/unit/test_pnl_calculator.py @@ -0,0 +1,152 @@ +import pytest +from agent.pnl_calculator import DailyPnLCalculator + + +class TestDailyPnLCalculator: + + def test_first_day_zero_pnl(self): + """First trading day should have zero P&L.""" + calculator = DailyPnLCalculator(initial_cash=10000.0) + + result = calculator.calculate( + previous_day=None, + current_date="2025-01-15", + current_prices={"AAPL": 150.0} + ) + + assert result["daily_profit"] == 0.0 + assert result["daily_return_pct"] == 0.0 + assert result["starting_portfolio_value"] == 10000.0 + assert result["days_since_last_trading"] == 0 + + def test_positive_pnl_from_price_increase(self): + """Portfolio gains value when holdings appreciate.""" + calculator = DailyPnLCalculator(initial_cash=10000.0) + + # Previous day: 10 shares of AAPL at $100, cash $9000 + previous_day = { + "date": "2025-01-15", + "ending_cash": 9000.0, + "ending_portfolio_value": 10000.0, # 10 * $100 + $9000 + "holdings": [{"symbol": "AAPL", "quantity": 10}] + } + + # Current day: AAPL now $150 + current_prices = {"AAPL": 150.0} + + result = calculator.calculate( + previous_day=previous_day, + current_date="2025-01-16", + current_prices=current_prices + ) + + # New value: 10 * $150 + $9000 = $10,500 + # Profit: $10,500 - $10,000 = $500 + assert result["daily_profit"] == 500.0 + assert result["daily_return_pct"] == 5.0 + assert result["starting_portfolio_value"] == 10500.0 + assert result["days_since_last_trading"] == 1 + + def test_negative_pnl_from_price_decrease(self): + """Portfolio loses value when holdings depreciate.""" + calculator = DailyPnLCalculator(initial_cash=10000.0) + + previous_day = { + "date": "2025-01-15", + "ending_cash": 9000.0, + "ending_portfolio_value": 10000.0, + "holdings": [{"symbol": "AAPL", "quantity": 10}] + } + + # AAPL drops from $100 to $80 + current_prices = {"AAPL": 80.0} + + result = calculator.calculate( + previous_day=previous_day, + current_date="2025-01-16", + current_prices=current_prices + ) + + # New value: 10 * $80 + $9000 = $9,800 + # Loss: $9,800 - $10,000 = -$200 + assert result["daily_profit"] == -200.0 + assert result["daily_return_pct"] == -2.0 + + def test_weekend_gap_calculation(self): + """Calculate P&L correctly across weekend.""" + calculator = DailyPnLCalculator(initial_cash=10000.0) + + # Friday + previous_day = { + "date": "2025-01-17", # Friday + "ending_cash": 9000.0, + "ending_portfolio_value": 10000.0, + "holdings": [{"symbol": "AAPL", "quantity": 10}] + } + + # Monday (3 days later) + current_prices = {"AAPL": 120.0} + + result = calculator.calculate( + previous_day=previous_day, + current_date="2025-01-20", # Monday + current_prices=current_prices + ) + + # New value: 10 * $120 + $9000 = $10,200 + assert result["daily_profit"] == 200.0 + assert result["days_since_last_trading"] == 3 + + def test_multiple_holdings(self): + """Calculate P&L with multiple stock positions.""" + calculator = DailyPnLCalculator(initial_cash=10000.0) + + previous_day = { + "date": "2025-01-15", + "ending_cash": 8000.0, + "ending_portfolio_value": 10000.0, + "holdings": [ + {"symbol": "AAPL", "quantity": 10}, # Was $100 + {"symbol": "MSFT", "quantity": 5} # Was $200 + ] + } + + # Prices change + current_prices = { + "AAPL": 110.0, # +$10 + "MSFT": 190.0 # -$10 + } + + result = calculator.calculate( + previous_day=previous_day, + current_date="2025-01-16", + current_prices=current_prices + ) + + # AAPL: 10 * $110 = $1,100 (was $1,000, +$100) + # MSFT: 5 * $190 = $950 (was $1,000, -$50) + # Cash: $8,000 (unchanged) + # New total: $10,050 + # Profit: $50 + assert result["daily_profit"] == 50.0 + + def test_missing_price_raises_error(self): + """Raise error if price data missing for holding.""" + calculator = DailyPnLCalculator(initial_cash=10000.0) + + previous_day = { + "date": "2025-01-15", + "ending_cash": 9000.0, + "ending_portfolio_value": 10000.0, + "holdings": [{"symbol": "AAPL", "quantity": 10}] + } + + # Missing AAPL price + current_prices = {"MSFT": 150.0} + + with pytest.raises(ValueError, match="Missing price data for AAPL"): + calculator.calculate( + previous_day=previous_day, + current_date="2025-01-16", + current_prices=current_prices + )