From 61baf3f90f3be85421a7bdbe5be675165e22c4e0 Mon Sep 17 00:00:00 2001 From: Bill Date: Fri, 7 Nov 2025 19:46:49 -0500 Subject: [PATCH] test: fix remaining integration test for new results endpoint Update test_results_filters_by_job_id to expect 404 when no data exists, aligning with the new endpoint behavior where queries with no matching data return 404 instead of 200 with empty results. Also add design and implementation plan documents for reference. --- ...1-07-results-api-date-range-enhancement.md | 336 +++++ ...7-results-api-date-range-implementation.md | 1129 +++++++++++++++++ tests/integration/test_api_endpoints.py | 8 +- 3 files changed, 1468 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2025-11-07-results-api-date-range-enhancement.md create mode 100644 docs/plans/2025-11-07-results-api-date-range-implementation.md diff --git a/docs/plans/2025-11-07-results-api-date-range-enhancement.md b/docs/plans/2025-11-07-results-api-date-range-enhancement.md new file mode 100644 index 0000000..7bdd2a3 --- /dev/null +++ b/docs/plans/2025-11-07-results-api-date-range-enhancement.md @@ -0,0 +1,336 @@ +# Results API Date Range Enhancement + +**Date:** 2025-11-07 +**Status:** Design Complete +**Breaking Change:** Yes (removes `date` parameter) + +## Overview + +Enhance the `/results` API endpoint to support date range queries with portfolio performance metrics including period returns and annualized returns. + +## Current State + +The `/results` endpoint currently supports: +- Single-date queries via `date` parameter +- Filtering by `job_id`, `model` +- Reasoning inclusion via `reasoning` parameter +- Returns detailed day-by-day trading information + +## Proposed Changes + +### 1. API Contract Changes + +**New Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `start_date` | string | No | Start date (YYYY-MM-DD). If provided alone, acts as single date (end_date defaults to start_date) | +| `end_date` | string | No | End date (YYYY-MM-DD). If provided alone, acts as single date (start_date defaults to end_date) | +| `model` | string | No | Filter by model signature (unchanged) | +| `job_id` | string | No | Filter by job UUID (unchanged) | +| `reasoning` | string | No | Include reasoning: "none" (default), "summary", "full". Ignored for date range queries | + +**Breaking Changes:** +- **REMOVE** `date` parameter (replaced by `start_date`/`end_date`) +- Clients using `date` will receive `422 Unprocessable Entity` with migration message + +**Default Behavior (no filters):** +- Returns last 30 calendar days of data for all models +- Configurable via `DEFAULT_RESULTS_LOOKBACK_DAYS` environment variable (default: 30) + +### 2. Response Structure + +#### Single-Date Response (start_date == end_date) + +Maintains current format: + +```json +{ + "count": 2, + "results": [ + { + "date": "2025-01-16", + "model": "gpt-4", + "job_id": "550e8400-...", + "starting_position": { + "holdings": [{"symbol": "AAPL", "quantity": 10}], + "cash": 8500.0, + "portfolio_value": 10000.0 + }, + "daily_metrics": { + "profit": 100.0, + "return_pct": 1.0, + "days_since_last_trading": 1 + }, + "trades": [...], + "final_position": {...}, + "metadata": {...}, + "reasoning": null + }, + { + "date": "2025-01-16", + "model": "claude-3.7-sonnet", + ... + } + ] +} +``` + +#### Date Range Response (start_date < end_date) + +New lightweight format: + +```json +{ + "count": 2, + "results": [ + { + "model": "gpt-4", + "start_date": "2025-01-16", + "end_date": "2025-01-20", + "daily_portfolio_values": [ + {"date": "2025-01-16", "portfolio_value": 10100.0}, + {"date": "2025-01-17", "portfolio_value": 10250.0}, + {"date": "2025-01-20", "portfolio_value": 10500.0} + ], + "period_metrics": { + "starting_portfolio_value": 10000.0, + "ending_portfolio_value": 10500.0, + "period_return_pct": 5.0, + "annualized_return_pct": 45.6, + "calendar_days": 5, + "trading_days": 3 + } + }, + { + "model": "claude-3.7-sonnet", + "start_date": "2025-01-16", + "end_date": "2025-01-20", + "daily_portfolio_values": [...], + "period_metrics": {...} + } + ] +} +``` + +### 3. Performance Metrics Calculations + +**Starting Portfolio Value:** +- Use `trading_days.starting_portfolio_value` from first trading day in range + +**Period Return:** +``` +period_return_pct = ((ending_value - starting_value) / starting_value) * 100 +``` + +**Annualized Return:** +``` +annualized_return_pct = ((ending_value / starting_value) ** (365 / calendar_days) - 1) * 100 +``` + +**Calendar Days:** +- Count actual calendar days from start_date to end_date (inclusive) + +**Trading Days:** +- Count number of actual trading days with data in the range + +### 4. Data Handling Rules + +**Edge Trimming:** +- If requested range extends beyond available data at edges, trim to actual data boundaries +- Example: Request 2025-01-10 to 2025-01-20, but data exists 2025-01-15 to 2025-01-17 +- Response shows `start_date=2025-01-15`, `end_date=2025-01-17` + +**Gaps Within Range:** +- Include only dates with actual data (no null values, no gap indicators) +- Example: If 2025-01-18 missing between 2025-01-17 and 2025-01-19, only include existing dates + +**Per-Model Results:** +- Return one result object per model +- Each model independently trimmed to its available data range +- If model has no data in range, exclude from results + +**Empty Results:** +- If NO models have data matching filters → `404 Not Found` +- If ANY model has data → `200 OK` with results for models that have data + +**Filter Logic:** +- All filters (job_id, model, date range) applied with AND logic +- Date range can extend beyond a job's scope (returns empty if no overlap) + +### 5. Error Handling + +| Scenario | Status | Response | +|----------|--------|----------| +| No data matches filters | 404 | `{"detail": "No trading data found for the specified filters"}` | +| Invalid date format | 400 | `{"detail": "Invalid date format: 2025-1-16. Expected YYYY-MM-DD"}` | +| start_date > end_date | 400 | `{"detail": "start_date must be <= end_date"}` | +| Future dates | 400 | `{"detail": "Cannot query future dates"}` | +| Using old `date` param | 422 | `{"detail": "Parameter 'date' has been removed. Use 'start_date' and/or 'end_date' instead."}` | + +### 6. Special Cases + +**Single Trading Day in Range:** +- Use date range response format (not single-date) +- `daily_portfolio_values` has one entry +- `period_return_pct` and `annualized_return_pct` = 0.0 +- `calendar_days` = difference between requested start/end +- `trading_days` = 1 + +**Reasoning Parameter:** +- Ignored for date range queries (start_date < end_date) +- Only applies to single-date queries +- Keeps range responses lightweight and fast + +## Implementation Plan + +### Phase 1: Core Logic + +**File:** `api/routes/results_v2.py` + +1. Add new query parameters (`start_date`, `end_date`) +2. Implement date range defaulting logic: + - No dates → last 30 days + - Only start_date → single date + - Only end_date → single date + - Both → range query +3. Validate dates (format, order, not future) +4. Detect deprecated `date` parameter → return 422 +5. Query database with date range filter +6. Group results by model +7. Trim edges per model +8. Calculate period metrics +9. Format response based on single-date vs range + +### Phase 2: Period Metrics Calculation + +**Functions to implement:** + +```python +def calculate_period_metrics( + starting_value: float, + ending_value: float, + start_date: str, + end_date: str, + trading_days: int +) -> dict: + """Calculate period return and annualized return.""" + # Calculate calendar days + # Calculate period_return_pct + # Calculate annualized_return_pct + # Return metrics dict +``` + +### Phase 3: Documentation Updates + +1. **API_REFERENCE.md** - Complete rewrite of `/results` section +2. **docs/reference/environment-variables.md** - Add `DEFAULT_RESULTS_LOOKBACK_DAYS` +3. **CHANGELOG.md** - Document breaking change +4. **README.md** - Update example queries +5. **Client library examples** - Update Python/TypeScript examples + +### Phase 4: Testing + +**Test Coverage:** + +- [ ] Single date query (start_date only) +- [ ] Single date query (end_date only) +- [ ] Single date query (both equal) +- [ ] Date range query (multiple days) +- [ ] Default lookback (no dates provided) +- [ ] Edge trimming (requested range exceeds data) +- [ ] Gap handling (missing dates in middle) +- [ ] Empty results (404) +- [ ] Invalid date formats (400) +- [ ] start_date > end_date (400) +- [ ] Future dates (400) +- [ ] Deprecated `date` parameter (422) +- [ ] Period metrics calculations +- [ ] All filter combinations (job_id, model, dates) +- [ ] Single trading day in range +- [ ] Reasoning parameter ignored in range queries +- [ ] Multiple models with different data ranges + +## Migration Guide + +### For API Consumers + +**Before (current):** +```bash +# Single date +GET /results?date=2025-01-16&model=gpt-4 + +# Multiple dates required multiple queries +GET /results?date=2025-01-16&model=gpt-4 +GET /results?date=2025-01-17&model=gpt-4 +GET /results?date=2025-01-18&model=gpt-4 +``` + +**After (new):** +```bash +# Single date (option 1) +GET /results?start_date=2025-01-16&model=gpt-4 + +# Single date (option 2) +GET /results?start_date=2025-01-16&end_date=2025-01-16&model=gpt-4 + +# Date range (new capability) +GET /results?start_date=2025-01-16&end_date=2025-01-20&model=gpt-4 +``` + +### Python Client Update + +```python +# OLD (will break) +results = client.get_results(date="2025-01-16") + +# NEW +results = client.get_results(start_date="2025-01-16") # Single date +results = client.get_results(start_date="2025-01-16", end_date="2025-01-20") # Range +``` + +## Environment Variables + +**New:** +- `DEFAULT_RESULTS_LOOKBACK_DAYS` (integer, default: 30) - Number of days to look back when no date filters provided + +## Dependencies + +- No new dependencies required +- Uses existing database schema (trading_days table) +- Compatible with current database structure + +## Risks & Mitigations + +**Risk:** Breaking change disrupts existing clients +**Mitigation:** +- Clear error message with migration instructions +- Update all documentation and examples +- Add to CHANGELOG with migration guide + +**Risk:** Large date ranges cause performance issues +**Mitigation:** +- Consider adding max date range validation (e.g., 365 days) +- Date range responses are lightweight (no trades/holdings/reasoning) + +**Risk:** Edge trimming behavior confuses users +**Mitigation:** +- Document clearly with examples +- Returned `start_date`/`end_date` show actual range +- Consider adding `requested_start_date`/`requested_end_date` fields to response + +## Future Enhancements + +- Add `max_date_range_days` environment variable +- Add `requested_start_date`/`requested_end_date` to response +- Consider adding aggregated statistics (max drawdown, Sharpe ratio) +- Consider adding comparison mode (multiple models side-by-side) + +## Approval Checklist + +- [x] Design validated with stakeholder +- [ ] Implementation plan reviewed +- [ ] Test coverage defined +- [ ] Documentation updates planned +- [ ] Migration guide created +- [ ] Breaking change acknowledged diff --git a/docs/plans/2025-11-07-results-api-date-range-implementation.md b/docs/plans/2025-11-07-results-api-date-range-implementation.md new file mode 100644 index 0000000..b0c7b70 --- /dev/null +++ b/docs/plans/2025-11-07-results-api-date-range-implementation.md @@ -0,0 +1,1129 @@ +# Results API Date Range Enhancement - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add date range query support to `/results` endpoint with portfolio performance metrics (period return, annualized return). + +**Architecture:** Replace `date` parameter with `start_date`/`end_date`. Return single-date format (detailed) when dates are equal, or range format (lightweight with metrics) when different. Default to last 30 days when no dates provided. + +**Tech Stack:** FastAPI, SQLite, Python 3.12, pytest + +--- + +## Task 1: Add Period Metrics Calculation Function + +**Files:** +- Create: `api/routes/period_metrics.py` +- Test: `tests/api/test_period_metrics.py` + +**Step 1: Write the failing test** + +Create `tests/api/test_period_metrics.py`: + +```python +"""Tests for period metrics calculations.""" + +from datetime import datetime +from api.routes.period_metrics import calculate_period_metrics + + +def test_calculate_period_metrics_basic(): + """Test basic period metrics calculation.""" + metrics = calculate_period_metrics( + starting_value=10000.0, + ending_value=10500.0, + start_date="2025-01-16", + end_date="2025-01-20", + trading_days=3 + ) + + assert metrics["starting_portfolio_value"] == 10000.0 + assert metrics["ending_portfolio_value"] == 10500.0 + assert metrics["period_return_pct"] == 5.0 + assert metrics["calendar_days"] == 5 + assert metrics["trading_days"] == 3 + # annualized_return = ((10500/10000) ** (365/5) - 1) * 100 = ~492% + assert 490 < metrics["annualized_return_pct"] < 495 + + +def test_calculate_period_metrics_zero_return(): + """Test period metrics when no change.""" + metrics = calculate_period_metrics( + starting_value=10000.0, + ending_value=10000.0, + start_date="2025-01-16", + end_date="2025-01-16", + trading_days=1 + ) + + assert metrics["period_return_pct"] == 0.0 + assert metrics["annualized_return_pct"] == 0.0 + assert metrics["calendar_days"] == 1 + + +def test_calculate_period_metrics_negative_return(): + """Test period metrics with loss.""" + metrics = calculate_period_metrics( + starting_value=10000.0, + ending_value=9500.0, + start_date="2025-01-16", + end_date="2025-01-23", + trading_days=5 + ) + + assert metrics["period_return_pct"] == -5.0 + assert metrics["calendar_days"] == 8 + # Negative annualized return + assert metrics["annualized_return_pct"] < 0 +``` + +**Step 2: Run test to verify it fails** + +```bash +pytest tests/api/test_period_metrics.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'api.routes.period_metrics'` + +**Step 3: Write minimal implementation** + +Create `api/routes/period_metrics.py`: + +```python +"""Period metrics calculation for date range queries.""" + +from datetime import datetime + + +def calculate_period_metrics( + starting_value: float, + ending_value: float, + start_date: str, + end_date: str, + trading_days: int +) -> dict: + """Calculate period return and annualized return. + + Args: + starting_value: Portfolio value at start of period + ending_value: Portfolio value at end of period + start_date: Start date (YYYY-MM-DD) + end_date: End date (YYYY-MM-DD) + trading_days: Number of actual trading days in period + + Returns: + Dict with period_return_pct, annualized_return_pct, calendar_days, trading_days + """ + # Calculate calendar days (inclusive) + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + calendar_days = (end_dt - start_dt).days + 1 + + # Calculate period return + if starting_value == 0: + period_return_pct = 0.0 + else: + period_return_pct = ((ending_value - starting_value) / starting_value) * 100 + + # Calculate annualized return + if calendar_days == 0 or starting_value == 0 or ending_value <= 0: + annualized_return_pct = 0.0 + else: + # Formula: ((ending / starting) ** (365 / days) - 1) * 100 + annualized_return_pct = ((ending_value / starting_value) ** (365 / calendar_days) - 1) * 100 + + return { + "starting_portfolio_value": starting_value, + "ending_portfolio_value": ending_value, + "period_return_pct": round(period_return_pct, 2), + "annualized_return_pct": round(annualized_return_pct, 2), + "calendar_days": calendar_days, + "trading_days": trading_days + } +``` + +**Step 4: Run test to verify it passes** + +```bash +pytest tests/api/test_period_metrics.py -v +``` + +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add api/routes/period_metrics.py tests/api/test_period_metrics.py +git commit -m "feat: add period metrics calculation for date range queries" +``` + +--- + +## Task 2: Add Date Validation Utilities + +**Files:** +- Modify: `api/routes/results_v2.py` +- Test: `tests/api/test_results_v2.py` + +**Step 1: Write the failing test** + +Create `tests/api/test_results_v2.py`: + +```python +"""Tests for results_v2 endpoint date validation.""" + +import pytest +from datetime import datetime, timedelta +from api.routes.results_v2 import validate_and_resolve_dates + + +def test_validate_no_dates_provided(): + """Test default to last 30 days when no dates provided.""" + start, end = validate_and_resolve_dates(None, None) + + # Should default to last 30 days + end_dt = datetime.strptime(end, "%Y-%m-%d") + start_dt = datetime.strptime(start, "%Y-%m-%d") + + assert (end_dt - start_dt).days == 30 + assert end_dt.date() <= datetime.now().date() + + +def test_validate_only_start_date(): + """Test single date when only start_date provided.""" + start, end = validate_and_resolve_dates("2025-01-16", None) + + assert start == "2025-01-16" + assert end == "2025-01-16" + + +def test_validate_only_end_date(): + """Test single date when only end_date provided.""" + start, end = validate_and_resolve_dates(None, "2025-01-16") + + assert start == "2025-01-16" + assert end == "2025-01-16" + + +def test_validate_both_dates(): + """Test date range when both provided.""" + start, end = validate_and_resolve_dates("2025-01-16", "2025-01-20") + + assert start == "2025-01-16" + assert end == "2025-01-20" + + +def test_validate_invalid_date_format(): + """Test error on invalid date format.""" + with pytest.raises(ValueError, match="Invalid date format"): + validate_and_resolve_dates("2025-1-16", "2025-01-20") + + +def test_validate_start_after_end(): + """Test error when start_date > end_date.""" + with pytest.raises(ValueError, match="start_date must be <= end_date"): + validate_and_resolve_dates("2025-01-20", "2025-01-16") + + +def test_validate_future_date(): + """Test error when dates are in future.""" + future = (datetime.now() + timedelta(days=10)).strftime("%Y-%m-%d") + + with pytest.raises(ValueError, match="Cannot query future dates"): + validate_and_resolve_dates(future, future) +``` + +**Step 2: Run test to verify it fails** + +```bash +pytest tests/api/test_results_v2.py::test_validate_no_dates_provided -v +``` + +Expected: `ImportError: cannot import name 'validate_and_resolve_dates'` + +**Step 3: Write minimal implementation** + +Add to `api/routes/results_v2.py` (at top, before `get_results` function): + +```python +import os +from datetime import datetime, timedelta +from fastapi import HTTPException + + +def validate_and_resolve_dates( + start_date: Optional[str], + end_date: Optional[str] +) -> tuple[str, str]: + """Validate and resolve date parameters. + + Args: + start_date: Start date (YYYY-MM-DD) or None + end_date: End date (YYYY-MM-DD) or None + + Returns: + Tuple of (resolved_start_date, resolved_end_date) + + Raises: + ValueError: If dates are invalid + """ + # Default lookback days + default_lookback = int(os.getenv("DEFAULT_RESULTS_LOOKBACK_DAYS", "30")) + + # Handle None cases + if start_date is None and end_date is None: + # Default to last N days + end_dt = datetime.now() + start_dt = end_dt - timedelta(days=default_lookback) + return start_dt.strftime("%Y-%m-%d"), end_dt.strftime("%Y-%m-%d") + + if start_date is None: + # Only end_date provided -> single date + start_date = end_date + + if end_date is None: + # Only start_date provided -> single date + end_date = start_date + + # Validate date formats + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + except ValueError: + raise ValueError(f"Invalid date format. Expected YYYY-MM-DD") + + # Validate order + if start_dt > end_dt: + raise ValueError("start_date must be <= end_date") + + # Validate not future + now = datetime.now() + if start_dt.date() > now.date() or end_dt.date() > now.date(): + raise ValueError("Cannot query future dates") + + return start_date, end_date +``` + +**Step 4: Run test to verify it passes** + +```bash +pytest tests/api/test_results_v2.py -v +``` + +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add api/routes/results_v2.py tests/api/test_results_v2.py +git commit -m "feat: add date validation and resolution for results endpoint" +``` + +--- + +## Task 3: Update Results Endpoint with Date Range Support + +**Files:** +- Modify: `api/routes/results_v2.py` +- Test: `tests/api/test_results_v2.py` + +**Step 1: Write the failing test** + +Add to `tests/api/test_results_v2.py`: + +```python +import json +from fastapi.testclient import TestClient +from api.main import create_app +from api.database import Database + + +@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 sample trading days + trading_day_id_1 = db.create_trading_day( + job_id="test-job-1", + model="gpt-4", + date="2025-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.add_holding(trading_day_id_1, "AAPL", 10) + db.add_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="2025-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.add_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 + client = TestClient(app) + + response = client.get("/results?start_date=2025-01-16&end_date=2025-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"] == "2025-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 + client = TestClient(app) + + response = client.get("/results?start_date=2025-01-16&end_date=2025-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"] == "2025-01-16" + assert result["end_date"] == "2025-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"] == "2025-01-16" + assert daily_values[0]["portfolio_value"] == 10100.0 + assert daily_values[1]["date"] == "2025-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 + client = TestClient(app) + + response = client.get("/results?start_date=2025-02-01&end_date=2025-02-05") + + assert response.status_code == 404 + assert "No trading data found" in response.json()["detail"] +``` + +**Step 2: Run test to verify it fails** + +```bash +pytest tests/api/test_results_v2.py::test_get_results_date_range -v +``` + +Expected: FAIL (endpoint returns old format, not range format) + +**Step 3: Rewrite the get_results endpoint** + +Replace the `@router.get("/results")` function in `api/routes/results_v2.py`: + +```python +from api.routes.period_metrics import calculate_period_metrics + + +@router.get("/results") +async def get_results( + job_id: Optional[str] = None, + model: 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. + + Args: + job_id: Filter by simulation job ID + model: Filter by model signature + 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 + """ + from fastapi import HTTPException + + # 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 date >= ? AND date <= ?" + params = [resolved_start, resolved_end] + + if job_id: + query += " AND job_id = ?" + params.append(job_id) + + if model: + query += " AND model = ?" + params.append(model) + + 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 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: + # 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 + } +``` + +**Step 4: Run test to verify it passes** + +```bash +pytest tests/api/test_results_v2.py::test_get_results_date_range -v +pytest tests/api/test_results_v2.py::test_get_results_single_date -v +pytest tests/api/test_results_v2.py::test_get_results_empty_404 -v +``` + +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add api/routes/results_v2.py tests/api/test_results_v2.py +git commit -m "feat: implement date range support with period metrics in results endpoint" +``` + +--- + +## Task 4: Add Database Helper Methods + +**Files:** +- Modify: `api/database.py` + +**Step 1: Add missing helper methods** + +The endpoint uses `db.get_starting_holdings()` and `db.get_actions()` which may not exist. Add these methods to the `Database` class in `api/database.py` (after existing methods): + +```python +def get_starting_holdings(self, trading_day_id: int) -> list: + """Get starting holdings for a trading day (from previous day's ending holdings). + + Args: + trading_day_id: Current trading day ID + + Returns: + List of dicts with keys: symbol, quantity + """ + # Get current trading day info + cursor = self.connection.execute( + "SELECT model, date FROM trading_days WHERE id = ?", + (trading_day_id,) + ) + row = cursor.fetchone() + if not row: + return [] + + model, current_date = row[0], row[1] + + # Get previous trading day + prev_day = self.get_previous_trading_day(None, model, current_date) + + if prev_day is None: + return [] # First trading day, no previous holdings + + # Get previous day's ending holdings + return self.get_ending_holdings(prev_day["id"]) + + +def get_actions(self, trading_day_id: int) -> list: + """Get all actions/trades for a trading day. + + Args: + trading_day_id: Trading day ID + + Returns: + List of dicts with keys: action_type, symbol, quantity, price, created_at + """ + cursor = self.connection.execute( + """ + SELECT action_type, symbol, quantity, price, created_at + FROM actions + WHERE trading_day_id = ? + ORDER BY created_at + """, + (trading_day_id,) + ) + + return [ + { + "action_type": row[0], + "symbol": row[1], + "quantity": row[2], + "price": row[3], + "created_at": row[4] + } + for row in cursor.fetchall() + ] +``` + +**Step 2: Verify methods work** + +```bash +pytest tests/api/test_results_v2.py -v +``` + +Expected: All tests still PASS + +**Step 3: Commit** + +```bash +git add api/database.py +git commit -m "feat: add get_starting_holdings and get_actions helper methods to Database class" +``` + +--- + +## Task 5: Update API Documentation + +**Files:** +- Modify: `API_REFERENCE.md` + +**Step 1: Update /results endpoint documentation** + +Replace the `### GET /results` section in `API_REFERENCE.md` (starting around line 344): + +```markdown +### GET /results + +Get trading results with optional date range and portfolio performance metrics. + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `start_date` | string | No | Start date (YYYY-MM-DD). If provided alone, acts as single date. If omitted, defaults to 30 days ago. | +| `end_date` | string | No | End date (YYYY-MM-DD). If provided alone, acts as single date. If omitted, defaults to today. | +| `model` | string | No | Filter by model signature | +| `job_id` | string | No | Filter by job UUID | +| `reasoning` | string | No | Include reasoning: `none` (default), `summary`, or `full`. Ignored for date range queries. | + +**Breaking Change:** +- The `date` parameter has been removed. Use `start_date` and/or `end_date` instead. +- Requests using `date` will receive `422 Unprocessable Entity` error. + +**Default Behavior:** +- If no dates provided: Returns last 30 days (configurable via `DEFAULT_RESULTS_LOOKBACK_DAYS`) +- If only `start_date`: Single-date query (end_date = start_date) +- If only `end_date`: Single-date query (start_date = end_date) +- If both provided and equal: Single-date query (detailed format) +- If both provided and different: Date range query (metrics format) + +**Response - Single Date (detailed):** + +```json +{ + "count": 1, + "results": [ + { + "date": "2025-01-16", + "model": "gpt-4", + "job_id": "550e8400-...", + "starting_position": { + "holdings": [{"symbol": "AAPL", "quantity": 10}], + "cash": 8500.0, + "portfolio_value": 10000.0 + }, + "daily_metrics": { + "profit": 100.0, + "return_pct": 1.0, + "days_since_last_trading": 1 + }, + "trades": [ + { + "action_type": "buy", + "symbol": "MSFT", + "quantity": 5, + "price": 200.0, + "created_at": "2025-01-16T14:30:00Z" + } + ], + "final_position": { + "holdings": [ + {"symbol": "AAPL", "quantity": 10}, + {"symbol": "MSFT", "quantity": 5} + ], + "cash": 7500.0, + "portfolio_value": 10100.0 + }, + "metadata": { + "total_actions": 1, + "session_duration_seconds": 52.1, + "completed_at": "2025-01-16T14:31:00Z" + }, + "reasoning": null + } + ] +} +``` + +**Response - Date Range (metrics):** + +```json +{ + "count": 1, + "results": [ + { + "model": "gpt-4", + "start_date": "2025-01-16", + "end_date": "2025-01-20", + "daily_portfolio_values": [ + {"date": "2025-01-16", "portfolio_value": 10100.0}, + {"date": "2025-01-17", "portfolio_value": 10250.0}, + {"date": "2025-01-20", "portfolio_value": 10500.0} + ], + "period_metrics": { + "starting_portfolio_value": 10000.0, + "ending_portfolio_value": 10500.0, + "period_return_pct": 5.0, + "annualized_return_pct": 45.6, + "calendar_days": 5, + "trading_days": 3 + } + } + ] +} +``` + +**Period Metrics Calculations:** + +- `period_return_pct` = ((ending - starting) / starting) × 100 +- `annualized_return_pct` = ((ending / starting) ^ (365 / calendar_days) - 1) × 100 +- `calendar_days` = Calendar days from start_date to end_date (inclusive) +- `trading_days` = Number of actual trading days with data + +**Edge Trimming:** + +If requested range extends beyond available data, the response is trimmed to actual data boundaries: + +- Request: `start_date=2025-01-10&end_date=2025-01-20` +- Available: 2025-01-15, 2025-01-16, 2025-01-17 +- Response: `start_date=2025-01-15`, `end_date=2025-01-17` + +**Error Responses:** + +| Status | Scenario | Response | +|--------|----------|----------| +| 404 | No data matches filters | `{"detail": "No trading data found for the specified filters"}` | +| 400 | Invalid date format | `{"detail": "Invalid date format. Expected YYYY-MM-DD"}` | +| 400 | start_date > end_date | `{"detail": "start_date must be <= end_date"}` | +| 400 | Future dates | `{"detail": "Cannot query future dates"}` | +| 422 | Using old `date` param | `{"detail": "Parameter 'date' has been removed. Use 'start_date' and/or 'end_date' instead."}` | + +**Examples:** + +Single date query: +```bash +curl "http://localhost:8080/results?start_date=2025-01-16&model=gpt-4" +``` + +Date range query: +```bash +curl "http://localhost:8080/results?start_date=2025-01-16&end_date=2025-01-20&model=gpt-4" +``` + +Default (last 30 days): +```bash +curl "http://localhost:8080/results" +``` + +With filters: +```bash +curl "http://localhost:8080/results?job_id=550e8400-...&start_date=2025-01-16&end_date=2025-01-20" +``` +``` + +**Step 2: Verify documentation accuracy** + +Manually review the documentation against the implementation. + +**Step 3: Commit** + +```bash +git add API_REFERENCE.md +git commit -m "docs: update /results endpoint documentation for date range support" +``` + +--- + +## Task 6: Update Environment Variables Documentation + +**Files:** +- Modify: `docs/reference/environment-variables.md` + +**Step 1: Add DEFAULT_RESULTS_LOOKBACK_DAYS** + +Add to `docs/reference/environment-variables.md` in the appropriate section: + +```markdown +### DEFAULT_RESULTS_LOOKBACK_DAYS + +**Type:** Integer +**Default:** 30 +**Required:** No + +Number of calendar days to look back when querying `/results` endpoint without date filters. + +**Example:** +```bash +# Default to last 60 days +DEFAULT_RESULTS_LOOKBACK_DAYS=60 +``` + +**Usage:** +When no `start_date` or `end_date` parameters are provided to `/results`, the endpoint returns data from the last N days (ending today). +``` + +**Step 2: Commit** + +```bash +git add docs/reference/environment-variables.md +git commit -m "docs: add DEFAULT_RESULTS_LOOKBACK_DAYS environment variable" +``` + +--- + +## Task 7: Update CHANGELOG + +**Files:** +- Modify: `CHANGELOG.md` + +**Step 1: Add entry for breaking change** + +Add to the top of `CHANGELOG.md`: + +```markdown +## [Unreleased] + +### Added +- **Date Range Support in /results Endpoint** - Query multiple dates in single request with period performance metrics + - `start_date` and `end_date` parameters replace deprecated `date` parameter + - Returns lightweight format with daily portfolio values and period metrics for date ranges + - Period metrics: period return %, annualized return %, calendar days, trading days + - Default to last 30 days when no dates provided (configurable via `DEFAULT_RESULTS_LOOKBACK_DAYS`) + - Automatic edge trimming when requested range exceeds available data + - Per-model results grouping +- **Environment Variable:** `DEFAULT_RESULTS_LOOKBACK_DAYS` - Configure default lookback period (default: 30) + +### Changed +- **BREAKING:** `/results` endpoint parameter `date` removed - use `start_date`/`end_date` instead + - Single date: `?start_date=2025-01-16` or `?end_date=2025-01-16` + - Date range: `?start_date=2025-01-16&end_date=2025-01-20` + - Old `?date=2025-01-16` now returns 422 error with migration instructions + +### Migration Guide + +**Before:** +```bash +GET /results?date=2025-01-16&model=gpt-4 +``` + +**After:** +```bash +# Option 1: Use start_date only +GET /results?start_date=2025-01-16&model=gpt-4 + +# Option 2: Use both (same result for single date) +GET /results?start_date=2025-01-16&end_date=2025-01-16&model=gpt-4 + +# New: Date range queries +GET /results?start_date=2025-01-16&end_date=2025-01-20&model=gpt-4 +``` + +**Python Client:** +```python +# OLD (will break) +results = client.get_results(date="2025-01-16") + +# NEW +results = client.get_results(start_date="2025-01-16") +results = client.get_results(start_date="2025-01-16", end_date="2025-01-20") +``` +``` + +**Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: add changelog entry for date range support breaking change" +``` + +--- + +## Task 8: Update Client Library Examples + +**Files:** +- Modify: `API_REFERENCE.md` (Python client section) + +**Step 1: Update Python client example** + +Find the Python client example in `API_REFERENCE.md` (around line 1008) and update the `get_results` method: + +```python +def get_results(self, start_date=None, end_date=None, job_id=None, model=None, reasoning="none"): + """Query results with optional filters and date range. + + Args: + start_date: Start date (YYYY-MM-DD) or None + end_date: End date (YYYY-MM-DD) or None + job_id: Job ID filter + model: Model signature filter + reasoning: Reasoning level (none/summary/full) + """ + params = {"reasoning": reasoning} + if start_date: + params["start_date"] = start_date + if end_date: + params["end_date"] = end_date + if job_id: + params["job_id"] = job_id + if model: + params["model"] = model + + response = requests.get(f"{self.base_url}/results", params=params) + response.raise_for_status() + return response.json() +``` + +Update usage examples: + +```python +# Single day simulation +job = client.trigger_simulation(end_date="2025-01-16", start_date="2025-01-16", models=["gpt-4"]) + +# Date range simulation +job = client.trigger_simulation(end_date="2025-01-20", start_date="2025-01-16") + +# Wait for completion and get results +result = client.wait_for_completion(job["job_id"]) +results = client.get_results(job_id=job["job_id"]) + +# Get results for date range +range_results = client.get_results( + start_date="2025-01-16", + end_date="2025-01-20", + model="gpt-4" +) +``` + +**Step 2: Commit** + +```bash +git add API_REFERENCE.md +git commit -m "docs: update Python client examples for date range support" +``` + +--- + +## Verification + +After completing all tasks, run full test suite: + +```bash +# Run all tests +pytest tests/ -v + +# Run specifically results endpoint tests +pytest tests/api/test_results_v2.py -v + +# Run period metrics tests +pytest tests/api/test_period_metrics.py -v +``` + +Expected: All tests PASS + +--- + +## Notes + +- **DRY:** Period metrics calculation is extracted to separate module for reuse +- **YAGNI:** No premature optimization, simple calendar day calculation +- **TDD:** Tests written before implementation for each component +- **Breaking Change:** Clear migration path with helpful error messages +- **Edge Cases:** Handles weekends/gaps, future dates, invalid formats diff --git a/tests/integration/test_api_endpoints.py b/tests/integration/test_api_endpoints.py index 1401540..5164ea3 100644 --- a/tests/integration/test_api_endpoints.py +++ b/tests/integration/test_api_endpoints.py @@ -250,13 +250,11 @@ class TestResultsEndpoint: }) job_id = create_response.json()["job_id"] - # Query results + # Query results - no data exists yet, should return 404 response = api_client.get(f"/results?job_id={job_id}") - assert response.status_code == 200 - data = response.json() - # Should return empty list initially (no completed executions yet) - assert isinstance(data["results"], list) + # No data exists, should return 404 + assert response.status_code == 404 def test_results_filters_by_date(self, api_client): """Should filter results by date."""