Python: Базовые показатели портфеля

10 окт 2018 Александр  Мыльцев

В предыдущей статье мы рассказали про базовые показатели отдельного актива, которые интересны сами по себе.

Для анализа поведения инвестиционного портфеля необходимы его расчетные характеристики. Математический аппарат для подобных расчётов предложен Г. Марковицем еще в 1952 году и используется успешно финансовом мире. Положения Г. Марковица составляют «ядро» Современной теории портфеля (СТП) и часто именуются распределением активов (asset allocation).

Итак, строго говоря, портфель – это множество, состоящее из пар:

где – это актив из предыдущей статьи, и – вес актива, и в общем случае, но из соображений статистики, о которых мы расскажем в следующих статьях, на практике не превышает 10.

Каждый показатель портфеля аналогичен по смыслу соответствующему показателю актива. И каждый показатель портфеля вычисляется на основе составляющих его активов.

Для избежания путаницы в обозначениях, далее мы будем обозначать показатель с верхним индексом p, относящийся к портфелю, а с верхним индексом a – относящийся к активу. Например, и – доходность портфеля и актива соответственно.

Доходность

Все определения мы дали в предыдущей статье. Здесь и далее приведём только формулы для вычислений:

Доходность (Rate of Return):

Накопленная доходность (Accumulated Rate of Return):

Реальная доходность (Real Rate of Return):

Накопленная реальная доходность (Real Accumulated Rate of Return):

Каждое из значений в Python вычисляется так:

> ror_p = (np.array([asset.rate_of_return() for asset in assets]) * weights).sum(axis=0)
> aror_p = (ror_p + 1.).cumprod() - 1.
> ror_p_real = (ror_p + 1.) / (inflation + 1.) - 1.
> aror_p_real = (aror_p + 1.) / (inflation_accumulated + 1.) - 1.

Идея вычисления ror_p словами выражается так: массив доходностей каждого актива поэлементно умножается на соответствующий вес массива весов weights, а далее вычисляется сумма значений, равная доходности портфеля. Остальные значения вычисляются способом, знакомым из предыдущей статьи. Во избежание ошибки, weightsдолжен иметь размерность (1,len(assets)), то есть быть вектором-столбцом. Проще всего его создать инструкцией np.array([ [1,2,3] ]).T

Этот код правильно отработает в контексте, в котором заранее определены:

  • массив assets из объектов типа class PortfolioAsset, в котором определены методы rate_of_return, compound_annual_growth_rate, risk:
class PortfolioAsset:
  def rate_of_return(): pass

  def compound_annual_growth_rate(): pass

  def risk(): pass

Конкретные реализации тел методов можно подставить из предыдущей статьи.

  • массив вещественных чисел weights. Ограничения:
    • weights.sum() == 1.
    • weights.size == assets.size
    • np.all(0.0 <= weights and weights <= 1.0)

Риск

Под риском в рамках СТП понимается стандартное отклонение доходности. Поэтому вычисление месячного риска и риска, приведённого к году, для портфеля выполняется ровно также, как и для одного актива:

При решении задач, связанных с оптимизацией характеристик портфеля, такой подход к расчету риска не применяется, ввиду большой вычислительной трудоёмкости связан: после изменения весов приходится пересчитывать весь объем данных. Г. Марковиц предложил намного более элегантный и простой вариант вычисления риска. О методах оптимизации портфеля и других вариантах расчета риска речь пойдет в следующих публикациях.

Вычисление в Python:

> risk_p_monthly = ror_p.std()
> ror_p_mean = (1. + ror_p).mean()
> risk_p_yearly = np.sqrt((risk_p_monthly**2 + ror_p_mean**2)**12 - ror_p_mean**24)

Среднегодовая доходность

Среднегодовая доходность (Compound annual growth rate, CAGR) портфеля вычисляется как среднее геометрическое на выбранном периоде. Через данные месячных доходностей CAGR вычисляется по формуле:

где – рациональное число равное количеству лет.

Реальная среднегодовая доходность (Real CAGR) портфеля вычисляется по формуле:

Что после простых преобразований эквивалентно формуле:

Вычисляется в Python:

> cagr_p = (ror_p + 1.).prod() ** (1 / years_total) - 1.
> cagr_p_real = (ror_p_real + 1.).prod() ** (1 / years_total) - 1.
> cagr_p_real = (cagr_p + 1.) / (inflation_accumulated[-1] + 1.) - 1.

В следующей статье мы разберем оптимизацию простейшего портфеля в Python.

Понравилась статья?

Самое интересное и важное в нашей рассылке

Анонсы свежих статей Информация о вебинарах Советы экспертов

Нажимая на кнопку "Подписаться", я соглашаюсь с политикой конфиденциальности


Комментарии (8)

  1. Дмитрий 18 октября 2018, 19:52 # 0
    Огромное спасибо за статью!

    Я вычисляю ror_p для портфеля из 4 активов.

    ror_p = (np.array([asset.rate_of_return() for asset in assets]) * weights).sum()

    Получаю ошибку ValueError: operands could not be broadcast together with shapes (4,12) (4,)

    Веса при этом я задаю так:

    weights = [1 / len(assets)] * 4

    Подскажите, что не так?

    1. Александр 18 октября 2018, 20:48(Комментарий был изменён) # 0
      Такова оборотная сторона лаконичности и отсутствия типов в Python. А вы в статье нашли ошибку. Благодарим!

      Чего хочет сделать numpy: поэлементно перемножить два вектора — каждый asset.rate_of_return() размера (1,12) на weights размера (4,1). Не выйдет. В нашей кодовой базе rate_of_return возвращает не np.array, а внутренний TimeSeries, о котором, возможно, расскажу в одной из следующих статей.

      Для того, чтобы код заработал, нужно weights сделать размера (1,4). Увы, в силу той же причины в первом предложении, транспонировать weights.T не получится. Проще всего и без хаков сделать weights = np.array([[1 / len(assets)] * 4]).T.

      Также в статье используется Python 3.x, где «/» — вещественное деление.
    2. Дмитрий 19 октября 2018, 01:30 # 0
      Александр,

      спасибо за оперативный ответ.

      Несколько замечаний:

      1. Одноразмерный ndarray, увы, так не транспонируется:
      docs.scipy.org/doc/numpy-1.15.0/reference/generated/numpy.transpose.html
      «Transposing a 1-D array returns an unchanged view of the original array.»

      Можно использовать метод reshape или еще лучше явно задать массив в одну колонку c помощью []:
      > weights = np.array([[1 / len(assets)] * len(assets)])
      > weights.shape
      (1, 4)

      Но и это в итоге не работает :) Теперь получается так:
      > ror_p = (np.array([asset.ror() for asset in assets]) * weights).sum()
      ValueError: operands could not be broadcast together with shapes (4,12) (1,4)

      2. Т.о. транспонировать надо только первый массив, а weights можно оставить обычным одномерным массивом.
      ror_p = (np.array([asset.ror() for asset in assets]).T * weights).sum()

      3. ndarray.sum() для двухмерного массива суммирует его сразу по обеим размерностям. Поэтому формула для aror_p не заработает.
      Чтобы видеть ror_p как вектор, поможет аргумент axis=1:
      ror_p = (np.array([asset.ror() for asset in assets]).T * weights).sum(axis=1)

      4.
      «Идея вычисления ror_p словами выражается так: массив накопленных доходностей каждого актива поэлементно умножается на соответствующий вес массива весов weights, а далее вычисляется сумма значений, равная накопленной доходности портфеля.»
      Здесь наверно имеется ввиду массив доходностей (ненакопленных).

      5. risk_p_yearly = np.sqrt(risk_p_monthly**2 + ror_p_mean**2)**12 — ror_p_mean**24)
      В этой формуле пропущена скобка. И в аналогичной формуле для одного актива в предыдущей статье — тоже.

      С нетерпением жду третьей статьи! И про вашу реализацию TimeSeries будет крайне интересно почитать.
      1. Дмитрий 19 октября 2018, 02:30 # 0
        Александр,

        еще пара замечаний:

        6. про расчет реальной доходности (обычной и накопленной):
        ror_p_real = (ror_p + 1.) / (inflation + 1.).cumprod() - 1.
        aror_p_real = (aror_p + 1.) / (inflation_accumulated + 1.).cumprod() - 1.
        
        Зачем вам здесь .cumprod()? В ваших расчетах для одного актива его нет.

        7. расчет CAGR портфеля:
        Чтобы извлечь корень N-й степени, нужно ** (1/N)
        (в первой статье формула для CAGR одного актива верная)

        Простите :)
        1. Александр 19 октября 2018, 23:47 # 0
          Мне и коллегам не представить лучшего результата, чем внимательное чтение наших статей. Спасибо!

          1. Транспонировать одномерный массив можно разными способами. Я написал про наиболее простой с точки зрения количества символов. Работает так:

          In [6]: rors = np.random.random_sample((4,7))
          
          In [7]: rors
          Out[7]:
          array([[0.54137612, 0.2403977 , 0.82893013, 0.78803846, 0.09837761,
                  0.10435889, 0.79707075],
                 [0.31765169, 0.47855312, 0.37699653, 0.01696002, 0.04257652,
                  0.16438825, 0.25542569],
                 [0.27054694, 0.65526151, 0.1218574 , 0.02015463, 0.18820723,
                  0.29564549, 0.75822548],
                 [0.36847091, 0.72339878, 0.07806208, 0.26617602, 0.59872463,
                  0.58285909, 0.70220445]])
          
          In [17]: rors.shape
          Out[17]: (4, 7)
          
          In [14]: weights = np.array([[1 / len(rors)] * len(rors)])
          
          In [18]: weights
          Out[18]: array([[0.25, 0.25, 0.25, 0.25]])
          
          In [19]: weights.shape
          Out[19]: (1, 4)
          
          In [20]: weights.T.shape
          Out[20]: (4, 1)
          
          In [21]: rors * weights.T
          Out[21]:
          array([[0.13534403, 0.06009943, 0.20723253, 0.19700962, 0.0245944 ,
                  0.02608972, 0.19926769],
                 [0.07941292, 0.11963828, 0.09424913, 0.00424   , 0.01064413,
                  0.04109706, 0.06385642],
                 [0.06763674, 0.16381538, 0.03046435, 0.00503866, 0.04705181,
                  0.07391137, 0.18955637],
                 [0.09211773, 0.1808497 , 0.01951552, 0.066544  , 0.14968116,
                  0.14571477, 0.17555111]])
          
          2. Можно и так – алгебра работает :)

          3. Вы почти верно говорите. ror_p должен быть размерности (12,1) в вашем случае – длине ror_a, а не количеству активов. Поэтому должен быть sum(axis=0) – исправил.

          4. Принято и исправлено. Благодарю.

          5. Аналогично.

          6. Да, не нужен. Исправил.

          7. Исправил. Спасибо.

        2. Дмитрий 20 октября 2018, 02:04 # 0
          6. aror_p_real = (aror_p + 1.) / (inflation_accumulated + 1.).cumprod() — 1.

          В формуле aror_p_real .cumprod() имхо тоже не нужен, тк учтен и в aror_p и в inflation_accumulated.
          1. Александр 20 октября 2018, 11:52 # 0
            Исправил. Спасибо.
          наверх