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:
2025-11-07 19:26:06 -05:00
parent 5c95180941
commit 2612b85431
2 changed files with 299 additions and 57 deletions

View File

@@ -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
}