双因子排序及因子构建

数据准备

股价数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# stock price
conn = duckdb.connect(DB_FILE, read_only=True)

stock_price_table_name = "stock_zh_a_hist_m" if MODE == "monthly" else "stock_zh_a_hist_d"

stock_price  = conn.sql(f"""
                select
                    DISTINCT
                    t1.stock_code,
                    t1.date,
                    t1.close price,
                    t2.stock_name,
                    t2.mktcap
                from {stock_price_table_name} t1
                left join stock_individual_info_em t2 on t1.stock_code = t2.stock_code
                """)
stock_price_df = calculate_excess_returns(stock_price.to_df()).dropna()

警告:这里的数据其实存在一个非常明显的问题,市值规模数据mktcap正常情况下应该使用历史数据, 这里用的是当前数据, 所以评估结果本身不具备参考价值

去除小盘股数据

1
2
3
4
5
6
DROP_RATE = 0.30
small_caps = stock_price_df.sort_values(by=["stock_code", "date"]).drop_duplicates(subset=["stock_code"], keep="last").sort_values(by="mktcap", ascending=True)

small_cap_stocks = small_caps.head(int(len(small_caps) * DROP_RATE)).stock_code

stock_price_df = stock_price_df.sort_values(by=["stock_code", "date"]).query("stock_code not in @small_cap_stocks")

be 数据

be也就是book-equity

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
be = conn.sql(f"""
              SELECT
                DISTINCT
                stock_code,
                be,
                date
              FROM stock_zcfz_em
              """
)

book_equity = be.to_df().query("stock_code in @stock_price_df['stock_code'].unique()")

市值

将市值数据滞后一周,防止 look-ahead bias(我们只能用较早的财务数据 而不能用最新的 因为最新的往往在当时还未取得)

1
2
3
4
5
size = (stock_price_df
  .assign(sorting_date=lambda x: x["date"]+pd.DateOffset(months=1))
  .rename(columns={"mktcap": "size"})
  .get(["stock_code", "sorting_date", "size"])
)

然后计算 bm

bm = book_equity / market_cap ,理论上越大公司就越有价值

1
2
3
4
5
6
7
bm = (book_equity
  .merge(stock_price_df, how="inner", on=["stock_code", "date"])
  .assign(bm=lambda x: x["be"]/x["mktcap"],
          sorting_date=lambda x: x["date"]+pd.DateOffset(months=6))
  .assign(accounting_date=lambda x: x["sorting_date"])
  .get(["stock_code", "sorting_date", "accounting_date", "bm"])
)

同样, 我们对数据进行滞后处理(这里用了半年前的数据, 同时限定财务数据必须在一年以内, 不然没有参考价值)

构建完整数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
data_for_sorts = (stock_price_df
  .merge(bm,
         how="left",
         left_on=["stock_code", "date"],
         right_on=["stock_code", "sorting_date"])
  .merge(size,
         how="left",
         left_on=["stock_code", "date"],
         right_on=["stock_code", "sorting_date"])
  .get(["stock_code", "date", "ret_excess",
        "mktcap", "size", "bm", "accounting_date"])
)


data_for_sorts = (data_for_sorts
  .sort_values(by=["stock_code","date"])
  .groupby(["stock_code", ])
  .apply(lambda x: x.assign(
      bm=x["bm"].fillna(method="ffill"),
      accounting_date=x["accounting_date"].fillna(method="ffill")
    )
  )
  .reset_index(drop=True)
  .assign(threshold_date = lambda x: (x["date"]-pd.DateOffset(months=12)))
  .query("accounting_date > threshold_date")   # 保证财务数据是一年内的
  .drop(columns=["accounting_date", "threshold_date"])
  .dropna()
)

计算 SMB 因子和 HML 因子

SMB = small - big, 表示小市值公司和大公司市值的收益差值 HML = high - low, 表示高价值(高 bm)和低 bm 的收益差值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
portfolios['portfolio_size'] = portfolios['portfolio_size'].astype(int)
portfolios['portfolio_bm'] = portfolios['portfolio_bm'].astype(int)

n_size = portfolios['portfolio_size'].max()
n_bm = portfolios['portfolio_bm'].max()

# SMB(小盘 - 大盘),逐日计算,并处理缺失(若某日某一侧缺失则结果为 NaN)
small_ret = portfolios.loc[portfolios['portfolio_size'] == 1].groupby('date')['ret'].mean()
big_ret   = portfolios.loc[portfolios['portfolio_size'] == n_size].groupby('date')['ret'].mean()
smb = (small_ret - big_ret).rename('SMB').reset_index()

# HML(高 BE/ME - 低 BE/ME)
high_ret = portfolios.loc[portfolios['portfolio_bm'] == n_bm].groupby('date')['ret'].mean()
low_ret  = portfolios.loc[portfolios['portfolio_bm'] == 1].groupby('date')['ret'].mean()
hml = (high_ret - low_ret).rename('HML').reset_index()

# 若希望将 SMB/HML 合并成一个 DataFrame
factors = smb.merge(hml, on='date', how='outer')

这里我们使用独立排序(注意fama-french 3因子模型使用的是 double sort!), 分别计算了 SMB 和 HML

下面是这两个因子均值的汇总: SMB: -0.004243 HML: -0.017698

然后分别使用截距项回归验证这两个因子的作用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import statsmodels.api as sm
from regtabletotext import prettify_result


model_fit_smb = (sm.OLS.from_formula(
    formula="SMB ~ 1",
    data=factors
  )
  .fit(cov_type="HAC", cov_kwds={"maxlags": 6})
)
prettify_result(model_fit_smb)
1
2
3
4
5
6
7
model_fit_hml = (sm.OLS.from_formula(
    formula="HML ~ 1",
    data=factors
  )
  .fit(cov_type="HAC", cov_kwds={"maxlags": 6})
)
prettify_result(model_fit_hml)

也就是说, 至少在 A 股, SMB 以及 HML 并不是显著有效的因子

讨论

在石川的«因子投资»中论证过 SMB 和 HML 这两个重要因子,在书中这两个因子的效用还是得到了验证;
以规模效应(SMB)为例, 在石川的书中,对小盘股进行了筛选(由于 A 股特有的借壳上市机制, 小盘股)剔除了市值最小的 30%的股票, 另外他的时间跨度是 2000-2020 年. 书中也提到了再 2015 年之前规模因子是有效的, 但是之后SMB效果就不再明显;
还有一点, 前面我也提到, 由于历史市值规模的缺失,导致我的评估结果没有办法真实反映因子的作用。

参考

value-and-bivariate-sorts
因子投资, 石川