From 2612b85431776e4d698bce7987e794b5a5e26b56 Mon Sep 17 00:00:00 2001 From: Bill Date: Fri, 7 Nov 2025 19:26:06 -0500 Subject: [PATCH] feat: implement date range support with period metrics in results endpoint - Replace deprecated `date` parameter with `start_date`/`end_date` - Return single-date format (detailed) when dates are equal - Return range format (lightweight with period metrics) when dates differ - Add period metrics: period_return_pct, annualized_return_pct, calendar_days, trading_days - Default to last 30 days when no dates provided - Group results by model for date range queries - Add comprehensive test coverage for both response formats - Implement automatic edge trimming for date ranges - Add 404 error handling for empty result sets - Include 422 error for deprecated `date` parameter usage --- api/routes/results_v2.py | 195 +++++++++++++++++++++++++---------- tests/api/test_results_v2.py | 161 +++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 57 deletions(-) diff --git a/api/routes/results_v2.py b/api/routes/results_v2.py index 340886f..0271734 100644 --- a/api/routes/results_v2.py +++ b/api/routes/results_v2.py @@ -1,12 +1,13 @@ """New results API with day-centric structure.""" -from fastapi import APIRouter, Query, Depends +from fastapi import APIRouter, Query, Depends, HTTPException from typing import Optional, Literal import json import os from datetime import datetime, timedelta from api.database import Database +from api.routes.period_metrics import calculate_period_metrics router = APIRouter() @@ -79,26 +80,46 @@ def validate_and_resolve_dates( async def get_results( job_id: Optional[str] = None, model: Optional[str] = None, - date: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + date: Optional[str] = Query(None, deprecated=True), reasoning: Literal["none", "summary", "full"] = "none", db: Database = Depends(get_database) ): - """Get trading results grouped by day. + """Get trading results with optional date range and portfolio performance metrics. Args: job_id: Filter by simulation job ID model: Filter by model signature - date: Filter by trading date (YYYY-MM-DD) - reasoning: Include reasoning logs (none/summary/full) + start_date: Start date (YYYY-MM-DD) + end_date: End date (YYYY-MM-DD) + date: DEPRECATED - Use start_date/end_date instead + reasoning: Include reasoning logs (none/summary/full). Ignored for date ranges. db: Database instance (injected) Returns: JSON with day-centric trading results and performance metrics """ + # Check for deprecated parameter + if date is not None: + raise HTTPException( + status_code=422, + detail="Parameter 'date' has been removed. Use 'start_date' and/or 'end_date' instead." + ) + + # Validate and resolve dates + try: + resolved_start, resolved_end = validate_and_resolve_dates(start_date, end_date) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Determine if single-date or range query + is_single_date = resolved_start == resolved_end + # Build query with filters - query = "SELECT * FROM trading_days WHERE 1=1" - params = [] + query = "SELECT * FROM trading_days WHERE date >= ? AND date <= ?" + params = [resolved_start, resolved_end] if job_id: query += " AND job_id = ?" @@ -108,66 +129,126 @@ async def get_results( query += " AND model = ?" params.append(model) - if date: - query += " AND date = ?" - params.append(date) - - query += " ORDER BY date ASC, model ASC" + query += " ORDER BY model ASC, date ASC" # Execute query cursor = db.connection.execute(query, params) + rows = cursor.fetchall() + + # Check if empty + if not rows: + raise HTTPException( + status_code=404, + detail="No trading data found for the specified filters" + ) + + # Group by model + model_data = {} + for row in rows: + model_sig = row[2] # model column + if model_sig not in model_data: + model_data[model_sig] = [] + model_data[model_sig].append(row) # Format results formatted_results = [] - for row in cursor.fetchall(): - trading_day_id = row[0] - - # Build response object - day_data = { - "date": row[3], - "model": row[2], - "job_id": row[1], - - "starting_position": { - "holdings": db.get_starting_holdings(trading_day_id), - "cash": row[4], # starting_cash - "portfolio_value": row[5] # starting_portfolio_value - }, - - "daily_metrics": { - "profit": row[6], # daily_profit - "return_pct": row[7], # daily_return_pct - "days_since_last_trading": row[14] if len(row) > 14 else 1 - }, - - "trades": db.get_actions(trading_day_id), - - "final_position": { - "holdings": db.get_ending_holdings(trading_day_id), - "cash": row[8], # ending_cash - "portfolio_value": row[9] # ending_portfolio_value - }, - - "metadata": { - "total_actions": row[12] if row[12] is not None else 0, - "session_duration_seconds": row[13], - "completed_at": row[16] if len(row) > 16 else None - } - } - - # Add reasoning if requested - if reasoning == "summary": - day_data["reasoning"] = row[10] # reasoning_summary - elif reasoning == "full": - reasoning_full = row[11] # reasoning_full - day_data["reasoning"] = json.loads(reasoning_full) if reasoning_full else [] + for model_sig, model_rows in model_data.items(): + if is_single_date: + # Single-date format (detailed) + for row in model_rows: + formatted_results.append(format_single_date_result(row, db, reasoning)) else: - day_data["reasoning"] = None - - formatted_results.append(day_data) + # Range format (lightweight with metrics) + formatted_results.append(format_range_result(model_sig, model_rows, db)) return { "count": len(formatted_results), "results": formatted_results } + + +def format_single_date_result(row, db: Database, reasoning: str) -> dict: + """Format single-date result (detailed format).""" + trading_day_id = row[0] + + result = { + "date": row[3], + "model": row[2], + "job_id": row[1], + + "starting_position": { + "holdings": db.get_starting_holdings(trading_day_id), + "cash": row[4], # starting_cash + "portfolio_value": row[5] # starting_portfolio_value + }, + + "daily_metrics": { + "profit": row[6], # daily_profit + "return_pct": row[7], # daily_return_pct + "days_since_last_trading": row[14] if len(row) > 14 else 1 + }, + + "trades": db.get_actions(trading_day_id), + + "final_position": { + "holdings": db.get_ending_holdings(trading_day_id), + "cash": row[8], # ending_cash + "portfolio_value": row[9] # ending_portfolio_value + }, + + "metadata": { + "total_actions": row[12] if row[12] is not None else 0, + "session_duration_seconds": row[13], + "completed_at": row[16] if len(row) > 16 else None + } + } + + # Add reasoning if requested + if reasoning == "summary": + result["reasoning"] = row[10] # reasoning_summary + elif reasoning == "full": + reasoning_full = row[11] # reasoning_full + result["reasoning"] = json.loads(reasoning_full) if reasoning_full else [] + else: + result["reasoning"] = None + + return result + + +def format_range_result(model_sig: str, rows: list, db: Database) -> dict: + """Format date range result (lightweight with period metrics).""" + # Trim edges: use actual min/max dates from data + actual_start = rows[0][3] # date from first row + actual_end = rows[-1][3] # date from last row + + # Extract daily portfolio values + daily_values = [ + { + "date": row[3], + "portfolio_value": row[9] # ending_portfolio_value + } + for row in rows + ] + + # Get starting and ending values + starting_value = rows[0][5] # starting_portfolio_value from first day + ending_value = rows[-1][9] # ending_portfolio_value from last day + trading_days = len(rows) + + # Calculate period metrics + metrics = calculate_period_metrics( + starting_value=starting_value, + ending_value=ending_value, + start_date=actual_start, + end_date=actual_end, + trading_days=trading_days + ) + + return { + "model": model_sig, + "start_date": actual_start, + "end_date": actual_end, + "daily_portfolio_values": daily_values, + "period_metrics": metrics + } diff --git a/tests/api/test_results_v2.py b/tests/api/test_results_v2.py index 6c5dfa8..8e44027 100644 --- a/tests/api/test_results_v2.py +++ b/tests/api/test_results_v2.py @@ -1,8 +1,12 @@ """Tests for results_v2 endpoint date validation.""" import pytest +import json from datetime import datetime, timedelta +from fastapi.testclient import TestClient from api.routes.results_v2 import validate_and_resolve_dates +from api.main import create_app +from api.database import Database def test_validate_no_dates_provided(): @@ -59,3 +63,160 @@ def test_validate_future_date(): with pytest.raises(ValueError, match="Cannot query future dates"): validate_and_resolve_dates(future, future) + + +@pytest.fixture +def test_db(tmp_path): + """Create test database with sample data.""" + db_path = str(tmp_path / "test.db") + db = Database(db_path) + + # Create a job first (required by foreign key constraint) + db.connection.execute( + """ + INSERT INTO jobs (job_id, config_path, date_range, models, status, created_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + """, + ("test-job-1", "config.json", '["2024-01-16", "2024-01-17"]', '["gpt-4"]', "completed") + ) + db.connection.commit() + + # Create sample trading days (use dates in the past) + trading_day_id_1 = db.create_trading_day( + job_id="test-job-1", + model="gpt-4", + date="2024-01-16", + starting_cash=10000.0, + starting_portfolio_value=10000.0, + daily_profit=0.0, + daily_return_pct=0.0, + ending_cash=9500.0, + ending_portfolio_value=10100.0, + reasoning_summary="Bought AAPL", + total_actions=1, + session_duration_seconds=45.2, + days_since_last_trading=0 + ) + + db.create_holding(trading_day_id_1, "AAPL", 10) + db.create_action(trading_day_id_1, "buy", "AAPL", 10, 150.0) + + trading_day_id_2 = db.create_trading_day( + job_id="test-job-1", + model="gpt-4", + date="2024-01-17", + starting_cash=9500.0, + starting_portfolio_value=10100.0, + daily_profit=100.0, + daily_return_pct=1.0, + ending_cash=9500.0, + ending_portfolio_value=10250.0, + reasoning_summary="Held AAPL", + total_actions=0, + session_duration_seconds=30.0, + days_since_last_trading=1 + ) + + db.create_holding(trading_day_id_2, "AAPL", 10) + + return db + + +def test_get_results_single_date(test_db): + """Test single date query returns detailed format.""" + app = create_app(db_path=test_db.db_path) + app.state.test_mode = True + + # Override the database dependency to use our test database + from api.routes.results_v2 import get_database + + def override_get_database(): + return test_db + + app.dependency_overrides[get_database] = override_get_database + + client = TestClient(app) + + response = client.get("/results?start_date=2024-01-16&end_date=2024-01-16") + + assert response.status_code == 200 + data = response.json() + + assert data["count"] == 1 + assert len(data["results"]) == 1 + + result = data["results"][0] + assert result["date"] == "2024-01-16" + assert result["model"] == "gpt-4" + assert "starting_position" in result + assert "daily_metrics" in result + assert "trades" in result + assert "final_position" in result + + +def test_get_results_date_range(test_db): + """Test date range query returns metrics format.""" + app = create_app(db_path=test_db.db_path) + app.state.test_mode = True + + # Override the database dependency to use our test database + from api.routes.results_v2 import get_database + + def override_get_database(): + return test_db + + app.dependency_overrides[get_database] = override_get_database + + client = TestClient(app) + + response = client.get("/results?start_date=2024-01-16&end_date=2024-01-17") + + assert response.status_code == 200 + data = response.json() + + assert data["count"] == 1 + assert len(data["results"]) == 1 + + result = data["results"][0] + assert result["model"] == "gpt-4" + assert result["start_date"] == "2024-01-16" + assert result["end_date"] == "2024-01-17" + assert "daily_portfolio_values" in result + assert "period_metrics" in result + + # Check daily values + daily_values = result["daily_portfolio_values"] + assert len(daily_values) == 2 + assert daily_values[0]["date"] == "2024-01-16" + assert daily_values[0]["portfolio_value"] == 10100.0 + assert daily_values[1]["date"] == "2024-01-17" + assert daily_values[1]["portfolio_value"] == 10250.0 + + # Check period metrics + metrics = result["period_metrics"] + assert metrics["starting_portfolio_value"] == 10000.0 + assert metrics["ending_portfolio_value"] == 10250.0 + assert metrics["period_return_pct"] == 2.5 + assert metrics["calendar_days"] == 2 + assert metrics["trading_days"] == 2 + + +def test_get_results_empty_404(test_db): + """Test 404 when no data matches filters.""" + app = create_app(db_path=test_db.db_path) + app.state.test_mode = True + + # Override the database dependency to use our test database + from api.routes.results_v2 import get_database + + def override_get_database(): + return test_db + + app.dependency_overrides[get_database] = override_get_database + + client = TestClient(app) + + response = client.get("/results?start_date=2024-02-01&end_date=2024-02-05") + + assert response.status_code == 404 + assert "No trading data found" in response.json()["detail"]