mirror of
https://github.com/Xe138/AI-Trader.git
synced 2026-04-01 17:17:24 -04:00
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user