自分で決めてた、って気づいたのはあの夜だった。
怖くなったのは、二日あとだった。
Abstract
- 対象: OANDA practice / USDJPY / 5分足(M5) / bid・ask
- やったこと: 完了済みM5を保存する新しいSQLiteを作り、長く貯めてきたM5のSQLiteからは条件を固定してpandas DataFrameへ投影し、三本ちょうど揃った15分区間だけをM15へ集約
- 結果: 欠けたM5は補間しない。三本揃わない15分bucketは完全なM15に混ぜず、別に記録した。M15は派生DataFrameに留めて、M5をSQLiteの正本のまま残した
- 非対象: ORBのバックテスト、成績比較、売買判断。今回は、入力データの経路だけ

1. Introduction — 二日あと
前回、他人の手法をUSDJPYの1時間足へ運んだ。
レンジの時刻も、ATRの平均も、16:00に閉じる線も、最後にはわたしが決めていた。人のルールなら、決めるところが済んでいると思ってた。
そうじゃなかった。
それが怖いと分かるまで、二日かかった。遅い。
もう一度ORBを走らせるのは、やめた。ルールを触る前に、前回いちばん最初に諦めたものへ戻る。
15分足が、手元になかった。
yfinanceの15分足は、せいぜい60日ぶんしか遡れない。720日には、ぜんぜん届かない。だから1時間足へ翻訳した。あの検証は、そこから始まってた。
OANDAのAPIなら、5分足が取れる。5分足が三本で、15分。今回はルールを直すんじゃなくて、データの解像度を一段だけ戻す。
「M5を取って、三本ずつまとめればいい」
そこまで打って、エンターを押さなかった。
DataFrameは、プロセスが終わったら消える。それに前回、元の足は残していない。残っているのはトレード明細だけで、取得元が過去の足を直したら、同じ数字はもう作れないかもしれない。そう、自分の記録の最後に書いた。
三本の前に、一本目が消えない場所が要る。
2. Method — 置き場所
2.1 まず、二時間だけ
OANDAのpractice APIから、USDJPYの5分足を取る。最初は、固定した二時間だけにした。
取るのは、bidとaskのOHLCと、volume。保存するのは確定した足(complete)だけで、まだ動いている途中の足は入れない。時刻はUTCで持って、期間は[start, end)の半開区間。5分境界に乗らない時刻が混ざったら、入れる前に止まる。
tokenも、口座情報も、注文も入れない。APIの応答を丸ごと保存するのも、やめた。
足に必要な列だけ。「足だけ」と決めた。
コマンドを走らせる。24本。新しいSQLiteの中に、五分おきの時刻が並んだ。
2.2 同じ足を、二人にしない
まだ安心しない。同じ二時間を、もう一度取る。
first run 24 rows
second run 24 rows
48本には、ならなかった。
行のkeyは、「どこから来た・どの通貨ペアの・どの粒度の・どの価格の・いつの」足か。同じkeyの行がもう居たら、行を増やさずに、新しい値で上書きする。
同じ時刻の足を、二人にしない。ひとまず、それだけ決めた。
なくさない、だけじゃなくて、重ねて増やさない置き場所になった。
2.3 取得と保存の条件
| 項目 | 条件 |
|---|---|
| source | OANDA practice candle API |
| instrument | USDJPY |
| granularity / price | M5 / BA |
| 保存対象 | complete=trueの足だけ |
| 時刻 | UTC、[start, end) |
| 重複 | 同一keyはupsert(行を増やさない) |
| 正本 | M5 SQLite |
| 分析用 | read-onlyでDataFrameへ投影 |
固定二時間の24本は、新しいSQLiteを作る入り口の確認だけに使った。このあとで開く、長く貯めてきた方のSQLiteとは、別の証拠として分けている。schemaの全列とretryの条件は、後ろとコードに残す。
2.4 作業机と、置き場所
DataFrameで見ると、それっぽい。timestampがindexになって、bidもaskも列になる。headを出すと、そこにずっとあったみたいに見える。
でも、プロセスを終えたら消える。
DataFrameは作業机で、DBは置き場所だった。
当たり前のことを、わたしは一回消してみないと分からなかった。
二時間分の入り口を確かめたあと、前から貯めていた長期のSQLiteを開く。こっちは読み取り専用。分析の側には、書ける口を持たせない。作業机の上で何をしても、置き場所は変わらないようにしておく。
schemaは、最小コードが作った方と同じじゃない。足に必要な共通の16列だけを使って、同じ形に開く。
条件を固定する。
USD_JPY / M5 / BA / complete=1
2024-01-01 22:00:00Z <= ts < 2026-07-14 10:05:00Z
戻ってきたのは、188,981本。
カーソルで数字をなぞって、もう一度数えた。数えたといっても、同じコマンドをもう一回打っただけだけど。
2.5 閉じる。戻す。もう一度見る
いったん閉じる。
プロセスも閉じる。
もう一度開いて、同じSQLiteに、同じ条件を渡す。
見るのは三つ。行数と、最初と最後のtimestampと、抽出した行をおなじ形に並べ直して作ったhash。
全部、一致した。
小さい声で「ありがとう」と言った。
AIは、いつもどおりに返してきた。その返事を見てから、ターミナルを閉じた。
閉じても戻る。
たぶん、これで終わりだと思った。
3. Results — 三本
3.1 三本あれば、一本
次の夜、同じDataFrameをSQLiteから戻した。
こんどこそ、三本ずつにする。
UTCのtimestampから、900で割った余りを引く。それが、その足の属する15分の頭になる。0分、5分、10分の三本が、同じ頭を持つ。
| 15分足の項目 | 規則 |
|---|---|
| bucket | UTCの15分開始境界 |
| 必要な5分足 | 開始+0秒、+300秒、+600秒の三本ちょうど |
| OHLC | open=first / high=max / low=min / close=last |
| volume | 三本の合計 |
| price | bidとaskは別々に集約。混ぜてmidは作らない |
| 欠損 | 補間せず、不完全bucketを分離 |
| ET | UTC正本からAmerica/New_Yorkのlabelを派生 |
書いた判定の、真ん中はここ(minute_data.py):
working["bucket_ts_utc"] = working["ts_utc"] - (
working["ts_utc"] % 900
)
# ... bucketごとにgroupbyして ...
complete_mask = (
(stats["component_count"] == 3)
& (stats["unique_count"] == 3)
& (stats["first_ts"] == stats["bucket_ts_utc"])
& (stats["last_ts"] == stats["bucket_ts_utc"] + 600)
)
三本いて、重複がなくて、最初の一本が頭で、最後の一本が頭の600秒あと。四つ全部を通ったbucketだけが、完全な15分足になる。
三本あれば、一本。
そのつもりだった。
3.2 三本に、ならない場所
走らせると、完全にならなかったbucketが、別の入れ物に残った。
空のはずの、入れ物だった。
M5の側へ戻って、隣り合うtimestampの差を取る。ぜんぶ300秒なら、隙間はない。
300秒じゃない場所が、192か所あった。
使った行は、すべてcomplete=1。一本ずつは、確定してる。completeは「この足はもう閉まってる」という印で、「次の足がちゃんと隣にいる」という意味じゃなかった。
「取れた」と「揃った」は、同じじゃなかった。
大きい隙間は、だいたい週末の形をしてる。金曜の夜から、日曜の夜まで。ぜんぶがそうだとは、言えない。短くて、名前を付けられない隙間も混ざってる。分類は後ろに置いて、ここでは決めない。
欠けた場所に、前の足を写せば、見た目は三本になる。
一行、打ちかけた。
埋めれば三本になる。でも、それは取れた三本じゃない。
……やめた。
ない足は作らない。三本がちょうど揃ったbucketだけをまとめて、一本か二本しかなかったbucketは、数えて、別に残した。
3.3 三本あっても
ここで、もう一つ気づく。
三本が揃っていても、全部は残らない。
15分足に残るのは、最初のopen、三本でいちばん高いhigh、いちばん低いlow、最後のclose。highの値は列に残っても、そのhighがどの5分足のものだったかは、どの列にもない。三本それぞれがどんな形だったかも、高値と安値のどちらに先に触れたかも、15分足だけからは戻らない。
三本を一つにしたら、一本ぶん増えるんじゃなかった。
見えなくなるものが、増えた。
一つにするのは、足し算とか、圧縮とか、そういう自動の作業だと思ってた。
エディタの右側に、行を二つ足す。
M5はSQLiteに残す。M15は別のDataFrameにする。
M15を、M5のtableに書き戻さない。
三本それぞれの形を後で見たくなったら、正本のほうへ戻る。

3.4 確認結果
| 確認 | 値 |
|---|---|
| M5 | 188,981本 |
| timestamp gap | 192件 |
| complete M15 | 62,957本 |
| M5が1〜2本あった不完全bucket | 61件 |
※ 61件は、少なくともM5が1本あった不完全bucket。M5が三本ともない15分区間は、そもそもbucketとして観測されないから、この数に含まれない。
62,957 × 3 = 188,871本。不完全bucketの中にいた110本を足すと、188,981本。入りと出の勘定は、合ってる。
4. Discussion — 一つだけ
置き場所は、できた。それで全部が揃ったわけじゃない。隙間は埋めてないから、空いたまま。OANDAが自分で配っているM15と、わたしが作ったM15が同じかどうかも、見比べていない。
言えるのは、同じDBに同じ条件を渡せば、同じDataFrameが戻ってくる、そこまで。
まとめ方を変えたくなったら、M15を直すんじゃなくて、M5からやり直す。正本が残ってるかぎり、派生は何度でも作り直せる。
UTCの列は、そのままにした。
そこからNew Yorkのlabelを別に作って、9:30の行を選ぶ。UTCに固定の時差を足すやり方は、最初にやめた。夏時間と冬時間で、9:30のUTCは別の場所へ動く。でも、9:30という名前は、同じ場所に残った。
ターミナルに、15分足が一行出た。
その行を、m15_opening_rangeという別の変数に置く。
SQLiteのM5は消さない。
M15で上書きもしない。
カーソルが、一行の下で点滅していた。
次は、この一本だけ持っていく。
Appendix — 再現条件と技術的な境界
A. 取得・投影・集約条件
| 項目 | 値 |
|---|---|
| source / environment | oanda_rest_v20 / practice |
| instrument / granularity / price | USD_JPY / M5 / BA |
| 対象 | complete=1 |
| 固定抽出範囲 | [2024-01-01T22:00:00Z, 2026-07-14T10:05:00Z) |
| M5最初 / 最後 | 2024-01-01T22:00:00Z / 2026-07-14T10:00:00Z |
| 時刻正本 | UTC |
| ET label | America/New_Yorkから派生 |
| M15の欠損方針 | 補間せず不完全bucketを除外・記録 |
B. DB・抽出の同一性
| 項目 | 値 |
|---|---|
| DB basename | rates.sqlite |
| DB SHA-256 | edb6e54fe519af25b27508102fa8d9e2d949b8acddd3286fb59c59c65171cf46 |
| SQLite quick check | ok |
| 既存DB user version | 0 |
| 再現用最小DB schema version | 1 |
| M5 extraction SHA-256 | f6d0e1cd1bd50ec11f7f3f0bd34e31b61a39970a687f3c5ac83682ae2ea1d512 |
既存DBと再現用最小DBは、共通の16列を使う別schemaとして接続した。同一schemaとは扱っていない。hashはDB binaryと固定抽出の同一性を照合するための値であり、無欠損やデータ品質全体の証明ではない。
C. gapとM15完全性
| 項目 | 値 |
|---|---|
| gap | 192件 |
| 欠けたM5 slot | 77,276 |
| 週末休場候補 | 132件 |
| 長い非週末休場候補 | 4件 |
| 短い未分類gap | 56件 |
| complete M15 | 62,957本 |
| M5が1〜2本あった不完全bucket | 61件 |
| 内訳 | M5が1本: 12件 / 2本: 49件 |
gapの三分類は候補判定であり、正式な市場calendarと照合した確定分類ではない。M5が0本の15分区間は、不完全bucketの61件には含まれず、M5のgap監査でのみ表現される。
D. 集約で残るもの・残らないもの
- bidとaskは別々に集約した。
- volumeは三本の合計。OANDA candleのvolumeは対象時間の価格生成数であり、取引数量ではない。
- bid/askのextremaが同時刻とは限らないため、authoritativeなmid high/lowは作っていない。
- M15だけから、M5三本の個別OHLC、高値・安値へ触れた順序、足内の価格経路は復元できない。
- DST前後の確認では、2026-03-06 14:30 UTCと2026-03-09 13:30 UTCのどちらも9:30 ETとして選択できた。
E. 再現コードと公開境界
- 再現コード: GitHub
- 新規SQLite生成:
python chapters/season2/ch02_minute_data_db/run.py ... - 既存DB監査・manifest生成:
python chapters/season2/ch02_minute_data_db/build_manifest.py --db "$SOURCE_DB" --instrument USD_JPY --start 2024-01-01T22:00:00Z --end 2026-07-14T10:05:00Z --manifest results/reference/ch02_minute_data_db/manifest.json - 固定manifest: manifest.json
- Python 3.13.14 / pandas 3.0.2 / requests 2.34.2
- 公開再現コードcommit:
4f66d3896e4139b7da5a23d78029eee9162adcca - 固定manifest生成時のcode commit:
b87983acf4d39878993d04a7bc9cd13cd711b113-dirty - 長期取得runのrequest数・complete受信数・未確定足除外数は上流DBに残っていないため、推測せず
nullとした。固定二時間smokeの24本×2回はこのcounterに流用していない。 - OANDAの行単位レート、SQLite本体、行単位CSVは同梱しない。公開対象はコード、人工fixture、集計、hash、manifest。
この固定範囲とデータ仕様に基づく取得・集約結果。OANDA native M15との値比較、ORBの成績検証は実施していない。データ仕様の変更や修正で結果は変わり得る。投資助言ではなく、売買判断はご自身の責任で。
