Brinson Model 详解含 Python 多期归因实践

一、Brinson Model 简介

Brinson Model,解构投资组合收益构成的方法。Brinson, Hood, and Beebower (1986) 推出该方法,把投资收益分解到两个部分,资产配置效果(Allocation Effect)与资产选择效果(Selection Effect)。

Brinson Model 基于一个假定的、通常的投资决策框架。首先,明确投资目标,用基准指数(benchmark)来构建实现该目标的途径;然后,解构目标,拆分成若干小目标,最后,选择具体的投资标的实现构建的子目标。以目标为导向,拆解落地到具体标的构建投资组合有几个好处:

  1. 对于管理人而言,区分了投资目标设定的责任与具体投资管理的责任,基准的表现好坏与投资目标设定有关,相对基准表现的好坏与投资经理的具体执行有关;
  2. 对于销售或投资顾问而言,将目标聚焦到帮助投资者设定适当的投资目标,选择适合的资管产品,而不是以投资经理来代替资管产品本身;
  3. 对于出资人而言,更容易确立投资目标,建立自己财产的管理体系,也更容易在投资组合表现不好的光景里接受事实,而不是将愤怒转移到销售、投顾或投资经理,毕竟他们自己是决定基准目标的最终决策人。

管理人或投资顾问帮助投资者设定符合投资目标的资产构成与权重,即长期投资目标 「战略资产配置」(SAA, Strategic Assets Allocation);投资经理根据现实的环境与投资机会对资产权重进行调整,称为「战术资产配置」(TAA,Tactic Assets Allocation),落实具体到操作,选择具体的标的来构建每一资产子类。

Brinson Model 拆解投资组合的收益构成,可方便模型的使用者清晰地观察到 TAA的决策效果(Allocation Effect)与投资经理对具体投资标的选择的效果( Selection Effect)。

1. BHB Model

1986年 最初的 Brinson Model 又名 BHB model,以 Brinson, Hood, and Beebower 三人名字首字母命名。

公式(0) BHB Model:

Alpha = Allocation Effect + Selection Effect + Interactive Effect

1.1 Allocation Effect (资产配置效果)

资产配置 (Allocation) ,即 TAA 的过程, 子目标权重偏离基准目标子类资产类别的权重,资产配置简单而言即高配或低配指数资产权重,以投资组合 100% 持有股票资产为例,假设其对标基准为「沪深300指数」,相对「沪深300指数」权重股所属行业的权重超配或低配形成的「超额回报贡献」为 Allocation Effect。

公式(1) 指数回报由行业回报贡献构成

B = \sum_{i=1}^{i=n}W_iB_i

公式(2) 投资组合配置行业的权重获得回报

B_s = \sum_{i=1}^{i=n}w_iB_i

公式 (3) 因资产配置而形成的超额收益

B_s - B = \sum_{i=1}^{i=n}w_iB_i - \sum_{i=1}^{i=n}W_iB_i = \sum_{i=1}^{i=n}(w_i-W_i)B

公式 (4) 第i个行业的因资产配置而形成的超额收益贡献

A_i = (w_i - W_i)B_i

公式 (5) 资产配置效果 (Allocation Effect)

\sum_{i=1}^{i=n}A_i = B_s - B

1.2 Selection Effect (资产选择效果)

资产选择(Selection), 即选择具体的标的构建子类资产。首先,根据基准的子类资产的权重构建一个「名义基金」,把资产选择效果从资产配置效果中分离出来,在特定的子类别中考察资产选择的效果。

公式(6)名义基金的收益

R_s = \sum_{i=1}^{i=n}W_iR_i

公式(7)名义基金相对基准的超额收益

R_s - B =\sum_{i=1}^{i=n}W_iR_i - \sum_{i=1}^{i=n}W_iB_i = \sum_{i=1}^{i=n}W_i\times(R_i-B_i)

公式(8)子类资产的资产选择效果

S_i = W_i(R_i- B_i)

公式(9)资产选择效果 (Selection Effect)

\sum_{i=1}^{i=n}=R_s-B

1.3 Interactive(交互效应)

由于BHB Model 中 Seletion Effect 使用「名义基金」来代替了实际的组合,因此资产配置效果与资产选择效果的算数合计值不等于投资组合实际的超额收益,其中还有尾差。

公式(10)资产配置效果、资产选择效果合计与组合超额收益不等

Selection + Allocation = (R_s - B) + (B_s - B) =R_s+B_s-2B \neq R-B

公式(11)组合超额收益完全拆解

\underbrace{R_s - B}_{Selection} + \underbrace{B_s - B}_{Allocation} + \underbrace{R-R_s -B_s+B }_{Interaction} = R-B

Interactive (交互效应), Brinson, Hood, and Beebower在论文中以 Other 表示,可能 Interactive 一词更有解释力,今天人们普遍采用该词。 Interactive 不是一个残差项,而是一个直接计算可得的值,为子类资产实际与基准权重的差\times子类资产实际与基准回报的差

R-R_s-B_s+B=\sum_{i=i}^{i=n}w_iR_i-\sum_{i=1}^{i=n}W_iR_i-\sum_{i=1}^{i=n}w_iB_i+\sum_{i=1}^{i=n}W_iB_i

公式(12)右边公式简化

\sum_{i=1}^{i=n}(w_i-W_i)(R_i-B_i) = \sum_{i=1}^{i=n}I_i

2. BF Model

Brinson-Fachler (BF) model 与 BHB model 的差异主要增加考虑了于子类资产的收益相对基准的收益。

在 BHB model 中,超配收益为正的子类资产获得正向的 allocation effect(资产配置效果),超配收益为负的子类资产获得负向的allocation effect(资产配置效果),这些 与是否该子类资产是否跑赢整体基准收益无关。BF model 对此进行了调整。

公式(13)

B_s - B = \sum_{i=1}^{i=n}(w_i-W_i)B_i = \sum_{i=1}^{i=n}(w_i-W_i)(B_i-B)

因为\sum_{i=i}^{i=n}w_i = \sum_{i=i}^{i=n}W_i=1,常数项B被介绍进来。

公式(14)调整的子类资产的 Allocation Effect(资产配置效果)

A_i = (w_i-W_i)(B_i-B)

3. Interactive(交互效应)

BHB、BF 两个 Brinson model 都存在容易令人困惑的地方—— Interactive Effect(交互效应)。交互效应并不是投资决策的一部分,投资经理并不会通过交互效应来提升投资组合的价值,只是计算资产配置与个券选择上因为权重的不同而产生的差。
大多数的投资决策,首先考虑资产配置,然后考虑个券选择。而对于之下而上专注个股投资对与Brinson model而言并不适用,其投资决策过程中没有资产配置,那么也就无从考虑「资产配置效果」。
由于 Interacttion 不易被理解,因此消除此项的计算更为合理。将 Selection Effect (资产选择效果)的定义稍加修改,从 R_s - B 改为 R-B_s,即:

公式(15)融合Interactive(交互效应)的 Selection Effect(资产选择效果)
R-B_s = \sum_{i=1}^{i=n}w_iR_i-\sum_{i=1}^{i=n}w_iB_i = \sum_{i=1}^{i=n}w_i(R_i-B_i)
投资组合超额收益便完全由Selection Effect(资产选择效果)与Allocation Effect(资产配置效果)构成了:

Selection + Allocation = (R-B_s)+(B_s-B)=R-B

公式(16)子类资产的 Selection Effect(资产配置效果)
S_i = w_i(R_i-B_i)

二、Brinson Model 实现的现实问题

Brinson Model 给出了拆解收益,分析超额收益的框架方法,实操应用仍有诸多问题需要解决。Brinson Model计算的是静态截面数据,即一段期间内的投资组合收益与基准收益的比较,要求期初资产持有至期末,期间不涉及权重调整。实际的投资中,必定产生交易,必然期初的权重会产生变动与调整。

  • 投资组合申赎等产生的现金流变动会影响到期初权重的调整;
  • 投资组合的投资交易行为会影响到期初权重的调整。

为了尽可能消除投资组合的起初权重调整,应经可能将计算期间拆分至日频,并进行期间累计。累计收益的计算需考虑截面收益的时间价值,使得 Allocation Effect 与 Selection Effect 在时间序列上的汇总与总体的超额收益相等。

1. 计算期间的问题

Brinson 拆解的是投资收益率的构成,在实际的投资中,并非理想地“买入并持有”,每天都会有每一资产的权重变动,交易产生的收益,以及收益再投资的问题。

设想的计算方案1: 假设投资组合每日期初100%仓位,与基准每日进行比对,计算alpha,几何累计的方式,将alpha累计到完整期间。

  • 优点:无期初投资为0的困扰
  • 缺陷:投资组合与基准每日再投资金额不同步,导致累计的alpha不准确;

设想的计算方案2:投资组合与基准各自计算期间全部累计收益金额,根据期初投资金额计算各类资产的权重与收益率。

  • 优点:计算简便,大多数情况下计算准确;
  • 缺陷:存在期初资产为0的情形,将导致计算失败。

2. 国内 Benchmark 数据源的问题

国内指数成分股存在分红与拆股的问题,指数对此不做调整。通过成分股涨跌幅来推导指数回报,存在差异。采用全收益指数可以解决一部分成分股分红产生的问题,但拆股问题仍然无解。因此,在国内计算Brinson,alpha结果与实际投资组合与指数的差异不相等。

3. Brinson跨期计算的缓释方法

思路:

  • 期初投资保持一致,假设投资总金额为1元,分别投向按照成份股的占比投向「投资组合」(Portfolio)与「基准」(index),分别计算「投资组合」与「基准」的跨期总收益,直接进行总收益的比对,获得 alpha 结果/
  • 基准指数:按照买入并持有的假设,根据期初成份股权重,及成分股每日涨跌,推算出期间成分股收益,根据指数权重发布情况每月一调整;
  • 投资组合:每日累计的方法计算投资收益,将每日产生的收益加回至下一日期初,参与下一日收益的计算,形成“复利”。 投资组合每日交易中,将买入金额算至期初投资,解决期初投资为0的问题。
  • 投资组合期间收益与基准指数期间收益进行Brinson拆解,解决Brinson跨期计算。
    缺陷:
  • 基准指数收益推算接近于全收益指数,与实际的指数收益存在差异,因为拆股与合股导致的问题无法解决,指数权重数量越多,发生次数越频繁,实际的差异也就越大。

三、Brinson Model 在 python 中的实现

1. Benchmark 的期间收益及资产类别拆分计算的代码

class Benchmark:
    """
    Benchmark 期间内「行业」每日期初权重与回报:
        根据指数公司权重公布日的权重(作为「期初权重」),按照成分股每日涨跌幅,推算每个交易日的「期初权重」

        目标:取完整权重区间每日成分权重
        方法:取期初公布权重,按成分股每日收益,假设 buy and hold, 计算出每日权重
        步骤:
            1. _benchmark_components_begin, 取期初公布权重;
            2. _benchmark_components_return, 取期间成分股每日涨跌
            3. _benchmark_componentes_weighs, 推算成分股每日权重
            4. _add_industry, 加入行业分类信息

        局限: 获取的指数为「全收益」指数, 未扣除分红影响. 虽然可以考虑使用未除权价,但不能排除拆股的情形.

    :return:
        components_w_rtn -> pd.DataFrame
            - cols: [weights, rtn, w_rtn]
            - index: [reportDate, secuTicker]

    """

    def __init__(self,
                 bench_code: str,
                 start_date: str | dt.date | pd.Timestamp,
                 end_date: str | dt.date | pd.Timestamp,
                 industry: dict,
                 calendar: Literal['XSHG'] = 'XSHG'
                 ):
        """

        bench_code: benchmark code, like 000300 -> 沪深300
        date: 起始日期 str -> YYYY-MM-DD
        end_date: 截止日期 str -> YYYY-MM-DD
        industry_category: 行业分类 like 申万一级行业
        calendar: 交易日历 XSHG -> 上海证券交易所

        数据来源: JYDB

        """
        self.bench_code = bench_code
        self.start_date = date_formate(date=start_date, mode='date')
        self.end_date = date_formate(date=end_date, mode='date')
        self.industry = industry
        self.calendar = calendar

    @staticmethod
    def _algorithm_daily_rtn_weights(ohlc: pd.DataFrame,
                                     init_weights: pd.Series) -> pd.DataFrame:
        """
        通过成分股期初权重,按照每日收益推算每日期初权重,获取每日「权重,收益率,加权收益率」
        目前仅适用「按市值加权」

        参数要求:
            ohlc:
                pd.DataFrame,
                columns: ['preClose', 'open', 'high', 'low', 'close', 'adj_factor']
                index: ['reportDate', secuTicker']

            init_weights:
                pd.Series
                index: ['reportDate', secuTicker']

        方法:
        假设期初投资 1元钱, 按期初权重投资到成分股, 其权重为分配到的金额投资。
        采用「买入并持有的策略」进行投资,期末按市值加权比例收回/补充投资, 使得投资额回到1元。
        每日计算期初期末,每日调整,获得每日市值加权的权重。

        算法:
        1. 计算出 rtn_factor, 收盘价/前收盘价, 获得 rtn_factor, 交易所发布的「前收盘价」经过了除权调整;
        2. 成分股 rtn_factor 累乘,获得每日期末净值(期初投资额公允价值调整)
        3. 每日期末净值/成分股净值,获得每日期末成分股权重
        4. 成分股权重从期末调整至期初 (shift(1)) 得到成分股每日权重 (期初)
        5. 成分股每日权重(期初)* 成分股涨跌幅 得到成分股「每日加权收益率」

        :return: pd.DataFrame, with cols ['weights', 'rtn', 'w_rtn'] while index ['reportDate', 'secuTicker']

        """
        # 1. 计算出 rtn_factor
        rtn_factor = (ohlc['close'] / ohlc['preClose']) \
            .unstack() \
            .cumprod() \
            .stack() \
            .rename('rtn_factor')

        # 2. 计算每日权重
        rtn_df = pd.DataFrame(rtn_factor) \
            .join(init_weights) \
            .join((ohlc['close'] / ohlc['preClose'] - 1).rename('rtn'))

        w_factor = (rtn_df['rtn_factor'] * rtn_df['weights']) \
            .unstack() \
            .shift(1) \
            .stack() \
            .rename('w_factor')

        rtn_df = rtn_df.join(w_factor, how='right')
        rtn_df['weights'] = rtn_df.groupby(level=0, group_keys=False)['w_factor'].apply(lambda x: x / x.sum())

        # 3. 计算每日成分股加权收益率
        rtn_df['w_rtn'] = rtn_df['weights'] * rtn_df['rtn']

        return rtn_df[['weights', 'rtn', 'w_rtn']]

    @staticmethod
    def _get_disclose_date(date,
                           calendar,
                           direction):
        """
        根据 date 获取成分股权重公布日

        direction:
            - previous: 期初成分股权重公布日
            - next: 期末成分股权重公布日

        方法:
            - 取 date 上月最后一个日历日,即本月首个日历日 - 1个日历日
            - 判断若非交易日,则取最近一个交易日
            - 若期末,则 date 调增一个月

        :return:
            self.disclose_date
        """
        date = date_formate(date=date, mode='date')
        cals = xcals.get_calendar(calendar)
        direction = direction

        if direction == 'previous':
            pass
        elif direction == 'next':
            date = date + relativedelta(months=1)
        else:
            raise KeyError('direction error!')

        # 取 date 本月本月首个日历日前一个最近的交易日
        disclose_date = dt(year=date.year, month=date.month, day=1) - relativedelta(days=1)
        disclose_date = cals.date_to_session(disclose_date, direction='previous')

        return dt.strftime(disclose_date, '%Y-%m-%d')

    @staticmethod
    def _components_weighted_return(date: str | dt | dt.date | pd.Timestamp,
                                    bench_code: str,
                                    calendar: Literal['XSHG'] = 'XSHG') -> pd.DataFrame:
        """
        按日期推算得出该日期所在的指数权重公布期间段内的权重

        input params:
            date: str | dt | dt.date | pd.Timestamp 期间内的任意日期
            bench_code: 指数代码
            calendar: 交易日历, 默认采用「上交所」日历

        source: JYDB, exchange-calendars

        methodology:
            1. 根据 date 获取期间 start_date 与 end_date
                - start_date 为本期期初指数权重公布的日期
                - end_date 为下期期初指数权重公布的日期
            2. 取区间内期初权重 init_weights 与 成分股每日涨跌幅, 推算得出成分股每日期初权重

        scripts:
            1. _get_disclose_date 获取期初成分股权重披露日期
            2. _algorithm_daily_rtn_weights 成分股每日权重的算法

        :return:  rtn_df
                    pd.DataFrame
                    with cols ['weights', 'rtn', 'w_rtn'] while index ['reportDate', 'secuTicker']

        """
        # 取期间的起始日期与终止日期
        start_date = date_formate(date=Benchmark._get_disclose_date(date=date,
                                                                    calendar=calendar,
                                                                    direction='previous'),
                                  mode='str')

        end_date = date_formate(date=Benchmark._get_disclose_date(date=date,
                                                                  calendar=calendar,
                                                                  direction='next'),
                                mode='str')

        # 取成期初成分股权重
        components_weight = jydb.query_index_component_weights(index_code=bench_code,
                                                               date=start_date) \
            .reset_index()

        init_weights = components_weight.set_index('secuTicker')['weights']
        init_weights = init_weights / init_weights.sum()

        # 取期间内成分股每日涨跌幅
        ohlc = jydb.quote_ohlc_secu(secu_ticker=components_weight['secuTicker'].to_list(),
                                    start_date=start_date,
                                    end_date=end_date) \
            .set_index(['reportDate', 'secuTicker']) \
            .query('category == "stocks"')

        # 推导获取成分股「每日权重」与「每日收益」
        rtn_df = Benchmark._algorithm_daily_rtn_weights(ohlc=ohlc, init_weights=init_weights)

        return rtn_df

    @property
    def _get_disclosure_batches(self):
        """
        获取 benchmark 成分股披露的批次,已知每月末披露一次


        """
        start_date = date_formate(date=self.start_date, mode='date')
        end_date = date_formate(date=self.end_date, mode='date')

        num_months = (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month) + 1
        batches = [start_date + relativedelta(months=n) for n in range(num_months)]

        return batches

    @property
    def bench_period_weighted_return(self):
        """
        累积 benchmark 期间加权回报率的计算

        """
        # 1. 成分股「每日权重」 与 「回报」
        Batches = self._get_disclosure_batches

        w_rtn = pd.DataFrame()
        for date in Batches:
            cache_rtn_df = self._components_weighted_return(date=date,
                                                            bench_code=self.bench_code,
                                                            calendar=self.calendar)
            w_rtn = pd.concat([w_rtn, cache_rtn_df])

        start_date = date_formate(date=self.start_date, mode='str')
        end_date = date_formate(date=self.end_date, mode='str')
        w_rtn = w_rtn.query(f'reportDate >= "{start_date}" and reportDate <= "{end_date}"').reset_index()
        w_rtn['industry'] = w_rtn['secuTicker'].map(self.industry)
        industry_w_rtn = w_rtn.groupby(['reportDate', 'industry'])[['weights', 'w_rtn']].sum()
        industry_w_rtn['rtn'] = industry_w_rtn['w_rtn'] / industry_w_rtn['weights']

        # 解析
        # (1) 假设期初 1元 本金, Benchmark 期间内 一共获得了多少 return,
        B_factor = (industry_w_rtn['w_rtn'].unstack().sum(axis=1) + 1).cumprod()
        B = B_factor.iloc[-1] - 1

        # (2) 假设期初 1元,每只票的 contribution 是多少?
        industry_w_rtn = industry_w_rtn.join(B_factor.shift(1).rename('adj_factor'))
        industry_w_rtn['adj_factor'] = industry_w_rtn['adj_factor'].fillna(1)
        contribution = industry_w_rtn['w_rtn'] * industry_w_rtn['adj_factor']
        contribution = contribution.unstack().sum()

        # (3) 平均 rate of return = contribution / 平均 weights
        weights = industry_w_rtn['weights'].unstack().sum()
        weights = weights / weights.sum()
        rtn = contribution / weights

        bench = pd.DataFrame(weights.rename('Wi')) \
            .join(rtn.rename('bi')) \
            .join(contribution.rename('WB'))
        bench['B'] = B

        return bench

    @property
    def bench_daily_weighted_return(self):
        """
        Benchmark 期初至期末, 每日的成分股权重

        :return:
        pd.DataFrame
                    with cols ['weights', 'rtn', 'w_rtn'] while index ['reportDate', 'secuTicker']


        """
        # 1. 成分股「每日权重」 与 「回报」
        Batches = self._get_disclosure_batches

        w_rtn = pd.DataFrame()
        for date in Batches:
            cache_rtn_df = self._components_weighted_return(date=date,
                                                            bench_code=self.bench_code,
                                                            calendar=self.calendar)
            w_rtn = pd.concat([w_rtn, cache_rtn_df])

        start_date = date_formate(date=self.start_date, mode='str')
        end_date = date_formate(date=self.end_date, mode='str')
        w_rtn = w_rtn.query(f'reportDate >= "{start_date}" and reportDate <= "{end_date}"').reset_index()

        # 2. 行业 「每日权重」与 「回报」
        w_rtn['industry'] = w_rtn['secuTicker'].map(self.industry)
        industry_w_rtn = w_rtn.groupby(['reportDate', 'industry'])[['weights', 'rtn', 'w_rtn']].sum()
        industry_w_rtn['rtn'] = industry_w_rtn['w_rtn'] / industry_w_rtn['weights']

        return industry_w_rtn

2. Portfolio 的期间收益及资产类别拆分计算的代码:

class Portfolio:
    """
    Portfolio 期间内股票资产的每日期初权重与回报:
    1. 从 lantern 取投资组合中的股票资产, 及其产生的每日收益 assetPL

    3. 计算当期收益率:rtn = gainLoss / init, 根据 weight 计算出 w_rtn,
    4. 跨期调整,w_rtn * adjust_factor
    5. 将个券数据聚合到行业,weight 与 w_rtn 按行业汇总,计算行业 rtn = w_rtn / weight

    ** adjust_factor 的计算:汇总当日净值生成 净值序列,shift(1).fillna(1),以期初净值作为跨期因子,将单利调整为连续复利,实际计算只需要 r * 期初单位净值就可以了。

    """

    def __init__(self,
                 fund_code: str,
                 start_date: str | dt.date | pd.Timestamp,
                 end_date: str | dt.date | pd.Timestamp,
                 industry: dict,
                 calendar: Literal['XSHG'] = 'XSHG'
                 ):
        """

        fund_code: benchmark code, like 000300 -> 沪深300
        date: 起始日期 str -> YYYY-MM-DD
        end_date: 截止日期 str -> YYYY-MM-DD
        calendar: 交易日历 XSHG -> 上海证券交易所

        数据来源: lantern_db (自建的投资组合数据库)

        """
        self.fund_code = fund_code
        self.start_date = date_formate(date=start_date, mode='date')
        self.end_date = date_formate(date=end_date, mode='date')
        self.industry = industry
        self.calendar = calendar

    @staticmethod
    def _load_assets(fund_code: str,
                     start_date: str,
                     end_date: str,
                     industry: dict
                     ):
        """
        数据库取值,筛选A股资产
        """
        fund_abbr = lantern_api.query_fundAbbr(fund_code=fund_code)[fund_code]

        # 1. lantern 数据库取值,并筛选出 stocks
        assetPL = gain_loss.AssetPl(fund_abbr, start_date, end_date).fit()
        assets = assetPL.gainLoss.query('assetClass == "stocks"').copy()
        assets['industry'] = assets['secuTicker'].map(industry)

        return assets

    @property
    def portfolio_period_components_w_rtn(self):
        """
        计算投资组合期间成份的加权回报率
        """
        start_date = date_formate(date=self.start_date, mode='str')
        end_date = date_formate(date=self.end_date, mode='str')
        assets = self._load_assets(fund_code=self.fund_code,
                                   start_date=start_date,
                                   end_date=end_date,
                                   industry=self.industry)

        cals = xcals.get_calendar(self.calendar)
        sessions = cals.sessions_in_range(start=start_date, end=end_date)
        assets = assets[assets['reportDate'].apply(lambda x: x in sessions)]

        w_rtn = assets.groupby(['reportDate', 'industry'])[['init', 'netPL']].sum()

        w_rtn['rtn'] = w_rtn['netPL'] / w_rtn['init']
        w_rtn['weights'] = w_rtn.groupby(level=0, group_keys=False)['init'].apply(lambda x: x / x.sum())
        w_rtn['w_rtn'] = w_rtn['weights'] * w_rtn['rtn']

        # (1) 假设期初投资为1元,复利计算期末是多少钱
        p_factor = (w_rtn.groupby(level=0, group_keys=False)['w_rtn'].sum() + 1).cumprod()
        p = p_factor.iloc[-1] - 1

        # (2) 假设期初 1元,每只票的 contribution 是多少?
        w_rtn = w_rtn.join(p_factor.shift(1).rename('adj_factor'))
        w_rtn['adj_factor'] = w_rtn['adj_factor'].fillna(1)
        contribution = w_rtn['w_rtn'] * w_rtn['adj_factor']
        contribution = contribution.unstack().sum()

        # (3) 平均 rate of return = contribution / 平均 weights
        weights = w_rtn['weights'].unstack().sum()
        weights = weights / weights.sum()
        rtn = contribution / weights

        port = pd.DataFrame(weights.rename('wi')) \
            .join(rtn.rename('pi')) \
            .join(contribution.rename('wp'))
        port['P'] = p

        return port

3. BF 模型计算的代码

class Brinson_Model:
    """

    只支持完整月度,或完整月度累积的分析,不支持区间分析,原因:jydb 每月最后一个交易日提供的 benchmarks 成分构成

    """

    def __init__(self,
                 fund_code: str,
                 bench_code: str,
                 start_date: str | dt.date | pd.Timestamp,
                 end_date: str | dt.date | pd.Timestamp,
                 industry: Literal['申万'] = '申万',
                 calendar: Literal['XSHG'] = 'XSHG',
                 model: Literal['BHB', 'BF'] = 'BF'
                 ):
        """
        date: str -> 'YYYY-MM-DD' | dt.datetime | pd.Timestamp
        end_date: str -> 'YYYY-MM-DD' | dt.datetime | pd.Timestamp


        """

        self.fund_code = fund_code
        self.bench_code = bench_code
        self.start_date = start_date
        self.end_date = end_date
        self.industry = jydb.query_stock_industry(standard=industry, level=1, secu_category=1)
        self.calendar = calendar
        self.model = model

        self.cache = cache
        self.industry_a_stocks = None
        self.trading_days = None
        self.bench_components_weights = None

    @property
    def benchmark(self):
        """
        取 benchmark 然后聚合

        """
        bench = Benchmark(bench_code=self.bench_code,
                          start_date=self.start_date,
                          end_date=self.end_date,
                          industry=self.industry,
                          calendar=self.calendar)

        bench = bench.bench_period_weighted_return.sort_index()
       
        return bench

    @property
    def portfolio(self):
        """
        取 portfolio
        """
        port = Portfolio(fund_code=self.fund_code,
                         start_date=self.start_date,
                         end_date=self.end_date,
                         industry=self.industry,
                         calendar=self.calendar)
        port = port.portfolio_period_components_w_rtn.sort_index()
       
        return port

    @property
    def _model_data(self):
        """

        """
        benchmark = self.benchmark
        portfolio = self.portfolio

        industry = list(set(self.industry.values()))
        ind = pd.Index(data=industry, name='industry')
        model_df = pd.DataFrame(index=ind).sort_index()

        model_df = model_df.join(benchmark, how='left').join(portfolio, how='left')
        model_df[['B', 'P']] = model_df[['B', 'P']].fillna(method='ffill').fillna(method='bfill')
        model_df = model_df.fillna(0)

        cols = ['Wi', 'wi', 'bi', 'pi', 'B', 'WB', 'wp', 'P']
        return model_df[cols].fillna(0)

    @property
    def BF_model(self):
        """
        BF_mode without interactive effect

        """
        model_data = self._model_data
        Wi = model_data['Wi']
        bi = model_data['bi']
        B = model_data['B']
        wi = model_data['wi']
        pi = model_data['pi']
        P = model_data['P']

        allocation = (bi - B) * (wi - Wi)
        selection = wi * (pi - bi)
        alpha = allocation + selection

        result = pd.DataFrame(index=model_data.index)
        result = result \
            .join(allocation.rename('allocation')) \
            .join(selection.rename('selection')) \
            .join(alpha.rename('alpha'))

        B = round(list(set(B))[0] * 1e2, 2)
        P = round(list(set(P))[0] * 1e2, 2)
        alpha = P - B

        result_detail = result.sort_values('alpha', ascending=False).round(4) * 1e2
        result = result.sum().round(4) * 1e2

        result = {
                    'detail': result_detail,
                    'alpha': [P, B, alpha],
                    'result': result,
        }

        return result
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容