B面#2.5分足を三本まとめたら、消えたものがあった

FX Bot開発ログ

自分で決めてた、って気づいたのはあの夜だった。
怖くなったのは、二日あとだった。

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 取得と保存の条件

項目条件
sourceOANDA practice candle API
instrumentUSDJPY
granularity / priceM5 / 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分足の項目規則
bucketUTCの15分開始境界
必要な5分足開始+0秒、+300秒、+600秒の三本ちょうど
OHLCopen=first / high=max / low=min / close=last
volume三本の合計
pricebidとaskは別々に集約。混ぜてmidは作らない
欠損補間せず、不完全bucketを分離
ETUTC正本から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に書き戻さない。

三本それぞれの形を後で見たくなったら、正本のほうへ戻る。

M5三本からM15一本への集約模式図

3.4 確認結果

確認
M5188,981本
timestamp gap192件
complete M1562,957本
M5が1〜2本あった不完全bucket61件

※ 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 / environmentoanda_rest_v20 / practice
instrument / granularity / priceUSD_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 labelAmerica/New_Yorkから派生
M15の欠損方針補間せず不完全bucketを除外・記録

B. DB・抽出の同一性

項目
DB basenamerates.sqlite
DB SHA-256edb6e54fe519af25b27508102fa8d9e2d949b8acddd3286fb59c59c65171cf46
SQLite quick checkok
既存DB user version0
再現用最小DB schema version1
M5 extraction SHA-256f6d0e1cd1bd50ec11f7f3f0bd34e31b61a39970a687f3c5ac83682ae2ea1d512

既存DBと再現用最小DBは、共通の16列を使う別schemaとして接続した。同一schemaとは扱っていない。hashはDB binaryと固定抽出の同一性を照合するための値であり、無欠損やデータ品質全体の証明ではない。

C. gapとM15完全性

項目
gap192件
欠けたM5 slot77,276
週末休場候補132件
長い非週末休場候補4件
短い未分類gap56件
complete M1562,957本
M5が1〜2本あった不完全bucket61件
内訳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の成績検証は実施していない。データ仕様の変更や修正で結果は変わり得る。投資助言ではなく、売買判断はご自身の責任で。