FREDの危機監視系列を取得テストし、SQLiteのmarket.dbへ追加する

これを

市場データを危機監視に使いやすい特徴量へ変換し、SQLiteへ保存する

前回までに、FREDやStooqから取得した市場データをSQLiteの market.db に保存できるようにしました。

ただし、取得した生データをそのまま危機監視に使うのは少し扱いづらいです。

理由は、FREDやStooqから取得した系列には、日次・週次・月次・四半期といった頻度の違いがあり、さらに系列ごとに意味も異なるためです。

たとえば、VIXは市場の警戒感を見る指標、米国債利回りは金利環境を見る指標、社債スプレッドは信用不安を見る指標です。

これらをそのまま並べるだけでは、後で危機スコアを作るときに扱いにくくなります。

そこで今回は、market.db に保存した生データをもとに、危機監視用の特徴量を作成し、分析用データベースである research.sqlite に保存します。

今回の位置づけ

今回の作業は、金融時系列データを「取得する段階」から「危機監視に使いやすい形へ加工する段階」へ進めるものです。

全体の流れは以下です。

  1. fetch_fred_to_sqlite.py でFREDやStooqから生データを取得する
  2. market.dbseries_observations に保存する
  3. build_crisis_features.py で危機監視用の特徴量を生成する
  4. research.sqlitecrisis_features に保存する
  5. 次回以降、build_crisis_score.py で危機スコアを作る

今回はこのうち、build_crisis_features.py の作成と検証を行います。

生データと分析用データを分ける理由

今回のポイントは、market.db を直接加工して上書きしないことです。

生データと分析用データは、役割が違います。

market.db は、FREDやStooqから取得した元データを保存する場所です。

一方、research.sqlite は、分析しやすいように加工したデータを保存する場所です。

この2つを分けておくと、後で別の分析をしたくなったときに便利です。

たとえば、金価格の長期データは、危機監視の日次スコアには長すぎるかもしれません。

しかし、ドル、金、原油、金融危機を長期比較する用途では価値があります。

そのため、生データは削除せずに残し、危機監視用の特徴量を作る段階で必要な期間だけを使う方針にします。

今回使う市場データ

今回の特徴量生成では、以下の系列を使います。

  • DGS10:米10年国債利回り
  • DGS2:米2年国債利回り
  • DGS3MO:米3か月国債利回り
  • VIXCLS:VIX指数
  • BAMLH0A0HYM2:米ハイイールド債OAS
  • BAMLC0A4CBBB:米BBB格社債OAS
  • SOFR:SOFR
  • NFCI:Chicago Fed National Financial Conditions Index
  • ANFCI:Chicago Fed Adjusted National Financial Conditions Index
  • DRTSCILM:銀行の貸出態度を示すSLOOS系列
  • DEXJPUS:ドル円
  • DCOILWTICO:WTI原油価格
  • XAUUSD_STOOQ:Stooqから取得した金価格

これらをそのまま使うのではなく、後で危機スコアに使いやすい名前へ変換します。

たとえば、VIXCLSvixBAMLH0A0HYM2hy_oas のように扱います。

作成する特徴量

今回作る特徴量は、大きく分けて4種類です。

1. イールドカーブ系

米国債利回りから、イールドカーブを見るための特徴量を作ります。

  • yc_10y_2y = dgs10 - dgs2
  • yc_10y_3m = dgs10 - dgs3mo

イールドカーブは、景気後退懸念や金融政策への見方を確認するためによく使われます。

特に、長期金利と短期金利の差が小さくなったり、逆転したりすると、市場が将来の景気悪化を警戒している可能性があります。

そのため、10年-2年、10年-3か月の差を特徴量として保存しておきます。

2. 信用スプレッド系

社債市場のストレスを見るために、信用スプレッド系の特徴量を作ります。

  • hy_oas:ハイイールド債OAS
  • bbb_oas:BBB格社債OAS
  • hy_minus_bbb = hy_oas - bbb_oas

信用不安が強まると、リスクの高い社債ほど投資家から避けられやすくなります。

その結果、ハイイールド債のスプレッドが拡大しやすくなります。

hy_minus_bbb を作ることで、ハイイールド債とBBB格社債のストレス差を見やすくします。

3. 5日変化

危機監視では、水準だけでなく、短期間の変化も重要です。

たとえば、VIXが高いかどうかだけでなく、直近数日で急に上がったかどうかも見たいです。

そこで、主要な特徴量について5日変化を作ります。

  • vix_chg_5d
  • hy_oas_chg_5d
  • bbb_oas_chg_5d
  • hy_minus_bbb_chg_5d
  • yc_10y_2y_chg_5d
  • yc_10y_3m_chg_5d
  • nfci_chg_5d
  • anfci_chg_5d

これにより、直近1週間程度で市場環境がどの方向に動いたかを見やすくなります。

4. 20日z-score

20日z-scoreは、直近20日間の平均からどのくらい離れているかを見るための特徴量です。

同じVIXの25でも、普段から25前後で推移している時期と、普段は15前後なのに急に25になった時期では意味が違います。

z-scoreを使うと、「その系列にとって今の値がどれくらい異常か」を比較しやすくなります。

危機スコアを作るときには、単純な水準だけでなく、このような標準化した特徴量が役立ちます。

5. 20日移動平均乖離

20日移動平均乖離は、現在値が直近20日平均からどれくらい離れているかを見るための特徴量です。

z-scoreほど標準化はされませんが、直近の平均からのズレを直感的に見られます。

今回は、VIX、信用スプレッド、ドル円、WTI、金価格などに対して作成します。

日次化とforward fillの考え方

FREDの系列は、すべてが日次ではありません。

VIXや米国債利回りは日次で取得できます。

一方で、NFCIやANFCIは週次、SLOOSは四半期です。

このまま横結合すると、低頻度系列は多くの日付で欠損になります。

しかし、危機スコアを日次で作るなら、各日付で参照できる値が必要です。

そこで、低頻度系列についてはforward fillします。

forward fillは、直近の公表値を次の公表値が出るまで使う方法です。

たとえば、四半期ごとに発表されるSLOOSは、毎日更新されるわけではありません。

それでも、次の値が出るまでは、直近の値がその時点で利用できる最新情報です。

この考え方で、週次・四半期系列も日次の危機監視データに組み込みます。

build_crisis_features.pyを作成する

それでは、build_crisis_features.py を作成します。

vim build_crisis_features.py

コードは以下です。

import sqlite3
import pandas as pd
import numpy as np

MARKET_DB = "market.db"
RESEARCH_DB = "research.sqlite"

# 危機監視用の分析期間

ANALYSIS_START_DATE = "1990-01-01"

# True にすると保存前に crisis_features を全削除

RESET_CRISIS_FEATURES = True

# 使用する系列

FEATURE_SERIES = {
"DGS10": "dgs10",
"DGS2": "dgs2",
"DGS3MO": "dgs3mo",
"VIXCLS": "vix",
"BAMLH0A0HYM2": "hy_oas",
"BAMLC0A4CBBB": "bbb_oas",
"SOFR": "sofr",
"NFCI": "nfci",
"ANFCI": "anfci",
"DRTSCILM": "sloos",
"DEXJPUS": "usdjpy",
"DCOILWTICO": "wti",
"XAUUSD_STOOQ": "gold",
}

def load_series_wide(db_path: str, series_map: dict[str, str]) -> pd.DataFrame:
series_ids = list(series_map.keys())
placeholders = ",".join(["?"] * len(series_ids))

```
sql = f"""
SELECT series_id, date, value
FROM series_observations
WHERE series_id IN ({placeholders})
ORDER BY date
"""

with sqlite3.connect(db_path) as conn:
    df = pd.read_sql_query(sql, conn, params=series_ids)

if df.empty:
    raise RuntimeError("series_observations から対象系列を取得できませんでした")

df["date"] = pd.to_datetime(df["date"])
df["value"] = pd.to_numeric(df["value"], errors="coerce")

wide = df.pivot(index="date", columns="series_id", values="value").sort_index()

rename_map = {k: v for k, v in series_map.items() if k in wide.columns}
wide = wide.rename(columns=rename_map)
wide.columns.name = None

return wide
```

def zscore_rolling(s: pd.Series, window: int = 20) -> pd.Series:
mean_ = s.rolling(window).mean()
std_ = s.rolling(window).std()

```
return (s - mean_) / std_
```

def build_features(wide: pd.DataFrame) -> pd.DataFrame:
df = wide.copy()

```
# 危機監視用の分析期間だけ使う
df = df[df.index >= ANALYSIS_START_DATE]

if df.empty:
    raise RuntimeError(f"{ANALYSIS_START_DATE} 以降のデータがありません")

# 日次軸へそろえる
full_index = pd.date_range(df.index.min(), df.index.max(), freq="D")
df = df.reindex(full_index)

# 低頻度系列も含めて前方補完
df = df.ffill()

# イールドカーブ
df["yc_10y_2y"] = df["dgs10"] - df["dgs2"]
df["yc_10y_3m"] = df["dgs10"] - df["dgs3mo"]

# 信用スプレッド差
df["hy_minus_bbb"] = df["hy_oas"] - df["bbb_oas"]

# 5日変化
change_cols = [
    "vix", "hy_oas", "bbb_oas", "hy_minus_bbb",
    "yc_10y_2y", "yc_10y_3m", "sofr", "nfci", "anfci", "sloos",
    "usdjpy", "wti", "gold"
]

for col in change_cols:
    if col in df.columns:
        df[f"{col}_chg_5d"] = df[col] - df[col].shift(5)

# 20日 z-score
z_cols = [
    "vix", "hy_oas", "bbb_oas", "hy_minus_bbb",
    "yc_10y_2y", "yc_10y_3m", "sofr", "nfci", "anfci",
    "usdjpy", "wti", "gold"
]

for col in z_cols:
    if col in df.columns:
        df[f"{col}_z20"] = zscore_rolling(df[col], window=20)

# 20日移動平均乖離
ma_cols = ["vix", "hy_oas", "bbb_oas", "usdjpy", "wti", "gold"]

for col in ma_cols:
    if col in df.columns:
        ma20 = df[col].rolling(20).mean()
        df[f"{col}_ma20_gap"] = df[col] - ma20

preferred_order = [
    "dgs10", "dgs2", "dgs3mo",
    "yc_10y_2y", "yc_10y_3m",
    "hy_oas", "bbb_oas", "hy_minus_bbb",
    "vix", "sofr", "nfci", "anfci", "sloos",
    "usdjpy", "wti", "gold"
]

others = [c for c in df.columns if c not in preferred_order]
df = df[[c for c in preferred_order if c in df.columns] + others]

df.index.name = "date"

return df
```

def init_research_db(db_path: str) -> None:
with sqlite3.connect(db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS crisis_features (
date TEXT NOT NULL,
feature_name TEXT NOT NULL,
value REAL,
PRIMARY KEY (date, feature_name)
);
""")
conn.commit()

def reset_crisis_features_table(db_path: str) -> None:
with sqlite3.connect(db_path) as conn:
conn.execute("DELETE FROM crisis_features;")
conn.commit()

def save_features_long(db_path: str, feature_df: pd.DataFrame) -> int:
out = feature_df.reset_index().melt(
id_vars="date",
var_name="feature_name",
value_name="value"
)

```
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_features(date, feature_name, value)
        VALUES (?, ?, ?)
    """, rows)

    conn.commit()
    return cur.rowcount
```

def main():
print("Loading series from market.db ...")

```
wide = load_series_wide(MARKET_DB, FEATURE_SERIES)

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("Building crisis features ...")

feature_df = build_features(wide)

print(f"Analysis start date: {ANALYSIS_START_DATE}")
print(f"Feature shape: {feature_df.shape}")
print(f"Feature date range: {feature_df.index.min().date()} -> {feature_df.index.max().date()}")

print("Preparing research.sqlite ...")

init_research_db(RESEARCH_DB)

if RESET_CRISIS_FEATURES:
    print("Resetting existing crisis_features ...")
    reset_crisis_features_table(RESEARCH_DB)

print("Saving to research.sqlite ...")

n = save_features_long(RESEARCH_DB, feature_df)

print(f"Upserted {n} rows into crisis_features")
```

if **name** == "**main**":
main()

コードのポイント

このコードで特に重要なのは、次の3点です。

1. 分析期間を1990年以降に制限している

ANALYSIS_START_DATE = "1990-01-01"

危機監視用の特徴量では、すべての生データを最初から使う必要はありません。

後述しますが、今回のデータには1793年から始まる金価格が含まれていました。

そのまま日次化すると、危機監視には長すぎる期間まで特徴量が作られてしまいます。

そこで、危機監視用の特徴量は1990年以降に制限しています。

2. 低頻度系列をforward fillしている

df = df.ffill()

NFCIやANFCIは週次、SLOOSは四半期です。

これらは毎日値があるわけではありません。

しかし、危機スコアを日次で作る場合、各日付で参照できる値が必要になります。

そのため、直近の公表値を次の公表値まで使う形にしています。

3. 保存前にcrisis_featuresを削除している

RESET_CRISIS_FEATURES = True

特徴量の定義や対象期間を変えた場合、古い行が残ると分析結果が混ざります。

INSERT OR REPLACE は同じ主キーの行を置き換えますが、今回対象外になった古い日付の行までは削除しません。

そのため、再生成するときは保存前に crisis_features を空にするようにしました。

スクリプトを実行する

作成したスクリプトを実行します。

python build_crisis_features.py

実行結果は以下です。

Loading series from market.db ...
Loaded columns: ['anfci', 'bbb_oas', 'hy_oas', 'wti', 'usdjpy', 'dgs10', 'dgs2', 'dgs3mo', 'sloos', 'nfci', 'sofr', 'vix', 'gold']
Raw shape: (15484, 13)
Raw date range: 1793-03-01 -> 2026-03-13
Building crisis features ...
Analysis start date: 1990-01-01
Feature shape: (13221, 47)
Feature date range: 1990-01-01 -> 2026-03-13
Preparing research.sqlite ...
Resetting existing crisis_features ...
Saving to research.sqlite ...
Upserted 621387 rows into crisis_features

ここで見るべきポイントは、Raw date rangeFeature date range の違いです。

読み込んだ生データの期間は、1793-03-01 から 2026-03-13 です。

一方で、特徴量として作成した期間は、1990-01-01 から 2026-03-13 です。

これは、生データを削除したという意味ではありません。

生データは market.db に残したまま、危機監視用の特徴量だけ1990年以降に制限しています。

Feature shape: (13221, 47) は、13221日分、47個の特徴量が作られたことを表します。

保存件数も次の計算と一致します。

13221日 × 47特徴量 = 621387行

そのため、保存件数としても整合しています。

ハマりどころ:正常終了しても結果が正しいとは限らない

今回の作業で一番大事だったのは、エラーではなく「成功しているように見える異常」を見つけたことです。

最初に作った版でも、スクリプト自体は正常終了していました。

しかし、結果は以下のようになっていました。

Feature shape: (85114, 47)
Upserted 4000358 rows into crisis_features

Pythonのエラーは出ていません。

SQLiteへの保存もできています。

しかし、危機監視用の特徴量としては、85114日分というのは大きすぎます。

1990年から2026年までの日次データなら、だいたい1.3万日程度です。

そのため、Feature shape を見た時点で、何かの系列が想定より古い日付から始まっている可能性を疑いました。

原因確認:系列ごとの開始日を調べる

どの系列が原因かを調べるため、market.db 側で系列ごとの開始日を確認します。

sqlite3 market.db
SELECT series_id, MIN(date) AS min_date, MAX(date) AS max_date, COUNT(*) AS cnt
FROM series_observations
WHERE series_id IN (
'DGS10','DGS2','DGS3MO','VIXCLS','BAMLH0A0HYM2','BAMLC0A4CBBB',
'SOFR','NFCI','ANFCI','DRTSCILM','DEXJPUS','DCOILWTICO','XAUUSD_STOOQ'
)
GROUP BY series_id
ORDER BY min_date;

結果は以下でした。

XAUUSD_STOOQ|1793-03-01|2026-03-13|15201
DCOILWTICO|1990-01-01|2026-03-09|9441
DEXJPUS|1990-01-01|2026-03-06|9440
DGS10|1990-01-01|2026-03-12|9444
DGS2|1990-01-01|2026-03-12|9444
DGS3MO|1990-01-01|2026-03-12|9444
VIXCLS|1990-01-02|2026-03-12|9443
ANFCI|1990-01-05|2026-03-06|1888
NFCI|1990-01-05|2026-03-06|1888
DRTSCILM|1990-04-01|2026-01-01|144
BAMLC0A4CBBB|1996-12-31|2026-03-12|7718
BAMLH0A0HYM2|1996-12-31|2026-03-12|7718
SOFR|2018-04-03|2026-03-12|2073

原因は、XAUUSD_STOOQ でした。

金価格データが 1793-03-01 から入っていたため、最初のコードでは1793年から2026年までの日次インデックスを作っていました。

full_index = pd.date_range(df.index.min(), df.index.max(), freq="D")

このコード自体は間違っていません。

ただし、読み込むデータに非常に古い系列が含まれている場合、その古い日付まで含めて日次化されます。

今回はそれが危機監視用の特徴量としては長すぎる結果につながりました。

なぜ金価格データを削除しないのか

ここで、XAUUSD_STOOQ の古いデータを削除するという対応も考えられます。

しかし、今回は削除しません。

理由は、古い金価格データにも別の用途があるからです。

長期の金融市場分析では、金価格、ドル、原油、金利、過去の金融危機を比較したくなる可能性があります。

そのとき、1793年からの金価格データは価値があります。

問題は、データが古いことではありません。

長期分析用のデータを、危機監視用の日次特徴量にそのまま混ぜたことです。

そのため、今回は次の方針にしました。

  • market.db には長期データを残す
  • build_crisis_features.py では1990年以降に制限する
  • research.sqlite には危機監視に使う特徴量だけ保存する

このように分けることで、長期分析と危機監視を両立できます。

SQLiteで保存結果を確認する

特徴量を保存したら、research.sqlite の中身を確認します。

sqlite3 research.sqlite

以下のSQLを実行します。

SELECT
feature_name,
MIN(date) AS min_date,
MAX(date) AS max_date,
COUNT(*) AS total_rows,
COUNT(value) AS non_null_rows
FROM crisis_features
GROUP BY feature_name
ORDER BY feature_name;

ここで重要なのは、COUNT(*) だけでなく、COUNT(value) も見ることです。

COUNT(*) は、値がNULLの行も含めて数えます。

一方、COUNT(value) は、値が入っている行だけを数えます。

つまり、total_rowsnon_null_rows を比較することで、特徴量ごとの欠損状況を確認できます。

確認結果の一部は以下です。

anfci|1990-01-01|2026-03-13|13221|13221
anfci_chg_5d|1990-01-01|2026-03-13|13221|13216
anfci_z20|1990-01-01|2026-03-13|13221|13202
bbb_oas|1990-01-01|2026-03-13|13221|10665
bbb_oas_chg_5d|1990-01-01|2026-03-13|13221|10660
bbb_oas_ma20_gap|1990-01-01|2026-03-13|13221|10646
bbb_oas_z20|1990-01-01|2026-03-13|13221|10646
dgs10|1990-01-01|2026-03-13|13221|13221
dgs2|1990-01-01|2026-03-13|13221|13221
dgs3mo|1990-01-01|2026-03-13|13221|13221
gold|1990-01-01|2026-03-13|13221|13221
gold_chg_5d|1990-01-01|2026-03-13|13221|13216
gold_ma20_gap|1990-01-01|2026-03-13|13221|13202
gold_z20|1990-01-01|2026-03-13|13221|13202
hy_minus_bbb|1990-01-01|2026-03-13|13221|10665
hy_minus_bbb_chg_5d|1990-01-01|2026-03-13|13221|10660
hy_minus_bbb_z20|1990-01-01|2026-03-13|13221|10646
hy_oas|1990-01-01|2026-03-13|13221|10665
hy_oas_chg_5d|1990-01-01|2026-03-13|13221|10660
hy_oas_ma20_gap|1990-01-01|2026-03-13|13221|10646
hy_oas_z20|1990-01-01|2026-03-13|13221|10646
nfci|1990-01-01|2026-03-13|13221|13221
nfci_chg_5d|1990-01-01|2026-03-13|13221|13216
nfci_z20|1990-01-01|2026-03-13|13221|13202
sofr|1990-01-01|2026-03-13|13221|2901
sofr_chg_5d|1990-01-01|2026-03-13|13221|2896
sofr_z20|1990-01-01|2026-03-13|13221|2882
usdjpy|1990-01-01|2026-03-13|13221|13221
usdjpy_chg_5d|1990-01-01|2026-03-13|13221|13216
usdjpy_ma20_gap|1990-01-01|2026-03-13|13221|13202
usdjpy_z20|1990-01-01|2026-03-13|13221|13202
vix|1990-01-01|2026-03-13|13221|13220
vix_chg_5d|1990-01-01|2026-03-13|13221|13215
vix_ma20_gap|1990-01-01|2026-03-13|13221|13201
vix_z20|1990-01-01|2026-03-13|13221|13201
wti|1990-01-01|2026-03-13|13221|13221
wti_chg_5d|1990-01-01|2026-03-13|13221|13216
wti_ma20_gap|1990-01-01|2026-03-13|13221|13202
wti_z20|1990-01-01|2026-03-13|13221|13202
yc_10y_2y|1990-01-01|2026-03-13|13221|13221
yc_10y_2y_chg_5d|1990-01-01|2026-03-13|13221|13216
yc_10y_2y_z20|1990-01-01|2026-03-13|13221|13202
yc_10y_3m|1990-01-01|2026-03-13|13221|13221
yc_10y_3m_chg_5d|1990-01-01|2026-03-13|13221|13216
yc_10y_3m_z20|1990-01-01|2026-03-13|13221|13202

確認結果の読み方

今回の確認では、全特徴量の total_rows13221 でそろいました。

これは、すべての特徴量が同じ日次インデックスにそろっていることを意味します。

一方で、non_null_rows は特徴量によって異なります。

これは必ずしも異常ではありません。

たとえば、5日変化の特徴量は、最初の5日分を計算できません。

そのため、*_chg_5d は通常の系列より少しだけ non_null_rows が少なくなります。

また、20日z-scoreや20日移動平均乖離は、最初の20日程度を計算できません。

そのため、*_z20*_ma20_gap の値が20件前後少ないのは自然です。

さらに、OAS系の系列は1996年末ごろから、SOFRは2018年以降から始まります。

このような欠損は、取得ミスではなく元系列の開始時期によるものです。

欠損を見たときは、次の3つに分けて考える必要があります。

  • 計算仕様上、自然に発生する欠損
  • 元系列の開始時期による欠損
  • 取得失敗や処理ミスによる欠損

この切り分けをしておくと、正常な欠損をエラー扱いせずに済みます。

次の危機スコアで使う特徴量候補

今回作成した crisis_features は、次に作る build_crisis_score.py の土台になります。

最初の版では、複雑な機械学習モデルではなく、ルールベースのスコアから始める予定です。

優先して使う特徴量

  • vix_z20
  • yc_10y_2y_z20
  • yc_10y_3m_z20
  • nfci_z20
  • anfci_z20

これらは比較的長い期間で使いやすく、市場の警戒感、イールドカーブ、金融環境をまとめて見られます。

追加で使いたい特徴量

  • hy_oas_z20
  • bbb_oas_z20
  • hy_minus_bbb_z20

信用市場のストレスを見るには、OAS系の特徴量が重要です。

ただし、これらは1996年末ごろから始まるため、スコアの開始日を考える必要があります。

補助的に見る特徴量

  • sofr_z20
  • sloos
  • gold_z20
  • usdjpy_z20

SOFRは重要ですが、履歴が2018年以降と短いため、最初の危機スコアでは補助指標として扱う方がよさそうです。

SLOOSも四半期系列なので、日次スコアの中心というより、信用環境の背景を見るための補助指標として使う方が自然です。

危機スコアの開始日は1997年以降が扱いやすい

今回の特徴量は、1990年以降で作成しました。

ただし、すべての特徴量が1990年から使えるわけではありません。

特に、信用スプレッド系の hy_oasbbb_oas は1996年末ごろから始まります。

そのため、次に作る危機スコアでは、1997-01-01 以降を対象にすると扱いやすそうです。

SOFRまで含めて完全にそろえようとすると、2018年以降になります。

しかし、それではドットコムバブル崩壊、リーマンショック、欧州債務危機などの比較に使える期間が短くなります。

そのため、最初は1997年以降を対象にし、SOFRは補助指標として扱う方針にします。

今回できたこと

今回の作業で、危機監視用の特徴量テーブルを作る流れができました。

  • market.db から必要な市場データを読み込んだ
  • 系列IDを分析で使いやすい名前に変換した
  • 日次インデックスへそろえた
  • 週次・四半期系列をforward fillした
  • イールドカーブ系の特徴量を作成した
  • 信用スプレッド系の特徴量を作成した
  • 5日変化を作成した
  • 20日z-scoreを作成した
  • 20日移動平均乖離を作成した
  • research.sqlitecrisis_features に保存した
  • 1793年から始まる金価格データによる日次展開の巨大化に気づいた
  • 生データは残したまま、特徴量生成側で1990年以降に制限した
  • SQLiteで保存件数と欠損状況を確認した

ハマりどころ

正常終了したログだけでは安心できない

今回の最初の実行では、PythonエラーもSQLiteエラーも出ていませんでした。

しかし、Feature shape: (85114, 47) という数字を見れば、危機監視用としては大きすぎることに気づけます。

処理が正常終了したかどうかだけでなく、行数、期間、欠損数を確認することが大事です。

生データの期間が分析用途に合うとは限らない

金価格データのように、長期データが入っていること自体は悪いことではありません。

しかし、危機監視用の日次特徴量としては長すぎる場合があります。

用途に応じて、特徴量生成側で期間を切る方が安全です。

INSERT OR REPLACEだけでは古い対象外レコードは消えない

一度1793年起点で crisis_features を作ってしまうと、あとから1990年以降に制限して再実行しても、古い日付のレコードが残る可能性があります。

INSERT OR REPLACE は同じ主キーの行を置き換えるだけで、今回対象外になった古い日付の行までは削除しません。

そのため、今回は保存前に以下で全削除しました。

conn.execute("DELETE FROM crisis_features;")

次にやること

次は、今回作成した crisis_features を使って、build_crisis_score.py を作ります。

最初の版では、説明しやすいルールベースの危機スコアにする予定です。

  • VIXが通常より高いか
  • 信用スプレッドが拡大しているか
  • イールドカーブが悪化しているか
  • NFCIやANFCIが悪化しているか

これらを重み付きで合計し、スコアに応じて次のようなレジームに分類します。

  • normal
  • caution
  • warning
  • risk_off

まずは説明できるシンプルなルールから始めて、過去の危機局面と照らし合わせながら調整していきます。

まとめ

今回は、FREDやStooqから取得した市場データを、危機監視に使いやすい特徴量へ変換する build_crisis_features.py を作成しました。

生データは market.db に残し、分析用の特徴量は research.sqlitecrisis_features に保存する構成にしました。

日次、週次、四半期の系列を日次インデックスへそろえ、イールドカーブ、信用スプレッド差、5日変化、20日z-score、20日移動平均乖離を作成しています。

途中で、金価格データ XAUUSD_STOOQ が1793年から入っていたため、最初の特徴量生成では85114日分まで膨らむ問題がありました。

この問題は、生データを削除せず、危機監視用の特徴量生成側で 1990-01-01 以降に期間制限することで対応しました。

修正後は、1990年以降の13221日分、47特徴量を生成し、621387行として保存できました。

これで、危機スコアを作るための土台ができました。

次は build_crisis_score.py を作成し、危機スコアとレジーム判定へ進みます。

[/writing]

カテゴリ

開発ログ

タグ候補

Python, SQLite, FRED, Stooq, 金融データ分析, 危機監視, 特徴量生成, 時系列分析

タイトル候補

市場データを危機監視に使いやすい特徴量へ変換し、SQLiteへ保存する

メタディスクリプション案

FREDやStooqから取得した市場データを、危機監視に使いやすい特徴量へ変換する build_crisis_features.py を作成。日次化、forward fill、5日変化、20日z-score、金価格データの1793年問題への対応を整理しました。

コメント

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