このプロジェクトについて
EDINET(有価証券報告書等)の開示データをもとに、日本株の財務指標・投資判断スコアを日次で算出・公開しているサービスです。
公開サイトはこちら →
https://stocks.happy-life-design.net
前回の記事はこちら →
https://qiita.com/rightcross-ae86/items/7a5045fb173b2889f4a7
発端: 祝日でも「本日のスコア」が更新され続けていた
投資判断スコア(daily_scores)とシグナル(investment_signals)を毎日算出する定期実行は、平日固定(月〜金)のスケジュールで動いていました。日本の祝日は判定対象に入っておらず、海の日(2026-07-20)・山の日(2026-08-11)のような祝日にも通常どおり起動していました。
実データを確認したところ、祝日は株価データ(stock_prices)が完全に0件でした(取引自体が発生しないため)。それにもかかわらず、スコア算出処理は前営業日の終値を暗黙に使って計算を成立させ、祝日の日付でdaily_scores/investment_signalsに行を書き込んでいました。画面側にはどの日付を基準に計算したかを示す表示が元々無かったため、祝日にサイトを見たユーザーには「本日更新された最新スコア」に見えていました。実際の値は前営業日と同じ計算結果です。
対応①: カバー率で休場日を機械的に判定する
祝日カレンダーを持たせる代わりに、その日のstock_pricesカバー率(対象ユニバースのうち実績データがある銘柄の割合)で判定する方式にしました。祝日2日分の実測でカバー率0%、通常営業日は直近90日の最低でも93%超だったため、両者の間に十分な余白があります。
HOLIDAY_COVERAGE_THRESHOLD = 0.05 # これ未満は休場日(取引自体が無い)と判断してスキップ
MIN_COVERAGE_THRESHOLD = 0.80 # これ未満〜休場日閾値以上は収集失敗の疑いとしてスキップ
def check_market_coverage(cur, target_date: date) -> float:
"""target_date時点のstock_pricesカバー率(対象ユニバースに対する実績データ有り銘柄の割合)"""
cur.execute("""
SELECT
(SELECT COUNT(*) FROM companies
WHERE is_active = TRUE AND market IN ('プライム','スタンダード','グロース')) AS total,
(SELECT COUNT(DISTINCT sp.company_id)
FROM stock_prices sp
JOIN companies c ON c.id = sp.company_id
WHERE sp.trade_date = %s
AND c.is_active = TRUE
AND c.market IN ('プライム','スタンダード','グロース')) AS covered
""", (target_date,))
row = cur.fetchone()
total = row["total"]
covered = row["covered"]
return (covered / total) if total else 0.0
書き込み直前にこのカバー率を確認し、5%未満なら休場日としてスキップ(exit 0)、5%以上80%未満なら取得失敗の疑いとして警告を出してスキップ(exit 1)、80%以上のときだけ通常どおり計算・書き込みを行うようにしました。
coverage = check_market_coverage(cur, score_date)
if coverage < HOLIDAY_COVERAGE_THRESHOLD:
print(f"[info] {score_date} はstock_pricesカバー率{coverage:.1%}のため休場日と判断しスキップします")
return
if coverage < MIN_COVERAGE_THRESHOLD:
print(f"[warn] {score_date} はstock_pricesカバー率{coverage:.1%}と低く、"
f"データ収集失敗の疑いがあるためスキップします", file=sys.stderr)
sys.exit(1)
同じ判定をスコア算出処理・シグナル検出処理の両方に追加し、祝日(2026-08-11)で正しくスキップされること、通常営業日(2026-08-25)で従来どおり書き込まれることを実データで確認しました。
対応②: 画面にも基準日を表示する
カバー率ガードを入れると、祝日はdaily_scoresが更新されなくなります。ところが一覧を返すAPIはMAX(score_date)を無条件に返す実装だったため、祝日は前営業日のデータがそのまま表示され続けます。これ自体は正しい挙動ですが、画面にはこれが「いつの時点のスコアか」を示す表示が元々ありませんでした。APIレスポンスにscore_dateを追加し、画面上に基準日を表示、本日の日付と一致しない場合は注記を出すようにしました。
副産物: 修正を反映しようとしたら本番トップページが一瞬止まった
上記の画面修正を反映するためnpm run buildを実行したところ、本番トップページがGET /で404を返す状態になっていました(Playwrightでの確認時に発覚)。
原因は、ビルドコマンドが静的出力ディレクトリを一度削除してから作り直す仕様のため、稼働中のAPIコンテナがbind mountで参照していた旧ディレクトリの参照が宙に浮き、コンテナ内から見ると静的ファイル置き場が空になっていたことでした。以前発生したrm -rfによるbind mount孤立と同じ発生メカニズムです。docker compose restart apiで数分のうちに復旧し、他のコンテナへの影響はありませんでした。
これまで「ビルドコマンドの実行だけで本番に反映される」という前提で運用していましたが、実際にはビルド直後に必ずコンテナの再起動が必要という前提に運用メモを訂正しました。
結果
| 項目 | 修正前 | 修正後 |
|---|---|---|
| 祝日のdaily_scores/investment_signals | 前営業日データを祝日の日付で書き込み | 書き込みをスキップ |
| 画面のスコア基準日表示 | 無し | あり(本日と異なる場合は注記) |
| 収集失敗(カバー率5〜80%)時の挙動 | ガード無し(祝日と同様に書き込まれうる) | 警告を出して書き込みをスキップ |
まとめ
定期実行のスケジュールが「平日固定」だったために、祝日という限られた日数分だけ、実質的に古いデータが最新の顔をして表示され続けていました。祝日カレンダーを持たせる代わりに、その日の実データのカバー率で機械的に判定する方式にしたことで、収集失敗時の異常なデータ書き込みも同じ仕組みで防げるようになりました。あわせて、修正の反映作業自体で本番ページが一瞬止まる事故も経験し、ビルドとコンテナ再起動を分けて考えていた運用の前提を訂正しています。気になる点・ご質問があればコメントでお知らせください。