複数の危機指標を1本の危機スコアにまとめ、SQLiteへ保存する
前回までに、FREDやStooqから取得した市場データをもとに、危機監視用の特徴量を作成できるようにしました。
具体的には、VIX、HY OAS、BBB OAS、イールドカーブ、NFCI、ANFCI、SOFR、SLOOS、金、原油、ドル円などを整形し、research.sqlite の crisis_features テーブルへ保存するところまで進めています。
ただし、特徴量を作っただけでは、まだ少し使いづらいです。
たとえば、ある日の状態を見たときに、次のような判断が必要になります。
- VIXは高いのか
- 信用スプレッドは広がっているのか
- NFCIやANFCIは悪化しているのか
- イールドカーブは危険方向に動いているのか
- 全体として通常なのか、警戒すべきなのか
これを毎回、人間が個別に見て判断するのは大変です。
そこで今回は、複数の危機特徴量をまとめて、0〜100点の「危機スコア」に変換する build_crisis_score.py を作成しました。
この記事では、危機スコアの考え方、実装、実行結果、SQLite上での確認までを整理します。
今回作るもの
今回作る build_crisis_score.py の役割は、バラバラの危機特徴量を1本の「危険度メーター」にまとめることです。
処理の流れは次のようになります。
research.sqliteのcrisis_featuresを読み込む- スコア計算に使う特徴量だけを横持ちに戻す
- 各特徴量の向きをそろえる
- 0〜100点の危機スコアを計算する
normal/caution/warning/risk_offの状態判定を付けるcrisis_scoreテーブルへ保存する
最終的には、次のようなテーブルを作ります。
date score regime 2026-03-10 22.5 normal 2026-03-11 38.1 caution 2026-03-12 67.4 warning 2026-03-13 81.2 risk_off
これにより、個別の特徴量を見るだけでなく、「今の市場環境がどの程度危険なのか」を1つの数値として確認できるようになります。
前回までの流れ
ここまでの処理は、大きく3段階に分けています。
fetch_fred_to_sqlite.pyで市場データを取得するbuild_crisis_features.pyで危機監視用の特徴量を作るbuild_crisis_score.pyで特徴量を危機スコアにまとめる
イメージとしては、build_crisis_features.py は材料を切る工程、build_crisis_score.py は材料を料理して最終判断しやすくする工程です。
特徴量のままだと、VIX、信用スプレッド、金融環境指数、イールドカーブなどをそれぞれ見て判断する必要があります。
危機スコアに変換しておくことで、後から可視化したり、過去の危機局面と比較したりしやすくなります。
危機スコアに使う特徴量
今回は、まずシンプルに次の8つの特徴量を使います。
vix_z20hy_oas_z20bbb_oas_z20hy_minus_bbb_z20nfci_z20anfci_z20yc_10y_2y_z20yc_10y_3m_z20
z20 は、20日ベースのz-scoreです。
ざっくり言えば、「直近20日と比べて、どのくらい異常な水準にあるか」を見るための値です。
このうち、VIX、信用スプレッド、NFCI、ANFCIは、値が高いほど危険方向と考えます。
一方で、イールドカーブ系は少し扱いが異なります。
yc_10y_2y_z20 や yc_10y_3m_z20 は、低下方向、つまり逆イールド方向に進むほど危険として扱いたいため、スコア計算時に符号を反転します。
regime 判定の考え方
スコアは0〜100点で計算し、次のように状態名を付けます。
normal:通常caution:注意warning:警戒risk_off:退避寄り
今回の初期ルールでは、次の閾値にしています。
0以上 35未満 : normal 35以上 60未満 : caution 60以上 80未満 : warning 80以上 : risk_off
この閾値は、最初から完成形として決め打ちするものではありません。
まずは初期版として動かし、過去の危機局面でどのように反応するかを見ながら調整していく前提です。
build_crisis_score.py を作成する
今回作成したスクリプトは次の通りです。
import sqlite3
import pandas as pd
import numpy as np
RESEARCH_DB = "research.sqlite"
# 危機スコアの開始日
SCORE_START_DATE = "1997-01-01"
# True にすると保存前に crisis_score を全削除
RESET_CRISIS_SCORE = True
# 使用する特徴量と重み
# 最初はシンプルに、危機検知に効きやすいものを中心にする
SCORE_FEATURES = {
"vix_z20": 1.2,
"hy_oas_z20": 1.4,
"bbb_oas_z20": 1.0,
"hy_minus_bbb_z20": 1.2,
"nfci_z20": 1.0,
"anfci_z20": 1.0,
"yc_10y_2y_z20": 0.8,
"yc_10y_3m_z20": 0.8,
}
# 値が高いほど危険な方向にそろえるための符号
# イールドカーブ系は z が低いほど危険になりやすいので -1 をかける
FEATURE_DIRECTION = {
"vix_z20": 1.0,
"hy_oas_z20": 1.0,
"bbb_oas_z20": 1.0,
"hy_minus_bbb_z20": 1.0,
"nfci_z20": 1.0,
"anfci_z20": 1.0,
"yc_10y_2y_z20": -1.0,
"yc_10y_3m_z20": -1.0,
}
def load_features_wide(db_path: str, feature_weights: dict[str, float]) -> pd.DataFrame:
feature_names = list(feature_weights.keys())
placeholders = ",".join(["?"] * len(feature_names))
sql = f"""
SELECT date, feature_name, value
FROM crisis_features
WHERE feature_name IN ({placeholders})
ORDER BY date
"""
with sqlite3.connect(db_path) as conn:
df = pd.read_sql_query(sql, conn, params=feature_names)
if df.empty:
raise RuntimeError("crisis_features から対象特徴量を取得できませんでした")
df["date"] = pd.to_datetime(df["date"])
df["value"] = pd.to_numeric(df["value"], errors="coerce")
wide = df.pivot(index="date", columns="feature_name", values="value").sort_index()
wide.columns.name = None
return wide
def normalize_to_unit_interval(s: pd.Series, clip_min: float = -3.0, clip_max: float = 3.0) -> pd.Series:
x = s.clip(lower=clip_min, upper=clip_max)
return (x - clip_min) / (clip_max - clip_min)
def compute_score(wide: pd.DataFrame) -> pd.DataFrame:
df = wide.copy()
# スコア対象期間
df = df[df.index >= SCORE_START_DATE]
if df.empty:
raise RuntimeError(f"{SCORE_START_DATE} 以降のデータがありません")
# 方向調整後の値を作る
oriented_cols = []
for col in SCORE_FEATURES:
if col not in df.columns:
df[col] = np.nan
directed = df[col] * FEATURE_DIRECTION[col]
adj_col = f"{col}_adj"
df[adj_col] = directed
oriented_cols.append(adj_col)
# 全特徴量が揃っている日だけでスコア計算
adj_df = df[oriented_cols].copy()
valid_mask = adj_df.notna().all(axis=1)
# 各特徴量を 0〜1 に押し込む
norm_df = pd.DataFrame(index=adj_df.index)
for col in oriented_cols:
norm_df[col] = normalize_to_unit_interval(adj_df[col])
# 重み付き平均
weights = np.array([SCORE_FEATURES[col.replace("_adj", "")] for col in oriented_cols], dtype=float)
weight_sum = weights.sum()
score_raw = norm_df.mul(weights, axis=1).sum(axis=1) / weight_sum
score_100 = score_raw * 100.0
df["score"] = np.where(valid_mask, score_100, np.nan)
# regime 判定
df["regime"] = np.select(
[
df["score"] >= 80,
df["score"] >= 60,
df["score"] >= 35,
],
[
"risk_off",
"warning",
"caution",
],
default="normal"
)
# score が NaN の日は regime も null 扱い
df.loc[df["score"].isna(), "regime"] = None
# 補助列
df["valid_feature_count"] = adj_df.notna().sum(axis=1)
df["required_feature_count"] = len(oriented_cols)
# 出力列
keep_cols = list(SCORE_FEATURES.keys()) + ["score", "regime", "valid_feature_count", "required_feature_count"]
out = df[keep_cols].copy()
out.index.name = "date"
return out
def init_research_db(db_path: str) -> None:
with sqlite3.connect(db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS crisis_score (
date TEXT PRIMARY KEY,
score REAL,
regime TEXT,
valid_feature_count INTEGER,
required_feature_count INTEGER
);
""")
conn.commit()
def reset_crisis_score_table(db_path: str) -> None:
with sqlite3.connect(db_path) as conn:
conn.execute("DELETE FROM crisis_score;")
conn.commit()
def save_score(db_path: str, score_df: pd.DataFrame) -> int:
out = score_df.reset_index()[["date", "score", "regime", "valid_feature_count", "required_feature_count"]].copy()
out["date"] = pd.to_datetime(out["date"]).dt.strftime("%Y-%m-%d")
out = out.replace({np.nan: None})
rows = list(out.itertuples(index=False, name=None))
with sqlite3.connect(db_path) as conn:
cur = conn.executemany("""
INSERT OR REPLACE INTO crisis_score(
date, score, regime, valid_feature_count, required_feature_count
)
VALUES (?, ?, ?, ?, ?)
""", rows)
conn.commit()
return cur.rowcount
def main():
print("Loading crisis features from research.sqlite ...")
wide = load_features_wide(RESEARCH_DB, SCORE_FEATURES)
print(f"Loaded columns: {list(wide.columns)}")
print(f"Raw shape: {wide.shape}")
print(f"Raw date range: {wide.index.min().date()} -> {wide.index.max().date()}")
print("Computing crisis score ...")
score_df = compute_score(wide)
print(f"Score start date: {SCORE_START_DATE}")
print(f"Score shape: {score_df.shape}")
print(f"Score date range: {score_df.index.min().date()} -> {score_df.index.max().date()}")
print("Latest rows:")
print(score_df.tail(10).to_string())
print("Preparing research.sqlite ...")
init_research_db(RESEARCH_DB)
if RESET_CRISIS_SCORE:
print("Resetting existing crisis_score ...")
reset_crisis_score_table(RESEARCH_DB)
print("Saving to research.sqlite ...")
n = save_score(RESEARCH_DB, score_df)
print(f"Upserted {n} rows into crisis_score")
if __name__ == "__main__":
main()
スコア計算のポイント
今回の処理では、各特徴量を次のように扱っています。
vix_z20:高いほど危険hy_oas_z20:高いほど危険bbb_oas_z20:高いほど危険hy_minus_bbb_z20:高いほど危険nfci_z20/anfci_z20:高いほど危険yc_10y_2y_z20/yc_10y_3m_z20:低いほど危険なので、内部で符号を反転
そのあと、各値を -3 〜 +3 の範囲にクリップし、0〜1に変換しています。
最後に、特徴量ごとの重みをかけて平均し、0〜100点のスコアに変換します。
かなり単純な方式ですが、初期版としては扱いやすいです。
複雑なモデルにする前に、まずは「どの指標が効いて、どのような日にスコアが上がるのか」を確認しやすい形にしています。
実行する
スクリプトを実行します。
python build_crisis_score.py
実行結果は次のようになりました。
Loading crisis features from research.sqlite ...
Loaded columns: ['anfci_z20', 'bbb_oas_z20', 'hy_minus_bbb_z20', 'hy_oas_z20', 'nfci_z20', 'vix_z20', 'yc_10y_2y_z20', 'yc_10y_3m_z20']
Raw shape: (13221, 8)
Raw date range: 1990-01-01 -> 2026-03-13
Computing crisis score ...
Score start date: 1997-01-01
Score shape: (10664, 12)
Score date range: 1997-01-01 -> 2026-03-13
Latest rows:
vix_z20 hy_oas_z20 bbb_oas_z20 hy_minus_bbb_z20 nfci_z20 anfci_z20 yc_10y_2y_z20 yc_10y_3m_z20 score regime valid_feature_count required_feature_count
date
2026-03-04 0.807812 0.052784 0.296578 -0.090732 1.276155 1.271257 -2.056812 0.833333 59.438821 caution 8 8
2026-03-05 2.348306 0.374235 0.548017 0.262678 1.154408 1.150212 -1.504600 2.079823 62.003113 warning 8 8
2026-03-06 3.463609 1.650751 1.060845 1.916295 2.025600 2.031908 -0.285523 2.342101 73.181938 warning 8 8
2026-03-07 2.600021 1.462178 0.960036 1.672412 1.777279 1.782367 -0.201299 1.968407 70.396756 warning 8 8
2026-03-08 2.135261 1.307370 0.868604 1.479875 1.592672 1.597124 -0.101895 1.705840 67.745484 warning 8 8
2026-03-09 0.959510 1.644490 1.060392 1.852276 1.451470 1.455653 -1.471454 0.663579 70.417150 warning 8 8
2026-03-10 0.734039 0.398062 0.682883 0.265274 1.344845 1.349088 -0.380809 1.157302 58.952546 caution 8 8
2026-03-11 0.496507 0.606357 1.462599 0.200174 1.272788 1.277532 -0.837150 1.943590 59.548978 caution 8 8
2026-03-12 1.174326 1.286405 2.076794 0.811393 1.151525 1.155586 -2.917442 2.247268 68.063244 warning 8 8
2026-03-13 1.061141 1.206051 1.860624 0.740831 1.046573 1.050116 -2.327336 1.906621 66.160164 warning 8 8
Preparing research.sqlite ...
Resetting existing crisis_score ...
Saving to research.sqlite ...
Upserted 10664 rows into crisis_score
実行結果を確認する
まず、対象の8特徴量は正しく読み込めています。
Loaded columns: ['anfci_z20', 'bbb_oas_z20', 'hy_minus_bbb_z20', 'hy_oas_z20', 'nfci_z20', 'vix_z20', 'yc_10y_2y_z20', 'yc_10y_3m_z20'] Raw shape: (13221, 8)
crisis_features から、スコア計算に必要な8系列を取得できています。
また、スコア対象期間は 1997-01-01 以降にしています。
Score start date: 1997-01-01 Score shape: (10664, 12) Score date range: 1997-01-01 -> 2026-03-13
1997年以降にしている理由は、HY OASやBBB OASなどの信用スプレッド系が揃いやすくなるためです。
直近では、必要な特徴量がすべて揃っています。
valid_feature_count = 8 required_feature_count = 8
つまり、直近のスコアは8つの特徴量すべてを使って計算できています。
直近の判定を見る
直近の結果を見ると、2026-03-13 時点では次のようになっています。
2026-03-13 score = 66.160164 regime = warning
今回のルールでは、60以上80未満は warning です。
つまり、通常よりもかなり警戒が必要な状態として判定されています。
直近の流れを見ると、次のようになっています。
- 2026-03-05 〜 2026-03-09:
warning - 2026-03-10 〜 2026-03-11:
caution - 2026-03-12 〜 2026-03-13:再び
warning
この結果だけを見ると、危機度はやや高止まりしているように見えます。
特に、VIX、信用スプレッド、NFCI、ANFCI、イールドカーブが複合的に効いて、warning に入っている状態です。
SQLiteで保存結果を確認する
スクリプト上では保存成功と表示されていますが、念のためSQLite側でも確認します。
sqlite3 research.sqlite
全体件数を確認する
まず、crisis_score テーブルの期間と件数を確認します。
SELECT MIN(date) AS min_date, MAX(date) AS max_date, COUNT(*) AS total_rows, COUNT(score) AS non_null_score_rows FROM crisis_score;
結果です。
1997-01-01|2026-03-13|10664|10641
total_rows は10664行、score が入っている行は10641行です。
差分は23行あります。
この23行は、主に開始直後に必要な特徴量がまだ揃わず、スコアを計算できなかった日です。
z-scoreやOAS系の開始タイミングを考えると、この程度のNULLは自然です。
regime の分布を確認する
次に、状態判定の分布を確認します。
SELECT regime, COUNT(*) FROM crisis_score GROUP BY regime ORDER BY regime;
結果です。
|23 caution|5819 normal|2159 risk_off|172 warning|2491
空欄の23行は、スコアがNULLだった行です。
分布を見ると、risk_off は172行だけなので、本当に強い警戒日に絞られています。
一方で、caution が5819行と多めです。
これは、今回の初期ルールがやや警戒寄りに出る設定になっていることを示しています。
危機監視レーダーの初期版としては、見逃しを減らす意味で悪くありません。
ただし、実運用に近づける場合は、閾値の再調整が必要になりそうです。
直近20件を確認する
直近の状態も確認します。
SELECT * FROM crisis_score ORDER BY date DESC LIMIT 20;
結果です。
2026-03-13|66.1601642050738|warning|8|8 2026-03-12|68.0632443681154|warning|8|8 2026-03-11|59.5489775950895|caution|8|8 2026-03-10|58.9525456470283|caution|8|8 2026-03-09|70.417150420088|warning|8|8 2026-03-08|67.7454844082668|warning|8|8 2026-03-07|70.3967555220573|warning|8|8 2026-03-06|73.181937747609|warning|8|8 2026-03-05|62.003113251451|warning|8|8 2026-03-04|59.4388207509542|caution|8|8 2026-03-03|75.8919993309965|warning|8|8 2026-03-02|69.8629812897748|warning|8|8 2026-03-01|75.4666737319145|warning|8|8 2026-02-28|79.0368612873017|warning|8|8 2026-02-27|82.9290503469762|risk_off|8|8 2026-02-26|67.1483747736185|warning|8|8 2026-02-25|61.6260975193054|warning|8|8 2026-02-24|68.2708045600426|warning|8|8 2026-02-23|70.2039103816834|warning|8|8 2026-02-22|55.9179940527823|caution|8|8
直近20日では、caution と warning が多く、2026-02-27には risk_off も出ています。
この時点では、スコアの出力自体は正常で、かつ市場ストレスが高めに出ていることが分かります。
スコアの範囲を確認する
次に、スコア全体の最小値、最大値、平均値を確認します。
SELECT MIN(score) AS min_score, MAX(score) AS max_score, AVG(score) AS avg_score FROM crisis_score WHERE score IS NOT NULL;
結果です。
10.4194420962834|95.8463821606999|48.8659829060574
スコアは10台から95台まで広がっています。
0〜100の範囲にある程度きれいに分布しており、極端に圧縮されすぎているわけでも、すぐに100付近へ張り付いているわけでもありません。
この点は初期版としてかなり良さそうです。
ただし、平均値が約48.9なので、現在の閾値だと caution が出やすい設計になっています。
高スコア日を確認する
危機スコアが本当にそれらしい動きをしているかを見るために、高スコア日も確認します。
SELECT * FROM crisis_score WHERE score IS NOT NULL ORDER BY score DESC LIMIT 30;
結果です。
2010-05-07|95.8463821606999|risk_off|8|8 2015-12-11|93.3092821957565|risk_off|8|8 2021-11-26|91.7035074032255|risk_off|8|8 2020-02-24|91.542824771873|risk_off|8|8 2014-12-12|91.3011661827053|risk_off|8|8 2017-08-11|91.2436196337221|risk_off|8|8 2014-01-24|90.9186781571242|risk_off|8|8 2010-05-06|90.8914322713623|risk_off|8|8 2012-04-09|90.6755493669421|risk_off|8|8 2011-08-04|90.5537196635391|risk_off|8|8 2019-08-05|90.4771314565138|risk_off|8|8 2025-04-04|90.430609983188|risk_off|8|8 2008-11-20|90.4095448819216|risk_off|8|8 2012-05-18|90.3618194167048|risk_off|8|8 2020-02-25|90.3572181114431|risk_off|8|8 2012-04-10|90.2671832128906|risk_off|8|8 2019-08-02|89.8603388690827|risk_off|8|8 2015-01-06|89.7160919089642|risk_off|8|8 2005-08-12|89.3633469118001|risk_off|8|8 2011-08-08|89.3315833141199|risk_off|8|8 2012-05-17|89.2828788634447|risk_off|8|8 2011-08-05|88.728786140397|risk_off|8|8 2007-07-20|88.5542891280665|risk_off|8|8 2011-09-22|88.5004086115123|risk_off|8|8 2019-05-07|88.3930700778644|risk_off|8|8 2008-11-21|88.1187676954605|risk_off|8|8 2011-03-16|87.8833871024975|risk_off|8|8 1999-09-24|87.6823211868518|risk_off|8|8 2010-05-08|87.5707801679763|risk_off|8|8 2006-05-19|87.0409367188216|risk_off|8|8
上位日を見ると、かなりそれらしい日付が並んでいます。
- 2008年11月:金融危機局面
- 2010年5月:フラッシュクラッシュ前後
- 2011年8月:欧州債務危機・米国格下げ局面
- 2020年2月:コロナショック初動
- 2019年8月:市場が荒れた局面
もちろん、これだけで完成とは言えません。
ただ、少なくとも「明らかに市場が荒れた日」を上位に持ってくる力はありそうです。
今回の結果から分かったこと
今回の build_crisis_score.py は、初期版としては正常に動いています。
確認できたことは次の通りです。
crisis_featuresから必要な8特徴量を取得できた- 1997年以降のスコアを計算できた
crisis_scoreテーブルへ保存できた- 直近では必要特徴量がすべて揃っている
- 高スコア日に過去の市場混乱期が含まれている
一方で、改善点もあります。
特に、normal よりも caution が多いため、今の閾値はやや警戒寄りです。
これは資産保全型の危機監視レーダーとしては悪くありませんが、日常的に使う場合は少し過敏かもしれません。
今後の調整候補
次に調整するなら、主に3つあります。
1. regime 閾値の再調整
現在は次の設定です。
normal : 35未満 caution : 35以上 60未満 warning : 60以上 80未満 risk_off : 80以上
ただ、caution が多いため、たとえば次のように閾値を上げる案があります。
normal : 45未満 caution : 45以上 65未満 warning : 65以上 85未満 risk_off : 85以上
このあたりは、過去の危機局面での反応を見ながら決めるのがよさそうです。
2. 特徴量の重み調整
現在は、VIXやOAS系がやや強めに効く設計です。
そのため、信用スプレッドやボラティリティが動いたときに、スコアが上がりやすくなります。
今後は、NFCI、ANFCI、イールドカーブとのバランスを見ながら、重みを調整する余地があります。
3. 過去危機イベントとの照合
一番重要なのは、過去の危機イベントと照合することです。
たとえば、次のような局面でスコアがどう動いたかを確認します。
- 1998年:LTCM危機
- 2000年前後:ITバブル崩壊
- 2008年:リーマンショック
- 2011年:欧州債務危機
- 2020年:コロナショック
- 2023年:米地銀不安
単に危機当日にスコアが高いかを見るだけでなく、危機の何日前から上がり始めたかを見る必要があります。
ここが確認できると、危機監視レーダーとしての実用性をかなり判断しやすくなります。
注意点
今回作った危機スコアは、投資判断を自動化するための完成モデルではありません。
あくまで、市場環境を俯瞰するための監視指標です。
特に、スコアが高いから必ず下落する、スコアが低いから安全、というものではありません。
市場の状態を整理するための補助指標として使い、過去データで妥当性を確認しながら調整していく必要があります。
まとめ
今回は、複数の危機特徴量を1本の危機スコアにまとめる build_crisis_score.py を作成しました。
research.sqlite の crisis_features から必要な特徴量を読み込み、0〜100点のスコアと normal / caution / warning / risk_off の状態判定を作成しています。
実行結果として、crisis_score テーブルに10664行を保存できました。
高スコア日には、2008年の金融危機、2010年のフラッシュクラッシュ前後、2011年の欧州債務危機、2020年のコロナショック初動などが含まれており、初期版としてはかなりそれらしい動きになっています。
一方で、現在の閾値では caution が多めに出ているため、今後は過去危機イベントと照合しながら、閾値や重みを調整していきます。
次回は、過去の主要危機日を指定して、その前後で crisis_score がどう動いたかを確認するSQLまたはスクリプトを作成します。
カテゴリ
開発ログ
タグ候補
Python, SQLite, FRED, 金融データ, 危機監視, 市場分析, pandas, データ分析
タイトル候補
- 複数の危機指標を1本の危機スコアにまとめ、SQLiteへ保存する
- VIX・OAS・NFCI・イールドカーブから危機スコアを作る
- Pythonで市場の危機監視スコアを作成し、SQLiteへ保存する
メタディスクリプション案
FREDやStooqから取得した市場データをもとに、VIX、信用スプレッド、NFCI、イールドカーブなどの特徴量を1本の危機スコアへ変換し、SQLiteへ保存するPythonスクリプトを作成した記録です。

コメント