← Agent Frameworks 🕐 11 min read
Agent Frameworks

Investment Opportunity Signal Detection — RFPs, Form D, Board Agendas, Allocation Season (2026)

Institutional capital does not move silently.

Institutional capital does not move silently. Every allocation decision leaves a documentary trail across procurement portals, SEC filings, board meeting packets, trade press, and consultant research decisions. The challenge is not data availability — most of it is public — it is signal extraction at scale and with low latency. A $500M mandate announced in a CalPERS board packet on a Tuesday morning is actionable intelligence for any asset manager present in the room by Thursday. Most managers are not in the room.

This note documents the full signal stack: where each signal lives, what triggers it, how to detect it programmatically, and how to route it into a sales intelligence graph. The intended consumer is a Python-based monitoring pipeline backed by a SQLite graph database with nodes for allocators, managers, consultants, mandates, and RFPs — and edges for relationships, mentions, and allocation events.


Part 1 — RFP and Manager Search Signals

1.1 Where Public Pensions Publish RFPs

Public pension systems are required to competitively procure investment management services above threshold dollar amounts — typically $100K–$500K in fees, which means virtually every institutional mandate. The publication channel depends on state and pension size:

State procurement portals (highest coverage, most standardized):

State Portal URL pattern Notes
California Cal eProcure caleprocure.ca.gov CalPERS, CalSTRS, UCOP all publish here; search “investment management”
Washington WEBS (Washington’s Electronic Business System) des.wa.gov/services/contracting-purchasing/vendors/webs WSIB and DRS post here; NAICS 523920 filter
New York NY State Contract Reporter nyscr.ny.gov NYSRF, NYSTRS; free RSS feed available
Texas ESBD (Electronic State Business Daily) esbd.texas.gov TRS, ERS, Teacher Retirement System posts
Illinois Illinois Procurement Bulletin purchase.Illinois.gov IMRF, ISBI, TRS of Illinois
Florida Vendor Bid System (VBS) myfloridamarketplace.myflorida.com FRS Investment Plan, SBA Florida
Ohio Ohio Procurement Gateway procure.ohio.gov OPERS, STRS Ohio, Ohio Police & Fire
Pennsylvania eMarketplace emarketplace.state.pa.us PSERS, SERS
New Jersey NJSTART njstart.gov NJDPB, NJDIVEST mandates

Pension system websites (supplementary, often earlier):

Many large pensions post RFPs directly on their own investment office pages before or alongside the state portal:

  • calpers.ca.gov/investments/request-for-proposals — CalPERS posts directly; board-approved RFPs appear here 1–3 days after board approval
  • wsib.wa.gov/about/investment-operations — WSIB posts manager search notices directly
  • osc.ny.gov/pension/investment-reports — NYSRF posts investment-related procurement notices
  • trs.texas.gov/TRS/trs/investments.page — TRS Texas posts consultant and manager search notices
  • isbi.illinois.gov/procurement — ISBI (Illinois State Board of Investment) direct posting

Consultant search announcements (separate signal):

When a pension hires or re-searches a consultant (NEPC, Callan, Mercer, Aon, Wilshire), that is a leading indicator of manager searches 6–18 months later. Consultant RFPs appear on the same procurement portals but contain keywords “investment consulting”, “OCIO”, “investment advisory”.

1.2 What Triggers an RFP

Understanding the trigger is more valuable than the RFP itself — triggers often generate advance signals before the formal RFP appears:

Manager termination → re-allocation RFP (highest urgency, 30–90 day lead time)

  • Board minutes will contain “staff recommends termination of [Manager]” or “investment staff has provided notice of termination”
  • Termination notice to manager is typically 30–90 days; RFP follows within 60 days of board approval
  • Signal chain: board agenda item → board vote → staff notice to manager → RFP publication → proposals due

Portfolio rebalancing (quarterly or annual, predictable)

  • Large pensions rebalance toward target allocations annually; if an asset class is underweight, a manager search follows
  • Signal: investment staff report showing “actual vs. target allocation” with underweight flag
  • Often discussed at IC/board meeting in Q4 for following FY action

New asset class entry (lower frequency, high AUM)

  • Board votes to add a new asset class (e.g., infrastructure debt, private credit, insurance-linked securities)
  • Triggers initial manager search — often larger mandates ($500M–$2B for large pensions)
  • Signal: IPS (Investment Policy Statement) amendment vote, asset allocation study, consultant recommendation memo

Consultant recommendation

  • Investment consultant presents manager review; recommends termination or addition
  • Consultant approval process precedes pension board action by 1–3 months
  • Signal: consultant quarterly manager monitoring report flagging a manager as “watch” or “recommend termination”

Board policy change / IPS revision

  • Changes to ESG policy, fee policy, manager concentration limits, or asset class targets
  • Signal: “Investment Policy Statement amendment” or “governance review” on board agenda

1.3 Programmatic RFP Discovery

Approach 1 — Google Custom Search API (CSE)

# rfp_google_search.py
# Requires: Google Custom Search API key, Search Engine ID configured for .gov domains
import requests
import sqlite3
from datetime import datetime, timedelta

GOOGLE_API_KEY = "YOUR_KEY"
SEARCH_ENGINE_ID = "YOUR_CSE_ID"  # configure to search .gov and pension domains

PENSION_DOMAINS = [
    "calpers.ca.gov", "calstrs.com", "wsib.wa.gov", "osc.ny.gov",
    "trs.texas.gov", "isbi.illinois.gov", "myfrs.com", "opers.org",
    "strsoh.org", "psers.pa.gov"
]

RFP_QUERIES = [
    'site:{domain} "request for proposal" "investment management" filetype:pdf',
    'site:{domain} "manager search" "investment" after:{date}',
    'site:{domain} "RFP" "alternative investments" OR "hedge fund" OR "private equity"',
]

def search_rfps(domain: str, days_back: int = 7) -> list[dict]:
    cutoff = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%d")
    results = []
    
    for query_template in RFP_QUERIES:
        query = query_template.format(domain=domain, date=cutoff)
        resp = requests.get(
            "https://www.googleapis.com/customsearch/v1",
            params={
                "key": GOOGLE_API_KEY,
                "cx": SEARCH_ENGINE_ID,
                "q": query,
                "dateRestrict": f"d{days_back}",
                "num": 10,
            },
            timeout=10,
        )
        if resp.status_code != 200:
            continue
        data = resp.json()
        for item in data.get("items", []):
            results.append({
                "domain": domain,
                "title": item.get("title"),
                "link": item.get("link"),
                "snippet": item.get("snippet"),
                "query": query,
                "discovered_at": datetime.utcnow().isoformat(),
            })
    return results

def upsert_rfp(db: sqlite3.Connection, rfp: dict):
    db.execute("""
        INSERT OR IGNORE INTO rfp_signals
        (url, title, snippet, domain, discovered_at, status)
        VALUES (:link, :title, :snippet, :domain, :discovered_at, 'new')
    """, rfp)
    db.commit()

if __name__ == "__main__":
    con = sqlite3.connect("signals.db")
    con.execute("""
        CREATE TABLE IF NOT EXISTS rfp_signals (
            url TEXT PRIMARY KEY,
            title TEXT,
            snippet TEXT,
            domain TEXT,
            discovered_at TEXT,
            status TEXT DEFAULT 'new'
        )
    """)
    for domain in PENSION_DOMAINS:
        hits = search_rfps(domain, days_back=7)
        for hit in hits:
            upsert_rfp(con, hit)
        print(f"{domain}: {len(hits)} RFP signals")

Approach 2 — Procurement portal RSS feeds

New York State Contract Reporter and several other state portals provide RSS feeds. Florida VBS provides email alerts. Texas ESBD has a subscription API.

# rfp_rss_watcher.py
import feedparser
import sqlite3
from datetime import datetime

PROCUREMENT_RSS_FEEDS = {
    "ny_state_contract_reporter": "https://www.nyscr.ny.gov/rss/opportunities.rss",
    # Florida: sign up for email via myfloridamarketplace; no public RSS
    # Texas ESBD: API at https://www.txsmartbuy.com/esbd — search NIGP code 946-31 (investment management)
}

INVESTMENT_KEYWORDS = [
    "investment management", "asset management", "portfolio management",
    "investment consulting", "hedge fund", "private equity", "fixed income",
    "real estate", "infrastructure", "manager search", "RFP investment",
    "alternative investment", "pension investment",
]

def is_investment_related(title: str, summary: str) -> bool:
    text = (title + " " + summary).lower()
    return any(kw in text for kw in INVESTMENT_KEYWORDS)

def poll_rfp_feeds(db: sqlite3.Connection):
    db.execute("""
        CREATE TABLE IF NOT EXISTS rfp_rss (
            guid TEXT PRIMARY KEY,
            feed TEXT,
            title TEXT,
            link TEXT,
            summary TEXT,
            published TEXT,
            discovered_at TEXT
        )
    """)
    
    for feed_name, url in PROCUREMENT_RSS_FEEDS.items():
        feed = feedparser.parse(url)
        for entry in feed.entries:
            if not is_investment_related(entry.get("title", ""), entry.get("summary", "")):
                continue
            db.execute("""
                INSERT OR IGNORE INTO rfp_rss
                (guid, feed, title, link, summary, published, discovered_at)
                VALUES (?, ?, ?, ?, ?, ?, ?)
            """, (
                entry.get("id", entry.get("link")),
                feed_name,
                entry.get("title"),
                entry.get("link"),
                entry.get("summary"),
                entry.get("published"),
                datetime.utcnow().isoformat(),
            ))
    db.commit()

Approach 3 — CalPERS direct scraping

CalPERS is unique in that its board packets and investment office page are structured enough for targeted scraping:

# calpers_rfp_scraper.py
import httpx
from bs4 import BeautifulSoup
import re

CALPERS_RFP_URL = "https://www.calpers.ca.gov/page/investments/request-for-proposals"

def scrape_calpers_rfps() -> list[dict]:
    resp = httpx.get(CALPERS_RFP_URL, timeout=20, follow_redirects=True)
    soup = BeautifulSoup(resp.text, "html.parser")
    rfps = []
    
    # CalPERS wraps RFP listings in accordion-style divs with PDF links
    for link in soup.find_all("a", href=re.compile(r"\.pdf$", re.I)):
        text = link.get_text(strip=True)
        href = link.get("href", "")
        if not href.startswith("http"):
            href = "https://www.calpers.ca.gov" + href
        rfps.append({"title": text, "url": href, "source": "calpers_direct"})
    
    return rfps

1.4 Typical RFP Timeline

Understanding the timeline lets you prioritize signals by their proximity to the award decision:

Day 0    — Board votes to approve manager search (board minutes/agenda signal)
Day 1-5  — Staff publishes RFP on pension website and state procurement portal
Day 1-7  — Procurement portal RSS / alert fires
Day 15   — Mandatory pre-proposal conference (sometimes)
Day 20   — Questions due from proposers
Day 25   — Answers published (addendum)
Day 45   — Proposals due (typically 30-45 days from RFP publication)
Day 60   — Finalist shortlist announced (3-5 managers)
Day 75   — Finalist presentations / due diligence
Day 90   — Staff recommendation memo prepared
Day 100  — Board approval of award (another board agenda signal)
Day 110  — Contract execution; press release / P&I announcement

Total: ~3-4 months for straightforward mandates; 5-6 months for complex or contested mandates

The highest-value entry point is the board agenda item at Day 0. The RFP publication at Day 1-5 is still early but requires a known presence. Responding to the P&I announcement at Day 110 is too late.

1.5 Getting on Notification Lists

Most major pensions maintain vendor notification lists. Registration is free and provides direct email notification of new RFPs:

  • CalPERS: Register as a vendor on Cal eProcure; select NIGP commodity codes 946-31 (Investment Management), 946-32 (Investment Consulting), 946-33 (Investment Advisory)
  • WSIB: Register on WEBS; select SIC code 6282 (Investment Advice)
  • TRS Texas: Register on ESBD; request notifications for “Investment Management Services” category
  • OPERS Ohio: Direct vendor list maintained by procurement office; email procurement@opers.org to register
  • CalSTRS: Separate from CalPERS; register at calstrs.com/vendor-registration

For smaller state pensions, direct outreach to the procurement or investment office is often more reliable than portal registration.


Part 2 — Form D as a Fundraising Signal

2.1 Form D Structure and Key Fields

Form D is filed with the SEC within 15 days of the first sale of securities in a Regulation D exempt offering. For hedge funds and private funds, every new capital raise triggers a Form D. The filing is public and machine-readable via EDGAR.

Key fields for sales intelligence:

Field Signal interpretation
nameOfIssuer Fund name — infer strategy from name patterns
dateOfFirstSale Raise start date — determines age of raise
totalOfferingAmount Target fund size
totalAmountSold Capital raised to date
numberOfAlreadyInvestors LP count — infer network penetration
stateOfFirstSale Primary LP jurisdiction
dateOfFilingFirstSale EDGAR filing date — latency from first sale
issuerState GP registration state — cluster with known fund families
relatedPersonsList GP principals — cross-reference with known personnel

% filled calculation:

fill_pct = totalAmountSold / totalOfferingAmount * 100

Interpretation:

  • < 25% — early raise; GP is actively approaching new LPs
  • 25–60% — mid-raise; GP may be selectively approaching second-tier LPs
  • 60–80% — late raise; near-close; urgency window for LP introductions
  • > 80% — close imminent; final close typically within 60–90 days
  • Amendments with increasing totalAmountSold signal active fundraising progress

2.2 EDGAR Access Patterns

Option A — EDGAR Full-Text Search API (best for real-time)

# form_d_watcher.py
import httpx
import sqlite3
import json
from datetime import datetime, date, timedelta

EDGAR_FTS_URL = "https://efts.sec.gov/LATEST/search-index?q=%22form+D%22&dateRange=custom&startdt={start}&enddt={end}&forms=D"
EDGAR_SUBMISSIONS_URL = "https://data.sec.gov/submissions/{cik}.json"
EDGAR_FORM_D_SEARCH = "https://efts.sec.gov/LATEST/search-index?forms=D&dateRange=custom&startdt={start}&enddt={end}"

# The proper EDGAR EFTS endpoint for Form D searches
EDGAR_SEARCH = "https://efts.sec.gov/LATEST/search-index"

def fetch_recent_form_d_filings(days_back: int = 1) -> list[dict]:
    """Fetch Form D filings from the past N days via EDGAR full-text search."""
    end_dt = date.today().isoformat()
    start_dt = (date.today() - timedelta(days=days_back)).isoformat()
    
    params = {
        "forms": "D",
        "dateRange": "custom",
        "startdt": start_dt,
        "enddt": end_dt,
        "_source": "file_date,entity_name,file_num,period_of_report",
        "from": 0,
        "size": 100,
    }
    headers = {"User-Agent": "YourFirm compliance@yourfirm.com"}  # SEC requires User-Agent
    
    resp = httpx.get(
        "https://efts.sec.gov/LATEST/search-index",
        params=params,
        headers=headers,
        timeout=30,
    )
    data = resp.json()
    hits = data.get("hits", {}).get("hits", [])
    return [h["_source"] for h in hits]

Option B — EDGAR Bulk Data (best for batch/historical)

EDGAR publishes daily and quarterly indices at https://www.sec.gov/Archives/edgar/full-index/. The company.idx file lists all filings by date with CIK and form type.

# edgar_bulk_form_d.py
import httpx
import csv
import io
from datetime import date

def fetch_edgar_daily_index(filing_date: date) -> list[dict]:
    """
    Download the EDGAR full-index for a given date.
    Returns all Form D filings for that date.
    """
    year = filing_date.year
    quarter = (filing_date.month - 1) // 3 + 1
    url = f"https://www.sec.gov/Archives/edgar/full-index/{year}/QTR{quarter}/company.idx"
    
    headers = {"User-Agent": "YourFirm compliance@yourfirm.com"}
    resp = httpx.get(url, headers=headers, timeout=60)
    
    # Parse fixed-width format
    form_d_filings = []
    lines = resp.text.split("\n")
    for line in lines[10:]:  # Skip header rows
        if not line.strip():
            continue
        parts = line.split("|")
        if len(parts) < 5:
            continue
        company_name, form_type, cik, date_filed, filename = parts[:5]
        if form_type.strip() in ("D", "D/A"):
            form_d_filings.append({
                "company_name": company_name.strip(),
                "form_type": form_type.strip(),
                "cik": cik.strip(),
                "date_filed": date_filed.strip(),
                "filename": filename.strip(),
            })
    
    return form_d_filings

2.3 Parsing Form D XML for Fund Intelligence

# parse_form_d.py
import httpx
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Optional

EDGAR_BASE = "https://www.sec.gov/Archives/edgar/"

@dataclass
class FormDSignal:
    cik: str
    fund_name: str
    date_of_first_sale: Optional[str]
    total_offering_amount: Optional[float]
    total_amount_sold: Optional[float]
    number_of_investors: Optional[int]
    state_of_first_sale: Optional[str]
    issuer_state: Optional[str]
    fill_pct: Optional[float] = None
    inferred_strategy: Optional[str] = None
    gp_names: list[str] = field(default_factory=list)
    
    def compute_fill_pct(self):
        if self.total_offering_amount and self.total_amount_sold:
            self.fill_pct = round(
                self.total_amount_sold / self.total_offering_amount * 100, 1
            )

STRATEGY_KEYWORDS = {
    "quant": ["quant", "systematic", "alpha", "factor", "stat arb", "market neutral"],
    "macro": ["macro", "global macro", "fx", "currency", "rates"],
    "credit": ["credit", "distressed", "CLO", "lending", "debt"],
    "equity_long_short": ["long/short", "long short", "equity hedge", "event driven"],
    "real_estate": ["real estate", "REIT", "property", "realty"],
    "private_equity": ["buyout", "growth equity", "venture", "PE fund", "private equity"],
    "infrastructure": ["infrastructure", "infra", "energy transition", "renewable"],
}

def infer_strategy(fund_name: str) -> Optional[str]:
    name_lower = fund_name.lower()
    for strategy, keywords in STRATEGY_KEYWORDS.items():
        if any(kw in name_lower for kw in keywords):
            return strategy
    return None

def parse_form_d_xml(xml_content: str, cik: str) -> Optional[FormDSignal]:
    """Parse a Form D XML filing and extract key fundraising fields."""
    try:
        root = ET.fromstring(xml_content)
        ns = {"d": "http://www.sec.gov/edgar/document/formd"}
        
        # Handle both namespace and no-namespace variants
        def find_text(path: str) -> Optional[str]:
            el = root.find(path)
            if el is None:
                el = root.find(path.replace("d:", ""))
            return el.text.strip() if el is not None and el.text else None
        
        fund_name = find_text(".//d:nameOfIssuer") or find_text(".//nameOfIssuer") or ""
        
        offering_amount_str = find_text(".//d:totalOfferingAmount") or find_text(".//totalOfferingAmount")
        amount_sold_str = find_text(".//d:totalAmountSold") or find_text(".//totalAmountSold")
        investors_str = find_text(".//d:numberOfAlreadyInvestors") or find_text(".//numberOfAlreadyInvestors")
        
        signal = FormDSignal(
            cik=cik,
            fund_name=fund_name,
            date_of_first_sale=find_text(".//d:dateOfFirstSale") or find_text(".//dateOfFirstSale"),
            total_offering_amount=float(offering_amount_str) if offering_amount_str else None,
            total_amount_sold=float(amount_sold_str) if amount_sold_str else None,
            number_of_investors=int(investors_str) if investors_str else None,
            state_of_first_sale=find_text(".//d:stateOfFirstSale") or find_text(".//stateOfFirstSale"),
            issuer_state=find_text(".//d:issuerState") or find_text(".//issuerState"),
        )
        signal.compute_fill_pct()
        signal.inferred_strategy = infer_strategy(fund_name)
        
        # Extract GP names from relatedPersonsList
        for person_el in root.iter("relatedPersonInfo"):
            first = person_el.findtext("relatedPersonFirstName", "").strip()
            last = person_el.findtext("relatedPersonLastName", "").strip()
            if first or last:
                signal.gp_names.append(f"{first} {last}".strip())
        
        return signal
    except Exception as e:
        print(f"Parse error for CIK {cik}: {e}")
        return None

2.4 Daily Form D Alert Pipeline

# daily_form_d_pipeline.py
"""
Daily pipeline: fetch yesterday's Form D filings → parse → score →
alert if quant/macro strategy fund in active raise window.

Run as a cron job: 0 8 * * * python daily_form_d_pipeline.py
"""
import httpx
import sqlite3
import json
from datetime import date, timedelta
from edgar_bulk_form_d import fetch_edgar_daily_index
from parse_form_d import parse_form_d_xml, FormDSignal

EDGAR_BASE = "https://www.sec.gov/Archives/edgar/"
HEADERS = {"User-Agent": "YourFirm compliance@yourfirm.com"}

TARGET_STRATEGIES = {"quant", "macro", "credit"}
ALERT_FILL_THRESHOLD_LOW = 20.0   # Fund is early and actively raising
ALERT_FILL_THRESHOLD_HIGH = 75.0  # Fund is near close — urgency window
MIN_OFFERING_SIZE = 50_000_000     # $50M minimum to filter noise

def fetch_form_d_xml(filename: str) -> Optional[str]:
    url = EDGAR_BASE + filename
    resp = httpx.get(url, headers=HEADERS, timeout=30)
    return resp.text if resp.status_code == 200 else None

def score_signal(signal: FormDSignal) -> int:
    """Return 0-100 priority score for this Form D signal."""
    score = 0
    
    if signal.inferred_strategy in TARGET_STRATEGIES:
        score += 40
    
    if signal.total_offering_amount and signal.total_offering_amount >= MIN_OFFERING_SIZE:
        score += 20
    if signal.total_offering_amount and signal.total_offering_amount >= 500_000_000:
        score += 10  # Bonus for large raise
    
    if signal.fill_pct is not None:
        if signal.fill_pct < 25:
            score += 20  # Early raise — most opportunity
        elif signal.fill_pct > 75:
            score += 15  # Near close — urgency
    
    if signal.number_of_investors is not None and signal.number_of_investors < 10:
        score += 10  # Few investors — still building LP base
    
    return min(score, 100)

def run_daily_pipeline():
    yesterday = date.today() - timedelta(days=1)
    filings = fetch_edgar_daily_index(yesterday)
    print(f"Found {len(filings)} Form D / D/A filings for {yesterday}")
    
    con = sqlite3.connect("signals.db")
    con.execute("""
        CREATE TABLE IF NOT EXISTS form_d_signals (
            cik TEXT,
            fund_name TEXT,
            date_of_first_sale TEXT,
            total_offering_amount REAL,
            total_amount_sold REAL,
            fill_pct REAL,
            number_of_investors INTEGER,
            state_of_first_sale TEXT,
            issuer_state TEXT,
            inferred_strategy TEXT,
            gp_names TEXT,
            priority_score INTEGER,
            date_filed TEXT,
            discovered_at TEXT,
            PRIMARY KEY (cik, date_filed)
        )
    """)
    
    alerts = []
    
    for filing in filings:
        xml_content = fetch_form_d_xml(filing["filename"])
        if not xml_content:
            continue
        
        signal = parse_form_d_xml(xml_content, filing["cik"])
        if not signal:
            continue
        
        score = score_signal(signal)
        
        con.execute("""
            INSERT OR REPLACE INTO form_d_signals VALUES (
                ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
            )
        """, (
            signal.cik, signal.fund_name, signal.date_of_first_sale,
            signal.total_offering_amount, signal.total_amount_sold,
            signal.fill_pct, signal.number_of_investors,
            signal.state_of_first_sale, signal.issuer_state,
            signal.inferred_strategy, json.dumps(signal.gp_names),
            score, filing["date_filed"],
            date.today().isoformat(),
        ))
        
        if score >= 60:
            alerts.append((score, signal))
    
    con.commit()
    
    # Route high-priority alerts
    alerts.sort(key=lambda x: x[0], reverse=True)
    for score, signal in alerts[:10]:
        print(f"""
ALERT (score={score}): {signal.fund_name}
  Strategy: {signal.inferred_strategy}
  Target size: ${signal.total_offering_amount:,.0f}
  Raised: ${signal.total_amount_sold:,.0f} ({signal.fill_pct}% filled)
  Investors so far: {signal.number_of_investors}
  State: {signal.state_of_first_sale}
  GPs: {', '.join(signal.gp_names[:3])}
        """.strip())

if __name__ == "__main__":
    run_daily_pipeline()

2.5 Cross-Referencing Form D with Known LP Data

FOIA requests to public pensions yield LP schedules — lists of funds in which a pension has invested, the commitment amount, and the vintage year. These are public records. Combining FOIA LP data with Form D filings reveals which GPs are likely approaching which LPs:

# lp_crossref.py
"""
Cross-reference Form D GPs against known LP relationships from FOIA data.
If a known GP (from an existing LP relationship) files a new Form D,
their existing LPs are likely being re-approached for the new fund.
"""
import sqlite3

def find_lp_crossref(con: sqlite3.Connection, fund_name: str, gp_names: list[str]) -> list[dict]:
    """
    Look up known LP relationships for a GP and return which LPs 
    are likely being approached for the new raise.
    """
    results = []
    
    # Check if any GP name matches known manager-LP relationships in graph
    for gp in gp_names:
        rows = con.execute("""
            SELECT lp_name, commitment_amount, vintage_year, fund_series
            FROM lp_manager_relationships
            WHERE manager_name LIKE ?
            ORDER BY vintage_year DESC
        """, (f"%{gp.split()[0]}%",)).fetchall()
        
        for row in rows:
            results.append({
                "lp_name": row[0],
                "prior_commitment": row[1],
                "prior_vintage": row[2],
                "prior_fund": row[3],
                "signal": f"GP {gp} has prior LP relationship with {row[0]}",
            })
    
    return results

Part 3 — Board Meeting Agenda and Minutes Signals

3.1 Why Agendas Are the Highest-Value Signal

Board agendas are published 7–14 days before a meeting. For large pensions, these meetings determine billion-dollar allocation decisions. The agenda is a forward-looking signal; minutes confirm what happened. For sales intelligence, the agenda is more valuable because it allows preparation before the decision is finalized.

Key advance-signal phrases in board agendas:

Phrase Signal interpretation Lead time
“New manager search — [asset class]” RFP will be approved at this meeting 7-14 days before RFP
“Manager termination notice — [Manager]” Termination approved; re-allocation RFP within 60 days 60-90 days before new RFP
“Alternatives allocation review” Asset class rebalancing under discussion; may trigger search 1-3 months before RFP
“Investment Policy Statement revision” Policy change may create new asset class entries 1-6 months before search
“RFP approval — [asset class]” RFP already drafted; about to be published Days before RFP publication
“Due diligence update — [Manager]” Finalist shortlist in process; may award or advance 30-60 days before award
“Consultant recommendation — [Manager]” Consultant has approved or recommended a specific manager 1-3 months before allocation
“First close — [Fund]” Confirms commitment to a specific fund; fund is in market Concurrent

3.2 Major Pension Board Agenda Publication Schedules

Pension Agenda availability Format URL pattern
CalPERS 10 business days prior PDF board packet calpers.ca.gov/page/board/agendas
CalSTRS 2 weeks prior PDF calstrs.com/board-meeting-information
WSIB 5-7 days prior PDF wsib.wa.gov/about/board-meetings
NYSRF 1 week prior HTML + PDF osc.ny.gov/pension/investment-advisory-committee
TRS Texas 1 week prior PDF trs.texas.gov/TRS/trs/board-meetings.page
OPERS Ohio 1 week prior PDF opers.org/about/board/meetings.shtml
PSERS Pennsylvania 5 days prior PDF psers.pa.gov/About/Board/Pages/BoardMeetings.aspx
LACERA 7 days prior PDF lacera.com/about-lacera/board-of-investments
ILIM (Illinois) 1 week prior PDF isbi.illinois.gov/meetings

3.3 Agenda Monitoring Pipeline

# board_agenda_monitor.py
"""
Pipeline: fetch new board agenda PDFs → OCR if scanned → extract text →
classify investment-related items → NER for manager/fund names → alert.

Dependencies: httpx, pdfplumber, spacy, sqlite3
Optional: pytesseract for scanned PDFs (Chandra OCR for production)
"""
import httpx
import pdfplumber
import spacy
import sqlite3
import re
import hashlib
from datetime import datetime
from pathlib import Path
from typing import Optional

nlp = spacy.load("en_core_web_sm")  # or en_core_web_trf for higher accuracy

INVESTMENT_TRIGGER_PATTERNS = [
    re.compile(r"new manager search", re.I),
    re.compile(r"manager termination", re.I),
    re.compile(r"alternatives allocation review", re.I),
    re.compile(r"investment policy statement", re.I),
    re.compile(r"RFP approval", re.I),
    re.compile(r"due diligence update", re.I),
    re.compile(r"consultant recommendation", re.I),
    re.compile(r"first close", re.I),
    re.compile(r"mandate award", re.I),
    re.compile(r"new allocation", re.I),
    re.compile(r"manager search", re.I),
    re.compile(r"approve new investment", re.I),
    re.compile(r"asset allocation study", re.I),
    re.compile(r"tactical asset allocation", re.I),
]

PENSION_AGENDA_SOURCES = {
    "calpers": {
        "index_url": "https://www.calpers.ca.gov/page/board/agendas",
        "pdf_pattern": re.compile(r'href="([^"]*board-packet[^"]*\.pdf)"', re.I),
    },
    "wsib": {
        "index_url": "https://www.wsib.wa.gov/about/board-meetings",
        "pdf_pattern": re.compile(r'href="([^"]*meeting[^"]*\.pdf)"', re.I),
    },
}

def extract_text_from_pdf(pdf_bytes: bytes) -> str:
    """Extract text from PDF bytes using pdfplumber."""
    import io
    text_parts = []
    with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
        for page in pdf.pages:
            text = page.extract_text()
            if text:
                text_parts.append(text)
    return "\n".join(text_parts)

def classify_agenda_items(text: str) -> list[dict]:
    """
    Split text into agenda items, classify each, extract named entities.
    Returns list of triggered items with entities.
    """
    # Split on common agenda item patterns (numbered items, Roman numerals)
    item_pattern = re.compile(
        r"(?:^|\n)(?:ITEM|Item)?\s*(?:\d+\.?[a-z]?\.?|[IVX]+\.)\s+(.+?)(?=\n(?:ITEM|Item)?\s*(?:\d+|[IVX]+)\.|\Z)",
        re.DOTALL
    )
    
    triggered = []
    
    # Also search full text for trigger phrases
    for pattern in INVESTMENT_TRIGGER_PATTERNS:
        matches = list(pattern.finditer(text))
        if not matches:
            continue
        
        for match in matches:
            # Get context window (500 chars before and after)
            start = max(0, match.start() - 200)
            end = min(len(text), match.end() + 500)
            context = text[start:end]
            
            # NER on context
            doc = nlp(context)
            org_entities = [
                ent.text for ent in doc.ents if ent.label_ in ("ORG", "PERSON", "GPE")
            ]
            
            triggered.append({
                "trigger_phrase": match.group(0),
                "context": context.strip(),
                "named_entities": list(set(org_entities)),
                "pattern": pattern.pattern,
            })
    
    return triggered

def process_agenda_pdf(source_name: str, pdf_url: str, pdf_bytes: bytes, db: sqlite3.Connection):
    """Process a single agenda PDF and store signals."""
    content_hash = hashlib.sha256(pdf_bytes).hexdigest()
    
    # Check if already processed
    existing = db.execute(
        "SELECT 1 FROM processed_agendas WHERE content_hash = ?", (content_hash,)
    ).fetchone()
    if existing:
        return
    
    text = extract_text_from_pdf(pdf_bytes)
    if not text:
        # Could invoke Chandra OCR here for scanned PDFs
        print(f"  No text extracted from {pdf_url} — may need OCR")
        return
    
    items = classify_agenda_items(text)
    
    db.execute("""
        INSERT OR IGNORE INTO processed_agendas (content_hash, source, url, processed_at, item_count)
        VALUES (?, ?, ?, ?, ?)
    """, (content_hash, source_name, pdf_url, datetime.utcnow().isoformat(), len(items)))
    
    for item in items:
        db.execute("""
            INSERT INTO agenda_signals (
                source, pdf_url, trigger_phrase, context,
                named_entities, discovered_at, content_hash
            ) VALUES (?, ?, ?, ?, ?, ?, ?)
        """, (
            source_name, pdf_url, item["trigger_phrase"], item["context"],
            ",".join(item["named_entities"]), datetime.utcnow().isoformat(),
            content_hash,
        ))
    
    db.commit()
    
    if items:
        print(f"AGENDA ALERT — {source_name} — {len(items)} investment triggers found:")
        for item in items[:3]:
            print(f"  [{item['trigger_phrase']}] Entities: {item['named_entities']}")

def init_db(con: sqlite3.Connection):
    con.executescript("""
        CREATE TABLE IF NOT EXISTS processed_agendas (
            content_hash TEXT PRIMARY KEY,
            source TEXT,
            url TEXT,
            processed_at TEXT,
            item_count INTEGER
        );
        CREATE TABLE IF NOT EXISTS agenda_signals (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            source TEXT,
            pdf_url TEXT,
            trigger_phrase TEXT,
            context TEXT,
            named_entities TEXT,
            discovered_at TEXT,
            content_hash TEXT
        );
    """)

def run_agenda_monitor():
    con = sqlite3.connect("signals.db")
    init_db(con)
    headers = {"User-Agent": "Mozilla/5.0"}
    
    for source_name, config in PENSION_AGENDA_SOURCES.items():
        index_resp = httpx.get(config["index_url"], headers=headers, timeout=20)
        pdf_urls = config["pdf_pattern"].findall(index_resp.text)
        
        for pdf_path in pdf_urls[:5]:  # Most recent 5 agendas
            full_url = pdf_path if pdf_path.startswith("http") else f"https://{source_name}.gov{pdf_path}"
            pdf_resp = httpx.get(full_url, headers=headers, timeout=30)
            if pdf_resp.status_code == 200:
                process_agenda_pdf(source_name, full_url, pdf_resp.content, con)

if __name__ == "__main__":
    run_agenda_monitor()

3.4 Entity Graph Integration

When named entities are extracted from agenda PDFs, they should be upserted into the signals graph as press_mention or board_mention edges:

# graph_upsert.py
def upsert_board_mention(con: sqlite3.Connection, entity_name: str, pension_name: str,
                          trigger: str, meeting_date: str, context: str):
    """
    Create or update a board_mention node and edge in the signals graph.
    Entity could be a manager, fund, or consultant.
    """
    # Fuzzy match entity against known nodes
    match = con.execute("""
        SELECT node_id, node_type FROM graph_nodes
        WHERE LOWER(name) LIKE LOWER(?)
        LIMIT 1
    """, (f"%{entity_name}%",)).fetchone()
    
    if match:
        node_id, node_type = match
        con.execute("""
            INSERT INTO graph_edges (from_node, to_node, edge_type, metadata, created_at)
            VALUES (?, ?, 'board_mention', ?, ?)
        """, (
            pension_name, node_id, 
            f'{{"trigger": "{trigger}", "context": "{context[:200]}", "meeting_date": "{meeting_date}"}}',
            meeting_date,
        ))
        con.commit()

Part 4 — Allocation Season Calendar

4.1 Fiscal Year and Peak Allocation Windows by Entity Type

Institutional capital allocation follows fiscal year calendars. Understanding which institutions are in their active commitment window at any given time determines where to focus sales energy.

ENTITY TYPE ALLOCATION CALENDAR
================================

PUBLIC PENSIONS (June 30 FY)
  Jan-Mar  — IC meetings review prior year performance; preliminary FY planning
  Apr-Jun  — Budget finalization; consultant recommendations for FY+1
  Jul-Sep  *** PEAK COMMITMENT SEASON ***
             New FY begins; approved allocations execute
             Manager search awards from spring RFPs close
             New commitment letters signed
  Oct-Dec  — Mid-year reviews; tactical adjustments
  
  Key signals in peak season:
  - Commitment announcements in P&I (Jul-Sep)
  - Form D new filings spike from public pension LP activity
  - First close announcements in FundFire (Aug-Oct)

CORPORATE PENSIONS (December 31 FY)
  Jan-Mar  *** PEAK COMMITMENT SEASON ***
             New FY; corporate treasury approves investment budget
             Pension committee reviews funded status from prior year
             New manager searches from Q4 board decisions execute
  Apr-Jun  — Mid-year review; risk management
  Jul-Sep  — Preliminary planning; consultant engagements
  Oct-Dec  — Board approvals for next year; RFPs issued

ENDOWMENTS (June 30 FY, academic calendar)
  Jul-Sep  *** PEAK COMMITMENT SEASON ***
             New academic year; endowment committee convenes
             Approves new commitments from spring investment committee
  Oct-Nov  — Manager due diligence trips; placement agent activity peaks
  Feb-Apr  — Investment committee meetings for summer decisions
  May-Jun  — Final approvals before fiscal year close

FOUNDATIONS (December 31 FY)
  Jan-Mar  *** PEAK COMMITMENT SEASON ***
             Board approves annual giving and investment budget
             New manager searches execute
  Oct-Dec  — Board meetings; approvals for next year pipeline

INSURANCE COMPANIES (Calendar year, Q4 budget cycle)
  Oct-Nov  *** PRE-COMMITMENT WINDOW ***
             Investment committee approves next-year asset allocation
             Manager search decisions made for Q1 execution
  Jan-Mar  — New commitments execute
  Q3       — Mid-year tactical; alternative allocation reviews

SOVEREIGN WEALTH FUNDS (Varies by charter; most are calendar year)
  Peak activity: Q1 and Q3 — board meeting cadence drives timing
  Signals: fewer public RFPs; press announcements and Form D LP lists

4.2 IC Meeting Cadence as a Timing Signal

Investment committee meetings are the decision nodes. Most institutional investors meet quarterly. The month of IC meeting determines when commitments get approved:

# allocation_calendar.py
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Optional

@dataclass
class AllocationWindow:
    firm_name: str
    firm_type: str  # 'public_pension', 'corporate_pension', 'endowment', 'foundation', 'insurance'
    fiscal_year_end_month: int  # 6 = June, 12 = December
    ic_meeting_months: list[int]  # months when IC meets (e.g. [3, 6, 9, 12])
    allocation_window_start: Optional[date] = None
    allocation_window_end: Optional[date] = None
    
    def compute_allocation_window(self, reference_date: date = date.today()):
        """
        Compute the next peak allocation window for this entity.
        Peak = first IC meeting after fiscal year start + 1 month execution time.
        """
        fy_start_month = (self.fiscal_year_end_month % 12) + 1
        
        # Find next IC meeting after FY start
        year = reference_date.year
        for month in sorted(self.ic_meeting_months):
            candidate = date(year, month, 1)
            if candidate >= reference_date and month >= fy_start_month:
                self.allocation_window_start = candidate
                self.allocation_window_end = candidate + timedelta(days=90)
                return
        
        # Look in next year
        for month in sorted(self.ic_meeting_months):
            candidate = date(year + 1, month, 1)
            if month >= fy_start_month:
                self.allocation_window_start = candidate
                self.allocation_window_end = candidate + timedelta(days=90)
                return

# Schema additions for SQLite graph
GRAPH_SCHEMA_ADDITIONS = """
ALTER TABLE firm_nodes ADD COLUMN fiscal_year_end_month INTEGER;
ALTER TABLE firm_nodes ADD COLUMN ic_meeting_months TEXT;  -- JSON array
ALTER TABLE firm_nodes ADD COLUMN allocation_window_start TEXT;
ALTER TABLE firm_nodes ADD COLUMN allocation_window_end TEXT;
ALTER TABLE firm_nodes ADD COLUMN peak_season_label TEXT;  -- 'Q1', 'Q3', etc.

CREATE INDEX IF NOT EXISTS idx_allocation_window 
ON firm_nodes (allocation_window_start, allocation_window_end);
"""

def is_in_allocation_window(firm_node: dict, reference_date: date = date.today()) -> bool:
    """Check if a firm is currently in its peak allocation window."""
    if not firm_node.get("allocation_window_start"):
        return False
    start = date.fromisoformat(firm_node["allocation_window_start"])
    end = date.fromisoformat(firm_node["allocation_window_end"])
    return start <= reference_date <= end

def get_firms_in_window(con, reference_date: date = date.today()) -> list[dict]:
    """Return all allocators currently in their peak allocation window."""
    rows = con.execute("""
        SELECT * FROM firm_nodes
        WHERE allocation_window_start <= ?
          AND allocation_window_end >= ?
          AND firm_type IN ('public_pension', 'corporate_pension', 'endowment', 'foundation')
        ORDER BY aum DESC
    """, (reference_date.isoformat(), reference_date.isoformat())).fetchall()
    return [dict(r) for r in rows]

4.3 Seasonal Signal Prioritization

# seasonal_priority.py
from datetime import date

SEASON_PRIORITY_MATRIX = {
    # (month, firm_type): priority_multiplier
    # Multiplier applied to base signal score when routing alerts
    (7, "public_pension"):   1.5,
    (8, "public_pension"):   1.8,  # Peak
    (9, "public_pension"):   1.6,
    (10, "public_pension"):  1.2,
    (1, "corporate_pension"): 1.6,
    (2, "corporate_pension"): 1.8,  # Peak
    (3, "corporate_pension"): 1.5,
    (7, "endowment"):        1.5,
    (8, "endowment"):        1.7,
    (9, "endowment"):        1.6,
    (11, "insurance"):       1.6,
    (12, "insurance"):       1.4,
    (1, "insurance"):        1.5,
}

def get_seasonal_multiplier(firm_type: str, month: int = date.today().month) -> float:
    return SEASON_PRIORITY_MATRIX.get((month, firm_type), 1.0)

Part 5 — Press Monitoring for Allocation Signals

5.1 High-Value Publications and Signal Phrases

Pensions & Investments (pionline.com) The primary trade publication for institutional investment. Many articles are publicly accessible; search and headline pages do not require a subscription.

High-signal phrases (P&I):

  • “mandate awarded” — confirms a manager selection
  • “pension selects” — direct allocation confirmation
  • “[Consultant] recommends” — pre-allocation signal
  • “emerges as finalist” — shortlist signal (30-60 days before award)
  • “approved new allocation” — board-level confirmation
  • “issued RFP for” — early pipeline signal
  • “to search for” — manager search announcement

FundFire (fundfire.com) — paywalled but headlines visible

  • “closes on” / “final close” — fundraising completion
  • “first close” — fund is actively raising
  • “[Fund] targets” — new raise announcement

Chief Investment Officer magazine (ai-cio.com) — free

  • “names [Manager] to” — allocation or hire
  • “restructures portfolio” — rebalancing signal
  • “adds [asset class]” — new asset class entry

Bloomberg Terminal / Bloomberg.com — paywalled

  • {ticker} BFUND <GO> for fundraising announcements
  • Bloomberg’s PE Wire service for fund closes

Institutional Investor (institutional-investor.com) — partially paywalled

  • Annual allocator rankings reveal AUM trends
  • Manager research reports signal consultant coverage

5.2 Automated Press Monitoring

# press_monitor.py
"""
Monitor P&I, ai-cio.com, and related RSS feeds for high-signal phrases.
Extract named entities (allocator, manager, consultant, fund).
Match against graph nodes and create press_mention edges.
"""
import feedparser
import httpx
from bs4 import BeautifulSoup
import spacy
import sqlite3
import re
from datetime import datetime

nlp = spacy.load("en_core_web_sm")

PRESS_RSS_FEEDS = {
    "ai_cio": "https://ai-cio.com/feed/",
    "institutional_investor": "https://www.institutionalinvestor.com/rss/articles.rss",
    "reuters_funds": "https://feeds.reuters.com/reuters/companyNews",  # Filter by finance
    # P&I does not provide a public RSS feed; use web scraping instead
}

# P&I article listing pages (publicly accessible)
PI_PUBLIC_PAGES = [
    "https://www.pionline.com/pension-funds",
    "https://www.pionline.com/alternatives",
    "https://www.pionline.com/hedge-funds",
    "https://www.pionline.com/money-management",
]

HIGH_SIGNAL_PATTERNS = [
    re.compile(r"mandate awarded", re.I),
    re.compile(r"pension selects?", re.I),
    re.compile(r"closes? on\b", re.I),
    re.compile(r"first close", re.I),
    re.compile(r"final close", re.I),
    re.compile(r"emerges? as finalist", re.I),
    re.compile(r"approved new allocation", re.I),
    re.compile(r"issued? (an? )?RFP", re.I),
    re.compile(r"to search for", re.I),
    re.compile(r"recommends? .{3,40} for .{3,40} (mandate|allocation)", re.I),
    re.compile(r"names? .{3,40} (manager|adviser|advisor)", re.I),
    re.compile(r"fund targeting \$", re.I),
    re.compile(r"new commitment", re.I),
    re.compile(r"awarded .{3,50} mandate", re.I),
]

KNOWN_ALLOCATOR_NAMES = [
    "CalPERS", "CalSTRS", "WSIB", "NYSRF", "TRS", "OPERS", "PSERS",
    "LACERA", "ISBI", "Ohio PERS", "Teacher Retirement", "PIMCO",
    "CPP Investments", "GIC", "Norges Bank", "CDPQ", "OTPP",
    # ... extend from graph nodes
]

KNOWN_CONSULTANT_NAMES = [
    "NEPC", "Callan", "Mercer", "Aon", "Wilshire", "Marquette",
    "RVK", "Verus", "Cambridge Associates", "Hamilton Lane",
    "Meketa", "Cliffwater", "Fund Evaluation Group",
]

def extract_allocation_entities(text: str) -> dict:
    """Extract allocator, manager, consultant, and fund names from article text."""
    doc = nlp(text)
    
    orgs = [ent.text for ent in doc.ents if ent.label_ == "ORG"]
    persons = [ent.text for ent in doc.ents if ent.label_ == "PERSON"]
    money = [ent.text for ent in doc.ents if ent.label_ == "MONEY"]
    
    # Classify orgs
    allocators = [o for o in orgs if any(a.lower() in o.lower() for a in KNOWN_ALLOCATOR_NAMES)]
    consultants = [o for o in orgs if any(c.lower() in o.lower() for c in KNOWN_CONSULTANT_NAMES)]
    managers = [o for o in orgs if o not in allocators and o not in consultants]
    
    return {
        "allocators": list(set(allocators)),
        "managers": list(set(managers)),
        "consultants": list(set(consultants)),
        "persons": list(set(persons)),
        "money_amounts": list(set(money)),
    }

def classify_article(title: str, summary: str) -> tuple[bool, list[str]]:
    """
    Returns (is_high_signal, matched_patterns).
    """
    text = title + " " + summary
    matched = []
    for pattern in HIGH_SIGNAL_PATTERNS:
        if pattern.search(text):
            matched.append(pattern.pattern)
    return len(matched) > 0, matched

def scrape_pi_headlines() -> list[dict]:
    """Scrape publicly accessible P&I article listings."""
    articles = []
    headers = {"User-Agent": "Mozilla/5.0"}
    
    for page_url in PI_PUBLIC_PAGES:
        resp = httpx.get(page_url, headers=headers, timeout=15)
        if resp.status_code != 200:
            continue
        
        soup = BeautifulSoup(resp.text, "html.parser")
        
        # P&I article cards typically have article titles in <h3> or <h2> with links
        for article_el in soup.find_all(["article", "div"], class_=re.compile(r"article|story|card", re.I))[:20]:
            title_el = article_el.find(["h2", "h3", "h4"])
            link_el = article_el.find("a", href=True)
            summary_el = article_el.find(["p", "div"], class_=re.compile(r"summary|excerpt|deck", re.I))
            
            if not title_el:
                continue
            
            title = title_el.get_text(strip=True)
            link = link_el["href"] if link_el else ""
            summary = summary_el.get_text(strip=True) if summary_el else ""
            
            if not link.startswith("http"):
                link = "https://www.pionline.com" + link
            
            articles.append({"title": title, "link": link, "summary": summary, "source": "pi"})
    
    return articles

def process_press_article(article: dict, db: sqlite3.Connection):
    """Classify article, extract entities, store signal, match to graph."""
    is_signal, patterns = classify_article(article["title"], article["summary"])
    if not is_signal:
        return
    
    full_text = article["title"] + " " + article["summary"]
    entities = extract_allocation_entities(full_text)
    
    db.execute("""
        INSERT OR IGNORE INTO press_signals (
            url, title, summary, source,
            matched_patterns, allocators, managers, consultants,
            money_amounts, discovered_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    """, (
        article["link"], article["title"], article.get("summary", ""),
        article.get("source", "unknown"),
        ",".join(patterns),
        ",".join(entities["allocators"]),
        ",".join(entities["managers"]),
        ",".join(entities["consultants"]),
        ",".join(entities["money_amounts"]),
        datetime.utcnow().isoformat(),
    ))
    db.commit()
    
    print(f"PRESS SIGNAL: {article['title'][:80]}")
    print(f"  Allocators: {entities['allocators']}")
    print(f"  Managers:   {entities['managers']}")
    print(f"  Amounts:    {entities['money_amounts']}")

def run_press_monitor():
    con = sqlite3.connect("signals.db")
    con.execute("""
        CREATE TABLE IF NOT EXISTS press_signals (
            url TEXT PRIMARY KEY,
            title TEXT,
            summary TEXT,
            source TEXT,
            matched_patterns TEXT,
            allocators TEXT,
            managers TEXT,
            consultants TEXT,
            money_amounts TEXT,
            discovered_at TEXT
        )
    """)
    
    # RSS feeds
    for feed_name, url in PRESS_RSS_FEEDS.items():
        feed = feedparser.parse(url)
        for entry in feed.entries:
            process_press_article({
                "title": entry.get("title", ""),
                "summary": entry.get("summary", ""),
                "link": entry.get("link", ""),
                "source": feed_name,
            }, con)
    
    # P&I scraping
    pi_articles = scrape_pi_headlines()
    for article in pi_articles:
        process_press_article(article, con)

if __name__ == "__main__":
    run_press_monitor()

5.3 Entity Relationship Patterns for Graph Edges

Press mentions enable inference of relationships that are not formally documented. The key patterns to detect and encode as graph edges:

[Pension] + "selects" + [Manager]     → ALLOCATED_TO edge (confirmed)
[Consultant] + "recommends" + [Manager] + "for" + [Client]  → RECOMMENDED edge
[Manager] + "emerges as finalist" + [Pension]   → FINALIST edge (30-60 days to award)
[Fund] + "closes on" + [Amount]        → FUNDRAISE_CLOSE edge
[Pension] + "issued RFP for" + [AssetClass]   → RFP_OPEN edge

Part 6 — Consultant Approval List Dynamics

6.1 Why Consultant Approval Is a Multiplier Signal

A consultant approval is not a single-client signal — it is a distribution event. When NEPC adds a fund to its approved list, NEPC advises approximately 400 clients collectively managing $1.6T in assets. A Callan approval reaches ~135 clients. Mercer reaches ~3,000 globally. Consultant approval is the highest-leverage signal in this entire stack because it is a one-to-many relationship.

Time lag: Consultant approval → client allocation = 3 to 18 months.

  • Fastest path: consultant presents approved fund at next quarterly client IC meeting → client approves → commitment executed (3-4 months)
  • Typical path: fund appears in consultant’s manager universe; consultant proactively presents to clients with relevant allocation gaps at next meeting cycle (6-12 months)
  • Longest path: client with infrequent IC meetings or budget constraints (12-18 months)

Implication: a consultant approval today is a pipeline signal for next year’s allocation season.

6.2 Detecting Consultant Approvals

Method 1 — Consultant website monitoring

Some consultants publish approved manager lists or market commentary mentioning new approvals:

  • Callan publishes annual manager research reports at callan.com/research
  • NEPC posts market commentary and manager reviews at nepc.com/insights
  • Cambridge Associates publishes investment research at cambridgeassociates.com/research
  • Mercer posts manager research updates at mercer.com/insights/investments

Monitor these pages for new PDFs containing fund names:

# consultant_approval_monitor.py
import httpx
from bs4 import BeautifulSoup
import re
import sqlite3
from datetime import datetime
import hashlib

CONSULTANT_RESEARCH_PAGES = {
    "callan": "https://www.callan.com/research/",
    "nepc": "https://nepc.com/institutional/insights/",
    "cambridge_associates": "https://www.cambridgeassociates.com/research/",
    "mercer": "https://www.mercer.com/insights/investments/",
    "aon": "https://www.aon.com/retirement-investment-consulting/insights.aspx",
    "wilshire": "https://wilshire.com/insights/",
    "meketa": "https://meketa.com/publications/",
}

APPROVAL_SIGNALS = [
    re.compile(r"added to (our |the )?(approved|recommended|preferred) (list|universe)", re.I),
    re.compile(r"(approved|recommended) (manager|fund|strategy)", re.I),
    re.compile(r"(new|newly) (approved|rated|covered)", re.I),
    re.compile(r"manager (research|review) update", re.I),
    re.compile(r"added .{3,40} to .{3,30} (platform|universe|list)", re.I),
    re.compile(r"(positive|favorable) (rating|recommendation)", re.I),
]

def monitor_consultant_pages(db: sqlite3.Connection):
    db.execute("""
        CREATE TABLE IF NOT EXISTS consultant_signals (
            url TEXT PRIMARY KEY,
            consultant TEXT,
            title TEXT,
            matched_pattern TEXT,
            fund_mentions TEXT,
            discovered_at TEXT
        )
    """)
    
    headers = {"User-Agent": "Mozilla/5.0"}
    
    for consultant, page_url in CONSULTANT_RESEARCH_PAGES.items():
        resp = httpx.get(page_url, headers=headers, timeout=15)
        if resp.status_code != 200:
            continue
        
        soup = BeautifulSoup(resp.text, "html.parser")
        
        # Look for research document links (PDFs or article pages)
        for link in soup.find_all("a", href=True):
            title = link.get_text(strip=True)
            href = link["href"]
            
            # Check title against approval signal patterns
            matched = [p.pattern for p in APPROVAL_SIGNALS if p.search(title)]
            if not matched:
                continue
            
            full_url = href if href.startswith("http") else page_url.rstrip("/") + "/" + href.lstrip("/")
            
            db.execute("""
                INSERT OR IGNORE INTO consultant_signals
                (url, consultant, title, matched_pattern, discovered_at)
                VALUES (?, ?, ?, ?, ?)
            """, (
                full_url, consultant, title, matched[0] if matched else "",
                datetime.utcnow().isoformat(),
            ))
        
        db.commit()

Method 2 — Form ADV Part 2B analysis

When a consultant analyst begins covering a new fund, their Form ADV Part 2B (individual brochure supplement) description is updated to include the fund. This is an SEC filing and is machine-readable.

# form_adv_monitor.py
"""
Monitor Form ADV Part 2B amendments (ADV/A filings) for investment consultants.
When a new fund name appears in an analyst's description, it may indicate
new manager coverage — a leading indicator of recommendation.

This is a weak signal but is uncorrelated with other sources.
"""
import httpx
import sqlite3
from datetime import date, timedelta
from edgar_bulk_form_d import fetch_edgar_daily_index  # reuse index fetching

TARGET_CONSULTANT_CIKS = {
    "nepc_llc": "0001222642",
    "callan": "0000794302",
    "mercer_investment": "0001383874",
    "aon_hewitt": "0001463863",
    "wilshire_associates": "0000107687",
    # Get remaining CIKs from EDGAR company search
}

def fetch_adv_amendment(cik: str, days_back: int = 7) -> list[dict]:
    """Check if a consultant filed an ADV/A (amendment) in the past N days."""
    end_dt = date.today()
    start_dt = end_dt - timedelta(days=days_back)
    
    submissions_url = f"https://data.sec.gov/submissions/CIK{cik.zfill(10)}.json"
    headers = {"User-Agent": "YourFirm compliance@yourfirm.com"}
    
    resp = httpx.get(submissions_url, headers=headers, timeout=20)
    if resp.status_code != 200:
        return []
    
    data = resp.json()
    recent_filings = []
    
    filings = data.get("filings", {}).get("recent", {})
    forms = filings.get("form", [])
    dates = filings.get("filingDate", [])
    
    for i, (form_type, filing_date) in enumerate(zip(forms, dates)):
        if form_type not in ("ADV", "ADV/A"):
            continue
        if filing_date < start_dt.isoformat():
            break  # Filings are sorted descending
        recent_filings.append({
            "form_type": form_type,
            "filing_date": filing_date,
            "accession": filings.get("accessionNumber", [None])[i],
        })
    
    return recent_filings

Method 3 — P&I and FundFire consultant research coverage

P&I and FundFire regularly report consultant manager research decisions. These appear in articles with patterns like:

  • “[Consultant] adds [Fund] to recommended list”
  • “[Consultant] upgrades [Manager] to buy”
  • “[Consultant] places [Manager] on watch”
  • “[Manager] receives [Consultant] recommendation”

These are captured by the press monitoring pipeline in Part 5. The additional step is routing consultant-related articles to the CONSULTANT_APPROVAL edge type rather than the generic press_mention type.

# consultant_press_classifier.py
def classify_consultant_article(title: str, summary: str, 
                                 consultant_names: list[str]) -> dict:
    """
    Detect if a press article describes a consultant research decision
    and classify the type (new approval, watch, termination, upgrade).
    """
    text = (title + " " + summary).lower()
    
    consultant_mentioned = [c for c in consultant_names if c.lower() in text]
    
    signal_type = None
    if re.search(r"(adds?|added|approved?|recommend)", text):
        signal_type = "new_approval"
    elif re.search(r"(watch|review|concern)", text):
        signal_type = "watch_flag"
    elif re.search(r"(terminat|removed?|drops?|downgrad)", text):
        signal_type = "termination"
    elif re.search(r"(upgrad|positive|favorable)", text):
        signal_type = "upgrade"
    
    return {
        "consultants_mentioned": consultant_mentioned,
        "signal_type": signal_type,
        "is_consultant_signal": bool(consultant_mentioned and signal_type),
    }

6.3 Multiplier Effect: Modeling Downstream Allocations

When a consultant approval is detected, the signal should propagate to all known clients of that consultant in the graph:

# consultant_multiplier.py
def propagate_consultant_approval(
    con: sqlite3.Connection,
    consultant_name: str,
    fund_name: str,
    approval_date: str,
    confidence: float = 0.6,
):
    """
    When consultant approves a fund, create inferred_opportunity edges
    for all known clients of that consultant.
    
    Confidence decay: approval confidence × client relationship confidence.
    """
    # Find all clients of this consultant
    client_rows = con.execute("""
        SELECT c.node_id, c.name, e.relationship_confidence
        FROM graph_nodes c
        JOIN graph_edges e ON e.to_node = c.node_id
        WHERE e.from_node = (
            SELECT node_id FROM graph_nodes WHERE name = ? AND node_type = 'consultant'
        )
        AND e.edge_type = 'advises'
    """, (consultant_name,)).fetchall()
    
    for client_id, client_name, rel_confidence in client_rows:
        inferred_confidence = confidence * (rel_confidence or 0.8)
        
        con.execute("""
            INSERT OR REPLACE INTO inferred_opportunities (
                allocator_node_id, fund_name, signal_source,
                signal_date, confidence, rationale
            ) VALUES (?, ?, 'consultant_approval', ?, ?, ?)
        """, (
            client_id, fund_name, approval_date,
            round(inferred_confidence, 3),
            f"{consultant_name} approved {fund_name} on {approval_date}; "
            f"{client_name} is a known {consultant_name} client",
        ))
    
    con.commit()
    print(f"Propagated {consultant_name} approval of {fund_name} to {len(client_rows)} clients")

Signal Integration and Routing

Unified Signal Router

All five signal types feed into a unified router that scores, deduplicates, and routes alerts to the appropriate sales intelligence action:

# signal_router.py
"""
Unified signal router. Consumes all signal tables, deduplicates by allocator,
applies seasonal multipliers, and generates prioritized daily brief.
"""
import sqlite3
from datetime import date
from allocation_calendar import get_seasonal_multiplier

SIGNAL_BASE_SCORES = {
    "board_agenda":         90,  # Highest — pre-decision
    "rfp_published":        80,  # RFP is live — active window
    "consultant_approval":  70,  # Multiplier event — inferred pipeline
    "press_mention":        60,  # Confirmed event — often post-decision
    "form_d_early_raise":   65,  # Fundraising signal — early LP outreach
    "form_d_near_close":    50,  # Urgency but late
    "rfp_google_discovery": 55,  # RFP found via search — may be stale
}

def generate_daily_brief(con: sqlite3.Connection, target_date: date = date.today()) -> list[dict]:
    """
    Generate prioritized signal brief for today.
    Returns list of signals sorted by final_score descending.
    """
    brief = []
    
    # Pull from each signal table
    agenda_signals = con.execute("""
        SELECT 'board_agenda' AS signal_type, source AS allocator,
               trigger_phrase, named_entities, discovered_at
        FROM agenda_signals
        WHERE discovered_at >= ?
    """, (target_date.isoformat(),)).fetchall()
    
    press_signals = con.execute("""
        SELECT 'press_mention' AS signal_type, allocators AS allocator,
               title AS trigger_phrase, managers, discovered_at
        FROM press_signals
        WHERE discovered_at >= ?
          AND allocators != ''
    """, (target_date.isoformat(),)).fetchall()
    
    form_d_signals = con.execute("""
        SELECT 
            CASE WHEN fill_pct < 30 THEN 'form_d_early_raise'
                 ELSE 'form_d_near_close' END AS signal_type,
            state_of_first_sale AS allocator,
            fund_name AS trigger_phrase,
            inferred_strategy, discovered_at
        FROM form_d_signals
        WHERE discovered_at >= ?
          AND priority_score >= 60
    """, (target_date.isoformat(),)).fetchall()
    
    for row in list(agenda_signals) + list(press_signals) + list(form_d_signals):
        row_dict = dict(zip([d[0] for d in con.execute("SELECT * FROM agenda_signals LIMIT 0").description or []], row))
        signal_type = row[0]
        
        base_score = SIGNAL_BASE_SCORES.get(signal_type, 50)
        
        # Apply seasonal multiplier if we can identify firm type
        # In production, look up firm type from graph
        seasonal_mult = 1.0  # Default; override with graph lookup
        
        final_score = int(base_score * seasonal_mult)
        
        brief.append({
            "signal_type": signal_type,
            "allocator": row[1],
            "description": row[2],
            "score": final_score,
            "discovered_at": row[-1],
        })
    
    return sorted(brief, key=lambda x: x["score"], reverse=True)

if __name__ == "__main__":
    con = sqlite3.connect("signals.db")
    brief = generate_daily_brief(con)
    
    print(f"\n=== DAILY SIGNAL BRIEF — {date.today()} ===\n")
    for i, signal in enumerate(brief[:15], 1):
        print(f"{i:2d}. [{signal['score']:3d}] {signal['signal_type']:<25} {signal['allocator'][:40]}")
        print(f"     {signal['description'][:80]}")
        print()

Cron Schedule

# crontab -e
# Run all monitors daily; brief generated at 7am Eastern

# Form D: run at 6am (EDGAR updates overnight)
0 6 * * 1-5 /usr/bin/python3 /path/to/daily_form_d_pipeline.py >> /var/log/signals/form_d.log 2>&1

# Board agendas: run at 6:30am
30 6 * * 1-5 /usr/bin/python3 /path/to/board_agenda_monitor.py >> /var/log/signals/agendas.log 2>&1

# Press monitor: run at 7am
0 7 * * 1-5 /usr/bin/python3 /path/to/press_monitor.py >> /var/log/signals/press.log 2>&1

# RFP portals: run at 7:30am
30 7 * * 1-5 /usr/bin/python3 /path/to/rfp_google_search.py >> /var/log/signals/rfps.log 2>&1

# Consultant page monitor: run weekly on Mondays
0 8 * * 1 /usr/bin/python3 /path/to/consultant_approval_monitor.py >> /var/log/signals/consultants.log 2>&1

# Daily brief: 8am
0 8 * * 1-5 /usr/bin/python3 /path/to/signal_router.py >> /var/log/signals/brief.log 2>&1

Appendix — State Pension Portal Quick Reference

Pension AUM Portal NIGP/SIC Code Notes
CalPERS $462B Cal eProcure 946-31 Largest US public pension; board packets 10 days prior
CalSTRS $325B Cal eProcure 946-31 Separate from CalPERS; separate procurement
NYSRF $247B NY Contract Reporter 946 Common Retirement Fund; OSC manages
NYSTRS $140B NY Contract Reporter 946 Teachers; separate from NYSRF
TRS Texas $175B ESBD Texas 946-31 Austin-based; large PE and real assets
ERS Texas $28B ESBD Texas 946-31 State employees; smaller
WSIB $170B WEBS Washington 6282 Sole investment authority for WA state
OPERS Ohio $98B Ohio Procurement 946 Largest OH pension
STRS Ohio $88B Ohio Procurement 946 Teachers
PSERS PA $73B eMarketplace PA 946 Teachers
SERS PA $31B eMarketplace PA 946 State employees
IMRF Illinois $53B IL Procurement Bulletin 946 Municipal
TRS Illinois $56B IL Procurement Bulletin 946 Teachers
ISBI Illinois $25B isbi.illinois.gov/procurement 946 Posts directly + portal
FRS Florida $180B FL VBS 946 SBA manages; email alert registration available
LACERA $79B LA County Procurement 946 Direct: lacera.com
SDCERA $13B SD County 946 Smaller; active in alternatives
VCERA $7B Ventura County 946 Active in hedge funds
MPERS Mississippi $28B MS MAGIC portal 946 Active in PE/real assets
KRS Kentucky $18B KY eProcurement 946 Notable recent history with alternatives

Key Timing Relationships Summary

Board agenda published      →  7-14 days  →  Board meeting (decision)
Board meeting (RFP vote)    →  1-5 days   →  RFP published on portal
RFP published               →  30-45 days →  Proposals due
Proposals due               →  30-45 days →  Board award
Board award                 →  30-60 days →  P&I announcement

Form D first sale           →  15 days    →  SEC filing required (often delayed)
Form D < 30% filled         →  60-180 days→  Active LP outreach window
Form D > 75% filled         →  30-60 days →  Final close imminent

Consultant approval         →  3-18 months→  Client allocation executed
P&I "emerges as finalist"   →  30-60 days →  Award announcement
P&I "mandate awarded"       →  0 days     →  Confirmed (post-decision)

The highest-leverage entry in each category: board agenda item (pre-decision), Form D < 30% filled (early raise), consultant approval (multiplier event). Press confirms what already happened; procurement portals and board agendas are where the opportunity still exists.