mirror of
https://github.com/Xe138/AI-Trader.git
synced 2026-04-01 17:17:24 -04:00
feat: add new day-centric results API endpoint
Implements new /results endpoint with day-centric data structure: - Returns starting_position, daily_metrics, trades, and final_position - Supports reasoning levels: none (default), summary, full - Uses database helper methods from trading_days schema - Replaces old positions-based endpoint Changes: - Created api/routes/results_v2.py with new endpoint - Registered router in api/main.py - Removed old /results endpoint (positions table) - Added comprehensive integration tests All tests pass.
This commit is contained in:
109
api/main.py
109
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
|
||||
|
||||
|
||||
|
||||
1
api/routes/__init__.py
Normal file
1
api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routes package."""
|
||||
112
api/routes/results_v2.py
Normal file
112
api/routes/results_v2.py
Normal file
@@ -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
|
||||
}
|
||||
137
tests/integration/test_results_api_v2.py
Normal file
137
tests/integration/test_results_api_v2.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user