0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python3ではじめるシステムトレード:突然現れた巨大ヘッジファンドジェーンストリート

0
Last updated at Posted at 2026-07-29

ジェーン・ストリート

Jane Street Groupは、顧客から資金を集めて運用報酬を得る通常のヘッジファンドではない。中心的な実態は、自社資本を用いて世界各地の市場で取引する自己勘定取引会社であり、同時に、規制下の証券会社を通じて顧客や取引所に流動性を供給するマーケットメーカーでもある。

同社の競争力は、単純な高速取引だけではなく、
$
\text{技術基盤}
+\text{市場横断的な価格評価}
+\text{巨額の自己資本}
+\text{リスク引受能力}
+\text{執行能力}
$
の組合せにある。
 2025年の純トレーディング収益は報道ベースで約396億ドル、従業員数は約3,500人とされる。これは、従業員数数十万人の大手銀行に匹敵、またはそれを上回るトレーディング収益を、極めて少人数で生み出していることを意味する。
Jane Streetは、単なる高速取引会社でも、通常のヘッジファンドでもない。その本質は、世界の複数市場を一つの巨大な価格体系として捉え、自己資本、計算能力、執行技術、リスク管理を用いて価格差を処理する市場横断型ディーラーである。同社の活動は通常、

  • 流動性を供給する
  • 価格差を縮小する
  • 情報を市場間で伝達する
  • 顧客の即時執行を可能にする
    という社会的機能を持つ。しかし、巨大な派生商品市場と相対的に薄い現物市場が組み合わされると、その能力は、
  • 価格差を発見する能力
  • 価格を形成する能力
  • 参照価格に作用する能力

へと連続的に変化する。
 インド事件の本質は、裁定取引と市場操作の境界が、取引形式ではなく、参照価格への作用を利益獲得の手段として利用したかどうかによって決まるという点にある。したがって、本件は一企業の不正疑惑にとどまらない。指数、オプション満期、現物市場の流動性、マーケットメーカーの資本力という現代市場の構造的問題を示す事例である。

1.創業者、設立経緯、組織拡大の歴史

Jane Streetは2000年、少人数のトレーダーと技術者によってニューヨークで設立された。公式資料は創業者の氏名を明示していないが、複数の報道・業界資料によれば、中心人物はTim Reynolds、Robert “Rob” Granieri、Marc Gerstein、Michael Jenkinsらである。GranieriらはSusquehanna International Groupでの取引経験を持ち、GersteinはIBM出身の技術者だったとされる。

当初の主要市場は、米国預託証券、すなわちADRとオプションだった。その後、ETF市場の拡大とともに、ETFと構成銘柄、先物、通貨、債券との価格差を処理する能力を高めた。

ETFは単一商品の高速売買ではなく、多数の構成資産を同時に評価する必要がある。このため、ETF市場はJane Streetの

  • 市場横断的な価格評価
  • バスケット取引
  • 在庫管理
  • 執行アルゴリズム
  • 大規模なリスク管理

という能力に適していた。
同社は明確なCEOを置く一般的な階層組織とは異なり、経営委員会やリスク委員会を中心とする共同経営的な構造を採用してきたと報じられている。2024年時点では約40人の持分保有者が約240億ドル相当の同社持分を保有していたとされる。
現在はニューヨーク、ロンドン、香港、アムステルダム、シンガポールを主要拠点とし、45か国、200以上の取引施設で活動する。公式には従業員数は3,000人超、最近の報道では約3,500人とされる。

2.ヘッジファンド、自己勘定取引会社、マーケットメーカーのどれか

Jane Streetの最も正確な分類は、

グローバルな自己勘定取引会社を中核とし、規制対象の証券会社を通じてマーケットメイクと顧客取引を行う金融グループ
である。

分析水準 分類
グループ全体 自己勘定取引会社
取引所における機能 マーケットメーカー、流動性供給者
顧客取引 ディーラー、ブローカー・ディーラー
運用会社との比較 ヘッジファンドとは異なる

3.収益、資本規模、従業員数、取扱市場

報道されている純トレーディング収益は、次のように急拡大している。

純トレーディング収益
2023年 約106億ドル
2024年 約205億ドル
2025年 約396億ドル
2026年第1四半期 約161億ドル

S&Pは同社の資本力と利益留保を高く評価する一方、短期市場性資金への依存、急速なバランスシート拡大、規制リスクを信用上の制約としている。2025年7月にはインド事件を受けて見通しが修正されたが、格付けはBBに据え置かれた。
取扱対象は、ETF、個別株式、国債・社債、先物、オプション、外国為替、コモディティ、暗号資産などに及ぶ。2025年には顧客との債券取引が9,000億ドルを超え、約25,000銘柄の債券と約3,800のオプション原資産について価格提示を行ったとされる。

4.クオンツ手法、価格形成、流動性供給、裁定取引

4.1 単純な超高速取引会社ではない

Jane Streetは低遅延技術を重視するが、XTX Marketsのような完全自動化型の価格予測会社とは若干異なる。
同社の公式説明は、人間の判断と自動化された取引システムを組み合わせる点を強調している。市場構造の変化、政策発表、危機、市場参加者自身の行動によってデータ生成過程が変化するため、過去データだけから固定的なモデルを構築することには限界があるという考え方である。
推定価格は、概念的には次のように表せる。
$
\widehat P_{i,t}
=f\left(
P_{\text{関連市場}},
P_{\text{先物}},
P_{\text{オプション}},
\text{金利},
\text{配当},
\text{為替},
\text{注文板},
\text{在庫},
\text{市場状態}
\right)
$
提示するビッド・アスクは、単なる予測値の上下に固定幅を加えたものではない。
$
\text{Spread}
=\text{逆選択費用}
+\text{在庫リスク}
+\text{ヘッジ費用}
$
$+\text{価格変動リスク}
+\text{資本コスト}
+\text{取引所・清算費用}
$
大量の取引を通じて小さな価格差を繰り返し獲得しながら、在庫を市場間で相殺する。
4.2 技術基盤
Jane Streetは大部分のシステムをOCamlで内製し、一部ではC、C++、FPGAを利用する。ネットワーク・カーネルを迂回する通信、専用ハードウェア、取引所との低遅延接続を構築している。機械学習用には数万台規模の高性能GPU、エクサバイト級のデータ基盤を利用するとしている。
ただし、低遅延は競争優位の一部分にすぎない。より重要なのは、ある市場で発生した情報を別市場の価格へ即座に反映し、複数資産を一体として管理できることである。

5.ETF、株式、債券、先物、オプションの位置づけ

5.1 ETF

ETFはJane Streetの中核事業である。
ETF価格を$P_{\mathrm{ETF}}$、構成資産バスケットの価値を$NAV$とすると、
$
B_t=P_{\mathrm{ETF},t}-NAV_t
$
が十分に正ならETFを売り、構成資産を買う。十分に負ならETFを買い、構成資産を売る。指定参加者として設定・解約を利用できれば、乖離の収束を直接実現できる。
もっとも、実際には海外資産、休場中の市場、為替、先物、税、配当、資金調達、設定・解約費用を考慮した「推定NAV」を用いる。
5.2 株式

株式は個別銘柄のマーケットメイクに加え、ETFヘッジ、指数裁定、オプション・ヘッジの手段となる。Jane Streetは2023年に北米株式取引の約10%を占めたと報じられている。
5.3 債券

債券市場では、個別債券の流動性が低く、価格が分断されやすい。国債、金利スワップ、CDS、債券ETF、同一発行体の他年限を用いて相対価値を推定し、顧客に即時価格を提示する。
ここでは単純な取引速度より、巨大な在庫を一時的に保有できる資本力と、ヘッジ市場へのアクセスが重要になる。
5.4 先物

先物は、指数・金利・商品・外国為替の方向性リスクを効率的に調整する主要なヘッジ手段である。
指数先物の理論価格は概ね、
$
F_t=S_t e^{(r-q)(T-t)}
$
で表される。$r$は資金調達金利、$q$は配当利回りである。
5.5 オプション

オプションは、ボラティリティ、歪度、相関、ジャンプ・リスクを取引する市場である。Jane Streetにとっては、単なる方向性取引ではなく、現物、先物、ETFとの関係を含む多次元的な相対価値取引となる。
オプション・ポートフォリオの微小な損益は、
$
dV
\approx
\Delta dS
+\frac12\Gamma(dS)^2
+\mathcal V d\sigma
+\Theta dt
$
と分解できる。
特に満期直前には、権利行使価格付近でガンマが大きくなり、指数のわずかな変化が損益を大きく変える。

6.主要競合との比較

会社 主な特徴 Jane Streetとの差
XTX Markets 機械学習中心の完全電子的マーケットメイク。約300人で日次約2,500億ドルを取引 Jane Streetよりモデル主導・自動化の色彩が強い。特にFXと現物株式で強い
Citadel Securities 米国株式、オプション、個人投資家注文、機関投資家取引で巨大なシェア 顧客注文執行と米国市場インフラへの組込みがJane Street以上に強い
Optiver オプションとボラティリティ取引を起源とする老舗マーケットメーカー デリバティブ・マーケットメイクの伝統が強い
IMC オプション、株式、ETFを中心とする電子マーケットメーカー Jane Streetと近いが、Jane Streetは債券・ETF・顧客取引を含む市場横断性がより顕著
Jane Street ETFを中核とするクロスアセット取引、人間判断と自動化、巨額自己資本 市場、商品、取引期間を横断する総合性が特徴
  • XTXは約300人で日次約2,500億ドルの取引を処理し、機械学習による価格予測を前面に出しています。
  • Citadel Securitiesは米国株式取引の約25%、日次約6,520億ドルを処理するとされ、米国株式・オプション市場で特に大きな存在です。
  • OptiverとIMCは、オプション・マーケットメイクを起源とする点でJane Streetと近い。
  • IMCは近年、インド、中国、外国為替、商品デリバティブへ事業を拡大している。

会計上の収益を比較する場合は注意が必要である。各社とも非上場企業であり、地域法人、純額・総額、顧客取引の範囲が統一されていません。

7.Jane Streetらしい取引戦略のトイモデル

1. 同一指数ETF間の相対価値

  • SPY
  • IVV
  • VOO

のように、同じS&P 500指数を追跡するETFを比較します。SPYとIVVはそれぞれ公式にS&P 500連動商品として説明されています。
例えばSPYとIVVについて、

$$
\log P_t^{SPY}
=\alpha_t+\beta_t\log P_t^{IVV}+u_t
$$

をローリング推定し、

$$
z_t
=\frac{u_t-\overline u_t}{s_t}
$$

を計算します。

  • $z_t>2$:SPY売り・IVV買い
  • $z_t<-2$:SPY買い・IVV売り
  • $|z_t|<0.25$:手仕舞い

とします。

これは非常に実装しやすい一方、実際の価格差は小さいため、日次終値では取引費用を上回る利益は期待しにくいでしょう。戦略の検証方法を学ぶためのトイモデルとして適しています。

売買判断

例えばSPYとIVVについて、

$$
X_t
=\log P_{\mathrm{SPY},t}-\log P_{\mathrm{IVV},t}=
\log\left(
\frac{P_{\mathrm{SPY},t}}
{P_{\mathrm{IVV},t}}
\right)
$$
を計算し、
$
Z_t
=\frac{
X_t-\overline X_t
}{
s_{X,t}
}
$
とします。$Z_t\geq2$ならば、SPYがIVVに対して相対的に高いため、
$
w_{\mathrm{SPY}}=-0.5,
\qquad
w_{\mathrm{IVV}}=0.5
$
とします。$Z_t\leq-2$ならば、反対に、
$
w_{\mathrm{SPY}}=0.5,
\qquad
w_{\mathrm{IVV}}=-0.5
$
とします。この処理を3組に対して行い、その日に最も$|Z_t|$が大きい組だけを取引します。
 なお、これはETFの設定・解約を利用した厳密な無リスク裁定ではなく、日次終値による相対価値・統計的裁定のバックテストです。実際のETF間裁定には、同時刻のビッド・アスク価格が必要です。

========== バックテスト結果 ==========
観測期間 : 2010-09-09 ~ 2026-07-28
観測日数 : 3,994
取引開始回数 : 300
累積収益率 : 1.50%
年率収益率 : 0.09%
年率ボラティリティ : 0.30%
シャープレシオ : 0.313
最大ドローダウン : -1.72%

image.png
image.png
image.png

2. マグニフィセント7とMAGS間の裁定

MAGSは7社に等ウェイトのエクスポージャーを持ち、四半期ごとに等ウェイトへ戻します。ただし、現物株だけでなくスワップ等も利用するアクティブETFなので、以下は完全複製裁定ではなく、MAGSと推定現物バスケットの統計的裁定です。

中心となる回帰は、

$$
\log P_{\mathrm{MAGS},t}
=\alpha_t
+
\beta_t\log B_t
+
\varepsilon_t
$$

です。$B_t$は、7銘柄を四半期ごとに等ウェイトへ戻して作った合成バスケットです。

$Z_t\geq2$ではMAGSを売って現物バスケットを買い、$Z_t\leq-2$ではMAGSを買って現物バスケットを売ります。

実際のMAGSはスワップやフォワードも利用し、日々ポートフォリオを調整しています。そのため、この残差には市場価格のプレミアム・ディスカウントだけでなく、経費、デリバティブ費用、複製誤差、リバランス日のずれも含まれます。(Roundhill Investments)

1.現物裁定

ETFにおける現物裁定は、ETF価格と、そのETFを構成する現物株バスケットの価値との差を利用します。

ETFの理論価値を、

$$
NAV_t=\sum_{i=1}^{n}q_iP_{i,t}
$$

とします。ここで、$q_i$はETF1口に対応する銘柄$i$の数量です。

ETF価格を$P_{\mathrm{ETF},t}$とすると、乖離は、

$$
D_t=P_{\mathrm{ETF},t}-NAV_t
$$

です。

  • ETFが割高な場合

$$
P_{\mathrm{ETF},t}>NAV_t+\text{取引費用}
$$

ならば、

  1. 構成銘柄を買う
  2. 構成銘柄をETF運用会社へ渡してETFを設定する
  3. 受け取ったETFを市場で売る

という取引を行います。

概念的な利益は、

$$
\Pi
=P_{\mathrm{ETF}} NAV \text{取引費用}
$$

です。

  • ETFが割安な場合

反対に、

  1. ETFを買う
  2. ETFを解約して構成銘柄を受け取る
  3. 構成銘柄を市場で売る

という取引を行います。

現物裁定の重要な点は、ETFの設定・解約によって価格差の収束を直接実現できることです。したがって、理論上は将来の平均回帰を待つ必要がありません。

2.統計的裁定

統計的裁定では、ETFと現物バスケットの間に通常成立している統計的関係を推定します。

例えば、

$$
\log P_{\mathrm{ETF},t}
=\alpha
+
\beta\log B_t
+
\varepsilon_t
$$

とします。

$B_t$は推定した現物バスケット価格、$\varepsilon_t$はETF価格とバスケットの通常の関係からの乖離です。

残差を標準化して、

$Z_t=\frac{\varepsilon_t - - \overline{\varepsilon}_t}{s}$

を計算します。

例えば、

  • $Z_t\geq2$:ETF売り、バスケット買い
  • $Z_t\leq-2$:ETF買い、バスケット売り
  • $Z_t$がゼロ付近:決済

とします。

しかし、統計的裁定では、

$$
\varepsilon_t\longrightarrow0
$$

となることは保証されていません。過去に平均回帰していたという統計的性質を利用しているだけです。

3.両者の本質的な違い

項目 現物裁定 統計的裁定
基準価格 実際のNAV・構成銘柄価値 推定された均衡関係
利益の根拠 同一価値の商品間の価格差 過去の平均回帰性
収束方法 設定・解約で直接収束させる 市場価格の自然な収束を待つ
利益の確実性 費用控除後に固定できれば高い 確率的であり保証されない
主な参加者 指定参加者、マーケットメーカー ヘッジファンド、クオンツ運用者
必要データ リアルタイム価格、正確な構成数量 過去価格、回帰・時系列モデル
主なリスク 執行、資金調達、決済、空売り モデル破綻、構造変化、発散
取引期間 秒から分程度が中心 日、週、場合によっては月

4.現物裁定も完全な無リスクではありません

現実には、現物裁定にも次のリスクがあります。

$$
\text{実現利益}
=\text{価格差}- \text{ビッド・アスク}-\text{手数料}-\text{市場インパクト}-\text{資金調達費用}-\text{空売り費用}
$$

特に、多数の構成銘柄を同時に取引する間に価格が変化すれば、当初観測した価格差を固定できません。したがって、厳密には、

すべての売買を同時に確定でき、設定・解約まで実行できる場合に、現物裁定に近づきます。

5.MAGSの場合

今回作成したMAGSと7銘柄のコードは、現物裁定ではなく統計的裁定です。理由は次のとおりです。

  • MAGSは単純な7銘柄の現物保有だけではありません
  • スワップなどのデリバティブを利用します
  • 実際の保有数量や日中の調整を完全には再現していません
  • 四半期等ウェイトという近似バスケットを使っています
  • ETFの設定・解約を実行していません

したがって、コードで使っている乖離は、

$$
\text{ETFの市場価格}
-\text{推定した現物バスケット価格}
$$

です。

これは真のプレミアム・ディスカウントだけではなく、

$$
\begin{aligned}
\text{観測残差}
={}&
\text{ETF価格の乖離}
+\text{複製誤差}
&+\text{ウェイト推定誤差}
+\text{デリバティブ評価差}
+\text{経費差}
\end{aligned}
$$

を含みます。したがって、現在のコードは、

MAGSと7銘柄の関係が一時的に通常状態から外れ、その後戻る

という仮説を検証するものです。一方、本当の現物裁定を行うには、ETFの正確な日次構成ファイル、リアルタイムの各銘柄価格、設定・解約単位、現金調整額、取引コスト、そして指定参加者としてのアクセスが必要になります。

========== バックテスト結果 ==========
観測期間 : 2023-04-11 ~ 2026-07-29
観測日数 : 828
ローリング期間 : 60営業日
取引開始回数 : 29
累積収益率 : 2.88%
年率収益率 : 0.87%
年率ボラティリティ : 0.81%
シャープレシオ : 1.065
最大ドローダウン : -0.77%
平均グロス投資比率 : 37.44%
image.png
image.png
image.png
image.png

コード

同一指数ETF間の相対価値

from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
plt.rcParams["font.family"] = "Yu Gothic"
plt.rcParams["axes.unicode_minus"] = False
# ============================================================
# 1. 設定
# ============================================================
FILES = {
    "SPY": Path("spy.csv"),
    "IVV": Path("ivv.csv"),
    "VOO": Path("voo.csv"),
}
WINDOW = 60                  # 平均・標準偏差の推定期間
ENTRY_Z = 2.0                # エントリー閾値
EXIT_Z = 0.25                # 決済閾値
TRANSACTION_COST_BPS = 2.0  # 売買コスト:片道2bp
BORROW_RATE = 0.01           # 空売り調達コスト:年率1%
TRADING_DAYS = 252
# ============================================================
# 2. CSVの読み込み
# ============================================================
def load_price(path: Path, ticker: str) -> pd.Series:
    """
    Date列とAdj CloseまたはClose列を持つCSVを読み込みます。
    Adj Closeが存在する場合は、Adj Closeを優先します。
    """
    df = pd.read_csv(path)
    if "Date" not in df.columns:
        raise ValueError(f"{path} にDate列がありません。")
    df["Date"] = pd.to_datetime(
        df["Date"],
        errors="coerce"
    )
    if "Adj Close" in df.columns:
        price_column = "Adj Close"
    elif "Close" in df.columns:
        price_column = "Close"
    else:
        raise ValueError(
            f"{path} にAdj CloseまたはClose列がありません。"
        )
    price = pd.to_numeric(
        df[price_column],
        errors="coerce"
    )
    series = pd.Series(
        price.to_numpy(),
        index=df["Date"],
        name=ticker,
        dtype=float,
    )
    series = (
        series
        .dropna()
        .sort_index()
    )
    series = series.loc[
        ~series.index.duplicated(keep="last")
    ]
    return series
price_list = []

for ticker, path in FILES.items():
    price_list.append(
        load_price(path, ticker)
    )
prices = pd.concat(
    price_list,
    axis=1,
    join="inner"
).dropna()
if len(prices) <= WINDOW:
    raise ValueError(
        f"共通するデータが不足しています。"
        f"少なくとも{WINDOW + 1}営業日が必要です。"
    )
# ============================================================
# 3. ETFの組合せ
# ============================================================
PAIR_MAP = {
    "SPY/IVV": ("SPY", "IVV"),
    "SPY/VOO": ("SPY", "VOO"),
    "IVV/VOO": ("IVV", "VOO"),
}
# ============================================================
# 4. 対数価格比率
# ============================================================
log_prices = np.log(prices)
spreads = pd.DataFrame(
    index=prices.index
)
z_scores = pd.DataFrame(
    index=prices.index
)
for pair_name, pair in PAIR_MAP.items():
    ticker_a, ticker_b = pair
    # 対数価格比率
    spread = (
        log_prices[ticker_a]
        - log_prices[ticker_b]
    )
    rolling_mean = (
        spread
        .rolling(
            WINDOW,
            min_periods=WINDOW
        )
        .mean()
    )
    rolling_std = (
        spread
        .rolling(
            WINDOW,
            min_periods=WINDOW
        )
        .std(ddof=1)
    )
    spreads[pair_name] = spread
    z_scores[pair_name] = (
        spread - rolling_mean
    ) / rolling_std.replace(0, np.nan)
# ============================================================
# 5. 最も乖離した1組を取引する
# ============================================================
signal_weights = pd.DataFrame(
    0.0,
    index=prices.index,
    columns=prices.columns,
)
signal_pair = pd.Series(
    pd.NA,
    index=prices.index,
    dtype="object",
    name="signal_pair",
)
current_pair = None
entry_sign = 0
for date in prices.index:
    z_today = z_scores.loc[date].dropna()
    # --------------------------------------------------------
    # ポジションを持っていない場合
    # --------------------------------------------------------
    if current_pair is None:
        if not z_today.empty:
            # 絶対値が最も大きい組合せ
            candidate_pair = (
                z_today
                .abs()
                .idxmax()
            )
            candidate_z = z_today[candidate_pair]
            if abs(candidate_z) >= ENTRY_Z:
                current_pair = candidate_pair
                # エントリー時のZスコアの符号
                entry_sign = int(
                    np.sign(candidate_z)
                )
    # --------------------------------------------------------
    # ポジションを保有している場合
    # --------------------------------------------------------
    else:
        current_z = z_scores.at[
            date,
            current_pair
        ]
        # 平均付近に戻るか、
        # 平均を通過した場合に決済
        exit_condition = (
            pd.isna(current_z)
            or abs(current_z) <= EXIT_Z
            or current_z * entry_sign <= 0
        )
        if exit_condition:
            current_pair = None
            entry_sign = 0
    # --------------------------------------------------------
    # 保有するウェイトを設定
    # --------------------------------------------------------
    if current_pair is not None:

        ticker_a, ticker_b = PAIR_MAP[
            current_pair
        ]
        current_z = z_scores.at[
            date,
            current_pair
        ]
        if current_z > 0:
            # A/Bが通常より高い
            # Aを売り、Bを買う
            signal_weights.at[
                date,
                ticker_a
            ] = -0.5
            signal_weights.at[
                date,
                ticker_b
            ] = 0.5
        else:
            # A/Bが通常より低い
            # Aを買い、Bを売る
            signal_weights.at[
                date,
                ticker_a
            ] = 0.5
            signal_weights.at[
                date,
                ticker_b
            ] = -0.5
        signal_pair.at[date] = current_pair
# ============================================================
# 6. シグナルを1日遅らせる
# ============================================================
# 当日の終値で計算したシグナルを
# 翌営業日のポジションとして使用します
weights = (
    signal_weights
    .shift(1)
    .fillna(0.0)
)
active_pair = signal_pair.shift(1)
# ============================================================
# 7. ETFの日次収益率
# ============================================================
returns = (
    prices
    .pct_change(fill_method=None)
    .fillna(0.0)
)
gross_return = (
    weights * returns
).sum(axis=1)
# ============================================================
# 8. 売買回転率と取引コスト
# ============================================================
turnover = (
    weights
    .diff()
    .abs()
    .sum(axis=1)
)
turnover.iloc[0] = (
    weights
    .iloc[0]
    .abs()
    .sum()
)
transaction_cost = (
    turnover
    * TRANSACTION_COST_BPS
    / 10_000
)
# ============================================================
# 9. 空売り調達コスト
# ============================================================
short_notional = (
    -weights.clip(upper=0)
).sum(axis=1)
borrow_cost = (
    short_notional
    * BORROW_RATE
    / TRADING_DAYS
)
# ============================================================
# 10. 戦略収益率
# ============================================================
strategy_return = (
    gross_return
    - transaction_cost
    - borrow_cost
)
equity = (
    1.0 + strategy_return
).cumprod()
running_max = equity.cummax()
drawdown = (
    equity / running_max
    - 1.0
)
# ============================================================
# 11. 結果をまとめる
# ============================================================
result = pd.concat(
    [
        prices,
        spreads.add_prefix("spread_"),
        z_scores.add_prefix("z_"),
        weights.add_prefix("weight_"),
        active_pair.rename("active_pair"),
        gross_return.rename("gross_return"),
        turnover.rename("turnover"),
        transaction_cost.rename(
            "transaction_cost"
        ),
        borrow_cost.rename("borrow_cost"),
        strategy_return.rename(
            "strategy_return"
        ),
        equity.rename("equity"),
        drawdown.rename("drawdown"),
    ],
    axis=1,
)
# ============================================================
# 12. パフォーマンス指標
# ============================================================
number_of_days = len(result)
total_return = (
    equity.iloc[-1] - 1.0
)
annual_return = (
    equity.iloc[-1]
    ** (TRADING_DAYS / number_of_days)
    - 1.0
)
annual_volatility = (
    strategy_return.std(ddof=1)
    * np.sqrt(TRADING_DAYS)
)
if strategy_return.std(ddof=1) > 0:

    sharpe_ratio = (
        strategy_return.mean()
        / strategy_return.std(ddof=1)
        * np.sqrt(TRADING_DAYS)
    )
else:
    sharpe_ratio = np.nan
maximum_drawdown = drawdown.min()
gross_exposure = (
    weights
    .abs()
    .sum(axis=1)
)
entry_flag = (
    (gross_exposure > 0)
    & (
        gross_exposure
        .shift(1)
        .fillna(0)
        == 0
    )
)
number_of_trades = int(
    entry_flag.sum()
)
print("========== バックテスト結果 ==========")
print(
    f"観測期間            : "
    f"{result.index.min().date()}"
    f"{result.index.max().date()}"
)
print( f"観測日数            : "f"{number_of_days:,}")

print( f"取引開始回数        : "f"{number_of_trades:,}")
print( f"累積収益率          : "f"{total_return:.2%}")
print( f"年率収益率          : "f"{annual_return:.2%}")
print( f"年率ボラティリティ  : "f"{annual_volatility:.2%}")
print( f"シャープレシオ      : " f"{sharpe_ratio:.3f}")
print( f"最大ドローダウン    : " f"{maximum_drawdown:.2%}")
# ============================================================
# 13. 基準化価格のグラフ
# ============================================================
normalized_prices = (
    prices / prices.iloc[0]
)
plt.figure(figsize=(4, 2.5))
for ticker in normalized_prices.columns:
    plt.plot(
        normalized_prices.index,
        normalized_prices[ticker],
        label=ticker,
    )
plt.title("SPY・IVV・VOOの基準化価格")
plt.xlabel("Date")
plt.ylabel("Normalized price")
plt.legend()
plt.grid()
plt.show()
# ============================================================
# 14. Zスコアのグラフ
# ============================================================
plt.figure(figsize=(4, 2.5))
for pair_name in z_scores.columns:
    plt.plot(
        z_scores.index,
        z_scores[pair_name],
        label=pair_name,
    )
plt.axhline(
    ENTRY_Z,
    linestyle="--"
)
plt.axhline(
    -ENTRY_Z,
    linestyle="--"
)
plt.axhline(
    EXIT_Z,
    linestyle=":"
)
plt.axhline(
    -EXIT_Z,
    linestyle=":"
)
plt.axhline(0)
plt.title(
    "同一指数ETF間の価格比率Zスコア"
)
plt.xlabel("Date")
plt.ylabel("Z-score")
plt.legend()
plt.grid()
plt.show()
# ============================================================
# 15. 累積資産のグラフ
# ============================================================
plt.figure(figsize=(4, 2.5))
plt.plot(
    equity.index,
    equity
)
plt.title(
    "SPY・IVV・VOO相対価値戦略の累積資産"
)
plt.xlabel("Date")
plt.ylabel("Equity")
plt.grid()
plt.show()
# ============================================================
# 16. CSVに保存
# ============================================================
result.to_csv(
    "spy_ivv_voo_relative_value_result.csv",
    encoding="utf-8-sig",
)

mags vs マグニフィセント7 裁定

from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ============================================================
# 1. グラフの日本語フォント
# ============================================================
plt.rcParams["font.family"] = "Yu Gothic"
plt.rcParams["axes.unicode_minus"] = False
# ============================================================
# 2. 設定
# ============================================================
FILES = {
    "MAGS": Path("mags.csv"),
    "AAPL": Path("aapl.csv"),
    "MSFT": Path("msft.csv"),
    "AMZN": Path("amzn.csv"),
    "GOOGL": Path("googl.csv"),
    "META": Path("meta.csv"),
    "NVDA": Path("nvda.csv"),
    "TSLA": Path("tsla.csv"),
}
STOCK_TICKERS = ["AAPL","MSFT","AMZN","GOOGL","META","NVDA","TSLA",]
WINDOW = 60
ENTRY_Z = 2.0
EXIT_Z = 0.25
TRADING_DAYS = 252
# 片道売買コスト
TRANSACTION_COST_BPS = 2.0
# 空売りコストの仮定
BORROW_RATE_MAGS = 0.01
BORROW_RATE_STOCKS = 0.005
# "dollar":MAGSとバスケットを50%ずつ
# "beta"  :ローリング回帰のβでヘッジ
HEDGE_MODE = "beta"
# βが異常値になった場合の制限
MIN_HEDGE_BETA = 0.50
MAX_HEDGE_BETA = 1.50
# ============================================================
# 3. CSVの読み込み
# ============================================================
def load_price(path: Path, ticker: str) -> pd.Series:
    """
    Date列とAdj CloseまたはClose列を持つCSVを読み込みます。
    Adj Closeがあれば優先して使用します。
    """
    if not path.exists():
        raise FileNotFoundError(
            f"{path} が見つかりません。"
        )
    df = pd.read_csv(path)
    if "Date" not in df.columns:
        raise ValueError(
            f"{path} にDate列がありません。"
        )
    df["Date"] = pd.to_datetime(
        df["Date"],
        errors="coerce",
    )
    if "Adj Close" in df.columns:
        price_column = "Adj Close"
    elif "Close" in df.columns:
        price_column = "Close"
    else:
        raise ValueError(
            f"{path} にAdj CloseまたはClose列がありません。"
        )
    price = pd.to_numeric(
        df[price_column],
        errors="coerce",
    )
    result = pd.Series(
        price.to_numpy(),
        index=df["Date"],
        name=ticker,
        dtype=float,
    )
    result = (
        result
        .dropna()
        .sort_index()
    )
    result = result.loc[
        ~result.index.duplicated(keep="last")
    ]
    if (result <= 0).any():
        raise ValueError(
            f"{ticker}の価格に0以下の値があります。"
        )
    return result
price_series = []
for ticker, path in FILES.items():
    price_series.append(
        load_price(path, ticker)
    )
# 8銘柄すべてにデータが存在する日だけを使用
prices = pd.concat(
    price_series,
    axis=1,
    join="inner",
).dropna()
if len(prices) <= WINDOW + 5:
    raise ValueError(
        f"共通するデータが不足しています。"
        f"少なくとも{WINDOW + 6}営業日程度必要です。"
    )
mags_price = prices["MAGS"]
stock_prices = prices[STOCK_TICKERS]
# ============================================================
# 4. 四半期等ウェイト・バスケットの作成
# ============================================================
def create_quarterly_equal_weight_basket(
    stock_price_data: pd.DataFrame,
) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]:
    """
    四半期の境界で7銘柄を等ウェイトに戻します。

    四半期中は売買せず、各銘柄の価格変動に応じて
    ウェイトが自然に変化するものとします。
    戻り値
    ------
    basket_index
        合成バスケット指数
    basket_weights
        各日の取引終了時点における構成ウェイト
    stock_returns
        各銘柄の日次収益率
   """
    stock_returns = (
        stock_price_data
        .pct_change(fill_method=None)
    )
    basket_index = pd.Series(
        np.nan,
        index=stock_price_data.index,
        name="basket_index",
        dtype=float,
    )
    basket_weights = pd.DataFrame(
        np.nan,
        index=stock_price_data.index,
        columns=stock_price_data.columns,
        dtype=float,
    )
    number_of_stocks = stock_price_data.shape[1]
    equal_weights = np.repeat(
        1.0 / number_of_stocks,
        number_of_stocks,
    )
    current_weights = equal_weights.copy()
    basket_index.iloc[0] = 1.0
    basket_weights.iloc[0] = current_weights
    quarters = stock_price_data.index.to_period("Q")
    for i in range(1, len(stock_price_data)):
        # 四半期が変わったとき、等ウェイトに戻します
        if quarters[i] != quarters[i - 1]:
            current_weights = equal_weights.copy()
        daily_returns = (
            stock_returns
            .iloc[i]
            .to_numpy(dtype=float)
        )
        if not np.isfinite(daily_returns).all():
            raise ValueError(
                f"{stock_price_data.index[i]}"
                "構成銘柄収益率に欠損があります。"
            )
        basket_return = float(
            np.dot(
                current_weights,
                daily_returns,
            )
        )
        basket_index.iloc[i] = (
            basket_index.iloc[i - 1]
            * (1.0 + basket_return)
        )
        # 価格変動後のウェイト
        current_weights = (
            current_weights
            * (1.0 + daily_returns)
            / (1.0 + basket_return)
        )
        # 数値誤差を除くため再正規化
        current_weights = (
            current_weights
            / current_weights.sum()
        )
        basket_weights.iloc[i] = current_weights
    return (
        basket_index,
        basket_weights,
        stock_returns,
    )
(
    basket_index,
    basket_weights,
    stock_returns,
) = create_quarterly_equal_weight_basket(
    stock_prices
)
basket_return = (
    basket_index
    .pct_change(fill_method=None)
    .fillna(0.0)
)
# ============================================================
# 5. MAGSと合成バスケットのローリング回帰
# ============================================================
# log(MAGS) = alpha + beta * log(Basket) + residual
y = np.log(mags_price)
x = np.log(basket_index)
rolling_mean_x = (
    x
    .rolling(
        WINDOW,
        min_periods=WINDOW,
    )
    .mean()
)
rolling_mean_y = (
    y
    .rolling(
        WINDOW,
        min_periods=WINDOW,
    )
    .mean()
)
rolling_var_x = (
    x
    .rolling(
        WINDOW,
        min_periods=WINDOW,
    )
    .var(ddof=1)
)
rolling_var_y = (
    y
    .rolling(
        WINDOW,
        min_periods=WINDOW,
    )
    .var(ddof=1)
)
rolling_cov_xy = (
    y
    .rolling(
        WINDOW,
        min_periods=WINDOW,
    )
    .cov(x)
)
beta = (
    rolling_cov_xy
    / rolling_var_x.replace(0, np.nan)
)
beta.name = "beta"
alpha = (
    rolling_mean_y
    - beta * rolling_mean_x
)
alpha.name = "alpha"
spread = (
    y
    - alpha
    - beta * x
)
spread.name = "spread"
# ============================================================
# 6. 回帰残差の標準偏差
# ============================================================
# 中心化平方和を使った回帰残差平方和
#
# SSE = (n - 1) ×
#       {Var(y) - beta × Cov(x, y)}
residual_sse = (
    (WINDOW - 1)
    * (
        rolling_var_y
        - beta * rolling_cov_xy
    )
)
# 数値誤差でわずかに負になる場合に備えます
residual_sse = residual_sse.clip(lower=0)
residual_std = np.sqrt(
    residual_sse
    / (WINDOW - 2)
)
residual_std.name = "residual_std"
z_score = (
    spread
    / residual_std.replace(0, np.nan)
)
z_score.name = "z_score"
# ============================================================
# 7. 売買シグナル
# ============================================================
# position =  1
# MAGS買い、現物バスケット売り
#
# position = -1
# MAGS売り、現物バスケット買い
#
# position =  0
# ポジションなし
signal_position = pd.Series(
    0,
    index=prices.index,
    name="signal_position",
    dtype=int,
)
current_position = 0
for date in prices.index:
    current_z = z_score.at[date]
    if pd.isna(current_z):
        current_position = 0
    elif current_position == 0:
        # MAGSが推定バスケットに対して割高
        if current_z >= ENTRY_Z:
            current_position = -1
        # MAGSが推定バスケットに対して割安
        elif current_z <= -ENTRY_Z:
            current_position = 1
    elif current_position == 1:
        # MAGS割安ポジションの決済
        if current_z >= -EXIT_Z:
            current_position = 0
    elif current_position == -1:
        # MAGS割高ポジションの決済
        if current_z <= EXIT_Z:
            current_position = 0
    signal_position.at[date] = current_position
# ============================================================
# 8. 各資産へのシグナル・ウェイト
# ============================================================
signal_weights = pd.DataFrame(
    0.0,
    index=prices.index,
    columns=prices.columns,
)
for date in prices.index:
    position = signal_position.at[date]
    if position == 0:
        continue
    current_beta = beta.at[date]
    if not np.isfinite(current_beta):
        continue
    hedge_beta = float(
        np.clip(
            abs(current_beta),
            MIN_HEDGE_BETA,
            MAX_HEDGE_BETA,
        )
    )
    if HEDGE_MODE == "dollar":
        # MAGS側50%、バスケット側50%
        mags_gross_weight = 0.5
        basket_gross_weight = 0.5
    elif HEDGE_MODE == "beta":
        # 総グロスを100%に保ちながら
        # 回帰βに応じて配分
        mags_gross_weight = (
            1.0
            / (1.0 + hedge_beta)
        )
        basket_gross_weight = (
            hedge_beta
            / (1.0 + hedge_beta)
        )
    else:
        raise ValueError(
            "HEDGE_MODEは'dollar'または'beta'にしてください。"
        )
    # position = 1ならMAGS買い
    # position = -1ならMAGS売り
    signal_weights.at[
        date,
        "MAGS"
    ] = (
        position
        * mags_gross_weight
    )
    # MAGSと反対方向に現物バスケットを保有
    basket_side = (
        -position
        * basket_gross_weight
    )
    for ticker in STOCK_TICKERS:
        signal_weights.at[
            date,
            ticker
        ] = (
            basket_side
            * basket_weights.at[date, ticker]
        )
# ============================================================
# 9. 先読みを避けるため1営業日遅らせる
# ============================================================
# 当日終値で計算したシグナルを、
# 翌営業日の収益率に適用します
weights = (
    signal_weights
    .shift(1)
    .fillna(0.0)
)
executed_position = (
    signal_position
    .shift(1)
    .fillna(0)
    .astype(int)
)
executed_position.name = "position"
# ============================================================
# 10. 各資産の日次収益率
# ============================================================
asset_returns = (
    prices
    .pct_change(fill_method=None)
    .fillna(0.0)
)
gross_return = (
    weights
    * asset_returns
).sum(axis=1)
gross_return.name = "gross_return"
# ============================================================
# 11. 売買回転率と取引コスト
# ============================================================
turnover = (
    weights
    .diff()
    .abs()
    .sum(axis=1)
)
turnover.iloc[0] = (
    weights
    .iloc[0]
    .abs()
    .sum()
)
turnover.name = "turnover"
transaction_cost = (
    turnover
    * TRANSACTION_COST_BPS
    / 10_000
)
transaction_cost.name = "transaction_cost"
# ============================================================
# 12. 空売りコスト
# ============================================================
annual_borrow_rates = pd.Series(
    {
        "MAGS": BORROW_RATE_MAGS,
        "AAPL": BORROW_RATE_STOCKS,
        "MSFT": BORROW_RATE_STOCKS,
        "AMZN": BORROW_RATE_STOCKS,
        "GOOGL": BORROW_RATE_STOCKS,
        "META": BORROW_RATE_STOCKS,
        "NVDA": BORROW_RATE_STOCKS,
        "TSLA": BORROW_RATE_STOCKS,
    }
)
short_weights = (
    -weights.clip(upper=0)
)
borrow_cost = (
    short_weights
    .mul(
        annual_borrow_rates
        / TRADING_DAYS,
        axis=1,
    )
    .sum(axis=1)
)
borrow_cost.name = "borrow_cost"
# ============================================================
# 13. 戦略収益率
# ============================================================
strategy_return = (
    gross_return
    - transaction_cost
    - borrow_cost
)
strategy_return.name = "strategy_return"
equity = (
    1.0 + strategy_return
).cumprod()
equity.name = "equity"
running_max = equity.cummax()
drawdown = (
    equity
    / running_max
    - 1.0
)
drawdown.name = "drawdown"
# ============================================================
# 14. 結果の整理
# ============================================================
result = pd.concat(
    [
        prices,
        basket_index,
        basket_return.rename("basket_return"),
        basket_weights.add_prefix("basket_weight_"),
        alpha,
        beta,
        spread,
        residual_std,
        z_score,
        signal_position,
        executed_position,
        weights.add_prefix("position_weight_"),
        gross_return,
        turnover,
        transaction_cost,
        borrow_cost,
        strategy_return,
        equity,
        drawdown,
    ],
    axis=1,
)
# ============================================================
# 15. パフォーマンス指標
# ============================================================
number_of_days = len(strategy_return)
total_return = (
    equity.iloc[-1] - 1.0
)
if equity.iloc[-1] > 0:
    annual_return = (
        equity.iloc[-1]
        ** (
            TRADING_DAYS
            / number_of_days
        )
        - 1.0
    )
else:
    annual_return = np.nan
annual_volatility = (
    strategy_return.std(ddof=1)
    * np.sqrt(TRADING_DAYS)
)
if strategy_return.std(ddof=1) > 0:
    sharpe_ratio = (
        strategy_return.mean()
        / strategy_return.std(ddof=1)
        * np.sqrt(TRADING_DAYS)
    )
else:
    sharpe_ratio = np.nan
maximum_drawdown = drawdown.min()
entry_flag = (
    (executed_position != 0)
    & (
        executed_position
        .shift(1)
        .fillna(0)
        == 0
    )
)
number_of_trades = int(
    entry_flag.sum()
)
average_gross_exposure = (
    weights
    .abs()
    .sum(axis=1)
    .mean()
)
print("========== バックテスト結果 ==========")
print(
    f"観測期間              : "
    f"{result.index.min().date()}"
    f"{result.index.max().date()}"
)
print(
    f"観測日数              : "
    f"{number_of_days:,}"
)
print(
    f"ローリング期間        : "
    f"{WINDOW}営業日"
)
print(
    f"取引開始回数          : "
    f"{number_of_trades:,}"
)
print(
    f"累積収益率            : "
    f"{total_return:.2%}"
)
print(
    f"年率収益率            : "
    f"{annual_return:.2%}"
)
print(
    f"年率ボラティリティ    : "
    f"{annual_volatility:.2%}"
)
print(
    f"シャープレシオ        : "
    f"{sharpe_ratio:.3f}"
)
print(
    f"最大ドローダウン      : "
    f"{maximum_drawdown:.2%}"
)
print(
    f"平均グロス投資比率    : "
    f"{average_gross_exposure:.2%}"
)
# ============================================================
# 16. MAGSと合成バスケット
# ============================================================
normalized_mags = (
    mags_price
    / mags_price.iloc[0]
)
plt.figure(figsize=(4, 2.5))
plt.plot(
    normalized_mags.index,
    normalized_mags,
    label="MAGS",
)
plt.plot(
    basket_index.index,
    basket_index,
    label="7銘柄合成バスケット",
)
plt.title(
    "MAGSと四半期等ウェイト合成バスケット"
)
plt.xlabel("Date")
plt.ylabel("基準化価格")
plt.legend()
plt.grid()
plt.show()
# ============================================================
# 17. ローリングβ
# ============================================================
plt.figure(figsize=(4, 2.5))
plt.plot(
    beta.index,
    beta,
)
plt.axhline(
    1.0,
    linestyle="--",
)
plt.title(
    "MAGSと合成バスケットのローリングβ"
)
plt.xlabel("Date")
plt.ylabel("Beta")
plt.grid()
plt.show()
# ============================================================
# 18. 残差のZスコア
# ============================================================
plt.figure(figsize=(4, 2.5))
plt.plot(
    z_score.index,
    z_score,
)
plt.axhline(
    ENTRY_Z,
    linestyle="--",
)
plt.axhline(
    -ENTRY_Z,
    linestyle="--",
)
plt.axhline(
    EXIT_Z,
    linestyle=":",
)
plt.axhline(
    -EXIT_Z,
    linestyle=":",
)
plt.axhline(
    0.0,
)
plt.title(
    "MAGSと合成バスケットの残差Zスコア"
)
plt.xlabel("Date")
plt.ylabel("Z-score")
plt.grid()
plt.show()
# ============================================================
# 19. 累積資産
# ============================================================
plt.figure(figsize=(4, 2.5))
plt.plot(
    equity.index,
    equity,
)
plt.title(
    "MAGS・現物バスケット統計的裁定の累積資産"
)
plt.xlabel("Date")
plt.ylabel("Equity")
plt.grid()
plt.show()
# ============================================================
# 20. CSV保存
# ============================================================
result.to_csv(
    "mags_stock_basket_statistical_arbitrage.csv",
    encoding="utf-8-sig",
)
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?