diff --git a/api/main.py b/api/main.py index 39465ee..3f09d48 100644 --- a/api/main.py +++ b/api/main.py @@ -24,6 +24,7 @@ from api.simulation_worker import SimulationWorker from api.database import get_db_connection from api.date_utils import validate_date_range, expand_date_range, get_max_simulation_days from tools.deployment_config import get_deployment_mode_dict, log_dev_mode_startup_warning +from api.routes import results_v2 import threading import time @@ -424,108 +425,9 @@ def create_app( logger.error(f"Failed to get job status: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") - @app.get("/results") - async def get_results( - job_id: Optional[str] = Query(None, description="Filter by job ID"), - date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)"), - model: Optional[str] = Query(None, description="Filter by model signature") - ): - """ - Query simulation results. - - Supports filtering by job_id, date, and/or model. - Returns position data with holdings. - - Args: - job_id: Optional job UUID filter - date: Optional date filter (YYYY-MM-DD) - model: Optional model signature filter - - Returns: - List of position records with holdings - """ - try: - conn = get_db_connection(app.state.db_path) - cursor = conn.cursor() - - # Build query with filters - query = """ - SELECT - p.id, - p.job_id, - p.date, - p.model, - p.action_id, - p.action_type, - p.symbol, - p.amount, - p.price, - p.cash, - p.portfolio_value, - p.daily_profit, - p.daily_return_pct, - p.created_at - FROM positions p - WHERE 1=1 - """ - params = [] - - if job_id: - query += " AND p.job_id = ?" - params.append(job_id) - - if date: - query += " AND p.date = ?" - params.append(date) - - if model: - query += " AND p.model = ?" - params.append(model) - - query += " ORDER BY p.date, p.model, p.action_id" - - cursor.execute(query, params) - rows = cursor.fetchall() - - results = [] - for row in rows: - position_id = row[0] - - # Get holdings for this position - cursor.execute(""" - SELECT symbol, quantity - FROM holdings - WHERE position_id = ? - ORDER BY symbol - """, (position_id,)) - - holdings = [{"symbol": h[0], "quantity": h[1]} for h in cursor.fetchall()] - - results.append({ - "id": row[0], - "job_id": row[1], - "date": row[2], - "model": row[3], - "action_id": row[4], - "action_type": row[5], - "symbol": row[6], - "amount": row[7], - "price": row[8], - "cash": row[9], - "portfolio_value": row[10], - "daily_profit": row[11], - "daily_return_pct": row[12], - "created_at": row[13], - "holdings": holdings - }) - - conn.close() - - return {"results": results, "count": len(results)} - - except Exception as e: - logger.error(f"Failed to query results: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + # OLD /results endpoint - REPLACED by results_v2.py + # This endpoint used the old positions table schema and is no longer needed + # The new endpoint is defined in api/routes/results_v2.py @app.get("/reasoning", response_model=ReasoningResponse) async def get_reasoning( @@ -745,6 +647,9 @@ def create_app( **deployment_info ) + # Include routers + app.include_router(results_v2.router) + return app diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..d1e594b --- /dev/null +++ b/api/routes/__init__.py @@ -0,0 +1 @@ +"""API routes package.""" diff --git a/api/routes/results_v2.py b/api/routes/results_v2.py new file mode 100644 index 0000000..d3dfe35 --- /dev/null +++ b/api/routes/results_v2.py @@ -0,0 +1,112 @@ +"""New results API with day-centric structure.""" + +from fastapi import APIRouter, Query, Depends +from typing import Optional, Literal +import json + +from api.database import Database + +router = APIRouter() + + +def get_database() -> Database: + """Dependency for database instance.""" + return Database() + + +@router.get("/results") +async def get_results( + job_id: Optional[str] = None, + model: Optional[str] = None, + date: Optional[str] = None, + 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 + date: Filter by trading date (YYYY-MM-DD) + reasoning: Include reasoning logs (none/summary/full) + db: Database instance (injected) + + Returns: + JSON with day-centric trading results and performance metrics + """ + + # Build query with filters + query = "SELECT * FROM trading_days WHERE 1=1" + params = [] + + if job_id: + query += " AND job_id = ?" + params.append(job_id) + + if model: + query += " AND model = ?" + params.append(model) + + if date: + query += " AND date = ?" + params.append(date) + + query += " ORDER BY date ASC, model ASC" + + # Execute query + cursor = db.connection.execute(query, params) + + # 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 [] + else: + day_data["reasoning"] = None + + formatted_results.append(day_data) + + return { + "count": len(formatted_results), + "results": formatted_results + } diff --git a/tests/integration/test_results_api_v2.py b/tests/integration/test_results_api_v2.py new file mode 100644 index 0000000..2921316 --- /dev/null +++ b/tests/integration/test_results_api_v2.py @@ -0,0 +1,137 @@ +import pytest +from fastapi.testclient import TestClient +from api.main import create_app +from api.database import Database +from api.routes.results_v2 import get_database + + +class TestResultsAPIV2: + + @pytest.fixture + def client(self, db): + """Create test client with overridden database dependency.""" + # Create fresh app instance + app = create_app() + # Override the database dependency + app.dependency_overrides[get_database] = lambda: db + client = TestClient(app) + yield client + # Clean up + app.dependency_overrides.clear() + + @pytest.fixture + def db(self, tmp_path): + """Create test database with sample data.""" + import importlib + migration_module = importlib.import_module('api.migrations.001_trading_days_schema') + create_trading_days_schema = migration_module.create_trading_days_schema + + db_path = tmp_path / "test.db" + db = Database(str(db_path)) + + # Create schema + db.connection.execute(""" + CREATE TABLE IF NOT EXISTS jobs ( + job_id TEXT PRIMARY KEY, + status TEXT + ) + """) + create_trading_days_schema(db) + + # Insert sample data + db.connection.execute( + "INSERT INTO jobs (job_id, status) VALUES (?, ?)", + ("test-job", "completed") + ) + + # Day 1 + day1_id = db.create_trading_day( + job_id="test-job", + model="gpt-4", + date="2025-01-15", + starting_cash=10000.0, + starting_portfolio_value=10000.0, + daily_profit=0.0, + daily_return_pct=0.0, + ending_cash=8500.0, + ending_portfolio_value=10000.0, + reasoning_summary="First day summary", + total_actions=1 + ) + db.create_holding(day1_id, "AAPL", 10) + db.create_action(day1_id, "buy", "AAPL", 10, 150.0) + + db.connection.commit() + return db + + def test_results_without_reasoning(self, client, db): + """Test default response excludes reasoning.""" + response = client.get("/results?job_id=test-job") + + assert response.status_code == 200 + data = response.json() + + assert data["count"] == 1 + assert data["results"][0]["reasoning"] is None + + def test_results_with_summary(self, client, db): + """Test including reasoning summary.""" + response = client.get("/results?job_id=test-job&reasoning=summary") + + data = response.json() + result = data["results"][0] + + assert result["reasoning"] == "First day summary" + + def test_results_structure(self, client, db): + """Test complete response structure.""" + response = client.get("/results?job_id=test-job") + + result = response.json()["results"][0] + + # Basic fields + assert result["date"] == "2025-01-15" + assert result["model"] == "gpt-4" + assert result["job_id"] == "test-job" + + # Starting position + assert "starting_position" in result + assert result["starting_position"]["cash"] == 10000.0 + assert result["starting_position"]["portfolio_value"] == 10000.0 + assert result["starting_position"]["holdings"] == [] # First day + + # Daily metrics + assert "daily_metrics" in result + assert result["daily_metrics"]["profit"] == 0.0 + assert result["daily_metrics"]["return_pct"] == 0.0 + + # Trades + assert "trades" in result + assert len(result["trades"]) == 1 + assert result["trades"][0]["action_type"] == "buy" + assert result["trades"][0]["symbol"] == "AAPL" + + # Final position + assert "final_position" in result + assert result["final_position"]["cash"] == 8500.0 + assert result["final_position"]["portfolio_value"] == 10000.0 + assert len(result["final_position"]["holdings"]) == 1 + assert result["final_position"]["holdings"][0]["symbol"] == "AAPL" + + # Metadata + assert "metadata" in result + assert result["metadata"]["total_actions"] == 1 + + def test_results_filtering_by_date(self, client, db): + """Test filtering results by date.""" + response = client.get("/results?date=2025-01-15") + + results = response.json()["results"] + assert all(r["date"] == "2025-01-15" for r in results) + + def test_results_filtering_by_model(self, client, db): + """Test filtering results by model.""" + response = client.get("/results?model=gpt-4") + + results = response.json()["results"] + assert all(r["model"] == "gpt-4" for r in results)