mirror of
https://github.com/Xe138/AI-Trader.git
synced 2026-04-01 17:17:24 -04:00
- 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
255 lines
7.7 KiB
Python
255 lines
7.7 KiB
Python
"""New results API with day-centric structure."""
|
|
|
|
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()
|
|
|
|
|
|
def get_database() -> Database:
|
|
"""Dependency for database instance."""
|
|
return Database()
|
|
|
|
|
|
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")
|
|
|
|
# Ensure strict YYYY-MM-DD format (e.g., reject "2025-1-16")
|
|
if start_date != start_dt.strftime("%Y-%m-%d"):
|
|
raise ValueError(f"Invalid date format. Expected YYYY-MM-DD")
|
|
if end_date != end_dt.strftime("%Y-%m-%d"):
|
|
raise ValueError(f"Invalid date format. Expected YYYY-MM-DD")
|
|
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
|
|
|
|
|
|
@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 with optional date range and portfolio performance metrics.
|
|
|
|
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
|
|
"""
|
|
|
|
# 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
|
|
}
|