Docs
Open Studio

US examples

Penny stock risk filter

On this page4

A revenue momentum strategy with price, volume and size filters, so it doesn't buy illiquid, low-quality or financially distressed stocks. Penny stocks, micro-caps and low-volume names carry outsized risks that can silently erode a strategy's performance; the filters here can be added to any US strategy.

Strategy logic

  1. Revenue momentum: trailing 3-month cumulative revenue at a 2-year high.
  2. No penny stocks: price above $5.
  3. Enough volume: 20-day average daily volume above 100,000 shares.
  4. No micro-caps: market cap above $300 million.
  5. Among stocks passing all four, hold the 10 with the highest year-over-year revenue growth, rebalanced monthly.

Why filter these stocks

US exchanges do not flag a category of financially distressed stocks (Taiwan, for example, flags "full cash delivery" stocks that require full upfront payment), but similar risks exist in other forms.

  • Penny stocks: the US Securities and Exchange Commission (SEC) defines penny stocks as securities trading below $5. They have wide bid-ask spreads that inflate transaction costs, low institutional ownership and analyst coverage, a higher exposure to pump-and-dump manipulation and frequent delisting risk.
  • Low-volume stocks: with average daily volume below 100,000 shares, large orders can move the price significantly, and positions may be hard to exit quickly during market stress.
  • Micro-caps: companies below $300 million in market cap are more likely to have volatile and unpredictable earnings, limited financial disclosure quality and higher bankruptcy risk. They also bring survivorship bias into backtests: many delisted stocks were once small caps.

Code

strategy.py
from finlab import data
from finlab.backtest import sim
from finlab.markets.us import USMarket

data.set_market('us')

close = data.get('price:adj_close')
close = close[close.index.dayofweek < 5]  # remove weekends

# Revenue momentum: quarterly revenue at 2-year high
revenue = data.get('us_income_statement:revenue')
rev_trailing_3q = revenue.rolling(3).sum()
cond_rev = rev_trailing_3q == rev_trailing_3q.rolling(8, min_periods=4).max()

# Revenue growth rate for ranking
rev_growth = revenue / revenue.shift(4) - 1

# --- Risk Filters ---

# Filter 1: Remove penny stocks (price < $5)
cond_price = close > 5

# Filter 2: Volume filter (average daily volume > 100K shares)
volume = data.get('price:volume')
volume = volume[volume.index.dayofweek < 5]
avg_volume = volume.rolling(20).mean()
cond_volume = avg_volume > 100000

# Filter 3: Market cap filter (> $300M)
market_cap = data.get('etl:us_market_value')
cond_mcap = market_cap > 300000000

# Combine all conditions
cond_all = cond_rev & cond_price & cond_volume & cond_mcap

# Rank by revenue growth, select top 10
result = rev_growth * cond_all
position = result[result > 0].is_largest(10)

report = sim(position, resample='M', market=USMarket(), fee_ratio=0.001, tax_ratio=0)
report.display()

How to adjust it

The filters are additive: to apply them to any existing strategy, add & cond_price & cond_volume & cond_mcap to its final position condition.

  • close > 5: removes every stock trading below $5. This single filter eliminates the vast majority of pump-and-dump candidates, shell companies and near-delisting stocks. It also makes the backtest more realistic, since historical penny stock data often suffers from survivorship bias.
  • avg_volume > 100000: uses a 20-day rolling average of daily share volume. 100,000 shares lets a strategy allocating reasonable capital (for example $100K–$1M per position) enter and exit without significant market impact.
  • market_cap > 300000000: a $300 million floor removes micro-caps, which are disproportionately represented in backtested alpha signals. Many academic anomalies (such as value and momentum) earn much of their historical alpha from micro-caps that are practically untradeable at scale.
  • revenue.rolling(3).sum(): trailing 3-quarter cumulative revenue smooths out single-quarter noise while staying responsive to recent trends.
  • rolling(8, min_periods=4).max(): a 2-year (8-quarter) rolling maximum that flags revenue breakouts.
  • rev_growth = revenue / revenue.shift(4) - 1: year-over-year quarterly growth removes seasonality and gives a clean ranking metric.
  • is_largest(10): the 10 stocks with the highest revenue growth among those passing every filter.
  • resample='M': monthly rebalancing balances the quarterly cadence of revenue updates with the need to respond to price and volume changes.
  • fee_ratio=0.001: a 0.1% commission.
  • tax_ratio=0: US stock trades carry no transaction tax.

The thresholds ($5, 100K shares, $300M) are reasonable defaults; adjust them to your capital size and risk tolerance. For larger portfolios, consider raising the market cap floor to $1B and the volume floor to 500K shares.

↑↓ Move Enter Open Esc Close