IMMポジションのma20_gapを危機スコアへ追加し、効果を検証する

IMMポジションのma20_gapを危機スコアへ追加し、効果を検証する

前回までに、FREDやStooqから取得した市場データをもとに、危機監視用の特徴量を生成し、SQLiteへ保存する流れを作りました。

さらに、CFTCのIMMポジションデータを使って、JPY・CHF・AUD・CAD の複数通貨を組み込んだ multi_v2 版の危機スコアも作成しています。

今回は、その multi_v2 を母体にして、position層へ ma20_gap を追加してみます。

目的は、既存の危機スコアを大きく作り替えることではありません。まずは差分を最小にして、ma20_gap を追加することで、過去のリスクイベントに対する警戒タイミングが改善するかを確認します。

今回やること

今回作るのは、既存の multi_v2 を壊さない検証用スコアです。

  • build_crisis_score_multi_position_v2.py を複製する
  • ma20_gap を position 層へ追加する
  • 保存先テーブルを別名にする
  • 既存の multi_v2 と比較する

保存先テーブルは、次のようにしました。

crisis_score_multi_position_v2_ma20

既存の crisis_score_multi_position_v2 はそのまま残します。こうしておくと、あとで比較しやすく、失敗しても既存テーブルを壊さずに済みます。

今回追加する特徴量

今回追加するのは、各通貨の rate_ma20_gap です。

  • imm_jpy_rate_ma20_gap
  • imm_chf_rate_ma20_gap
  • imm_aud_rate_ma20_gap
  • imm_cad_rate_ma20_gap

前提として、これらの特徴量はすでに crisis_features_multi_imm に保存されているものとします。

つまり今回は、特徴量生成側は変更しません。変更するのは、スコア生成スクリプトだけです。

設計方針

既存の multi_v2 では、position層は通貨別にサブスコアを作り、それを通貨ごとの重みで合成しています。

今回はその構造を維持したまま、各通貨の中身に ma20_gap を1要素追加します。

変更前のイメージは次の通りです。

各通貨の position サブスコア
= rate_z20 + rate_chg_4w

変更後は次のようにします。

各通貨の position サブスコア
= rate_z20 + rate_chg_4w + rate_ma20_gap

今回は chg_1wchg_2wz60safe_vs_risk spread は入れません。

理由は、変更点を増やしすぎると、どの特徴量が効いたのか分からなくなるためです。まずは ma20_gap 単独の効果を見ることを優先します。

通貨ごとの方向性

通貨ごとの危険方向は、既存の multi_v2 と同じにしました。

  • JPY: そのまま危険方向
  • CHF: そのまま危険方向
  • AUD: 反転
  • CAD: 反転

JPYとCHFは、安全通貨寄りの扱いです。そのため、IMMポジションが危険方向へ傾いたときは、そのままリスク上昇として扱います。

一方で、AUDとCADはリスク通貨寄りとして扱います。そのため、危険方向をそろえるために符号を反転します。

ma20_gap についても、z20chg_4w と同じ方向性で扱います。

スクリプトを複製する

まず、既存のスコア生成スクリプトをコピーします。

cp build_crisis_score_multi_position_v2.py build_crisis_score_multi_position_v2_ma20.py

比較用スクリプトも同様にコピーします。

cp check_crisis_events_multi_position_v2.py check_crisis_events_multi_position_v2_ma20.py

これで、既存版を壊さずに検証できます。

保存先テーブルを変更する

まず、スコア保存先のテーブル名を変更します。

TARGET_SCORE_TABLE = "crisis_score_multi_position_v2_ma20"

これにより、今回のスコアは既存の crisis_score_multi_position_v2 とは別テーブルに保存されます。

POSITION_FEATURES に ma20_gap を追加する

次に、position層で使う特徴量へ ma20_gap を追加します。

元の multi_v2 では、各通貨ごとに z20chg_4w を使っていました。

今回はそこに ma20_gap を加えます。

POSITION_FEATURES = [
    "imm_jpy_rate_z20",
    "imm_jpy_rate_chg_4w",
    "imm_jpy_rate_ma20_gap",

    "imm_chf_rate_z20",
    "imm_chf_rate_chg_4w",
    "imm_chf_rate_ma20_gap",

    "imm_aud_rate_z20",
    "imm_aud_rate_chg_4w",
    "imm_aud_rate_ma20_gap",

    "imm_cad_rate_z20",
    "imm_cad_rate_chg_4w",
    "imm_cad_rate_ma20_gap",
]

これで、position層の必要特徴量は8個から12個になります。

通貨ごとの重みは維持する

通貨間の重みは、既存の multi_v2 と同じにしました。

POSITION_BUCKET_WEIGHTS = {
    "jpy": 0.40,
    "chf": 0.30,
    "aud": 0.15,
    "cad": 0.15,
}

ここは変更しません。

今回見たいのは、通貨間の重みを変えた効果ではなく、各通貨の中に ma20_gap を足した効果です。

build_bucket_score() を ma20_gap 対応にする

次に、通貨別サブスコアを作る関数を変更します。

元の関数は、次の3つを受け取る形でした。

  • z_col
  • chg_col
  • risk_positive

今回はここに gap_col を追加します。

def build_bucket_score(
    z_col: str,
    chg_col: str,
    gap_col: str,
    risk_positive: bool,
) -> tuple[pd.Series, pd.Series]:
    parts = []
    valid_parts = []

    if z_col in df.columns:
        z = df[z_col] if risk_positive else -df[z_col]
        parts.append(z)
        valid_parts.append(df[z_col].notna().astype("Int64"))

    if chg_col in df.columns:
        chg_score = scale_series_to_0_100(df[chg_col])
        chg_score_as_z_like = (chg_score - 50.0) / 10.0
        chg = chg_score_as_z_like if risk_positive else -chg_score_as_z_like
        parts.append(chg)
        valid_parts.append(df[chg_col].notna().astype("Int64"))

    if gap_col in df.columns:
        gap_score = scale_series_to_0_100(df[gap_col])
        gap_score_as_z_like = (gap_score - 50.0) / 10.0
        gap = gap_score_as_z_like if risk_positive else -gap_score_as_z_like
        parts.append(gap)
        valid_parts.append(df[gap_col].notna().astype("Int64"))

    if not parts:
        return (
            pd.Series(np.nan, index=df.index, dtype=float),
            pd.Series(0, index=df.index, dtype="Int64"),
        )

    bucket_raw = pd.concat(parts, axis=1).mean(axis=1, skipna=True)
    bucket_score = scale_series_to_0_100(bucket_raw)
    bucket_valid = pd.concat(valid_parts, axis=1).sum(axis=1).astype("Int64")
    return bucket_score, bucket_valid

この変更のポイントは、ma20_gapchg_4w と同じ流れで扱っていることです。

  1. scale_series_to_0_100() で0〜100へ変換する
  2. (score - 50.0) / 10.0 でzスコア風の尺度に戻す
  3. 危険方向が逆の通貨では符号を反転する
  4. parts に追加する
  5. valid_parts に有効特徴量数を追加する

つまり、設計思想は変えずに、通貨バケットの中身を2要素から3要素に増やしただけです。

呼び出し側も変更する

build_bucket_score() の引数を増やしたので、呼び出し側も変更します。

bucket_scores["jpy"], bucket_valid_counts["jpy"] = build_bucket_score(
    "imm_jpy_rate_z20",
    "imm_jpy_rate_chg_4w",
    "imm_jpy_rate_ma20_gap",
    risk_positive=True,
)

bucket_scores["chf"], bucket_valid_counts["chf"] = build_bucket_score(
    "imm_chf_rate_z20",
    "imm_chf_rate_chg_4w",
    "imm_chf_rate_ma20_gap",
    risk_positive=True,
)

bucket_scores["aud"], bucket_valid_counts["aud"] = build_bucket_score(
    "imm_aud_rate_z20",
    "imm_aud_rate_chg_4w",
    "imm_aud_rate_ma20_gap",
    risk_positive=False,
)

bucket_scores["cad"], bucket_valid_counts["cad"] = build_bucket_score(
    "imm_cad_rate_z20",
    "imm_cad_rate_chg_4w",
    "imm_cad_rate_ma20_gap",
    risk_positive=False,
)

JPYとCHFは risk_positive=True、AUDとCADは risk_positive=False にしています。

position_score_required_feature_count を変更する

今回、position層で使う特徴量は12個になりました。

そのため、必要特徴量数も変更します。

df["position_score_required_feature_count"] = 12

ここを8のままにしてしまうと、後で有効特徴量数を確認するときに、実際の設計とズレてしまいます。

比較スクリプトに新テーブルを追加する

次に、比較スクリプトへ今回のテーブルを追加します。

TABLES = {
    "base": "crisis_score_base",
    "jpy_only": "crisis_score",
    "multi_v1": "crisis_score_multi_position",
    "multi_v2": "crisis_score_multi_position_v2",
    "multi_v2_ma20": "crisis_score_multi_position_v2_ma20",
}

これで、既存のスコアと今回の multi_v2_ma20 を同じ表で比較できます。

差分列を追加する

比較しやすいように、multi_v2multi_v2_ma20 の差分列を追加します。

if "multi_v2" in score_tables and "multi_v2_ma20" in score_tables:
    multi_v2_score = row.get("multi_v2_score")
    multi_v2_ma20_score = row.get("multi_v2_ma20_score")
    row["score_diff_multi_v2_ma20_minus_multi_v2"] = (
        None
        if multi_v2_score is None or multi_v2_ma20_score is None
        else multi_v2_ma20_score - multi_v2_score
    )

これで、ma20_gap を追加したことでスコアがどれくらい変わったかを確認できます。

CSVの保存名も変更する

比較結果のCSV名も、今回の検証用に変更しました。

def save_compare_csv(
    df: pd.DataFrame,
    out_path: str = "check_crisis_events_multi_position_v2_ma20_compare.csv",
) -> None:
    df.to_csv(out_path, index=False, encoding="utf-8-sig")

保存ファイル名を分けておくことで、既存の比較結果と混ざらずに済みます。

スコア生成を実行する

修正後、まずはスコア生成スクリプトを実行します。

python build_crisis_score_multi_position_v2_ma20.py

実行結果は次のようになりました。

Loaded features:
  rows: 10664
  range: 1997-01-01 -> 2026-03-13
Saved score table: crisis_score_multi_position_v2_ma20
Rows: 10664

crisis_score_multi_position_v2_ma20 に、1997年以降のスコアが保存されました。

テーブル件数を確認する

SQLiteでテーブル件数を確認します。

sqlite3 research.sqlite
SELECT COUNT(*) FROM crisis_score_multi_position_v2_ma20;

既存の multi_v2 と同じ件数であれば、基本的な保存処理は問題ありません。

今回の確認では、既存版と同じく 10664 行が保存されていました。

イベント比較を実行する

次に、過去のリスクイベントに対する比較を行います。

python check_crisis_events_multi_position_v2_ma20.py

比較対象は次の5つです。

  • base
  • jpy_only
  • multi_v1
  • multi_v2
  • multi_v2_ma20

比較結果はCSVにも保存します。

check_crisis_events_multi_position_v2_ma20_compare.csv

比較で見るポイント

今回の比較では、細かいスコア差よりも、次の3点を重視しました。

  1. イベント前の warning 到達日が早まるか
  2. risk_off 到達が過剰に早まりすぎないか
  3. 平時の誤警戒が増えすぎないか

危機スコアは、単に高ければよいわけではありません。

リスクイベントの前に早めに警戒できることは重要ですが、平時でも常に高いスコアになってしまうと、実用上は使いにくくなります。

そのため、前倒し改善とノイズ増加のバランスを見る必要があります。

比較結果の概要

今回の結果では、multi_v2_ma20 は既存の multi_v2 と比べて、スコア水準は少し動きました。

たとえば、次のイベントではわずかにスコアが上がっています。

  • ITバブル崩壊
  • 9.11後市場混乱
  • パリバ・ショック
  • リーマン破綻
  • ギリシャ・欧州債務危機
  • チャイナショック
  • 2018年末リスクオフ

ただし、改善幅は大きくありませんでした。

多くのイベントでは、multi_v2 との差はおおむね小さく、スコアを大きく押し上げるほどの効果は出ていません。

改善しなかった点

逆に、わずかにスコアが下がったイベントもありました。

  • LTCM危機
  • コロナショック初動
  • SVB破綻

特に重要なのは、warningrisk_off の到達日がほとんど変わらなかったことです。

今回の目的は、position層を少し厚くすることで、リスクイベント前の警戒タイミングが改善するかを見ることでした。

しかし結果としては、スコアの水準は少し変わるものの、レジーム到達タイミングの前倒し効果はほぼ出ませんでした。

今回の評価

今回の検証結果をまとめると、次のようになります。

  • 実装は成功
  • 保存テーブルも問題なし
  • 既存スコアとの比較もできた
  • 極端なノイズ増加は見られない
  • ただし改善幅は小さい
  • multi_v2 を置き換える決定打にはならない

そのため、現時点では multi_v2 を本命として維持し、multi_v2_ma20 は比較用として残しておくのがよさそうです。

今回の変更から分かったこと

ma20_gap を追加したことで、各通貨バケットの中身は少し厚くなりました。

ただし、今回の変更はあくまで「各通貨の中に1要素を足しただけ」です。

JPY・CHF・AUD・CAD の相対関係そのものを直接見ているわけではありません。

そのため、リスクオフ局面で安全通貨とリスク通貨の差が広がるような動きを、十分に捉えられていない可能性があります。

次に試すなら safe_vs_risk spread

次に試すなら、safe_vs_risk spread の方が良さそうです。

たとえば、次のような考え方です。

safe_vs_risk_spread
= (JPY + CHF) / 2 - (AUD + CAD) / 2

JPYとCHFを安全通貨群、AUDとCADをリスク通貨群として見ます。

その差を直接スコアに入れることで、単独通貨の変化ではなく、通貨群どうしの相対的な傾きを見られるようになります。

今回の ma20_gap 追加ではインパクトが小さかったため、次はこの safe_vs_risk spread を検証する価値がありそうです。

まとめ

今回は、既存の multi_v2 を母体にして、position層へ ma20_gap を追加しました。

実装としては、既存コードを複製し、保存先テーブルを分け、position層の通貨バケットへ ma20_gap を追加するだけです。

結果として、スクリプトは問題なく動作し、比較用テーブルも作成できました。

一方で、過去のリスクイベントに対する警戒タイミングの改善は限定的でした。

そのため、今回の版は本命スコアへの置き換えではなく、比較用として残しておくのがよさそうです。

次回は、JPY・CHF と AUD・CAD の相対関係を見る safe_vs_risk spread を追加し、危機スコアへの影響を検証してみます。

コメント

タイトルとURLをコピーしました