mirror of
https://github.com/Xe138/AI-Trader.git
synced 2026-04-01 17:17:24 -04:00
feat: add daily P&L calculator with weekend gap handling
Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
124
agent/pnl_calculator.py
Normal file
124
agent/pnl_calculator.py
Normal file
@@ -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
|
||||||
152
tests/unit/test_pnl_calculator.py
Normal file
152
tests/unit/test_pnl_calculator.py
Normal file
@@ -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
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user