609 lines
23 KiB
Python
609 lines
23 KiB
Python
from typing import 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,
|
|
PaginatedResponse,
|
|
)
|
|
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
|
|
website: Optional[str] = None
|
|
headquarters: Optional[str] = None
|
|
aum: int
|
|
check_size_lower: int
|
|
check_size_upper: int
|
|
geographic_focus: str
|
|
number_of_investments: int = 0
|
|
|
|
|
|
class InvestorUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
website: Optional[str] = None
|
|
headquarters: Optional[str] = None
|
|
aum: Optional[int] = None
|
|
check_size_lower: Optional[int] = None
|
|
check_size_upper: Optional[int] = None
|
|
geographic_focus: Optional[str] = None
|
|
number_of_investments: Optional[int] = None
|
|
|
|
|
|
@router.get("/investors", response_model=PaginatedResponse[InvestorFundData])
|
|
def read_investors(
|
|
page: int = Query(1, ge=1, description="Page number (starts at 1)"),
|
|
page_size: int = Query(10, ge=1, le=100, description="Items per page (max 100)"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get all investors with their funds as separate entries (paginated)
|
|
|
|
Each investor-fund combination is returned as a separate row.
|
|
An investor with 3 funds will appear as 3 entries.
|
|
"""
|
|
# Calculate offset
|
|
offset = (page - 1) * page_size
|
|
|
|
# Get total count
|
|
total_count = db.query(InvestorTable).count()
|
|
|
|
# Get paginated results
|
|
investors = (
|
|
db.query(InvestorTable)
|
|
.options(
|
|
selectinload(InvestorTable.portfolio_companies),
|
|
selectinload(InvestorTable.team_members),
|
|
selectinload(InvestorTable.sectors),
|
|
selectinload(InvestorTable.funds),
|
|
)
|
|
.offset(offset)
|
|
.limit(page_size)
|
|
.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,
|
|
fund_investment_stages=fund.investment_stages, # Now a relationship
|
|
fund_sectors=fund.sectors, # Now a relationship
|
|
# 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,
|
|
fund_investment_stages=None,
|
|
fund_sectors=None,
|
|
# Related data
|
|
portfolio_companies=investor.portfolio_companies,
|
|
team_members=investor.team_members,
|
|
sectors=investor.sectors,
|
|
)
|
|
investor_fund_list.append(investor_fund_data)
|
|
|
|
# Calculate total pages
|
|
total_pages = (total_count + page_size - 1) // page_size
|
|
|
|
return PaginatedResponse(
|
|
items=investor_fund_list,
|
|
total=total_count,
|
|
page=page,
|
|
page_size=page_size,
|
|
total_pages=total_pages,
|
|
)
|
|
|
|
|
|
@router.get("/investors/filter", response_model=PaginatedResponse[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"),
|
|
page: int = Query(1, ge=1, description="Page number (starts at 1)"),
|
|
page_size: int = Query(10, ge=1, le=100, description="Items per page (max 100)"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Filter investors based on various criteria (paginated)
|
|
|
|
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
|
|
# Note: stage filtering is now done at fund level via fund.investment_stages
|
|
# 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}%")
|
|
)
|
|
|
|
# Get total count before pagination
|
|
total_count = query.count()
|
|
|
|
# Calculate offset and apply pagination
|
|
offset = (page - 1) * page_size
|
|
investors = query.offset(offset).limit(page_size).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,
|
|
fund_investment_stages=fund.investment_stages, # Now a relationship
|
|
fund_sectors=fund.sectors, # Now a relationship
|
|
# 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,
|
|
fund_investment_stages=None,
|
|
fund_sectors=None,
|
|
# Related data
|
|
portfolio_companies=investor.portfolio_companies,
|
|
team_members=investor.team_members,
|
|
sectors=investor.sectors,
|
|
)
|
|
investor_fund_list.append(investor_fund_data)
|
|
|
|
# Calculate total pages
|
|
total_pages = (total_count + page_size - 1) // page_size
|
|
|
|
return PaginatedResponse(
|
|
items=investor_fund_list,
|
|
total=total_count,
|
|
page=page,
|
|
page_size=page_size,
|
|
total_pages=total_pages,
|
|
)
|
|
|
|
|
|
@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=PaginatedResponse[InvestorFundData],
|
|
)
|
|
def find_similar_investors(
|
|
investor_id: int,
|
|
limit: int = Query(10, description="Maximum number of similar investors to return"),
|
|
page: int = Query(1, ge=1, description="Page number (starts at 1)"),
|
|
page_size: int = Query(10, ge=1, le=100, description="Items per page (max 100)"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Find investors similar to a given investor based on characteristics (paginated)
|
|
|
|
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 is now handled at fund level
|
|
# Skip stage matching at investor level since stage_focus no longer exists
|
|
# 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 based on limit
|
|
scored_investors.sort(key=lambda x: x[0], reverse=True)
|
|
top_similar = scored_investors[:limit]
|
|
|
|
# Apply pagination to the top similar investors
|
|
total_count = len(top_similar)
|
|
offset = (page - 1) * page_size
|
|
paginated_similar = top_similar[offset : offset + page_size]
|
|
similar_investors = [inv for score, inv in paginated_similar]
|
|
|
|
# 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,
|
|
fund_investment_stages=fund.investment_stages, # Now a relationship
|
|
fund_sectors=fund.sectors, # Now a relationship
|
|
# 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,
|
|
fund_investment_stages=None,
|
|
fund_sectors=None,
|
|
# Related data
|
|
portfolio_companies=investor.portfolio_companies,
|
|
team_members=investor.team_members,
|
|
sectors=investor.sectors,
|
|
)
|
|
investor_fund_list.append(investor_fund_data)
|
|
|
|
# Calculate total pages
|
|
total_pages = (total_count + page_size - 1) // page_size
|
|
|
|
return PaginatedResponse(
|
|
items=investor_fund_list,
|
|
total=total_count,
|
|
page=page,
|
|
page_size=page_size,
|
|
total_pages=total_pages,
|
|
)
|