1
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?

【日本株分析プラットフォーム】新規上場銘柄で、データの完全性チェックが失敗した話(2026-10-12)

1
Last updated at Posted at 2026-10-11

このプロジェクトについて

EDINET(有価証券報告書等)の開示データをもとに、日本株の財務指標・投資判断スコアを日次で算出・公開しているサービスです。

公開サイトはこちら → https://stocks.happy-life-design.net
前回の記事(2026-10-11公開)はこちら → https://qiita.com/rightcross-ae86/items/62ab81f505124b0a6613

この記事について

毎朝の保守処理には、データベースの株価と指標が決めた条件に合っているかを確かめる「完全性チェック」があります。このチェックが、新規上場の銘柄で2日続けて失敗した件の記録です。

データが壊れていたのではありませんでした。原因は、チェックの判定と、毎日の指標の計算の2か所にありました。何が起きたか、原因が2つ重なっていたこと、どう直したか、どう確かめたかを書きます。直したあとに、追加したSQLでチェックが遅くなることも見つかったので、その対処も書きます。

この記事で扱う修正は、2026-10-09に本番へ反映しました。開発の記録は2026-10-11の夜までのものです。

この記事では、次のように呼びます。

  • 「完全性チェック」: データベースの株価と指標が、決めた条件に合っているかを、毎朝確かめる処理。条件に合わないと失敗して、通知が来ます
  • 「指標」: 株価から計算している数値。移動平均・MACD・RSI・ボリンジャーバンド・OBV(出来高の累積)など
  • 「新規上場銘柄」: 上場して間もなく、株価の履歴が短い銘柄
  • 「日次の計算」: 毎日の取り込みのあとに、指標を計算してデータベースに書く処理。通常は、最新の1日分だけを書きます
  • 「行」: 1社の1日分の株価、または1社の1日分の指標

先に要点

  • 新規上場の銘柄で、完全性チェックが2日続けて失敗しました。株価ではなく、指標の過去分が欠けていました
  • 原因は2つ重なっていました。(1)株価が5行未満の銘柄は指標を作らない設計なのに、チェックがそれを異常と判定していた (2)日次の計算が、株価が5行未満のときに登録された銘柄の過去分の指標を、あとから埋めない作りだった
  • (1)だけ直しても、数日後に(2)で同じ通知が再発します。そこで、両方を直しました。日次の計算は、株価が30行以下の銘柄だけ、全履歴を書くようにしました
  • 直したあと、追加したSQLのせいでチェック全体の実行時間が約77秒から約180秒に延びることが分かり、SQLを実行する直前に、コンピュータが直接動かせる形へ変換して速くする仕組み(JITコンパイル)を切って対処しました
  • 確かめは3通りです。直した銘柄の指標を株価から計算し直す・新旧のチェックを本番のデータベースで5つの状態に当てはめて比べる・本番で穴を再現して日次の計算を実行する
  • 未確認事項があります。全履歴を書き込む処理が、本番の毎日の取り込みで実際に動いた記録の確認は、その1つです

何が起きたか

2026-10-08の朝、完全性チェックが失敗して、通知が来ました。2026-10-01に上場した6銘柄が、株価4行・指標0行でした。

翌2026-10-09の朝も、2銘柄で失敗しました。こちらは株価5行・指標1行で、欠けている指標の過去分は、各4行でした。

日 失敗した銘柄 株価 指標
2026-10-08の朝 6銘柄 4行 0行
2026-10-09の朝 2銘柄 5行 1行

原因は2つ重なっていた

原因1: チェックが、新規上場の途中の状態を異常と判定した

指標は、株価が5行以上ある銘柄にだけ作ります。5行未満の銘柄には、指標を作らない設計です。

一方、完全性チェックは、2026-09-18に入れたもので、「株価のある企業は、株価と指標が1対1になっている」ことを確かめます。ずれている企業が1社でもあれば異常とする設定です。

新規上場の銘柄が株価4行・指標0行なのは、設計どおりの途中の状態です。それなのに、このチェックは異常と判定しました。新規上場の銘柄が引っかかったのは、2026-10-08が初めてでした。

原因2: 日次の計算が、過去分の指標をあとから埋めない

こちらが、実害のある原因です。新規上場の銘柄で、指標の過去分が欠けたままになります。

  • 日次の計算は、通常、最新の1行だけを書きます。書く行を絞って、計算の負荷を抑えているためです
  • 株価が5行未満のときに企業が登録されると、指標は作られません。5行目が「当日の行」として届いたとき、書かれる指標は、その日の1行だけです
  • 過去の日の株価は更新されていないので、「過去の日の株価が訂正された銘柄は、全履歴を計算し直す」という既存の仕組みの対象にもなりません
  • 定期的に全履歴を計算し直す処理も、ありません。そのため、自然には埋まりません

(1)だけ直すと、チェックは通るようになります。けれども、指標の過去分が欠けたままの銘柄が、静かに増えます。数日後、別の銘柄の株価が5行に届いたときに、同じ形の通知が、今度は(1)の誤判定ではなく本物の穴として出ます。(1)と(2)は、両方を直す必要がありました。

直したこと

直したのは3か所です。

  1. 欠けていた指標を埋めた: 失敗していた2銘柄の指標を、全履歴で計算し直しました(各1行から5行へ)
  2. チェックの判定を直した: 「株価が5行未満で、指標が0行」の銘柄を、異常から除きました。指標が1行でもあるのに株価が5行未満なのは、矛盾なので、これまでどおり異常にします
  3. 日次の計算を直した: 株価が30行以下の銘柄は、最新の1行ではなく、全履歴を書くようにしました。全履歴を書き直しても、同じ株価からは同じ値になるので、何度書いても結果は変わりません。対象は、上場してから約6週間の銘柄で、約30社と見積もっています

チェックの判定は、次のように変わりました。

株価の行数 指標の行数 直す前 直したあと
十分にある 株価と同じ 正常 正常
4行 0行 異常 正常(新規上場の途中の状態)
5行 1行 異常 異常(本物の穴)
4行 1行 異常 異常(矛盾)
4行 5行 異常 異常(株価に無い日の指標がある)

追加したSQLで、チェックが遅くなった

チェックを直すとき、「株価が5行未満の銘柄を除く」ために、株価の通算の行数を数えるSQLを足しました。ところが、足したSQLのせいで、チェック全体の実行時間が約77秒から約180秒に延びることを、実測で見つけました。このチェックには240秒の上限があります。上限に近づく延び方でした。

原因は2つありました。

  • 数える範囲: 株価の表は、古い期間を週ごとのまとまり(チャンク)に圧縮して保存していて、1,400を超えるチャンクがあります。通算の行数を数える条件を、株価の全行に対して付けると、約4秒のSQLが約100秒になりました(実測)。そこで、先に「指標に無い株価の行を持つ会社」だけを確定させ(MATERIALIZEDは、先に結果を確定させて保存しておく指定です)、その少数の会社にだけ、通算の行数を数えるようにしました
  • 実行直前の変換の仕組み: データベース(PostgreSQL)には、SQLを実行する直前に、コンピュータが直接動かせる形へ変換して速くする仕組み(JITコンパイル)があります。コストの見積もりがしきい値を超えると、働きます。足したSQLは、見積もりが大きくなり、約5,800の関数の変換に約27秒かかっていました。サブクエリ自体が実行されない場合でも、変換は先に行われます。接続の直後にこの仕組みを切ると、このSQLは約4秒でした

最終的に、チェックの接続の直後にSET jit = offを入れました。この仕組みを切る範囲は、そのチェックの接続だけです。

確かめたこと

確かめは3通りです。

  1. 直した2銘柄の指標を、株価から計算し直した: 指標の日付が、株価の日付と一致しました。株価だけから別に計算し直した値との差は、最大0.00005でした(データベースの小数4桁の丸めの範囲です)。もともとあった1行は、全ての列が変わっていません。2銘柄以外の4,618社は、株価・指標・企業情報とも、直す前と完全に一致しました
  2. 新旧のチェックのSQLを、本番のデータベースで5つの状態に当てはめて比べた: 「十分な株価と指標がそろった状態」「株価4行・指標0行」「株価5行・指標1行」「株価4行・指標5行」「株価4行・指標1行」を、本番のデータベースの中に作って、最後に取り消し(ROLLBACK)ました。新規上場の途中の状態だけが除かれ、本物の穴と矛盾は、これまでどおり検知されました
  3. 本番で穴を再現して、日次の計算を実行した: 直す前のコードでは、穴が埋まらず、指標が1行のままでした。直したあとのコードでは、穴が埋まり、穴を作る前の行と全ての列が一致しました。終わったあとの全社の指紋は、再現する前と完全に一致しました

あわせて、テストを足しました(この修正の時点で、全体で279件が通りました)。中でも次の1つは、直した内容の要です。チェックが除く条件の「5」と、指標を作る側の「5」が、同じ値であることを確かめます。どちらか片方だけを変えると、除外が効かなくなるか、本物の穴を隠してしまうためです。

技術的な補足(実際に使ったコード)

ここに載せるのは、実際に使ったスクリプトからの抜粋です。実物と変えたのは、長い箇所を...で省略したことと、共通のインデントを除いたことだけです。コメントとSQLは、実物のままです。SQLのコメントにある「TI」は、指標のことです。データベースの接続情報の行は載せていません。

1. 日次の計算: 株価が少ない銘柄は、全履歴を書く

指標を作るのは、株価が5行以上のときだけです(calc_indicatorsの冒頭)。

if df.empty or len(df) < 5:
    return []

全履歴を書くかどうかを決める判定です。corrected_idsは、過去の日の株価が訂正された銘柄です。

NEW_LISTING_FULL_REWRITE_ROWS = 30


def needs_full_history_write(cid: int, df: pd.DataFrame, corrected_ids: set) -> bool:
    ...
    return cid in corrected_ids or len(df) <= NEW_LISTING_FULL_REWRITE_ROWS

日次の計算の中での使われ方です。全履歴を書く銘柄はtail=None、そうでない銘柄はtail=1(最新の1行だけ)で計算します。

if needs_full_history_write(cid, df, corrected_ids):
    rows = calc_indicators(df, cid, tail=None)
    if rows:
        all_latest_rows.extend(rows)  # 全履歴を書き込み
        success += 1
    else:
        skipped += 1
    continue
rows = calc_indicators(df, cid, tail=1)
if rows:
    all_latest_rows.append(rows[-1])  # 最新1行のみ
    success += 1
else:
    skipped += 1

2. チェック: 新規上場の途中の状態を除く

1つ目のチェックです。「株価が5行未満で、指標が0行」の企業を除きます。sp.cが株価の行数、ti.cが指標の行数です。

FROM companies c
LEFT JOIN sp ON sp.company_id = c.id
LEFT JOIN ti ON ti.company_id = c.id
WHERE c.is_active = TRUE AND sp.company_id IS NOT NULL
  -- 2026/10/09 新規上場銘柄の誤検知対策: 株価が5行未満の銘柄はcalc_indicators()が
  -- TIを作らない設計(calc_technical_indicators.py `len(df) < 5`)のため、株価<5行かつTI=0行は
  -- 正常な過渡状態として除外する。TIが1行でもあるのに株価<5行、は矛盾なので従来どおり検知する。
  AND NOT (sp.c < 5 AND COALESCE(ti.c,0) = 0)

2つ目のチェック(直近30日の行単位の突き合わせ)です。通算の行数を数えるのは、先に確定させた少数の会社(hole_co)だけです。

-- 2026/10/09 新規上場銘柄の誤検知対策: 株価が通算5行未満の銘柄はTIが作られない設計なので
-- 「穴」とみなさない(上のti_sp_company_mismatchと同じ基準)。通算行数の確認(圧縮チャンクを含む
-- stock_prices への問い合わせ)は重いので、MATERIALIZEDで「TIに無い株価行を持つ会社」を先に確定させ、
-- その少数の会社にだけ行う(これをsp全行に対する条件にすると約4秒→約100秒になる。実測済み)。
hole_co AS MATERIALIZED (
    SELECT DISTINCT company_id FROM sp
    WHERE NOT EXISTS (SELECT 1 FROM ti WHERE ti.company_id = sp.company_id AND ti.d = sp.d)
),
bad AS (
    SELECT company_id FROM ti WHERE NOT EXISTS (SELECT 1 FROM sp WHERE sp.company_id = ti.company_id AND sp.d = ti.d)
    UNION
    SELECT company_id FROM hole_co
    WHERE (SELECT COUNT(*) FROM (SELECT 1 FROM stock_prices x WHERE x.company_id = hole_co.company_id LIMIT 5) t) >= 5
)

3. チェックの接続の直後に、実行直前の変換の仕組み(JITコンパイル)を切る

# 2026/10/09: JITを切る(このセッションのみ)。ti_orphan_or_hole_rows_recentは通算行数の確認で
# stock_prices(1,400超のチャンク)を参照するサブクエリを持ち、コスト見積もりがJITのしきい値を超えて
# 約5,800関数のコンパイルに約27秒かかっていた(サブクエリ自体は実行されない場合でも)。実測: JITオフで約4秒。
cur.execute("SET jit = off")

未確認事項

  • 全履歴を書き込む処理が、本番の毎日の取り込みで実際に動いた記録: 修正のあとの2日間(2026-10-10と2026-10-11)の朝のチェックは、成功しています。ただし、新規上場の銘柄が対象になった日の取り込みで、「全履歴を書き込みます」という表示が出たかどうかは、開発の記録からは確認できていません
  • 2026-10-08に失敗した6銘柄のうち、翌日の失敗が2銘柄になった経過: 6銘柄のうち、翌日も失敗した2銘柄以外の4銘柄が、なぜ翌日は失敗しなかったのかは、開発の記録から読み切れていません
  • 実行時間の内訳: 実行直前の変換(JITコンパイル)にかかった約27秒と、チェック全体の延び(約77秒から約180秒)の差の内訳は、記録から読み切れていません。2つは別々の測定として書きました
  • 30行という値: 全履歴を書く銘柄の上限の30行と、対象が約30社という数は、見積もりです。上場から約6週間の銘柄を対象にしたものです

まとめ

  • 新規上場の銘柄で、完全性チェックが2日続けて失敗しました。データは壊れておらず、指標の過去分が欠けていました
  • 原因は、株価が5行未満の途中の状態を異常と判定するチェックと、過去分をあとから埋めない日次の計算の、2つでした。片方だけ直すと、数日後に同じ形の通知が再発する関係でした
  • チェックは新規上場の途中の状態だけを除き、日次の計算は株価30行以下の銘柄の全履歴を書くようにしました。本物の穴と矛盾は、これまでどおり検知します
  • 追加したSQLで、チェック全体の実行時間が約77秒から約180秒に延びたため、数える範囲を絞り、SQLを実行する直前に変換して速くする仕組み(JITコンパイル)を切りました
  • 全履歴を書き込む処理が、本番の毎日の取り込みで実際に動いた記録の確認は、まだです

気になる点・ご質問があればコメントでお知らせください。

1
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
1
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?