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 approvalwsib.wa.gov/about/investment-operations— WSIB posts manager search notices directlyosc.ny.gov/pension/investment-reports— NYSRF posts investment-related procurement noticestrs.texas.gov/TRS/trs/investments.page— TRS Texas posts consultant and manager search noticesisbi.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.orgto 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 LPs25–60%— mid-raise; GP may be selectively approaching second-tier LPs60–80%— late raise; near-close; urgency window for LP introductions> 80%— close imminent; final close typically within 60–90 days- Amendments with increasing
totalAmountSoldsignal 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 | calstrs.com/board-meeting-information |
|
| WSIB | 5-7 days prior | wsib.wa.gov/about/board-meetings |
|
| NYSRF | 1 week prior | HTML + PDF | osc.ny.gov/pension/investment-advisory-committee |
| TRS Texas | 1 week prior | trs.texas.gov/TRS/trs/board-meetings.page |
|
| OPERS Ohio | 1 week prior | opers.org/about/board/meetings.shtml |
|
| PSERS Pennsylvania | 5 days prior | psers.pa.gov/About/Board/Pages/BoardMeetings.aspx |
|
| LACERA | 7 days prior | lacera.com/about-lacera/board-of-investments |
|
| ILIM (Illinois) | 1 week prior | 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.