このプロジェクトについて
EDINET(有価証券報告書等)の開示データをもとに、日本株の財務指標・投資判断スコアを日次で算出・公開しているサービスです。
公開サイトはこちら → https://stocks.happy-life-design.net
前回の記事(2026-10-13公開)はこちら → https://qiita.com/rightcross-ae86/items/741535dcd5d27d830add
この記事について
株式分割(1株を2株に分けるように、株を分けること)のあと、過去の株価が分割前の値のまま残り、価格に段差ができていた件の、2部構成の後半です。前半(2026-10-13公開)では、同じ型の96社を見つけて直し、直した所以外が変わっていないことを確かめました。ただし、直したのは今ある会社だけで、新しい分割が起きれば同じ段差ができる状態でした。
この後半では、新しい分割のたびに同じ段差ができないように、取り込みの直前に分割を検知して自動で直す仕組みを入れた話を書きます。あわせて、その仕組みを本番(実際に稼働しているシステム)で初めて動かした日に、毎日の取り込みが時間切れで1回失敗した話と、仕組みの限界を書きます。
この仕組みは、2026-10-12に本番へ入れました。開発の記録は2026-10-12の夜までのものです。
この記事では、次のように呼びます。
- 「行」: 1社の1日分の株価(始値・高値・安値・終値・出来高)
- 「取得元」: 株価データを取り込んでいる元のサービス
- 「外部サイト」: 値の照合や補完に使う、別の株価サイト
- 「分割比」: 1株が何株になったか。1株を2株に分けたなら2です。10株を1株にまとめる株式併合なら0.1です
- 「窓」: 毎日の取り込みが取り直す、直近7日分
- 「バッチ」: 毎日の取り込みで、会社をまとめて処理する1回分
- 「段差」: 価格が分割の前後で、急に半分や数分の1になること
- 「旧スケール」: 分割前の値のまま残っている状態
- 「指標」: 株価から計算している数値。移動平均・RSI・MACDなど
- 「ガード」: この記事で作った、分割を検知して補正する仕組み(
split_guardという名前のプログラムの部品)
先に要点
- 前半で直した型の段差は、原因が残っていたため、新しい分割のたびに再発する状態でした。毎日の取り込みの直前に分割を検知して自動で直す仕組み(ガード)を、2026-10-12に本番へ入れました
- ガードは3段です
- ①窓の中の「データベースの終値 ÷ 取得元の終値」が、分割比に揃っているかを見る
- ②取得元の分割の記録と合うかを確かめる
- ③全日付で突き合わせて、旧スケールの行だけを、前半の手動の補正と同じ手順で直す
- 想定外が1つでもあれば、何も変えずに警告だけを出します。誤って直してデータを壊すより、見逃して知らせる方を選びました
- 本番に入れる前に、自動テスト、全社の実データでの検知だけの確認(4,428社・疑い0件)、本番のデータベースの中で分割直後の状態を再現して補正させる試験(3種類の分割)を行いました
- 本番の初回の取り込みで、ガードが最初に行う、データベースから窓の株価を読む処理が、1バッチ約70秒かかりました。データベースへの命令文(SQL)を実行する直前に変換して速くする仕組み(JITコンパイル)が原因です。取り込みが25分の上限に達して、1回目が失敗しました。2回目の自動のやり直しで成功しました。接続の直後にJITを切る1行を足して直しました
- 限界があります。取得元に分割の記録が無い銘柄は、補正せず警告だけです。比が1.4倍に届かない小さな分割と、取得元の調整が遅れて窓の外に出たあとの分割は、検知できません。本番で実際に分割を補正した例は、記録の時点ではまだありません
仕組み: 取り込みの直前に、検知・確認・補正を行う
前半の原因は、取得元が分割のあと過去の全日付を調整して返す(遡及調整)のに、毎日の取り込みは窓(直近7日)しか取り直さないことでした。ガードは、毎日の取り込みの中で、窓の行を上書きする直前に、その会社が分割の調整を受けていないかを調べます。上書きの前に調べるのは、窓の行を先に新しい値で上書きすると、窓の中の旧い値が消えて、比で気づけなくなるためです。
どの場合も、最後は窓の行を取り込みます。
1. 検知: 窓の中の比が、分割比で揃っているか
窓の中で、データベースにもすでにある日について、「データベースの終値 ÷ 取得元の終値」を日付ごとに出します(ratios)。分割の直後は、窓の中の分割前の日だけ、この比が分割比(1株を2株に分けたなら2.0、10株を1株にまとめたなら0.1)になり、分割後の日は1.0になります。
次の4つを全て満たすときだけ、分割の疑いとします(1つ目の抜粋)。
- 比が1から0.02を超えて外れた日がある
- その比の中央値(真ん中の値)が、1.4以上1,000以下、または1.4分の1以下1,000分の1以上である。取得元が桁外れの値を返したときの比は、この範囲の外になるので、疑いにしません
- 外れた日の比が、全て中央値の0.98倍〜1.02倍以内で揃っている。揃っていなければ、分割ではなく個別の食い違いとみなします
- 比が外れた日が、比が1に近い日より前だけにある。旧い値の日が新しい値の日より後にあれば、分割の形ではないとみなします
2. 確認: 取得元の分割の記録と合うか
疑いがあった会社だけ、取得元に、全期間の履歴と分割の記録を問い合わせます。次の2つを満たす分割が記録にあるときだけ、先へ進みます。
- 効力発生日(分割が実際に効力を持つ日)が、疑いの最後の日より後で、取り込みの日の翌日以前にある
- 分割比が、窓から推定した比の0.97倍〜1.03倍以内で一致する
記録が無い、または合わなければ、補正せず警告だけを出します。
3. 補正: 前半の手動の補正と同じ手順
確認が取れたら、取得元の全期間とデータベースを全日付で突き合わせて、前半と同じように、行を3つに分けます。
- 旧スケール: 比が分割比の0.998倍〜1.002倍の行。別の外部サイトで補った行だけは、0.9倍〜1.1倍まで許します(取得元の該当日の値が、取引の途中の値のため、比がぴったりにならないことがあるためです)
- 調整済み: 比が1の0.998倍〜1.002倍の行
- どちらでもない行
旧スケールの最初の日から最後の日までの全行を、始値・高値・安値・終値・調整後終値を分割比で割り、出来高を分割比でかけて四捨五入し、更新日時を今の時刻にします(3つ目の抜粋)。更新日時が新しくなると、指標の日次の計算が、過去の価格が訂正されたと判断して、その会社の指標を全履歴で計算し直します。
補正は、1社ずつ、1つのトランザクション(途中で失敗したら全部取り消せる、ひとまとまりの更新)で行います。更新する行数が計画と違えば、例外(プログラムがエラーを知らせる仕組み)を出して止まり、取り消します。
4. 想定外のときは、何も変えずに警告だけを出す
次のどれかに当たれば、補正しません(2つ目の抜粋)。
- 旧スケールの行が見つからない
- 旧スケールの最後の行が、効力発生日以降にある
- 旧スケールの範囲の中に、調整済みの行がある(旧スケールが連続していない)
- 範囲の中に、旧スケールでも調整済みでもない行がある
- 範囲の中に、取得元に無い行があり、その行の出所が、取得元の値でも外部サイトで補った値でもない
最後の条件の「出所」は、その行をどの経路で入れたかを示す列です。どれも、警告を処理の記録(ログ)に出して、手作業の対応に回します。誤って直してデータを壊すより、見逃して警告を出す方を選んだ設計です。
5. ガードが失敗しても、取り込みは止めない
取り込みの側では、ガードの呼び出しを、エラーが出ても外へ伝えない書き方で囲みます。ガードの中でエラーが出ても警告にして、日次の取り込みを続けます(4つ目の抜粋)。取得元への問い合わせが失敗したときも同じです。
設定値
ガードの動きは、環境変数(プログラムの外から渡す設定値)SPLIT_GUARDで切り替えます。offは無効(緊急停止用)、detectは検知と警告だけで補正しない、onは検知から補正まで行う(既定)です。
本番に入れる前に確かめたこと
| 確かめたこと | 結果 |
|---|---|
| 自動テスト | 偽のデータベースと偽の取得元で、検知・照合・補正の条件・止める条件・設定値の切り替え・更新行数が計画と違うときの取り消し・取得元が失敗しても例外が外へ出ないこと、などを確かめた。実装の時点で28件 |
| 全社の実データで、検知だけを行う | 4,428社を、窓7日分で調べて、疑い0件(誤検知なし) |
| 本番のデータベースの中で、分割直後の状態を再現して補正させる | 3種類の分割(1株を2株、1株を100株、10株を1株にまとめる併合)で、ガードの補正の結果を、現在の本番の値と全行比べた(結果は下記)。最後に必ず取り消した |
3つ目の試験は、すでに直した(または調整が済んでいる)会社を、トランザクションの中で、分割直後の状態(分割前の値)に戻し、ガードに補正させて、現在の本番の値と全行を比べます。正解は、本番にある現在の値です。
- 1株を2株、1株を100株に分けた会社: 全行が、現在の本番と一致しました(行の中身から作る目印、ハッシュ値で比較)
- 10株を1株にまとめた会社: 終値・調整後終値・出来高・出所・時刻は全行で一致しました。始値3セル・高値1セル・安値2セルは、最大0.0005違いました。試験の側の戻し方(0.1をかけて4桁に丸めてから、0.1で割る)の丸め誤差と見ています
本番の初回で、取り込みが止まった
起きたこと
ガードを入れて最初の本番の取り込み(2026-10-12)で、1回目が失敗しました。この日は東証が休場で、新しい日付の行は無く、ガードが分割を検知することもありませんでした。
通常は約8分で終わる取り込みが、25分の上限(タイムアウト。処理に時間の上限を決め、超えたら止める仕組み)に達して止まりました。取り込みは、会社をまとめて処理する1回分(バッチ)を、この日は46回繰り返します。ガードの最初の照会(窓の中の株価をデータベースから読む1文)が、1バッチごとに約70秒かかっていました。単純にかけると50分を超えます。自動でやり直した2回目は、10.6分で成功しました。
原因: JITコンパイル
データベース(PostgreSQL)には、データベースへの命令文(SQL)を実行する直前に、コンピュータが直接動かせる形へ変換して速くする仕組み(JITコンパイル)があります。変換そのものにも時間がかかります。株価の表は、日付で区切って保存した部分(チャンク)を1,400を超える数だけ持っています。この表から窓の中の株価を読む1文は、JITが有効だと1回約70秒、JITを切ると約1秒でした(実測)。
同じJITで遅くなった別の件を、2026-10-12公開の記事に書いています(新規上場銘柄のチェックの記事) → https://qiita.com/rightcross-ae86/items/4281b9e529f4838dfa0a
対応
- 取り込みは1バッチごとに新しいデータベースの接続を開くので、データベース全体の設定でJITを一時的に切りました。実行中の取り込みを止めずに、速くできました
- ガードが最初に開く接続に、
SET jit = offを足しました(5つ目の抜粋)。窓の照会より前にこの1文を実行することを確かめる自動テストも、1件足しました。実装の時点の28件と合わせて29件です - 一時的な設定は、その日の指標の日次計算が終わったあとに、元へ戻しました
試験で気づけなかった理由
本番に入れる前に、全社4,428社で検知だけを行う確認をしました。この確認は総時間だけを見て、1バッチごとの照会にかかる時間を見ていませんでした。
また、ガードは例外が出ても取り込みを続ける作りですが、遅いことは例外になりません。この作りは、今回の遅さには効きませんでした。
次から守ることを、2つ決めました。
- 新しいSQLをこの表に足したら、本番と同じ条件で、1回あたりの時間を測る
- 時間の上限がある毎日の処理に、重い処理を足すときは、実行時間を先に見積もる
この仕組みの限界
- 取得元に分割の記録が無い銘柄: 前半の上場投資信託(ETF)1社のように、取得元に分割の記録が無い銘柄は、補正せず警告だけです。手作業で直します
- 小さな分割: 窓から推定した比が1.4倍に届かない分割は、検知しません。2株を3株に分ける分割(1.5倍)までは対象です
- 連続する複数の分割が同時に起きた場合: 比が合わず、補正しません
- 取得元の調整が遅れて反映された場合: 補正は取り込みの直前に行うので、取得元の調整が数日遅れて反映され、分割前の値の日がすでに窓の外へ出ていると、検知できません。この場合は、前半の取りこぼしの確認で使った、全社を調べ直す方法で拾います
技術的な補足(実際に使ったコード)
ここに載せるのは、実際のソースコードからの抜粋で、元のファイルの連続した行です。実物と変えたのは、共通のインデントを除いたことだけです。取得元の名前などが入る行は、抜粋の範囲に含めていません。データベースの接続情報も含まれません。
1. 検知: 比が揃っているか
ratiosは、日付ごとの「データベースの終値 ÷ 取得元の終値」です。devは、そのうち1からDEV_TOL(0.02)を超えて外れた日です。r0は、その比の中央値で、推定した分割比です。MIN_RATIOは1.4、MAX_RATIOは1,000です。比が揃わないとき、旧い値の日が新しい値の日より後にあるときは、None(分割の疑いなし)を返します。
dev = {d: r for d, r in ratios.items() if abs(r - 1) > DEV_TOL}
if not dev:
return None
r0 = statistics.median(dev.values())
if not (MIN_RATIO <= r0 <= MAX_RATIO or 1.0 / MAX_RATIO <= r0 <= 1.0 / MIN_RATIO):
return None
if any(abs(r / r0 - 1) > DEV_TOL for r in dev.values()):
return None # 比が揃っていない(分割ではなく個別の食い違い)
ok = [d for d, r in ratios.items() if abs(r - 1) <= DEV_TOL]
if ok and max(dev) >= min(ok):
return None # 旧スケールの日が新スケールの日より後にある(分割の形ではない)
return {"R": r0, "first": min(dev), "last": max(dev)}
2. 補正してよい条件
全日付の突き合わせで、oldは旧スケールの行、newは調整済みの行、otherはどちらでもない行です。split_dateは効力発生日、firstとlastは旧スケールの最初と最後の日です。条件に当たると、補正計画は作らず(None)、理由の文を返します。この理由が警告として出ます。
if not old:
return None, "旧スケールの行が見つからない"
first, last = old[0][0], old[-1][0]
if last >= split_date:
return None, f"旧スケール行が効力発生日({split_date})以降にある({last})"
if new and min(d for d, _ in new) <= last:
return None, "範囲内に新スケールの行がある(旧スケールが連続でない)"
in_range_other = [x for x in other if first <= x[0] <= last]
if in_range_other:
return None, f"範囲内に旧でも新でもない行が{len(in_range_other)}行ある(例 {in_range_other[0][0]} 比{in_range_other[0][2]:.4f})"
3. 補正
Rが分割比、planが補正計画(firstとlastが範囲の最初と最後の日、n_rangeが行数)です。%(R)sなどは、値を入れる場所です。WHEREは更新する行を選ぶ条件で、会社と日付の範囲を定数で指定した1文にしています。これは、圧縮された過去の行の更新で、一度に解凍する行数の上限に当たらないようにするための形です(前半と同じです)。RはDecimal(誤差の出にくい10進数)にして渡します。更新した行数が計画と違えば例外を出し、呼び出し側が取り消します(コメントのROLLBACKは、データベースの変更を取り消す操作です)。
CORRECT_SQL = """UPDATE stock_prices SET
open_price = open_price / %(R)s, high_price = high_price / %(R)s, low_price = low_price / %(R)s,
close_price = close_price / %(R)s, adjusted_close = adjusted_close / %(R)s,
volume = round(volume * %(R)s), updated_at = now()
WHERE company_id = %(cid)s AND trade_date >= %(lo)s AND trade_date <= %(hi)s"""
def apply_correction(cur, company_id, R, plan):
"""補正を実行(呼び出し側のトランザクション内)。company_idと日付範囲が定数の単一文(圧縮ハイパーテーブルの解凍上限対策)。
更新行数が計画と違えば例外(呼び出し側がROLLBACK)。"""
from decimal import Decimal
cur.execute(CORRECT_SQL, {"R": Decimal(repr(float(R))), "cid": company_id, "lo": plan["first"], "hi": plan["last"]})
if cur.rowcount != plan["n_range"]:
raise RuntimeError(f"UPDATE行数 {cur.rowcount} != 計画 {plan['n_range']}")
return cur.rowcount
4. 取り込みから呼ぶ
取り込みの側で、窓の行を上書きする前に、バッチの会社をガードに渡します。ガード専用の接続を開き、終わったら閉じます。例外は捕捉して警告にし、取り込みは続けます。
try:
_gconn = get_connection()
try:
split_guard.guard_batch(_gconn, sym_map, results)
finally:
_gconn.close()
except Exception as e:
print(f" ⚠️ split_guard 失敗(取り込みは続行): {e}", flush=True)
5. 窓の照会の前に、JITを切る
ガードの最初の接続で、窓の中の株価を読む前にSET jit = offを実行します。ANY(%s)は、バッチの会社のID一覧、d_fromとd_toは窓の最初と最後の日です。結果は、会社ごと・日付ごとの終値の表に入れます。
cur.execute("SET jit = off")
cur.execute("SELECT company_id, trade_date, close_price FROM stock_prices "
"WHERE company_id = ANY(%s) AND trade_date BETWEEN %s AND %s", (ids, d_from, d_to))
for cid, d, c in cur.fetchall():
dbc.setdefault(cid, {})[d] = float(c) if c is not None else None
未確認事項
- JITを切ったあとの、最初の定期の取り込み: 約8分で終わるかどうかは、この記事の時点の記録(2026-10-12の夜まで)では確かめられていません
- 本番で実際に分割を補正した例: 記録の時点では、まだありません。本番のデータベースの中での試験は、すでに直した会社を分割直後の状態に戻して行ったものです
- 警告の運用: 警告は処理の記録(ログ)に出ます。だれがいつ見るかは、この記事の時点で決めていません
- 前半の未確認事項: 取得元に値が存在しない13社と、取得元自身の系列が壊れているETF1社は、この記事の時点でも未対応のままです。取得元に分割の記録が無い銘柄は、ガードでは警告だけになります
まとめ
- 前半で直した段差は、新しい分割のたびに再発する状態だったため、取り込みの直前に検知・確認・補正を行うガードを入れました。想定外のときは、何も変えずに警告だけを出します
- 本番に入れる前に、自動テスト、全社の実データでの検知だけの確認、本番のデータベースの中での試験で確かめました
- 本番の初回に、ガードの最初の照会がJITコンパイルで遅くなり、取り込みが時間切れで1回失敗しました。接続の直後にJITを切って直しました。新しいSQLを足したときは、本番と同じ条件で1回あたりの時間を測ります
- 分割の記録が無い銘柄、小さな分割、遅れて反映された調整は、ガードでは直せません。本番で実際に補正した例がまだ無いことも、未確認事項に残しています
気になる点・ご質問があればコメントでお知らせください。