""" FastAPI service for options flow processing Replaces complex SQL with Python/pandas logic """ from fastapi import FastAPI, HTTPException, Query, Body from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional, List, Dict, Any from datetime import datetime, timedelta import pandas as pd import asyncpg import pytz 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 services.price_reaction_tracker import PriceReactionTracker from services.signal_tier_classifier import SignalTierClassifier from services.trade_checklist import TradeChecklist 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: pool = await get_pool() logger.info("✅ Database pool initialized") except Exception as e: logger.error(f"❌ Failed to initialize database pool: {e}") @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"} 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: # Build query with date filtering # Note: CreatedDate is TEXT, so we need to handle date comparisons carefully query = """ SELECT * FROM "OptionsFlow_monthly" WHERE "Premium" IS NOT NULL AND TRIM("Premium"::text) <> '' AND "StockEtf" = 'STOCK' AND "Symbol" NOT IN ('TSLA', 'NVDA') """ # Add date filtering using the parsed dates params = [] if start_date: # CreatedDate is TEXT, so compare as strings (assuming YYYY-MM-DD format) query += ' AND "CreatedDate" >= $1' params.append(start_date) if end_date: param_idx = len(params) + 1 query += ' AND "CreatedDate" <= $' + str(param_idx) params.append(end_date) # Add LIMIT for safety (prevent loading millions of rows) # Limit to 500k rows max query += ' LIMIT 500000' logger.info(f"Executing query with date range: {start_date} to {end_date}") # Execute with timeout try: # Set statement timeout for this query (60 seconds) await conn.execute('SET statement_timeout = 60000') rows = await conn.fetch(query, *params) await conn.execute('RESET statement_timeout') logger.info(f"✅ Fetched {len(rows)} rows from database") except Exception as query_error: await conn.execute('RESET statement_timeout') raise query_error except Exception as e: error_type = type(e).__name__ error_msg = str(e) logger.error(f"Database query error: {error_type} - {error_msg}") # Provide more helpful error messages if 'TimeoutError' in error_type or 'timeout' in error_msg.lower(): raise HTTPException( status_code=504, detail=f"Database query timed out. The query may be too large. Try narrowing the date range or ensure database indexes are optimized." ) elif 'Connection' in error_type or 'connection' in error_msg.lower(): raise HTTPException( status_code=503, detail=f"Database connection error: {error_msg}. Check database connectivity and configuration." ) else: raise HTTPException( status_code=500, detail=f"Database query failed: {error_msg}" ) 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) - includes VWAP 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) # Phase 1 Enhancements (BEFORE filtering so all signals get Phase 1 data): # Initialize Phase 1 columns with None/empty values first if not df_final.empty: # Initialize all Phase 1 columns to ensure they exist df_final['signal_tier'] = None df_final['is_tradeable'] = False df_final['checklist_score'] = None df_final['checklist_passed'] = False df_final['checklist_details'] = None df_final['price_reaction_5m_pct'] = None df_final['price_reaction_15m_pct'] = None df_final['price_reaction_30m_pct'] = None df_final['high_break_5m'] = None df_final['low_break_5m'] = None df_final['flow_led_to_move'] = None # VWAP fields should already be set by PriceContextService, but ensure they exist if 'vwap_at_signal' not in df_final.columns: df_final['vwap_at_signal'] = None if 'price_vs_vwap_pct' not in df_final.columns: df_final['price_vs_vwap_pct'] = None # 1. Signal Tier Classification logger.info("🔍 Classifying signal tiers...") if not df_final.empty: try: tier_classifier = SignalTierClassifier() df_final = tier_classifier.classify_tiers(df_final) # Debug: Check if tiers were calculated if 'signal_tier' in df_final.columns: tier_counts = df_final['signal_tier'].value_counts() logger.info(f"✅ Signal tiers calculated: {tier_counts.to_dict()}") else: logger.warning("⚠️ signal_tier column not found after classification") except Exception as e: logger.error(f"❌ Error in signal tier classification: {str(e)}", exc_info=True) # 2. Price Reaction Tracking (disabled - will be calculated on-demand) # Price reaction requires Yahoo Finance and is calculated when modal opens # 3. Trade Checklist Evaluation logger.info("✅ Evaluating trade checklist...") if not df_final.empty: try: checklist = TradeChecklist() df_final = checklist.evaluate_all(df_final) # Debug: Check if checklist was calculated if 'checklist_score' in df_final.columns: checklist_count = df_final['checklist_score'].notna().sum() logger.info(f"✅ Checklist scores calculated: {checklist_count}/{len(df_final)} signals") else: logger.warning("⚠️ checklist_score column not found after evaluation") except Exception as e: logger.error(f"❌ Error in trade checklist evaluation: {str(e)}", exc_info=True) # 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) # Debug: Log Phase 1 columns before converting to dict phase1_columns = ['signal_tier', 'checklist_score', 'checklist_passed', 'price_reaction_5m_pct', 'flow_led_to_move', 'vwap_at_signal', 'price_vs_vwap_pct'] existing_phase1_cols = [col for col in phase1_columns if col in df_final.columns] if existing_phase1_cols: logger.info(f"✅ Phase 1 columns in final output: {existing_phase1_cols}") # Sample a row to check values if len(df_final) > 0: sample_row = df_final.iloc[0] sample_phase1 = {col: sample_row.get(col) for col in existing_phase1_cols} logger.info(f"📊 Sample Phase 1 data: {sample_phase1}") else: logger.warning("⚠️ No Phase 1 columns found in final output!") # 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() elif isinstance(value, dict): # Keep dicts as-is (e.g., checklist_details) pass elif isinstance(value, (list, tuple)): # Keep lists as-is pass return OptionsFlowResponse( success=True, data=result_data, count=len(result_data), timestamp=datetime.now().isoformat() ) except Exception as e: logger.error(f"Error processing options flow: {e}", exc_info=True) raise HTTPException( status_code=500, detail=f"Processing failed: {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)) @app.post("/api/phase1/calculate") async def calculate_phase1_for_signal(request: Dict[str, Any] = Body(...)): """ Calculate Phase 1 metrics for a specific signal on-demand This is called when the Phase 1 modal opens - fetches Yahoo Finance data and calculates Phase 1 """ try: from services.yahoo_finance_service import YahooFinanceService symbol = request.get('symbol') flow_ts_utc = request.get('flow_ts_utc') flow_date_cst = request.get('flow_date_cst') row_data = request.get('row_data', {}) if not symbol or not flow_ts_utc: raise HTTPException( status_code=400, detail="symbol and flow_ts_utc are required" ) # Log the symbol we received logger.info(f"📥 Phase 1 calculation request for symbol: '{symbol}' (type: {type(symbol).__name__}, length: {len(symbol) if symbol else 0})") pool = await get_pool() # Parse timestamps - handle various formats logger.info(f"📥 Received flow_ts_utc: {flow_ts_utc} (type: {type(flow_ts_utc).__name__})") if isinstance(flow_ts_utc, str): try: original_ts = flow_ts_utc # Remove any trailing Z and handle ISO format ts_str = flow_ts_utc.strip() if ts_str.endswith('Z'): # Replace Z with +00:00 for fromisoformat ts_str = ts_str[:-1] + '+00:00' logger.info(f"📅 Parsing UTC timestamp (Z suffix): {original_ts} -> {ts_str}") elif 'T' in ts_str and '+' not in ts_str and '-' not in ts_str[-6:]: # ISO format without timezone - check if it might be local time # For now, assume UTC as the field name suggests, but log it ts_str = ts_str + '+00:00' logger.info(f"📅 Parsing timestamp without timezone, assuming UTC: {original_ts} -> {ts_str}") # Try parsing with fromisoformat try: flow_ts_utc = datetime.fromisoformat(ts_str) logger.info(f"✅ Parsed flow_ts_utc: {flow_ts_utc} (timezone: {flow_ts_utc.tzinfo})") except ValueError: # Fallback: try parsing just the date part date_part = ts_str.split('T')[0].split(' ')[0] flow_ts_utc = datetime.strptime(date_part, '%Y-%m-%d') logger.warning(f"⚠️ Could only parse date part from {original_ts}, using {flow_ts_utc}") except (ValueError, AttributeError) as e: logger.error(f"❌ Error parsing flow_ts_utc '{flow_ts_utc}': {e}") raise HTTPException( status_code=400, detail=f"Invalid flow_ts_utc format: {flow_ts_utc}. Error: {str(e)}" ) elif isinstance(flow_ts_utc, (int, float)): # Unix timestamp - assume UTC flow_ts_utc = datetime.fromtimestamp(flow_ts_utc, tz=pytz.UTC) logger.info(f"📅 Parsed Unix timestamp: {flow_ts_utc} (UTC)") # Ensure flow_ts_utc is timezone-aware (assume UTC if naive) if flow_ts_utc.tzinfo is None: flow_ts_utc = pytz.UTC.localize(flow_ts_utc) logger.info(f"📅 flow_ts_utc was naive, localized to UTC: {flow_ts_utc}") if isinstance(flow_date_cst, str): try: # Extract just the date part if it's a datetime string # Handle formats like "2025-12-10T05:00:00.000Z" or "2025-12-10" original_date = flow_date_cst.strip() # Split on 'T' first, then on space, take first part if 'T' in original_date: date_str = original_date.split('T')[0] elif ' ' in original_date: date_str = original_date.split(' ')[0] else: date_str = original_date # Validate it's in YYYY-MM-DD format (exactly 10 characters, 2 dashes) if len(date_str) == 10 and date_str.count('-') == 2: flow_date_cst = datetime.strptime(date_str, '%Y-%m-%d').date() logger.debug(f"Parsed flow_date_cst: {flow_date_cst} from '{original_date}'") else: raise ValueError(f"Date string '{date_str}' is not in YYYY-MM-DD format (length={len(date_str)}, dashes={date_str.count('-')})") except (ValueError, AttributeError) as e: logger.warning(f"Error parsing flow_date_cst '{flow_date_cst}': {e}") # Try to extract date from flow_ts_utc if available if flow_ts_utc and isinstance(flow_ts_utc, datetime): flow_date_cst = flow_ts_utc.date() logger.info(f"Using date from flow_ts_utc: {flow_date_cst}") else: # Default to today if we can't parse flow_date_cst = datetime.now().date() logger.warning(f"Using current date as fallback for flow_date_cst") elif flow_date_cst is None: # If not provided, try to get from flow_ts_utc if flow_ts_utc and isinstance(flow_ts_utc, datetime): flow_date_cst = flow_ts_utc.date() else: flow_date_cst = datetime.now().date() logger.warning(f"flow_date_cst not provided, using current date as fallback") elif flow_date_cst is None: # If not provided, try to get from flow_ts_utc if flow_ts_utc and isinstance(flow_ts_utc, datetime): flow_date_cst = flow_ts_utc.date() else: flow_date_cst = datetime.now().date() logger.warning(f"flow_date_cst not provided, using current date as fallback") # Initialize services with Yahoo Finance enabled price_service = PriceContextService(pool) price_service.use_yahoo_finance = True price_service.yahoo_service = YahooFinanceService() reaction_tracker = PriceReactionTracker() reaction_tracker.use_yahoo_finance = True reaction_tracker.yahoo_service = YahooFinanceService() tier_classifier = SignalTierClassifier() checklist = TradeChecklist() # Create a minimal DataFrame row for processing df_row = pd.DataFrame([{ 'symbol_norm': symbol, 'flow_ts_utc': flow_ts_utc, 'flow_date_cst': flow_date_cst, **row_data # Include all other row data (badges, premium, etc.) }]) # Calculate Phase 1 metrics result = {} # 1. Signal Tier (doesn't need price data) try: df_with_tier = tier_classifier.classify_tiers(df_row) if not df_with_tier.empty: result['signal_tier'] = df_with_tier.iloc[0].get('signal_tier') # Convert numpy bool to Python bool is_tradeable_val = df_with_tier.iloc[0].get('is_tradeable', False) result['is_tradeable'] = bool(is_tradeable_val) if is_tradeable_val is not None else False except Exception as e: logger.error(f"Error calculating signal tier: {e}") result['signal_tier'] = None result['is_tradeable'] = False # 2. Price Reaction (needs Yahoo Finance) try: # Check if signal is recent enough for intraday data now = datetime.now(pytz.timezone('America/Chicago')) time_diff_hours = (now - flow_ts_utc.replace(tzinfo=now.tzinfo)).total_seconds() / 3600 if time_diff_hours > 168: # 7 days logger.warning(f"⚠️ Price reaction unavailable: Signal is {time_diff_hours/24:.1f} days old") logger.warning(f" Yahoo Finance only provides intraday data for the last 7 days") logger.warning(f" For historical signals, price reaction data requires your own intraday price database") else: logger.info(f"📊 Calculating price reaction for {symbol} (signal is {time_diff_hours:.1f} hours old)") df_with_reactions = await reaction_tracker.enrich_with_reactions(df_row, pool) if not df_with_reactions.empty: result['price_reaction_5m_pct'] = df_with_reactions.iloc[0].get('price_reaction_5m_pct') result['price_reaction_15m_pct'] = df_with_reactions.iloc[0].get('price_reaction_15m_pct') result['price_reaction_30m_pct'] = df_with_reactions.iloc[0].get('price_reaction_30m_pct') # Convert numpy bools to Python bools flow_led = df_with_reactions.iloc[0].get('flow_led_to_move') result['flow_led_to_move'] = bool(flow_led) if flow_led is not None else None high_break = df_with_reactions.iloc[0].get('high_break_5m') result['high_break_5m'] = bool(high_break) if high_break is not None else None low_break = df_with_reactions.iloc[0].get('low_break_5m') result['low_break_5m'] = bool(low_break) if low_break is not None else None except Exception as e: logger.error(f"Error calculating price reaction: {e}") result['price_reaction_5m_pct'] = None result['price_reaction_15m_pct'] = None result['price_reaction_30m_pct'] = None result['flow_led_to_move'] = None result['high_break_5m'] = None result['low_break_5m'] = None # 3. VWAP (needs Yahoo Finance) try: # Ensure flow_ts_utc is timezone-aware # If it's naive (no timezone), assume it's UTC (as the name suggests) # If it has timezone info, use it if flow_ts_utc.tzinfo is None: # Naive datetime - assume UTC (as name suggests) flow_ts_utc = pytz.UTC.localize(flow_ts_utc) logger.debug(f"flow_ts_utc was naive, assumed UTC: {flow_ts_utc}") # Convert to Eastern Time for market hours check (US market opens at 9:30 AM ET) et_tz = pytz.timezone('America/New_York') signal_time_et = flow_ts_utc.astimezone(et_tz) cst_tz = pytz.timezone('America/Chicago') signal_time_cst = flow_ts_utc.astimezone(cst_tz) # Check if signal is recent enough and during market hours now_et = datetime.now(et_tz) now_cst = datetime.now(cst_tz) time_diff_hours = (now_et - signal_time_et).total_seconds() / 3600 signal_hour_et = signal_time_et.hour signal_minute_et = signal_time_et.minute logger.info(f"📅 Signal time: {signal_time_et.strftime('%Y-%m-%d %H:%M:%S %Z')} (ET) / {signal_time_cst.strftime('%H:%M:%S %Z')} (CST)") logger.info(f"📅 Current time: {now_et.strftime('%Y-%m-%d %H:%M:%S %Z')} (ET) / {now_cst.strftime('%H:%M:%S %Z')} (CST)") if time_diff_hours > 168: # 7 days logger.warning(f"⚠️ VWAP unavailable: Signal is {time_diff_hours/24:.1f} days old") logger.warning(f" Yahoo Finance only provides intraday data for the last 7 days") elif signal_hour_et < 9 or (signal_hour_et == 9 and signal_minute_et < 30): logger.warning(f"⚠️ VWAP unavailable: Signal time is {signal_time_et.strftime('%H:%M')} ET (before RTH open at 9:30 AM ET)") logger.warning(f" VWAP can only be calculated after market open (9:30 AM Eastern Time)") else: logger.info(f"📊 Calculating VWAP for {symbol} at {signal_time_et.strftime('%Y-%m-%d %H:%M')} ET") vwap_data = await price_service.calculate_vwap_at_time(symbol, flow_ts_utc) if vwap_data: result['vwap_at_signal'] = vwap_data.get('vwap') # Get price at signal time price_at_time = await price_service.get_price_at_time(symbol, flow_ts_utc) if price_at_time and price_at_time.get('close') and result.get('vwap_at_signal'): price_vs_vwap = ((price_at_time['close'] - result['vwap_at_signal']) / result['vwap_at_signal']) * 100 result['price_vs_vwap_pct'] = round(price_vs_vwap, 2) else: result['price_vs_vwap_pct'] = None else: result['vwap_at_signal'] = None result['price_vs_vwap_pct'] = None except Exception as e: logger.error(f"Error calculating VWAP: {e}") result['vwap_at_signal'] = None result['price_vs_vwap_pct'] = None # 4. Trade Checklist (needs VWAP, but we can calculate with what we have) try: # Add VWAP data to row for checklist df_row['vwap_at_signal'] = result.get('vwap_at_signal') df_row['price_vs_vwap_pct'] = result.get('price_vs_vwap_pct') df_with_checklist = checklist.evaluate_all(df_row) if not df_with_checklist.empty: result['checklist_score'] = df_with_checklist.iloc[0].get('checklist_score') # Convert numpy bool to Python bool checklist_passed_val = df_with_checklist.iloc[0].get('checklist_passed') result['checklist_passed'] = bool(checklist_passed_val) if checklist_passed_val is not None else False # Extract and convert checklist_details immediately checklist_details_raw = df_with_checklist.iloc[0].get('checklist_details') if checklist_details_raw is not None: # Convert nested numpy types in checklist_details import numpy as np if isinstance(checklist_details_raw, dict): result['checklist_details'] = { k: bool(v) if isinstance(v, np.bool_) else (int(v) if isinstance(v, np.integer) else (float(v) if isinstance(v, np.floating) else v)) for k, v in checklist_details_raw.items() } else: result['checklist_details'] = {} else: result['checklist_details'] = {} except Exception as e: logger.error(f"Error calculating checklist: {e}") result['checklist_score'] = None result['checklist_passed'] = False result['checklist_details'] = {} # Convert numpy types to native Python types for JSON serialization def convert_to_native(obj): """Recursively convert numpy types to native Python types""" import numpy as np # Handle None and NaN first if obj is None: return None try: if hasattr(pd, 'isna') and pd.isna(obj): return None except (TypeError, ValueError): pass try: if isinstance(obj, float) and np.isnan(obj): return None except (TypeError, ValueError): pass # Handle numpy types try: if isinstance(obj, np.bool_): return bool(obj) elif isinstance(obj, np.integer): return int(obj) elif isinstance(obj, np.floating): return float(obj) elif isinstance(obj, np.ndarray): return obj.tolist() except (TypeError, AttributeError): pass # Handle collections try: if isinstance(obj, dict): return {str(k): convert_to_native(v) for k, v in obj.items()} except (TypeError, AttributeError): return {} try: if isinstance(obj, (list, tuple)): return [convert_to_native(item) for item in obj] except (TypeError, AttributeError): return [] # Handle pandas Series/DataFrame (shouldn't happen, but just in case) if hasattr(obj, '__dict__') and not isinstance(obj, dict): try: # Try to convert to string representation return str(obj) except: return None return obj # Convert result dictionary result_converted = convert_to_native(result) return { 'success': True, 'data': result_converted } except Exception as e: logger.error(f"Error in Phase 1 calculation: {e}", exc_info=True) raise HTTPException( status_code=500, detail=f"Phase 1 calculation failed: {str(e)}" ) if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8010)