Universe Screening¶
The universe module implements investability screening with hysteresis-based entry/exit thresholds. It filters a raw stock universe down to securities that meet minimum standards of market capitalization, liquidity, price level, listing history, and data availability — the foundation for any systematic investment strategy.
Overview¶
Before constructing a portfolio, you need a clean investable universe. Stocks that are too small, too illiquid, or too newly listed create problems: they may be impossible to trade at the quantities needed, they generate excessive transaction costs, or they lack sufficient history for reliable estimation.
The universe module enforces investability standards through 8 screens, each with separate entry and exit thresholds (hysteresis) to reduce turnover at screen boundaries.
Why Hysteresis?¶
Without hysteresis, a stock hovering near a threshold (e.g., market cap of $199M vs $201M) would oscillate in and out of the universe each month. Hysteresis sets a lower exit threshold than the entry threshold — once a stock enters the universe, it stays until it drops below a more lenient exit level.
Entry threshold: $200M ─────────────────
│ Stock enters here
Exit threshold: $150M ─────────────────
│ Stock exits here
HysteresisConfig¶
Each screen uses a HysteresisConfig with entry and exit thresholds:
from optimizer.universe import HysteresisConfig
config = HysteresisConfig(
entry=200_000_000, # must exceed to enter
exit_=150_000_000, # must drop below to exit
)
| Field | Type | Description |
|---|---|---|
entry |
float |
Threshold a stock must exceed to enter the universe |
exit_ |
float |
Threshold below which a current member is removed |
exit_ must be <= entry
The exit threshold must be less than or equal to the entry threshold. This is enforced at construction time.
InvestabilityScreenConfig¶
The main configuration holds all 8 screen thresholds plus listing requirements:
from optimizer.universe import InvestabilityScreenConfig
config = InvestabilityScreenConfig(
market_cap=HysteresisConfig(entry=200_000_000, exit_=150_000_000),
addv_12m=HysteresisConfig(entry=750_000, exit_=500_000),
addv_3m=HysteresisConfig(entry=500_000, exit_=350_000),
trading_frequency=HysteresisConfig(entry=0.95, exit_=0.90),
price_us=HysteresisConfig(entry=3.0, exit_=2.0),
price_europe=HysteresisConfig(entry=2.0, exit_=1.5),
min_trading_history=252,
min_ipo_seasoning=60,
min_annual_reports=3,
min_quarterly_reports=8,
exchange_region=ExchangeRegion.US,
mcap_percentile_entry=0.10,
mcap_percentile_exit=0.075,
)
Screen Details¶
| Screen | Default Entry | Default Exit | Description |
|---|---|---|---|
market_cap |
$200M | $150M | Free-float market capitalization (USD) |
addv_12m |
$750K | $500K | 12-month average daily dollar volume |
addv_3m |
$500K | $350K | 3-month average daily dollar volume |
trading_frequency |
95% | 90% | Fraction of trading days with nonzero volume |
price_us |
$3.00 | $2.00 | Minimum price for US equities |
price_europe |
$2.00 | $1.50 | Minimum price for European equities |
mcap_percentile_entry |
10th | 7.5th | Exchange-relative market cap percentile |
Non-Hysteresis Requirements¶
| Requirement | Default | Description |
|---|---|---|
min_trading_history |
252 days | Minimum trading days of price history |
min_ipo_seasoning |
60 days | Minimum days since first price observation |
min_annual_reports |
3 | Minimum annual financial statements |
min_quarterly_reports |
8 | Minimum quarterly financial statements |
exchange_region |
US |
Region for price threshold selection |
Exchange Percentile Screen¶
The exchange percentile screen adds a relative dimension to the absolute market cap floor. A stock must exceed both the absolute market cap threshold and the percentile rank within its exchange to enter the universe. This prevents very small stocks from entering when listed on exchanges with low median capitalizations.
Presets¶
| Preset | Market Cap Entry | ADDV 12m Entry | Use Case |
|---|---|---|---|
for_developed_markets() |
$200M | $750K | Institutional-grade, strict liquidity |
for_broad_universe() |
$100M | $500K | Broader coverage, relaxed thresholds |
for_small_cap() |
$50M | $250K | Small-cap research, minimal screens |
Preset Details¶
# Strict institutional universe
config = InvestabilityScreenConfig.for_developed_markets()
# Broader coverage
config = InvestabilityScreenConfig.for_broad_universe()
# Relaxes: mcap to $100M, ADDV to $500K, history to 126 days, etc.
# Small-cap
config = InvestabilityScreenConfig.for_small_cap()
# Relaxes: mcap to $50M, ADDV to $250K, price to $1.00, etc.
Screening Functions¶
screen_universe (main entry point)¶
from optimizer.universe import screen_universe, InvestabilityScreenConfig
investable = screen_universe(
fundamentals=fundamentals_df,
price_history=price_df,
volume_history=volume_df,
financial_statements=statements_df,
config=InvestabilityScreenConfig.for_developed_markets(),
current_members=None, # pd.Index for hysteresis
)
print(f"Investable universe: {len(investable)} stocks")
print(investable) # pd.Index of passing tickers
| Parameter | Type | Description |
|---|---|---|
fundamentals |
pd.DataFrame |
Cross-sectional data indexed by ticker |
price_history |
pd.DataFrame |
Price matrix (dates x tickers) |
volume_history |
pd.DataFrame |
Volume matrix (dates x tickers) |
financial_statements |
pd.DataFrame or None |
Statement-level data |
config |
InvestabilityScreenConfig or None |
Screening config |
current_members |
pd.Index or None |
Current universe for hysteresis |
Lower-level functions¶
from optimizer.universe import (
apply_investability_screens,
compute_addv,
compute_listing_age,
compute_trading_frequency,
count_financial_statements,
compute_exchange_mcap_percentile_thresholds,
)
# Compute individual metrics
addv = compute_addv(price_history, volume_history, window=252)
listing_age = compute_listing_age(price_history)
freq = compute_trading_frequency(volume_history, window=252)
Code Examples¶
Basic universe screening¶
from optimizer.universe import screen_universe, InvestabilityScreenConfig
investable = screen_universe(
fundamentals=fundamentals,
price_history=prices,
volume_history=volume,
config=InvestabilityScreenConfig.for_developed_markets(),
)
# Use investable universe for optimization
selected_prices = prices[prices.columns.intersection(investable)]
Screening with hysteresis¶
import pandas as pd
# First month: no current members
month1_universe = screen_universe(
fundamentals=fundamentals_jan,
price_history=prices_jan,
volume_history=volume_jan,
config=InvestabilityScreenConfig.for_developed_markets(),
current_members=None,
)
# Second month: pass previous universe for hysteresis
month2_universe = screen_universe(
fundamentals=fundamentals_feb,
price_history=prices_feb,
volume_history=volume_feb,
config=InvestabilityScreenConfig.for_developed_markets(),
current_members=month1_universe,
)
Full pipeline with universe screening¶
from optimizer.pipeline import run_full_pipeline_with_selection
from optimizer.universe import InvestabilityScreenConfig
result = run_full_pipeline_with_selection(
prices=prices,
optimizer=optimizer,
fundamentals=fundamentals,
volume_history=volume,
investability_config=InvestabilityScreenConfig.for_developed_markets(),
scoring_config=CompositeScoringConfig(),
selection_config=SelectionConfig(n_stocks=100),
)
Gotchas and Tips¶
fundamentals DataFrame must be indexed by ticker
The fundamentals DataFrame should have tickers as the index, with columns for market_cap, price, and optionally exchange (for percentile screening).
Pass current_members for turnover reduction
Without current_members, every screening round applies entry thresholds to all stocks. Passing the previous universe enables hysteresis — existing members use the more lenient exit thresholds, reducing unnecessary churn.
Exchange percentile requires 'exchange' column
The exchange percentile screen requires an exchange column in the fundamentals DataFrame. Without it, only the absolute market cap floor is applied.
Combine with factor selection
Universe screening and factor selection are complementary: screening ensures investability, while factor selection picks the best stocks from the investable universe. Use run_full_pipeline_with_selection() to chain both steps.
Quick Reference¶
| Task | Code |
|---|---|
| Developed markets | InvestabilityScreenConfig.for_developed_markets() |
| Broad universe | InvestabilityScreenConfig.for_broad_universe() |
| Small-cap | InvestabilityScreenConfig.for_small_cap() |
| Screen universe | screen_universe(fundamentals, prices, volume, config=cfg) |
| With hysteresis | screen_universe(..., current_members=prev_universe) |
| Compute ADDV | compute_addv(prices, volume, window=252) |
| Listing age | compute_listing_age(prices) |
| Trading frequency | compute_trading_frequency(volume, window=252) |