
Fundamental Filter
- 2 installs
- 29.6k repo stars
- Updated August 4, 2026
- hkuds/vibe-trading
Screen stocks by fundamental metrics (PE, PB, ROE, statement fields) to build value or growth signals across A-share, US, and HK markets.
About
Filters stocks by fundamental metrics like PE, PB, and ROE to build value or growth screens for backtesting across A-share, US, and HK markets. A developer uses it to construct fundamental screen signals from financial data.
- Screen by PE/PB/ROE and statement fields
- Value and growth filter logic across A-shares, US, and HK
Fundamental Filter by the numbers
- 2 all-time installs (skills.sh)
- Ranked #869 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hkuds/vibe-trading --skill fundamental-filterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 29.6k |
| Last updated | August 4, 2026 |
| Repository | hkuds/vibe-trading ↗ |
What it does
Screen stocks by fundamental metrics (PE, PB, ROE, statement fields) to build value or growth signals across A-share, US, and HK markets.
Files
Fundamental Factor Screening
Purpose
Filter stocks using fundamental financial data (PE/PB/ROE, etc.) to build value or growth screen signals for backtesting. Supports multiple markets with different data sources.
Market Support
| Market | Data Source | Method | Supported Metrics |
|---|---|---|---|
| A-shares | tushare daily_basic | extra_fields in config.json | pe, pb, pe_ttm, ps_ttm, dv_ttm, total_mv, circ_mv, roe |
| A-shares | Tushare statements | fundamental_fields in config.json | income, balancesheet, cashflow, fina_indicator fields |
| US stocks | yfinance Ticker.info | Direct API call | trailingPE, forwardPE, priceToBook, returnOnEquity, marketCap, dividendYield |
| HK stocks | yfinance Ticker.info | Direct API call | trailingPE, priceToBook, returnOnEquity, marketCap |
Signal Logic
Value Filter (Default)
1. PE < pe_max AND PE > 0 (exclude loss-making stocks) 2. PB < pb_max 3. ROE > roe_min 4. All conditions met → long (1), otherwise → flat (0)
Growth Filter (Optional)
1. PE_TTM within reasonable range (0 < PE_TTM < pe_ttm_max) 2. ROE > roe_min (profitability floor) 3. Market cap > mv_min (exclude micro-caps)
A-Share Usage (tushare)
config.json
{
"source": "tushare",
"codes": ["000001.SZ", "600036.SH", "000858.SZ"],
"start_date": "2023-01-01",
"end_date": "2024-12-31",
"extra_fields": ["pe", "pb", "pe_ttm", "roe", "total_mv"],
"initial_cash": 1000000,
"commission": 0.001
}The extra_fields columns are automatically merged into the daily DataFrame by the DataLoader.
A-Share Statement Pre-Filter
Use fundamental_fields when the strategy needs PIT-safe financial statement data instead of daily valuation fields:
{
"source": "tushare",
"codes": ["000001.SZ", "600036.SH", "000858.SZ"],
"start_date": "2023-01-01",
"end_date": "2024-12-31",
"fundamental_fields": {
"income": ["total_revenue", "n_income"],
"balancesheet": ["total_hldr_eqy_exc_min_int"],
"fina_indicator": ["roe", "debt_to_assets"]
},
"initial_cash": 1000000,
"commission": 0.001
}The backtest runner queries the configured tables through TushareFundamentalProvider and merges each published statement snapshot into daily bars only after its announcement/disclosure date. Statement columns are prefixed by table name:
| Requested field | SignalEngine column |
|---|---|
income.total_revenue | income_total_revenue |
income.n_income | income_n_income |
balancesheet.total_hldr_eqy_exc_min_int | balancesheet_total_hldr_eqy_exc_min_int |
fina_indicator.roe | fina_indicator_roe |
Representative financial-quality pre-filter:
revenue = row.get("income_total_revenue")
profit = row.get("income_n_income")
net_assets = row.get("balancesheet_total_hldr_eqy_exc_min_int")
roe = row.get("fina_indicator_roe")
passes = (
revenue is not None and revenue > 0
and profit is not None and profit > 0
and net_assets is not None and net_assets > 0
and roe is not None and roe >= 8.0
)HK/US Stock Usage (yfinance)
For HK/US stocks, fundamental data is not available as daily time-series via the backtest loader. Instead, use yfinance Ticker info for point-in-time screening:
import yfinance as yf
def screen_us_stocks(tickers, criteria):
"""Screen US/HK stocks by fundamental criteria."""
passed = []
for symbol in tickers:
info = yf.Ticker(symbol).info
pe = info.get("trailingPE")
pb = info.get("priceToBook")
roe = info.get("returnOnEquity") # Decimal (e.g., 0.25 = 25%)
mcap = info.get("marketCap")
if pe is None or pb is None or roe is None:
continue # Skip stocks with missing data
if (0 < pe < criteria["pe_max"]
and pb < criteria["pb_max"]
and roe > criteria["roe_min"]
and (mcap or 0) > criteria.get("mcap_min", 0)):
passed.append({
"symbol": symbol,
"pe": pe,
"pb": pb,
"roe": round(roe * 100, 1), # Convert to percentage
"mcap": mcap,
})
return passed
# Example: screen S&P 500 components
criteria = {"pe_max": 20, "pb_max": 3.0, "roe_min": 0.08, "mcap_min": 10_000_000_000}
results = screen_us_stocks(["AAPL", "MSFT", "JNJ", "JPM", "XOM"], criteria)HK Stock Screening
# HK stocks use the same yfinance interface
hk_tickers = ["0700.HK", "9988.HK", "1810.HK", "2318.HK", "0005.HK"]
results = screen_us_stocks(hk_tickers, criteria) # Same function worksParameters
| Parameter | Default | Description |
|---|---|---|
| pe_max | 20.0 | PE ceiling (exclude overvalued) |
| pb_max | 3.0 | PB ceiling |
| roe_min | 8.0 | ROE floor (%), exclude low-profitability |
| pe_min | 0.0 | PE floor (exclude loss-making stocks) |
| mcap_min | 0 | Market cap floor (for US/HK, in USD) |
Common Pitfalls
extra_fieldscolumns may contain NaN (new listings, ST stocks) — mustfillnaordropnafundamental_fieldscolumns are prefixed by table and may be NaN before the first statement is published in the backtest window- Do not forward-fill statement rows manually before their
ann_date/f_ann_date; the runner's merge already enforces point-in-time visibility - Negative PE means loss-making — always filter with
pe > 0 - ROE units differ: tushare uses percentage (e.g., 15 = 15%), yfinance uses decimal (e.g., 0.15 = 15%)
- For portfolio strategies: N stocks passing the screen each get weight 1/N
- yfinance
Ticker.infois a point-in-time snapshot, not historical time-series — cannot directly use for daily rebalancing backtests on US/HK stocks - For US/HK daily fundamental backtests, consider using the screening results as a stock universe, then applying technical signals within that universe
Dependencies
pip install pandas numpy yfinanceSignal Convention
1/N= selected for long (N = number of stocks passing the screen),0= not selected
"""基本面因子过滤选股信号引擎。
基于 PE/PB/ROE 等财务指标对 A 股进行价值筛选,
满足全部条件的股票等权做多。支持 tushare `extra_fields`
以及 `fundamental_fields` 注入的财务报表字段。
"""
from typing import Dict, List
import numpy as np
import pandas as pd
class SignalEngine:
"""基本面因子过滤信号引擎。
通过 PE/PB/ROE 三重过滤筛选价值股,满足条件的股票等权分配。
Attributes:
pe_min: PE 下限(排除亏损股)。
pe_max: PE 上限(排除高估值)。
pb_max: PB 上限。
roe_min: ROE 下限(%)。
Example:
>>> engine = SignalEngine(pe_max=15, pb_max=2, roe_min=10)
>>> signals = engine.generate({"000001.SZ": df1, "600036.SH": df2})
"""
def __init__(
self,
pe_min: float = 0.0,
pe_max: float = 20.0,
pb_max: float = 3.0,
roe_min: float = 8.0,
revenue_min: float = 0.0,
net_assets_min: float = 0.0,
):
"""初始化基本面过滤引擎。
Args:
pe_min: PE 下限(排除亏损股,默认 0)。
pe_max: PE 上限(排除高估值)。
pb_max: PB 上限。
roe_min: ROE 下限(%)。
revenue_min: 营收下限,单位沿用 Tushare income 表。
net_assets_min: 净资产下限,单位沿用 Tushare balancesheet 表。
"""
self.pe_min = pe_min
self.pe_max = pe_max
self.pb_max = pb_max
self.roe_min = roe_min
self.revenue_min = revenue_min
self.net_assets_min = net_assets_min
def _passes_statement_filter(self, row: pd.Series) -> bool | None:
"""Return statement-filter decision, or None when statement data is absent."""
revenue = _first_number(row, ["income_total_revenue", "income_revenue"])
profit = _first_number(row, ["income_n_income"])
net_assets = _first_number(row, ["balancesheet_total_hldr_eqy_exc_min_int"])
roe = _first_number(row, ["fina_indicator_roe", "roe"])
statement_values = [revenue, profit, net_assets, roe]
if all(pd.isna(value) for value in statement_values):
return None
if any(pd.isna(value) for value in statement_values):
return False
return (
revenue >= self.revenue_min
and profit > 0
and net_assets > self.net_assets_min
and roe >= self.roe_min
)
def generate(self, data_map: Dict[str, pd.DataFrame]) -> Dict[str, pd.Series]:
"""基于基本面条件过滤,对满足条件的股票等权做多。
Args:
data_map: 标的代码到 DataFrame 的映射。
DataFrame 需包含 open/high/low/close/volume 列及 pe/pb/roe 等 extra_fields。
Returns:
标的代码到信号 Series 的映射。
"""
codes = list(data_map.keys())
if not codes:
return {}
# 获取所有日期的并集
all_dates = sorted(set().union(*(df.index for df in data_map.values())))
date_index = pd.DatetimeIndex(all_dates)
# 逐日判断每只股票是否满足条件
signals: Dict[str, pd.Series] = {code: pd.Series(0.0, index=date_index) for code in codes}
for dt in date_index:
qualified: List[str] = []
for code, df in data_map.items():
if dt not in df.index:
continue
row = df.loc[dt]
statement_pass = self._passes_statement_filter(row)
if statement_pass is not None:
if statement_pass:
qualified.append(code)
continue
pe = row.get("pe", np.nan)
pb = row.get("pb", np.nan)
roe = row.get("roe", np.nan)
if pd.isna(pe) or pd.isna(pb) or pd.isna(roe):
continue
if self.pe_min < pe <= self.pe_max and pb <= self.pb_max and roe >= self.roe_min:
qualified.append(code)
if qualified:
weight = 1.0 / len(qualified)
for code in qualified:
signals[code].at[dt] = weight
# 对齐到各自原始索引
result = {}
for code, df in data_map.items():
result[code] = signals[code].reindex(df.index).fillna(0.0)
return result
def _first_number(row: pd.Series, columns: List[str]) -> float:
"""Return the first numeric value found in row, otherwise NaN."""
for column in columns:
value = row.get(column, np.nan)
if pd.notna(value):
return float(value)
return np.nan
if __name__ == "__main__":
# 演示:用随机数据模拟基本面过滤
np.random.seed(42)
dates = pd.bdate_range("2024-01-01", "2024-12-31")
def _mock_stock(pe_range, pb_range, roe_range):
n = len(dates)
return pd.DataFrame({
"open": np.random.uniform(10, 50, n),
"high": np.random.uniform(10, 50, n),
"low": np.random.uniform(10, 50, n),
"close": np.random.uniform(10, 50, n),
"volume": np.random.uniform(1e6, 1e7, n),
"pe": np.random.uniform(*pe_range, n),
"pb": np.random.uniform(*pb_range, n),
"roe": np.random.uniform(*roe_range, n),
}, index=dates)
data_map = {
"000001.SZ": _mock_stock((5, 15), (0.5, 2.0), (8, 20)), # 大概率入选
"600036.SH": _mock_stock((3, 10), (0.3, 1.5), (12, 25)), # 高概率入选
"000858.SZ": _mock_stock((30, 80), (5, 15), (5, 10)), # 大概率不入选
}
engine = SignalEngine(pe_max=20, pb_max=3, roe_min=8)
signals = engine.generate(data_map)
for code in data_map:
sig = signals[code]
active_days = (sig > 0).sum()
print(f"{code}: {active_days}/{len(sig)} days in portfolio")