institutional-trader/backend/python_service/main.py

311 lines
12 KiB
Python

"""
FastAPI service for options flow processing
Replaces complex SQL with Python/pandas logic
"""
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta
import pandas as pd
import asyncpg
from db import get_pool, close_pool
from services.options_flow_processor import OptionsFlowProcessor
from services.price_context import PriceContextService
from services.alert_service import AlertService
from services.output_formatter import OutputFormatter
from utils.logger import logger
from utils.error_handler import handle_processing_error
app = FastAPI(title="Options Flow Processing Service", version="1.0.0")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class OptionsFlowRequest(BaseModel):
start_date: Optional[str] = None
end_date: Optional[str] = None
min_premium: Optional[float] = 80000
tol_pct: Optional[float] = 0.20
class OptionsFlowResponse(BaseModel):
success: bool
data: List[dict]
count: int
timestamp: str
@app.on_event("startup")
async def startup():
"""Initialize database pool on startup"""
try:
logger.info("Initializing database connection pool...")
pool = await get_pool()
# Test the connection with a quick query
async with pool.acquire() as conn:
await conn.fetchval("SELECT 1")
logger.info("✅ Database connection pool initialized successfully")
except Exception as e:
logger.error(f"⚠️ Failed to initialize database pool on startup: {str(e)}")
logger.warning("Service will start but database operations may fail. Connection will be retried on first request.")
# Don't raise - allow service to start and retry on first request
# This makes the service more resilient to temporary DB issues
@app.on_event("shutdown")
async def shutdown():
"""Close database pool on shutdown"""
await close_pool()
@app.get("/health")
async def health():
"""Health check endpoint"""
try:
pool = await get_pool()
async with pool.acquire() as conn:
await conn.fetchval("SELECT 1")
return {"status": "healthy", "service": "options-flow-processor"}
except Exception as e:
return {"status": "unhealthy", "error": str(e)}
@app.get("/api/options-flow", response_model=OptionsFlowResponse)
async def get_options_flow(
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
min_premium: Optional[float] = Query(80000, description="Minimum premium filter"),
tol_pct: Optional[float] = Query(0.20, description="Tape alignment tolerance")
):
"""
Get processed options flow data
Replaces the complex SQL query with Python processing
"""
try:
logger.info(f"Options flow request: start={start_date}, end={end_date}, min_premium={min_premium}")
pool = await get_pool()
# Default dates (only if not provided)
if not start_date:
start_date = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
logger.info(f"No start_date provided, using default: {start_date}")
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
logger.info(f"No end_date provided, using default: {end_date}")
logger.info(f"Processing with date range: {start_date} to {end_date}")
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
# Load raw options flow data (with timeout handling)
try:
async with pool.acquire() as conn:
query = """
SELECT *
FROM "OptionsFlow_monthly"
WHERE "Premium" IS NOT NULL
AND TRIM("Premium"::text) <> ''
AND "StockEtf" = 'STOCK'
AND "Symbol" NOT IN ('TSLA', 'NVDA')
"""
rows = await conn.fetch(query)
except Exception as e:
logger.error(f"Database query error: {type(e).__name__} - {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Database query failed: {str(e)}"
)
if not rows:
return OptionsFlowResponse(
success=True,
data=[],
count=0,
timestamp=datetime.now().isoformat()
)
# Convert to DataFrame
df = pd.DataFrame([dict(row) for row in rows])
# Process with Python service
processor = OptionsFlowProcessor(tol_pct=tol_pct)
df_processed = processor.process(df, start_dt, end_dt)
# Enrich with price context (optimized batch queries)
price_service = PriceContextService(pool)
df_with_prices = await price_service.enrich_flow_with_prices(df_processed, pool)
# Match alerts (batch processing)
alert_service = AlertService(pool)
df_final = await alert_service.match_alerts_to_flows(df_with_prices)
# Recalculate rocket score with price context and alerts
df_final = processor.process_rocket_score(df_final)
# Check if DataFrame is empty before filtering
if df_final.empty:
logger.warning("No data after processing, returning empty result")
return OptionsFlowResponse(
success=True,
data=[],
count=0,
timestamp=datetime.now().isoformat()
)
# Filter by minimum premium and badge requirements (matching SQL WHERE clause)
logger.info(f"📊 Before filtering: {len(df_final)} rows")
# Only filter if columns exist
if 'premium_num' in df_final.columns:
before_premium = len(df_final)
df_final = df_final[df_final['premium_num'] > min_premium].copy()
after_premium = len(df_final)
logger.info(f"📊 After premium filter (>${min_premium:,.0f}): {after_premium} rows (removed {before_premium - after_premium})")
else:
logger.warning("⚠️ premium_num column not found, skipping premium filter")
if df_final.empty:
logger.warning("⚠️ No data after premium filter")
return OptionsFlowResponse(
success=True,
data=[],
count=0,
timestamp=datetime.now().isoformat()
)
# Filter by badge requirements (only if columns exist)
if 'badge_round' in df_final.columns and 'badge_more' in df_final.columns:
before_badges = len(df_final)
df_final = df_final[
(df_final['badge_round'].isin(['🟢', '🔴'])) &
(df_final['badge_more'].str.contains('💎', na=False)) &
(df_final['badge_more'].str.contains('', na=False))
].copy()
after_badges = len(df_final)
logger.info(f"📊 After badge filter (🟢/🔴 + 💎 + ⭐): {after_badges} rows (removed {before_badges - after_badges})")
else:
logger.warning("⚠️ badge_round or badge_more columns not found, skipping badge filter")
if df_final.empty:
logger.warning("⚠️ No data after badge filter")
return OptionsFlowResponse(
success=True,
data=[],
count=0,
timestamp=datetime.now().isoformat()
)
# Additional direction filter (only if columns exist)
if 'direction' in df_final.columns and 'badge_round' in df_final.columns and 'bull_total' in df_final.columns and 'bear_total' in df_final.columns:
before_direction = len(df_final)
df_final = df_final[
((df_final['direction'] == 'BULL') &
(df_final['badge_round'] == '🟢') &
((df_final['bull_total'] - df_final['bear_total']) > 0)) |
((df_final['direction'] == 'BEAR') &
(df_final['badge_round'] == '🔴') &
((df_final['bull_total'] - df_final['bear_total']) < 0))
].copy()
after_direction = len(df_final)
logger.info(f"📊 After direction/net premium filter: {after_direction} rows (removed {before_direction - after_direction})")
else:
logger.warning("⚠️ Required columns for direction filter not found, skipping")
# Sort by timestamp descending
df_final = df_final.sort_values(['flow_ts_utc', 'rid'], ascending=[False, False])
# Format output to match SQL format
df_final = OutputFormatter.format_final_output(df_final)
# Convert DataFrame to list of dicts
result_data = df_final.to_dict('records')
# Format dates and handle NaN values
for record in result_data:
# Convert datetime objects to strings
for key, value in record.items():
if isinstance(value, datetime):
record[key] = value.isoformat()
elif pd.isna(value):
record[key] = None
elif isinstance(value, pd.Timestamp):
# Check if it's NaT (Not a Time)
if pd.isna(value):
record[key] = None
else:
record[key] = value.isoformat()
return OptionsFlowResponse(
success=True,
data=result_data,
count=len(result_data),
timestamp=datetime.now().isoformat()
)
except Exception as e:
error_info = handle_processing_error(
e,
context={
'start_date': start_date,
'end_date': end_date,
'min_premium': min_premium
},
raise_error=False
)
raise HTTPException(
status_code=500,
detail=error_info.get('error_message', str(e)) if isinstance(error_info, dict) else str(e)
)
@app.get("/api/options-flow/stats")
async def get_flow_stats(
symbol: Optional[str] = Query(None, description="Symbol to get stats for")
):
"""Get flow statistics"""
try:
pool = await get_pool()
query = """
SELECT
symbol,
COUNT(*) as total_trades,
SUM(premium_num) as total_premium,
SUM(CASE WHEN cp_norm = 'CALL' THEN vol_num ELSE 0 END) as call_volume,
SUM(CASE WHEN cp_norm = 'PUT' THEN vol_num ELSE 0 END) as put_volume
FROM processed_options_flow
"""
params = []
if symbol:
query += " WHERE symbol_norm = $1"
params.append(symbol.upper())
query += " GROUP BY symbol"
async with pool.acquire() as conn:
rows = await conn.fetch(query, *params)
return {
"success": True,
"data": [dict(row) for row in rows]
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8010)