このプロジェクトについて
EDINET(有価証券報告書等)の開示データをもとに、日本株の財務指標・投資判断スコアを日次で算出・公開しているサービスです。
公開サイトはこちら → https://stocks.happy-life-design.net
前回の記事(2026-10-07公開)はこちら → https://qiita.com/rightcross-ae86/items/378e419af3d1e6d0c58c
約4,100件を初めて書いた記事(2026-10-05公開・後半)はこちら → https://qiita.com/rightcross-ae86/items/f04353be602e00559767
この記事について
前回(2026-10-07公開の記事。以下、本文で「前回」と書くのは、全てこの2026-10-07公開の記事のことです)までは、前日の株価のコピーが混ざっていないかを調べた結果を書いてきました。この記事は、その調査の中で見つかった、別の種類の問題の話です。ある18営業日の株価が、1日の取引が終わる前の途中経過の値になっていました。
原因と範囲の調査には着手しています。開発の記録は2026-10-07のものまでで、それ以降の作業は反映していません。
この記事では、次のように呼びます。
- 「件」: 1社の1日分の株価(始値・高値・安値・終値・出来高)
- 「取得元」: 株価データを取り込んでいる元のサービス
- 「外部サイト」: 値の照合に使う、別の株価サイト
- 「穴埋め」: 取引がなかった日に、前の日の終値のまま、出来高0で入れてある株価
先に結論
- 2019年3月11日〜4月11日のうち、月〜木の18営業日の株価が、外部サイトの最終値とずれていました。始値・高値・安値はほぼ合い、終値と出来高がずれています。同じ期間の金曜日と、前後の期間は一致しました
- 流動性の高い8銘柄は、すべて、この期間の月〜木が一致しませんでした。出来高の少ない銘柄の試験(28社)でも、月〜木の大半が一致しませんでした
- この18営業日の株価は、全部で51,264件(2,854社)です。全社の照合は終わっていませんが、8銘柄がすべてずれていたことから、同じ状態の見込みです
- 外部サイトの値への置き換えが済んでいるのは、28社だけです。データの訂正は、この記事の時点では、これ以上は行っていません
- 原因は、まだ分かっていません
見つかったきっかけ
2026-10-05公開の記事の課題2で書いた、2014年7月以降の前日と同じ株価約4,100件について、外部サイトの値で復元できるかを試していました。外部サイトの値と日ごとに全て比べる試験(28社)をしたところ、前日と同じ値が続く件とは関係のない食い違いが、見つかりました。
流動性の高い8銘柄で確かめると、この期間の月〜木だけが一致しませんでした。
何がずれていたか
- 始値・高値・安値は、ほぼ一致しています
- 終値と出来高が、ずれています
- 例として、流動性の高い1銘柄の2019年3月11日は、出来高がデータベースでは11,708,000株、外部サイトの最終値では16,405,500株でした
- 取引の少ない銘柄では、その時点でまだ取引がなかったため、出来高0の穴埋めの株価になっているものもあります
確かめた範囲と、規模
| 確かめたもの | 結果 |
|---|---|
| 流動性の高い8銘柄の、この期間の月〜木 | 8銘柄とも、一致しなかった |
| 同じ8銘柄の、同じ期間の金曜日と、前後の期間(2018年6月〜2019年2月・2019年5月〜2020年12月) | 一致した(食い違い0) |
| 出来高の少ない銘柄の試験(28社) | 月〜木の大半が一致しなかった |
| 全社 | まだ照合していない |
この18営業日の株価の規模は、次のとおりです。
| 範囲 | 件数 | 社数 |
|---|---|---|
| この18営業日の全ての株価 | 51,264件 | 2,854社 |
| うち、2026-10-05公開の記事で書いた、同じ値の重複が集中していた期間(2019年3月〜2020年3月)の746社 | 13,198件 | 746社 |
| それ以外 | 約38,000件 | 約2,100社 |
51,264件のうち、出来高が0より大きいものは48,334件です。すべて、同じ取得元から取り込んだ株価です。
途中経過値とみる理由と、分からないこと
終値と出来高は、その日の取引が終わるまで確定しない値です。ここだけがずれ、始値・高値・安値はほぼ合っているので、取引が終わる前に取った値、つまり日中の途中経過値が入ったとみています。
ただし、これは見立てで、確かめた事実ではありません。取引時間中のどの時刻の値なのかも、分かっていません。
分からないことは、次の2つです。
- なぜ月〜木だけで、金曜日は合っているのか
- なぜ取得元に、そのような値が残っているのか
この18営業日の外にも、同じ種類に見えるずれが見つかった日が、ほかに3日あります(2019年11月18日・2020年4月3日・2020年10月2日)。流動性の高い8銘柄で確かめた範囲で、まだ詳しくは調べていません。
直し方と、現在の状況
外部サイトの値で置き換えます。株式分割などで、過去の値がまとめて直されている銘柄は、倍率をそろえて比べます。
- 28社: ずれていた分を、外部サイトの値に置き換え済みです。置き換えたあとは、対象外の会社の株価が1件も変わっていないことと、置き換えた会社の株価から計算し直した指標が合うことを確かめました
- 同じ期間にある746社のうち、2026-10-07の時点で、外部サイトの値を取得できたのが89社(試験の28社を含む)、取得できなかったのが97社(ETFなど、外部サイトに該当の期間の履歴がない)、まだ取得していないのが560社です。取得は、1本ずつ、数秒の間隔を空けて進めています
- 取得したのは値の読み取りまでで、データの訂正は、この時点では行っていません
- 746社の外の約2,100社(約38,000件)は、外部サイトの値の取得も、これからです。置き換えるかどうかは、まだ決めていません
株価から計算している指標も、この期間の値の影響を受けます。置き換えた28社は、指標も計算し直しました。
技術的な補足(実際に使ったコード)
ここに載せるのは、実際に使ったスクリプトからの抜粋です。実物と変えたのは1か所だけで、SQLのsourceに入れる値(外部サイトの名前)を'(外部サイト名)'にしました。データベースの接続情報の行は載せていません。長い箇所は...で省略しています。
1. 日ごとに、データベースと外部サイトの値を比べる
データベース側の値をv・db、外部サイト側の値をm・mkと呼んでいます。この比較は、読み取り専用の接続(conn.set_session(readonly=True))で行います。
# 一致行とそのスケール
match_r = {}
for d, (v, src) in db.items():
m = mk.get(d)
if m is not None:
k = rel(v, m)
if k in ('EXACT', 'SCALED'):
match_r[d] = 1.0 if k == 'EXACT' else v[3] / m[3]
...
for d in alld:
...
if m is not None and d in match_r:
out.append(dict(... cls='MATCH', ...)); continue
if m is None:
cls = 'FILLER_OK' if v[4] == 0 else 'DB_TRADE_MK_NONE'
prov = None
else:
cls = 'MISMATCH'; prov = None
日ごとの分類は、次の4つです。
| 分類 | 意味 |
|---|---|
MATCH |
外部サイトに行があり、完全一致か、株式分割などによる倍率違いだけ |
FILLER_OK |
外部サイトに行がなく、データベースは出来高0(取引がなかった日の穴埋め) |
DB_TRADE_MK_NONE |
外部サイトに行がないのに、データベースは出来高が0より大きい |
MISMATCH |
外部サイトに行があるのに、値が合わない |
2. 「合っている」の判定。倍率違いは、きれいな分数だけ許す
株式分割などで、過去の値がまとめて直されている銘柄は、外部サイトの値とデータベースの値が、一定の倍率でずれます。倍率は、分母が40以下の分数に限っています。
from fractions import Fraction
def clean_fraction(r, tol=5e-4, qmax=40):
"""rに最も近い p/q(q<=qmax)。誤差が tol*r 以内なら返す(最初に当たった分母ではなく誤差最小を選ぶ)"""
if r is None or r != r or r <= 0: return None
best = None
for q in range(1, qmax + 1):
p = round(r * q)
if p > 0:
e = abs(r - p / q)
if best is None or e < best[0] - 1e-15: best = (e, Fraction(p, q))
return best[1] if best and best[0] <= tol * r else None
def rel(db, mk):
if db is None or mk is None or any(v is None for v in mk): return 'NA'
if tuple(db) == tuple(mk): return 'EXACT'
if mk[3] <= 0 or any(v <= 0 for v in mk[:4]): return 'DIFF'
rf = clean_fraction(db[3] / mk[3])
if rf is None or rf == 1: return 'DIFF'
r = float(rf)
if any(abs(db[i] - mk[i] * r) > 1e-3 for i in range(4)): return 'DIFF'
if db[4] is None or abs(db[4] - mk[4] / r) > 1.0: return 'DIFF'
return 'SCALED'
この判定は、2026-10-05に厳しくし直しました。当初は判定が甘く、四本値がすべて同じで出来高が小さい株価だと、どんな倍率でも「倍率違い」として一致扱いになっていました。本番のデータを直す前に気づいて、今の形にしました。
3. 直す行を決める。18営業日は日付で列挙している
A_DATES = {dt.date.fromisoformat(s) for s in (
'2019-03-11 2019-03-12 2019-03-13 2019-03-14 2019-03-18 2019-03-19 2019-03-25 2019-03-26 2019-03-27 2019-03-28 '
'2019-04-01 2019-04-02 2019-04-03 2019-04-04 2019-04-08 2019-04-09 2019-04-10 2019-04-11').split()}
...
if cls == 'MISMATCH' and explained_shift: act = 'SHIFT_RESTORE'
elif cls == 'MISMATCH' and d in A_DATES: act = 'PARTIAL_RESTORE'
elif cls == 'MISMATCH': act = 'OTHER_RESTORE'
else: act = 'INSERT'
...
o, h, l, c, v = r.mkt
newp = [Decimal(str(z)) * Decimal(rf.numerator) / Decimal(rf.denominator) for z in (o, h, l, c)]
vv = Fraction(int(v)) / rf
-
PARTIAL_RESTOREが、この記事の18営業日です。外部サイトに行があるのに値が合わない行のうち、A_DATESにある日の行を、日中の途中経過値として、外部サイトの値で置き換えます -
SHIFT_RESTOREは、別の種類の問題(日付ずれ)です。データベースの値が「次の取引日の値」になっている行を、その日の値に戻します。この説明がつく行は、PARTIAL_RESTOREより先に、こちらに分類されます - 新しい値は、価格が「外部サイトの値×倍率」、出来高が「外部サイトの値÷倍率」です
この日付の一覧には、2019年3月20日(水)が入っていません。入れなかった理由は、記録から読み取れていません。
4. データベースに反映する。Before・After
置き換えは、次のSQLで行います。日付ごとに、会社の番号と日付の範囲を定数で指定して、1文で更新します。
cur.execute(f"""UPDATE stock_prices s SET open_price=v.o, high_price=v.h, low_price=v.l, close_price=v.c, volume=v.vol,
adjusted_close=NULL, source='(外部サイト名)', updated_at=now()
FROM (VALUES {','.join(vals)}) AS v(cid,o,h,l,c,vol)
WHERE s.company_id IN ({lit}) AND s.time >= '{lo}' AND s.time < '{hi}' AND s.company_id = v.cid""")
assert cur.rowcount == len(upd), (d, cur.rowcount, len(upd)); n_upd += cur.rowcount
| 列 | 置き換え前(Before) | 置き換え後(After) |
|---|---|---|
| 始値・高値・安値・終値 | 日中の途中経過値 | 外部サイトの値×倍率 |
| 出来高 | 日中の途中経過値 | 外部サイトの値÷倍率 |
| 調整後の終値 | 変更前の値 | NULL |
値の出どころ(source) |
取得元 | 外部サイトを示す値 |
| 更新日時 | 変更前の日時 | 置き換えを実行した時刻 |
| 売買代金・出来高加重平均 | そのまま | そのまま(変えない) |
置き換えは、1つのトランザクションの中で、次の順に確かめます。
try:
if MODE == 'rollback':
...
else:
assert check_state_equals_backup('適用前ガード') == 0, '適用前の状態がバックアップと違う'
apply_plan(); assert verify_after() == 0
if MODE == 'roundtrip':
rollback_from_backup(); assert check_state_equals_backup('往復後(=適用前に戻ったか)') == 0
conn.rollback(); print('ROUNDTRIP OK: 適用→復元で全列が元に戻ることを確認(トランザクションはROLLBACK済み・本番は未変更)')
elif MODE == 'dry':
...
else:
conn.commit(); print('COMMITTED')
except Exception:
conn.rollback(); print('ERROR: ROLLBACK済み'); raise
- 適用前に、触る行がバックアップと全列で同じであることを確かめます(違えば止めます)
- 適用したあと、触った行がすべて期待どおりの値になっていることを確かめます
- 往復試験(
roundtrip)では、適用したあとにバックアップから戻し、全列が元に戻ることを確かめて、ROLLBACKします。本番のデータは変わりません - 往復試験に成功してから、
realで本番に反映(COMMIT)します。28社は、この順で反映しました - 戻すときは、
rollbackモードでバックアップから元に戻します
原因について
取得元に再度問い合わせても、今も同じ値が返ってきます。取り込みのときだけに起きた不具合ではなく、取得元の側に残っている値とみています。なぜ取得元にそのような値が残っているのか、なぜ月〜木だけなのかは、まだ分かっていません。原因と範囲の調査には着手しています。
まとめ
- 2019年3月11日〜4月11日の月〜木の18営業日の株価が、外部サイトの最終値とずれていた。終値と出来高がずれ、金曜日と前後の期間は一致した。日中の途中経過値とみているが、確かめた事実ではなく見立て
- 全部で51,264件(2,854社)。全社の照合は終わっていないが、流動性の高い8銘柄はすべてずれていた
- 置き換え済みは28社だけ。残りは、値の取得を進めている途中で、データの訂正は未実施
- なぜ月〜木だけなのか、なぜ取得元にその値が残っているのかは、まだ分かっていない
気になる点・ご質問があればコメントでお知らせください。