from typing import List, Optional from db.db import get_db from db.models import InvestorTable, SectorTable from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from schemas.router_schemas import ( InvestmentStage, InvestorData, InvestorFundData, ) from sqlalchemy.orm import Session, selectinload router = APIRouter(tags=["Investor Routes"]) # Request schemas for creating/updating class InvestorCreate(BaseModel): name: str description: Optional[str] = None aum: int check_size_lower: int check_size_upper: int geographic_focus: str stage_focus: InvestmentStage number_of_investments: int = 0 class InvestorUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None aum: Optional[int] = None check_size_lower: Optional[int] = None check_size_upper: Optional[int] = None geographic_focus: Optional[str] = None stage_focus: Optional[InvestmentStage] = None number_of_investments: Optional[int] = None @router.get("/investors", response_model=List[InvestorFundData]) def read_investors(db: Session = Depends(get_db)): """Get all investors with their funds as separate entries Each investor-fund combination is returned as a separate row. An investor with 3 funds will appear as 3 entries. """ investors = ( db.query(InvestorTable) .options( selectinload(InvestorTable.portfolio_companies), selectinload(InvestorTable.team_members), selectinload(InvestorTable.sectors), selectinload(InvestorTable.funds), ) .all() ) # Transform to InvestorFundData format (one row per investor-fund combination) investor_fund_list = [] for investor in investors: # If investor has funds, create one entry per fund if investor.funds: for fund in investor.funds: investor_fund_data = InvestorFundData( # Investor fields investor_id=investor.id, investor_name=investor.name, investor_description=investor.description, investor_website=investor.website, investor_headquarters=investor.headquarters, aum=investor.aum, aum_as_of_date=investor.aum_as_of_date, aum_source_url=investor.aum_source_url, investment_thesis=investor.investment_thesis, portfolio_highlights=investor.portfolio_highlights, number_of_investments=investor.number_of_investments, # Fund fields fund_id=fund.id, fund_name=fund.fund_name, fund_size=fund.fund_size, fund_size_source_url=fund.fund_size_source_url, check_size_lower=fund.check_size_lower, check_size_upper=fund.check_size_upper, geographic_focus=fund.geographic_focus, investment_stage_focus=fund.investment_stage_focus, sector_focus=fund.sector_focus, # Related data (same for all funds of this investor) portfolio_companies=investor.portfolio_companies, team_members=investor.team_members, sectors=investor.sectors, ) investor_fund_list.append(investor_fund_data) else: # If no funds, create one entry with null fund fields investor_fund_data = InvestorFundData( # Investor fields investor_id=investor.id, investor_name=investor.name, investor_description=investor.description, investor_website=investor.website, investor_headquarters=investor.headquarters, aum=investor.aum, aum_as_of_date=investor.aum_as_of_date, aum_source_url=investor.aum_source_url, investment_thesis=investor.investment_thesis, portfolio_highlights=investor.portfolio_highlights, number_of_investments=investor.number_of_investments, # Fund fields (null) fund_id=None, fund_name=None, fund_size=None, fund_size_source_url=None, check_size_lower=None, check_size_upper=None, geographic_focus=None, investment_stage_focus=None, sector_focus=None, # Related data portfolio_companies=investor.portfolio_companies, team_members=investor.team_members, sectors=investor.sectors, ) investor_fund_list.append(investor_fund_data) return investor_fund_list @router.get("/investors/filter", response_model=List[InvestorFundData]) def filter_investors( stage: Optional[InvestmentStage] = Query( None, description="Filter by investment stage" ), min_check_size: Optional[int] = Query(None, description="Minimum check size"), max_check_size: Optional[int] = Query(None, description="Maximum check size"), geography: Optional[str] = Query( None, description="Geographic focus (partial match)" ), sector: Optional[str] = Query(None, description="Sector name (partial match)"), min_aum: Optional[int] = Query(None, description="Minimum AUM"), max_aum: Optional[int] = Query(None, description="Maximum AUM"), db: Session = Depends(get_db), ): """Filter investors based on various criteria Returns investor-fund combinations as separate rows. An investor with 3 funds will appear as 3 entries. """ # Start with base query query = db.query(InvestorTable).options( selectinload(InvestorTable.portfolio_companies), selectinload(InvestorTable.team_members), selectinload(InvestorTable.sectors), selectinload(InvestorTable.funds), ) # Apply filters if stage: query = query.filter(InvestorTable.stage_focus == stage) if min_check_size is not None: query = query.filter(InvestorTable.check_size_lower >= min_check_size) if max_check_size is not None: query = query.filter(InvestorTable.check_size_upper <= max_check_size) if geography: query = query.filter(InvestorTable.geographic_focus.ilike(f"%{geography}%")) if min_aum is not None: query = query.filter(InvestorTable.aum >= min_aum) if max_aum is not None: query = query.filter(InvestorTable.aum <= max_aum) # Filter by sector if provided if sector: query = query.join(InvestorTable.sectors).filter( SectorTable.name.ilike(f"%{sector}%") ) investors = query.all() # Transform to InvestorFundData format (one row per investor-fund combination) investor_fund_list = [] for investor in investors: # If investor has funds, create one entry per fund if investor.funds: for fund in investor.funds: investor_fund_data = InvestorFundData( # Investor fields investor_id=investor.id, investor_name=investor.name, investor_description=investor.description, investor_website=investor.website, investor_headquarters=investor.headquarters, aum=investor.aum, aum_as_of_date=investor.aum_as_of_date, aum_source_url=investor.aum_source_url, investment_thesis=investor.investment_thesis, portfolio_highlights=investor.portfolio_highlights, number_of_investments=investor.number_of_investments, # Fund fields fund_id=fund.id, fund_name=fund.fund_name, fund_size=fund.fund_size, fund_size_source_url=fund.fund_size_source_url, check_size_lower=fund.check_size_lower, check_size_upper=fund.check_size_upper, geographic_focus=fund.geographic_focus, investment_stage_focus=fund.investment_stage_focus, sector_focus=fund.sector_focus, # Related data portfolio_companies=investor.portfolio_companies, team_members=investor.team_members, sectors=investor.sectors, ) investor_fund_list.append(investor_fund_data) else: # If no funds, create one entry with null fund fields investor_fund_data = InvestorFundData( # Investor fields investor_id=investor.id, investor_name=investor.name, investor_description=investor.description, investor_website=investor.website, investor_headquarters=investor.headquarters, aum=investor.aum, aum_as_of_date=investor.aum_as_of_date, aum_source_url=investor.aum_source_url, investment_thesis=investor.investment_thesis, portfolio_highlights=investor.portfolio_highlights, number_of_investments=investor.number_of_investments, # Fund fields (null) fund_id=None, fund_name=None, fund_size=None, fund_size_source_url=None, check_size_lower=None, check_size_upper=None, geographic_focus=None, investment_stage_focus=None, sector_focus=None, # Related data portfolio_companies=investor.portfolio_companies, team_members=investor.team_members, sectors=investor.sectors, ) investor_fund_list.append(investor_fund_data) return investor_fund_list @router.get("/investors/{investor_id}", response_model=InvestorData) def read_investor(investor_id: int, db: Session = Depends(get_db)): """Get a specific investor by ID with all their funds""" investor = ( db.query(InvestorTable) .options( selectinload(InvestorTable.portfolio_companies), selectinload(InvestorTable.team_members), selectinload(InvestorTable.sectors), selectinload(InvestorTable.funds), ) .filter(InvestorTable.id == investor_id) .first() ) if not investor: raise HTTPException(status_code=404, detail="Investor not found") # Transform to InvestorData format (includes funds array) return InvestorData( investor=investor, portfolio_companies=investor.portfolio_companies, team_members=investor.team_members, sectors=investor.sectors, funds=investor.funds, ) @router.post("/investors", response_model=InvestorData) def create_investor(investor: InvestorCreate, db: Session = Depends(get_db)): """Create a new investor""" db_investor = InvestorTable(**investor.dict()) db.add(db_investor) db.commit() db.refresh(db_investor) # Reload with relationships investor_with_relations = ( db.query(InvestorTable) .options( selectinload(InvestorTable.portfolio_companies), selectinload(InvestorTable.team_members), selectinload(InvestorTable.sectors), selectinload(InvestorTable.funds), ) .filter(InvestorTable.id == db_investor.id) .first() ) # Transform to InvestorData format return InvestorData( investor=investor_with_relations, portfolio_companies=investor_with_relations.portfolio_companies, team_members=investor_with_relations.team_members, sectors=investor_with_relations.sectors, funds=investor_with_relations.funds, ) @router.put("/investors/{investor_id}", response_model=InvestorData) def update_investor( investor_id: int, investor: InvestorUpdate, db: Session = Depends(get_db) ): """Update an existing investor""" db_investor = ( db.query(InvestorTable).filter(InvestorTable.id == investor_id).first() ) if not db_investor: raise HTTPException(status_code=404, detail="Investor not found") update_data = investor.dict(exclude_unset=True) for field, value in update_data.items(): setattr(db_investor, field, value) db.commit() db.refresh(db_investor) # Reload with relationships investor_with_relations = ( db.query(InvestorTable) .options( selectinload(InvestorTable.portfolio_companies), selectinload(InvestorTable.team_members), selectinload(InvestorTable.sectors), selectinload(InvestorTable.funds), ) .filter(InvestorTable.id == investor_id) .first() ) # Transform to InvestorData format return InvestorData( investor=investor_with_relations, portfolio_companies=investor_with_relations.portfolio_companies, team_members=investor_with_relations.team_members, sectors=investor_with_relations.sectors, funds=investor_with_relations.funds, ) @router.delete("/investors/{investor_id}") def delete_investor(investor_id: int, db: Session = Depends(get_db)): """Delete an investor""" db_investor = ( db.query(InvestorTable).filter(InvestorTable.id == investor_id).first() ) if not db_investor: raise HTTPException(status_code=404, detail="Investor not found") db.delete(db_investor) db.commit() return {"message": "Investor deleted successfully"} @router.get("/investors/{investor_id}/similar", response_model=List[InvestorFundData]) def find_similar_investors( investor_id: int, limit: int = Query(10, description="Maximum number of similar investors to return"), db: Session = Depends(get_db), ): """Find investors similar to a given investor based on characteristics Returns investor-fund combinations as separate rows. """ # Get the target investor target_investor = ( db.query(InvestorTable) .options( selectinload(InvestorTable.portfolio_companies), selectinload(InvestorTable.team_members), selectinload(InvestorTable.sectors), selectinload(InvestorTable.funds), ) .filter(InvestorTable.id == investor_id) .first() ) if not target_investor: raise HTTPException(status_code=404, detail="Investor not found") # Get target investor's sector IDs for comparison target_sector_ids = {sector.id for sector in target_investor.sectors} # Query all other investors with their relationships candidates = ( db.query(InvestorTable) .options( selectinload(InvestorTable.portfolio_companies), selectinload(InvestorTable.team_members), selectinload(InvestorTable.sectors), selectinload(InvestorTable.funds), ) .filter(InvestorTable.id != investor_id) .all() ) # Calculate similarity scores scored_investors = [] for candidate in candidates: score = 0 # Stage focus match (30 points) if candidate.stage_focus == target_investor.stage_focus: score += 30 # Geographic focus match (20 points for exact, 10 for partial) if candidate.geographic_focus and target_investor.geographic_focus: if ( candidate.geographic_focus.lower() == target_investor.geographic_focus.lower() ): score += 20 elif ( candidate.geographic_focus.lower() in target_investor.geographic_focus.lower() or target_investor.geographic_focus.lower() in candidate.geographic_focus.lower() ): score += 10 # Check size overlap (20 points max) if ( candidate.check_size_lower and candidate.check_size_upper and target_investor.check_size_lower and target_investor.check_size_upper ): # Calculate overlap percentage overlap_start = max( candidate.check_size_lower, target_investor.check_size_lower ) overlap_end = min( candidate.check_size_upper, target_investor.check_size_upper ) if overlap_end > overlap_start: overlap = overlap_end - overlap_start target_range = ( target_investor.check_size_upper - target_investor.check_size_lower ) overlap_ratio = overlap / target_range if target_range > 0 else 0 score += int(20 * overlap_ratio) # AUM similarity (15 points max) if candidate.aum and target_investor.aum: aum_diff = abs(candidate.aum - target_investor.aum) max_aum = max(candidate.aum, target_investor.aum) similarity_ratio = 1 - (aum_diff / max_aum) if max_aum > 0 else 0 score += int(15 * similarity_ratio) # Sector overlap (30 points max) candidate_sector_ids = {sector.id for sector in candidate.sectors} if target_sector_ids and candidate_sector_ids: common_sectors = target_sector_ids.intersection(candidate_sector_ids) overlap_ratio = len(common_sectors) / len(target_sector_ids) score += int(30 * overlap_ratio) if score > 0: # Only include investors with some similarity scored_investors.append((score, candidate)) # Sort by score (descending) and take top N scored_investors.sort(key=lambda x: x[0], reverse=True) similar_investors = [inv for score, inv in scored_investors[:limit]] # Transform to InvestorFundData format (one row per investor-fund combination) investor_fund_list = [] for investor in similar_investors: # If investor has funds, create one entry per fund if investor.funds: for fund in investor.funds: investor_fund_data = InvestorFundData( # Investor fields investor_id=investor.id, investor_name=investor.name, investor_description=investor.description, investor_website=investor.website, investor_headquarters=investor.headquarters, aum=investor.aum, aum_as_of_date=investor.aum_as_of_date, aum_source_url=investor.aum_source_url, investment_thesis=investor.investment_thesis, portfolio_highlights=investor.portfolio_highlights, number_of_investments=investor.number_of_investments, # Fund fields fund_id=fund.id, fund_name=fund.fund_name, fund_size=fund.fund_size, fund_size_source_url=fund.fund_size_source_url, check_size_lower=fund.check_size_lower, check_size_upper=fund.check_size_upper, geographic_focus=fund.geographic_focus, investment_stage_focus=fund.investment_stage_focus, sector_focus=fund.sector_focus, # Related data portfolio_companies=investor.portfolio_companies, team_members=investor.team_members, sectors=investor.sectors, ) investor_fund_list.append(investor_fund_data) else: # If no funds, create one entry with null fund fields investor_fund_data = InvestorFundData( # Investor fields investor_id=investor.id, investor_name=investor.name, investor_description=investor.description, investor_website=investor.website, investor_headquarters=investor.headquarters, aum=investor.aum, aum_as_of_date=investor.aum_as_of_date, aum_source_url=investor.aum_source_url, investment_thesis=investor.investment_thesis, portfolio_highlights=investor.portfolio_highlights, number_of_investments=investor.number_of_investments, # Fund fields (null) fund_id=None, fund_name=None, fund_size=None, fund_size_source_url=None, check_size_lower=None, check_size_upper=None, geographic_focus=None, investment_stage_focus=None, sector_focus=None, # Related data portfolio_companies=investor.portfolio_companies, team_members=investor.team_members, sectors=investor.sectors, ) investor_fund_list.append(investor_fund_data) return investor_fund_list