資産目標を考慮した預金，NISA，iDeCoの投資戦略

# 0. はじめに

## 0-1. この記事を読めば

- わかる
    - （やってみた系記事なのであまりない．すみません）
    - あるポートフォリオで運用した時，資産目標を達成できる確率を求めるアルゴリズム
- わからない（書ききれなかったこと）
    - 資産目標を達成できる確率を最大化するポートフォリオの探索

## 0-2. 誰のための記事か

自分自身のポートフォリオ検証用．

## 0-3. アルゴリズムの概要

**太字の属性とポートフォリオ**は時系列で与えるとする．

- 入力
    - 投資家の属性
        - 年齢
        - 定年
        - **年収**（税率算出のため）
        - **年間投資可能額**
        - **資産目標**
        - NISA投資上限
        - iDeCo投資上限
    -  運用商品
        -  預金
        -  NISA
        -  iDeCo
    - **ポートフォリオ**（iDeCoの掛金は一定とする）
- 出力
    - 資産目標をすべて達成する確率
- 計算手法
    - 利回りに正規分布を仮定する
    - モンテカルロ法を用いて資産目標を達成できたシナリオを数える

# 1. 試算結果の要約

ここでは仮に23歳の新社会人が以下の年収と投資可能額を見込んでいるとする．（以下，単位：千円）

|年齢|年収（額面）|投資可能額|
|---|---|---|
|23|4,000|240|
|24|4,000|240|
|25|4,000|240|
|26|4,100|340|
|27|4,200|440|
|28|4,300|540|
|29|4,400|640|
|30|4,500|740|
|31|4,600|840|
|32|4,700|940|
|33|4,800|1,040|
|34|4,900|1,140|
|35|5,000|1,240|
|36|5,000|1,240|
|37|5,000|1,240|
|38|5,000|1,240|
|39|5,000|1,240|
|40|0|0|

40歳以降はライフスタイルに大きく依存するため，40歳を計算上の定年（=シミュレーション終了）として以下の資産目標を立てる．なお✓がついた資産で目標を達成するとする．

|年齢|必要額|預金|NISA|iDeCo|
|---|---|:---:|:---:|:---:|
|25|500|✓|||
|27|1,000|✓|||
|30|3,000|✓|✓||
|32|5,000|✓|✓||
|35|8,000|✓|✓||
|40|15,000|✓|✓|✓|

最後にNISAの投資上限は30万円/月，iDeCoの掛金上限は5.5万円/月とする．

次に運用商品を考える．今回は

1. 安定性を重視した預金
1. 流動性を重視したNISA
1. 節税効果を含む利回りを重視したiDeCo

を考える．預金では1年定期預金，NISA，iDeCoでは世界株を運用対象とする．リターンとボラティリティは以下の実績値を用いる．預金利率については執筆現在のメガバンクの水準を採用し，世界株については[MSCIの公式レポート](https://www.msci.com/indexes/index/892400)から取得した（2024年11月29日 基準）．

|商品|リターン|ボラティリティ|
|---|---|---|
|預金|0.125%|0%|
|NISA（世界株）|2.57%|14.83%|
|iDeCo（世界株）|2.57%|14.83%|

商品の価格推移はリターンとボラティリティのみで説明できると仮定する．

この時，以下のポートフォリオを考える：

**「iDeCoは節税効果がお得だから5,000円だけ続けよう！最初は預金に，だんだんとNISAに重点を置いて運用するぞ！」**

具体的には

|年齢|投資可能額|預金|NISA（世界株）|iDeCo（世界株）|
|---|---|:---:|:---:|:---:|
|23|240|(240-5) * 1.0|(240-5) * 0.0|5|
|24|240|(240-5) * 0.9|(240-5) * 0.1|5|
|25|240|(240-5) * 0.8|(240-5) * 0.2|5|
|26|340|(340-5) * 0.7|(340-5) * 0.3|5|
|27|440|(440-5) * 0.6|(440-5) * 0.4|5|
|28|540|(540-5) * 0.5|(540-5) * 0.5|5|
|29|640|(640-5) * 0.4|(640-5) * 0.6|5|
|30|740|(740-5) * 0.3|(740-5) * 0.7|5|
|31|840|(840-5) * 0.3|(840-5) * 0.7|5|
|32|940|(940-5) * 0.3|(940-5) * 0.7|5|
|33|1,040|(1,040-5) * 0.3|(1,040-5) * 0.7|5|
|34|1,140|(1,140-5) * 0.3|(1,140-5) * 0.7|5|
|35|1,240|(1,240-5) * 0.3|(1,240-5) * 0.7|5|
|36|1,240|(1,240-5) * 0.3|(1,240-5) * 0.7|5|
|37|1,240|(1,240-5) * 0.3|(1,240-5) * 0.7|5|
|38|1,240|(1,240-5) * 0.3|(1,240-5) * 0.7|5|
|39|1,240|(1,240-5) * 0.3|(1,240-5) * 0.7|5|
|40|0|0|0|0|

このとき前述の資産目標を達成できる可能性は58.00%となった．

達成できなかったケースは例として以下のようなものがあった．

```
Event not achieveed: Year 32, Required: 5000, Available: 4935.263347453611
Event not achieveed: Year 40, Required: 15000, Available: 13470.46051477064
Event not achieveed: Year 35, Required: 8000, Available: 7431.720715398233
```

# 2. 試算方法

## 2-1. インポート
まずライブラリをインポートする．乱数を発生させるためにnumpyを用いている．また型ヒントのためtypingを，クラス定義のためdataclassをインポートしている．

```python
import numpy as np

from typing import Tuple, Dict, List
from dataclasses import dataclass
```

## 2-2. クラス定義

```FinancialEvent```クラスは特定の年に一定の金額が必要となる金融イベントを表す．たとえば家の購入や子どもの大学進学などが該当する．必要金額と，どの資産（預金，NISA，iDeCo）で資金が必要となるかが定義されている．

```python
@dataclass
class FinancialEvent:
    """
    Represents a financial event that requires a certain amount of funds 
    at a specific year.
    
    Attributes:
        year (int): The year the event occurs.
        required_amount (int): The amount of money required for the event.
        include_deposit (bool): Whether deposit funds are included.
        include_nisa (bool): Whether NISA funds are included.
        include_ideco (bool): Whether iDeCo funds are included.
    """
    year: int
    required_amount: int
    include_deposit: bool
    include_nisa: bool
    include_ideco: bool
```

```InvestorProfile```クラスは投資家のプロフィールを表す．投資家の年齢，定年，年収，運用資金，そして準備する必要がある金融イベントからなる．また，NISAとiDeCoの年間最大投資額も属性として定義されている．

```investment_period```プロパティは現在の年齢と定年から投資期間を計算する．

```tax_rates```プロパティは投資家の年間収入に基づいて税率（所得税+住民税）を線形補完し，各年の税率をリストで返す．

```ideco_discount_factors```プロパティは，税率に基づいてiDeCoの控除率を計算し，iDeCoの実質投資額 / 投資額投資の比率をリストで返す．税率は100万円単位で与え線形補完しているため，ここには精緻化の余地がある．

:::note
税率20%の人がiDeCoに5,000円投資すると，所得から控除され1,000円の節税となる．実質投資額は4,000円となり，0.8倍の割引率```ideco_discount_factors```がかかっていることになる．

すなわち5,000円のポジションを持ちつつも，アロケーション上は4,000円の負担で済んでいると解釈する．
:::

```python
@dataclass
class InvestorProfile:
    """
    Represents an investor's financial profile.
    
    Attributes:
        age (int): Current age of the investor.
        retirement_age (int): Age at which the investor plans to retire.
        annual_income (List[int]): List of annual incomes over the investment period.
        annual_funds (List[int]): List of annual funds available for investment.
        financial_events (List[FinancialEvent]): List of financial events the investor
                                                 needs to prepare for.
        max_nisa_amount (int): Maximum allowable annual investment in NISA.
        max_ideco_amount (int): Maximum allowable annual investment in iDeCo.
    """
    age: int
    retirement_age: int
    annual_income: List[int]
    annual_funds: List[int]
    financial_events: List[FinancialEvent]
    max_nisa_amount: int
    max_ideco_amount: int

    @property
    def investment_period(self) -> int:
        """
        Calculates the investment period based on the retirement age and current age.

        Returns:
            int: Investment period in years.
        """
        return self.retirement_age - self.age

    @property
    def tax_rates(self) -> List[float]:
        """
        Interpolates tax rates based on the investor's annual income.
        
        Returns:
            List[float]: List of interpolated tax rates for each year.
        """
        tax_brackets = (
            (2000, 0.15),
            (3000, 0.17),
            (4000, 0.20),
            (5000, 0.22),
            (6000, 0.23),
            (7000, 0.24),
            (8000, 0.25),
            (9000, 0.26),
            (10000, 0.28)
        )
        income_thresholds = [x for x, _ in tax_brackets]
        rates = [y for _, y in tax_brackets]
        interpolate = scipy.interpolate.interp1d(
            income_thresholds, rates, fill_value="extrapolate")
        return [float(interpolate(self.annual_income[_])) 
                for _ in range(self.investment_period)]
    
    @property
    def ideco_discount_factors(self) -> List[float]:
        """
        Calculates the iDeCo investment ratios based on the tax rates.
        
        Returns:
            List[float]: List of iDeCo investment ratios for each year.
        """
        return [1 - _ for _ in self.tax_rates]
```

```FinancialAsset```クラスは金融資産を表す．金融資産には，資産の名前```name```，年間リターン```annual_return```，およびボラティリティ```volatility```を属性として含む．ボラティリティはデフォルト値として0.0を設定する．

さらにその子クラスとして```Depsoit```，```NISA```，```iDeCo```の3クラスを定義する．```Deposit```クラスのボラティリティは常に0.0のため，__init__メソッドで親クラス（FinancialAsset）のコンストラクタを呼び出しこれらの属性を初期化している．3つとも追加の属性やメソッドはない．

```python
@dataclass
class FinancialAsset:
    """
    Represents a financial asset.
    
    Attributes:
        name (str): Name of the asset.
        annual_return (float): Annual return rate of the asset.
        volatility (float): Volatility of the asset.
    """
    name: str
    annual_return: float
    volatility: float = 0.0

@dataclass
class Deposit(FinancialAsset):
    """
    Represents a deposit asset with zero volatility.
    
    Attributes:
        name (str): Name of the deposit.
        annual_return (float): Annual return rate of the deposit.
    """
    def __init__(self, name: str, annual_return: float):
        super().__init__(name, annual_return, 0.0)

@dataclass
class NISA(FinancialAsset):
    """Represents a NISA financial asset."""
    pass

@dataclass
class iDeCo(FinancialAsset):
    """Represents an iDeCo financial asset."""
    pass
```

## 2-3. コア部分

```calculate_portfolio_values```関数は，投資期間中のポートフォリオの価格を，次の入力をもとに計算する．

- investor: 投資家のプロフィール
- assets: 金融資産のリスト（今回は預金，NISA，iDeCo）
- nisa_allocations: 年間のNISA配分のリスト
- ideco_annual_amount: iDeCoに割り当てられる年間の金額

変数の初期化をしたあと，```for```分の中で時間$t$に沿ってそれぞれ以下のように価格を更新していく．

- 預金

```math
\begin{align*}
    \text{価格}(t) & = \text{価格}(t-1) * (1+ \text{預金利率}) + \text{追加投資額}(t) 
\end{align*}
```

- NISA, iDeCO

```math
\begin{align*}
    \text{価格変化率}(t) & = \mathcal{N}\left(\text{年間リターン}，\text{ボラティリティ}\right) \\
    \text{価格}(t) & = \text{価格}(t-1) * \text{価格変化率}(t) + \text{追加投資額}(t) 
\end{align*}
```

```for```分の最後では追加投資は行わず評価のみを行うものとする．

戻り値として各資産の価格の時系列```portfolio_values```を取る．

```python
def calculate_portfolio_values(
    investor: InvestorProfile,
    assets: List[FinancialAsset],
    nisa_allocations: List[float], 
    ideco_annual_amount: int
) -> List[Tuple[int, Dict[str, float]]]:
    """
    Calculates the portfolio values over the investment period based on the 
    given allocations and annual amounts.
    
    Args:
        investor (InvestorProfile): The investor's profile.
        assets (List[FinancialAsset]): List of financial asset.
        nisa_allocations (List[float]): List of annual NISA allocations.
        ideco_annual_amount (int): Annual amount allocated to iDeCo.
    
    Returns:
        List[Tuple[int, Dict[str, float]]]: Portfolio values for each year.
    """
    random_seed = np.random.randint(0, 1000000)
    np.random.seed(random_seed)
    # np.random.seed(0)
    # print(f"Random Seed: {random_seed}")  # 生成されるシード値を表示

    assert len(nisa_allocations) == investor.investment_period
    assert 0 <= min(nisa_allocations) and max(nisa_allocations) <= 1
    assert (max([f * w for (f, w) in zip(investor.annual_funds, nisa_allocations)]) 
            <= investor.max_nisa_amount)
    assert ideco_annual_amount <= investor.max_ideco_amount

    deposit_allocations = [1 - w for w in nisa_allocations]
    deposit_value = nisa_value = ideco_value = 0
    portfolio_values = []

    for year in range(investor.investment_period + 1):
        nisa_change = np.random.normal(assets['NISA'].annual_return,
                                       assets['NISA'].volatility)
        ideco_change = np.random.normal(assets['iDeCo'].annual_return,
                                        assets['iDeCo'].volatility)
        ideco_modified_amounts = ideco_annual_amount * investor.ideco_investment_ratios

        if year < investor.investment_period:
            deposit_value = (
                deposit_value * (1 + assets['Deposit'].annual_return) + 
                (investor.annual_funds[year] - ideco_modified_amounts[year]) * 
                deposit_allocations[year]
            )
            nisa_value = (
                nisa_value * (1 + nisa_change) + 
                (investor.annual_funds[year] - ideco_modified_amounts[year]) * 
                nisa_allocations[year]
            )
            ideco_value = ideco_value * (1 + ideco_change) + ideco_annual_amount
        else:
            assert year == investor.investment_period
            deposit_value = deposit_value * (1 + assets['Deposit'].annual_return)
            nisa_value = nisa_value * (1 + nisa_change)
            ideco_value = ideco_value * (1 + ideco_change)
        
        portfolio_values.append((year + investor.age, 
                                 {'Deposit': deposit_value, 
                                  'NISA': nisa_value, 
                                  'iDeCo': ideco_value}))

    return portfolio_values
```

最後にすべての金融イベントを達成する確率を返す関数```probability_of_achieving_events```を定義する．

ネストされた```check_event_achievement```関数で評価対象である資産の価格の和を```total_available_funds```として計算し，それが資産目標```event.required_amount```を超えていたら達成と判断している．

この確認作業を```simulation_count```だけ繰り返す．

```python
def probability_of_achieving_events(
    investor: InvestorProfile, 
    assets: List[FinancialAsset],
    nisa_allocations: List[float], 
    ideco_annual_amount: int, 
    simulation_count: int
) -> float:
    """
    Calculates the probability of achieving the financial events over the 
    investment period.
    
    Args:
        investor (InvestorProfile): The investor's profile.
        assets (List[FinancialAsset]): List of financial asset.
        nisa_allocations (List[float]): List of annual NISA allocations.
        ideco_annual_amount (int): Annual amount allocated to iDeCo.
        simulation_count (int): Number of simulations to run.
    
    Returns:
        float:
    """
    def check_event_achievement(
        investor: InvestorProfile, 
        portfolio_values: List[Tuple[int, Dict[str, float]]]
    ) -> bool:
        for event in investor.financial_events:
            portfolio_value = portfolio_values[int(event.year - investor.age)]
            total_available_funds = 0
            
            if event.include_deposit:
                total_available_funds += portfolio_value[1]['Deposit']
            if event.include_nisa:
                total_available_funds += portfolio_value[1]['NISA']
            if event.include_ideco:
                total_available_funds += portfolio_value[1]['iDeCo']
            
            if total_available_funds < event.required_amount:
                print(
                    f"Event not achieved: Year {event.year}, "
                    f"Required: {event.required_amount}, "
                    f"Available: {total_available_funds}"
                )
                return False
        return True

    achieved_count = 0
    for _ in range(simulation_count):
        portfolio_values = calculate_portfolio_values(
            investor, assets, nisa_allocations, ideco_annual_amount)
        if check_event_achievement(investor, portfolio_values):
            achieved_count += 1

    return achieved_count / simulation_count
```

# 3. 試算結果

[1. 試算結果の要約](#1-試算結果の要約) でもあげた条件でシミュレーションしてみる．

```python
age = 23
retirement_age = 40

annual_income = [
    4000, 4000, 4000,
    4100, 4200, 4300,
    4400, 4500, 4600,
    4700, 4800, 4900,
    5000, 5000, 5000,
    5000, 5000, 5000
]

annual_funds = [
    240, 240, 240,
    340, 440, 540,
    640, 740, 840,
    940, 1040, 1140,
    1240, 1240, 1240,
    1240, 1240, 1240
]

events = [
    FinancialEvent(year=25, required_amount=500,
        include_deposit=True, include_nisa=False, include_ideco=False),
    FinancialEvent(year=27, required_amount=1000,
        include_deposit=True, include_nisa=False, include_ideco=False),
    FinancialEvent(year=30, required_amount=3000,
        include_deposit=True, include_nisa=True, include_ideco=False),
    FinancialEvent(year=32, required_amount=5000,
        include_deposit=True, include_nisa=True, include_ideco=False),
    FinancialEvent(year=35, required_amount=8000,
        include_deposit=True, include_nisa=True, include_ideco=False),
    FinancialEvent(year=40, required_amount=15000,
        include_deposit=True, include_nisa=True, include_ideco=True)
]

investor = InvestorProfile(
    age=age, 
    retirement_age=retirement_age, 
    annual_income=annual_income, 
    annual_funds=annual_funds, 
    financial_events=events, 
    max_nisa_amount=300 * 12, 
    max_ideco_amount=55 * 12
)

deposit = Deposit('Deposit', 0.125 / 100)
nisa = NISA('NISA', 2.57 / 100, 14.83 / 100)
ideco = iDeCo('iDeCo', 2.57 / 100, 14.83 / 100)
assets = {'Deposit': deposit, 'NISA': nisa, 'iDeCo': ideco}


nisa_allocations = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6] + [0.7]*10
ideco_annual_amount=5*12
```

手元でシミュレーション回数を10,000回にして10回ほど試すと以下のようになった．

|#|達成確率（%）|
|---|---|
|1|58.00|
|2|46.86|
|3|57.63|
|4|46.79|
|5|47.14|
|6|47.00|
|7|47.11|
|8|47.09|
|9|47.06|
|10|45.20|

```python
print(
    f"{probability_of_achieving_events(investor,
                                       assets,
                                       nisa_allocations,
                                       ideco_annual_amount,
                                       simulation_count=10000
                                       ) * 100}%の確率でイベントをクリアできます．"
    )
```

結果としてはうまく値が収束していないように見受けられた．

# 4. 今後の課題

1. 収束精度の改善
1. ボラティリティの検討
1. NISAとiDeCoで別の商品を選ぶ
1. 個人向け国債のような商品を組み込む
1. イベントを達成するポートフォリオの最適化

# -1. おわりに

私事ではあるが新社会人からお世話になっていた会社を退職することになった．職場を移るにあたってiDeCoの案内を受け取り，この機会に加入するかと考えたのが本記事の始まりである．

iDeCoの欠点としてしばしば

- 年金のため定年まで資金がロックされる
- 税制の改正に振り回される

の2点があげられると思う．今回は後者の制度については横置きとし，前者について最適なNISA vs iDeCo比率を決めたいというのがモチベーションとしてあった．残念ながらポートフォリオの最適化までは至らなかった（scipyでうまく書けなかった）が資産目標（金融イベント）を立てて達成確率を計算するという発想にチャレンジできた．今後も折を見て改修していきたいと思う．