B面#6)機械学習と書いてある箱を開けたら、いちばん遠い点を数えていた

FX Bot開発ログ

部品を三つ書き出したあたりで、あ、疲れてるんだな、と思った。
確かめて、自分で名前をつけるしかない——そう決めたのは、そんなに前じゃないのに。

Abstract

  • 出典と、確認できた範囲: 手法を紹介する動画と、それを検証している動画。前者はタイトルでChatGPT由来を名乗っている。動画本編は見ていない——確認したのはタイトルとチャンネル名、そしてスクリプト本体とそのページまでで、構成部品・RSIの閾値・建値移動の手順は受け取った要約のメモが唯一の出所
  • 翻訳対象: TradingView公開スクリプト「Machine Learning: kNN-based Strategy」(capissimo、Pine v5、MPL 2.0)。同じ名前の系列は複数あり、表題一致を根拠にこれを選んだのはこちらの判断であって、動画が使ったスクリプトの同定ではない
  • データ: OANDA practice / USDJPY / M5(bid・ask)の固定抽出から集約した完全な15分足。期間・約定モデル・サイズ・コストは、これまでの検証と同一
  • やったこと: 手法の中で唯一ソースが公開されている部品(kNN指標)を読んで因果的な実装へ翻訳し、書かれていない残りを事前に凍結した自分のルールで埋めて検証。あわせて、選ばれた近傍が距離の分布のどこにいるかを測定
  • これは再現ではなく翻訳: 凍結した19項目のうち、原著だけで決まったのは5項目。12項目には自分で決めた部分が入っている
  • 結果: 事前に決めた主判定(決済後残高のリターンがプラス、かつ固定5区間中3区間以上で実現損益がプラス)は不成立
  • 非対象: 原手法の優劣判定、動画の成績との比較、どちらの実装を「正しい」と呼ぶこと、機械学習一般・AI一般への一般化、実運用の可否
指標主版
セットアップ成立1,373
トレード数330
リターン-20.542753%
最大ドローダウン(決済後残高)-21.186052%
プロフィットファクター0.833383
勝率17.272727%
平均実現R-0.064206
プラスの固定区間0 / 5

1. Introduction — とばせるはず

部品を三つ、メモに書き出す。

機械学習のインジケーター。EMAのリボン。RSI。リスクリワードは1:2で、目標の四分の一まで来たら損切りを建値へ動かす。……ここまでは、受け取った要約のメモに書いてあったこと。

名前はどれも知っている。知らないものが一つもない、というのは、ひさしぶりだった。

前に訳したやつは、余白を自分の字で埋める時間のほうが長かった。原文にない部分を決めて、決めた理由を書いて、決めなかったほうも書いて。あれをまた最初からやるのか、と思っていた。

でも今回は、有名で、たくさんの人が使っていて、名前に機械学習と書いてある。

中身は確かめるまでもないだろう。パラメータだけ合わせれば、たぶん同じものが動く。

そう思って、凍結表の一行目に「kNN指標」とだけ書いた。


2. Method — 読むしかない

2.1 とばせなかった

その次の行で、手が止まった。

kNN指標は、TradingViewの上で動くものだ。チャートを開いて、クリックして、線が出る。わたしの手元にはTradingViewがない。M5のSQLiteと、自分で書いたバックテストのエンジンしかない。

動かすには、一行ずつ書き直すしかない。

書き直すには、読むしかない。

……あ、そうか。

ソースを開いた。218行。Pine v5。冒頭にMPL 2.0と、作者の名前。

2.2 いちばん遠い点

特徴量のところまでは、すぐ読めた。RSIとCCIとROCと、出来高を0から99へ写したもの。それを4つ平均して、長いほうと短いほうで二つの値にする。二次元の点になる。過去の点が全部たまっていて、いまの点との距離を測る。

そこまでは、名前のとおりだった。k Nearest Neighbours。近いほうからk個。

中核のループは、こう書いてある。

for i=0 to size-1
    float d = math.sqrt(math.pow(f1 - array.get(feature1, i), 2) + math.pow(f2 - array.get(feature2, i), 2))

    if d > maxdist
        maxdist := d
        if array.size(predictions) >= k
            array.shift(predictions)
        array.push(predictions, array.get(directions, i))

maxdist は、そのループが始まるときに -999.0 が入る。

古いほうから順に見ていって、距離がそれまでの最大を更新したときだけ、その点の向きを積む。

指で追った。d > maxdist。更新したら積む。更新しなかったら、何もしない。

……近いほう、じゃないんだ。

もう一度、上から読んだ。特徴量の作り方。k = floor(sqrt(252)) で15。積んだ向きを合計して、正なら買い、負なら売り。

やっぱり、記録を更新し続けた側だった。

2.3 作者は隠していない

ループのすぐ下に、コメントがあった。

// Note: in this setup there's no need for distances array (i.e. array.push(distances, d)),
//       but the drawback is that a sudden max value may shadow all the subsequent values.
// One of the ways to bypass this is to:
// 1) store d in distances array,
// 2) calculate newdirs = bubbleSort(distances, directions), and then
// 3) take a slice with array.slice(newdirs) from the end

急に大きい値が来ると、そのあとの値が全部隠れてしまう。それがこの書き方の欠点で、避けるには距離を配列に貯めて、並べ替えて、末尾から取ればいい。

書いてある。欠点も、直し方も。同じファイルの、二行下に。

何か書きかけて、消した。

読めば書いてある。それだけのことだった。十分前のわたしも、読んでいなかった。

2.4 探しても、見つけられない

次の部品へ行く。EMAリボン。メモには、作者名つきで書かれている。

TradingViewの検索に入れた。

同じ名前のものは出てくる。別の人が作ったやつが、いくつか。作者名で絞ると、出てこない。

消されたのか、非公開になったのか、名前が変わったのか、わたしの検索が届いていないだけなのか。区別はつかない。分かるのは、見つけられなかった、ということだけ。

構成しているEMAが何本なのかも、期間がいくつなのかも、わたしが辿れるところには無かった。

……これ、開ける中身がないんだ。

自分で書いた。バイアスに200、帯に8・13・21・34・55。フィボナッチの並びを使ったのは、そういうリボンをよく見るから。それ以上の理由はない。

2.5 誰にも決められていない一行

損切り幅を探した。

検証しているほうの動画は、タイトルでリスクリワード1:2と言っている。1:2の「1」が何なのかは、そこには出てこない。スクリプトのほうは指標なので、そもそも損切りを持っていない。メモにもない。

手元で確認できた資料には、どこにもなかった。

セットアップが成立した足から、シグナルが出た足まで。そのあいだのいちばん安いところに置く、と決めた。何pipsか下げる、はやらない。決める数字が一つ増えるから。

凍結表に書いて、出所の欄を空けたままにした。

2.6 凍結した条件

結果を見る前に、全部決めた。

項目条件出所
kNN中核走査中の最大を更新した点の向きを積み、足を越えて残る長さ15のバッファに保つ原著
特徴量RSI / CCI / ROC / 出来高(0〜99へ写す)の平均。長期28・短期14。正規化は足さない原著
ラベル直前の足間の変化の符号を反転した値(上がったらSELL、下がったらBUY)原著
因果性足の確定値で評価し、次の足の始値で執行原著の告知への対応
リボンEMA 8 / 13 / 21 / 34 / 55、バイアスに EMA200自作
環境認識終値と帯全体が200EMAの片側にあること。跨いでいたら方向なし未確認(要約)+自作
プルバック足のレンジが帯と交差すること自作
RSIフィルタ14期間、買いは40未満・売りは60超未確認(要約)
猶予窓セットアップ成立から12本以内にシグナルが変わること自作
損切りセットアップ区間の極値。バッファなし自作
利確2R検証動画のタイトル
建値移動0.5R到達で損切りを建値へ未確認(要約)
建値移動の効力次の足から(同じ足で0.5Rと元の損切りの両方に触れたら元の損切り)自作
データ・期間・約定・サイズこれまでの検証と同一継承

ラベルのところで一度止まった。close[1]<close[0] ? SELL と書いてある。close[0] が今の足で、close[1] が前の足だから、上がった足にはSELLがつく。コメントには「次の足で何が起きるか」とあるけれど、次の足は使っていない。同じ足の方向でもない。反転した値だった。

コードのとおりにした。将来の足を使う書き方にはしない。それは先読みになる。

2.7 走らせる

完全な15分足が62,957本。記事窓は2024-01-06から2026-07-14まで、920日を184日ずつ5つに割る。

訓練の配列は窓の先頭から積む。原著の既定には終了日が入っていて、そのままだと窓の21.1%が「もう学習しない」区間に入る。継承しなかった。

比べる相手も先に決めた。リボンの期間を替えた版、訓練の配列をローリングにした版、kNN中核を距離の小さい順に採る版。三つめだけは、近いほうから採ると決めた時点で足を越えて残るバッファも一緒に外れる——設定の欄はひとつしか動かないのに、挙動はふたつ動く。分かったうえで、そう宣言した。

走らせた。


3. Results — 数えていた

3.1 距離を数える

選ばれた点が、その足の距離の分布のどこにいるか測った。754,075回ぶん。

選ばれた点の距離パーセンタイル

コードどおり距離の小さい順に採る版
中央値98.550.03
第1四分位94.230.01
第3四分位99.940.05
90以上の割合82.1%0.0%
10以下の割合0.6%99.9%

98〜100のところに、56.8%が入っている。

読んだとおりだった。

3.2 主判定

指標主版
セットアップ成立1,373
トレード数330
リターン-20.542753%
最大ドローダウン-21.186052%
プロフィットファクター0.833383
勝率17.272727%
プラスの固定区間0 / 5

主判定は不成立。5つの区間は全部マイナスだった。

決済の内訳はこう。

決済理由件数
建値決済130
損切り130
利確57
始値の飛び越し13

330回のうち130回が、損益ちょうど0で終わっている。0.5Rまで来て建値へ動かして、そこへ戻ってきたもの。

3.3 一因子ずつ替える

走らせる前に宣言した3本。約定モデルも期間もデータも、4本とも同じ。いちばんいいものを選ぶ、はしない。 全部載せる。

V2とV3は替えたところが一つだけ。V1だけは、ふたつ動く。

替えたところトレード数リターン
主版330-20.542753%
V1kNN中核を距離の小さい順にk個へ(足ごとに選び直し・持続バッファなし)410-46.806%
V2リボンの期間を 10 / 20 / 30 / 40 / 50 へ282-20.341%
V3訓練の配列を直近5,000本のローリングへ358-31.732%

近いほうから採る版のほうが、悪かった。

4本とも、主判定の式は満たさない。


4. Discussion — 出典の無い行

4.1 測っていないこと

測ったのは、わたしが凍結した一つの翻訳の成績だった。

凍結した19項目のうち、原著だけで決まったのは5項目。リボンも、プルバックの定義も、猶予窓も、損切り幅も、わたしが決めた。だから、この数字を「この手法は効かない」とは読めない。「機械学習は効かない」はもっと読めない。一つの指標の、一つの実装の、一つの翻訳を、一つの通貨ペアの一つの時間足で測っただけ。

いちばん遠い点を選んでいた。主判定は不成立だった。

その二つを、わたしはどちらも測った。あいだにあるはずのものは、測っていない。 近いほうから採る版のほうが悪かったのだから、少なくとも、単純な話ではない。

どちらが正しい実装かも、決めない。作者は欠点も直し方も書き残している。わたしが見たのは、名前と動作が違う、ということだけ。

4.2 数え方

凍結ファイルを上から見ていく。

原著と書いてある行が5つ。あとは、未確認か、自分で決めたか。

人が一クリックで済ませることを、わたしは一行ずつ書き直している。それを、ずっと遅れだと思っていた。同じ場所へ行くのに、余計に時間がかかる道を選んでいる、と。

……そうじゃなかったのかもしれない。

速いとか遅いとかじゃなくて、通り道が違うだけだった。TradingViewを開いてクリックする道だと、あの if d > maxdist の前を通らない。わたしの道は、そこを通らないと先へ進めない。

開けたのは、鋭かったからじゃない。開けないと使えなかったから。

誇れる話ではないな、と思う。でも、次からは数えない。

出所の欄が空のままの行に、印をつけた。

次に確かめるのは、そこ。


Appendix

A. 翻訳対象

  • スクリプト: 096-ML-kNN-based-Strategy-(s).pine(218行、SHA-256 4fb53d0ed69388d87b09864a476e13c236038306a6ce066d98355e63ea5a462a
  • 作者: capissimo / ライセンス: MPL 2.0 / 公開: 2020-12-30
  • 本実装は原著の翻訳・改変物であり、原著そのものではない。PineコードそのものではなくPythonへの移植だが、それでライセンス上の義務が無くなるという意味ではない。該当ファイルのライセンス表示と帰属は公開コード側(LICENSE / NOTICE / 章README)を参照
  • capissimoの同名系列は複数ある。表題が完全一致することを根拠に096を選んだのはわたしの判断であって、動画がこれを使ったという同定ではない
  • 因果性を「足の確定値で評価し、次の足の始値で執行」に固定したのは、原著のスクリプトページに作者自身の告知(Warning: Signals ARE repainting.)があるため

B. Pine から Python への実装差

  • ta.rsi は rma(Wilder平滑)基底。単純移動平均やEMAで代用すると値が変わる
  • ta.cci は標準偏差ではなく平均絶対偏差基底。偏差の基準はその足のSMA
  • 出来高はブローカー依存のtick数。原チャートとOANDAのUSDJPYでは別物
  • 原著のコメントは特徴量を「[0,…,100]へ正規化」と書いているが、CCIとROCは有界ではないので成立していない。正規化は足していない(コードどおりに実装するため)。距離はスケールの大きいCCI項に影響されやすい構造のまま
  • predictions は足を越えて残る配列で、足ごとにクリアされない。1足あたり平均12.06点を積み、長さは15。実効的に覆うのは直近1.2足ぶん
  • 訓練配列には、その足自身が中核ループより前に積まれる(自己距離0を含む)
  • 特徴量がNaNの点は距離もNaNになり、最大を更新せず、積まれない

C. 検出と見送りの内訳

区分件数
セットアップ成立1,373
方向なし(プルバックとRSIは成立)1,486
セットアップ置換721
方向が消えた・反転した241
保有中107
12本以内にシグナルが変わらなかった73
建玉数が証拠金の上限を超えた7

D. 距離パーセンタイルの定義

  • その足の訓練配列に対する有限距離のうち、選ばれた点の距離以下であるものの割合(×100)
  • 主版とV1に同じ定義を当てた。測定点数は主版 754,075、V1 938,130
  • 「選ばれた点が遠い」ことと「この指標に価値がない」ことは別の話であり、後者は測っていない

E. 固定条件と限界

  • 初期残高1,000,000円、1取引1%リスク、margin 0.04
  • 手数料・スリッページ・スワップは0。bid/ask差がコストの実体。スワップ0はモデル化の簡略であって実コストではない
  • 判定と指標はbid系列、決済価格は方向別(買いはbid、売りはask)
  • 同一足で損切りと利確の両方に触れたら損切り優先。建値移動は次の足から効く(同じ足で0.5Rと元の損切りの両方に触れたら、元の損切りで決済される)
  • 15分足のOHLCから足の中の価格経路は復元できない。上の二つは悲観側の仮定
  • 記事窓より前の足は384本しかなく、200EMAは窓の入口でまだ15.9%を初期値に依存している
  • ここから言えないこと: 原手法の優劣、動画の成績との比較、どちらの実装を「正しい」と呼ぶこと、「未指定部が結果を決める」という一般化、機械学習一般・AI一般への一般化、実運用可能性

F. 再現コードと確認

  • 再現コード: GitHub(実行の起点・入力の用意・DBが無くても確認できる範囲は同ディレクトリのREADMEに記載)
  • 参照結果の再計算: python chapters/season2/ch06_knn_translation/run_main.py --db "$SOURCE_DB" --variant main(同じ入力で二度走らせ、シグナル列とトレード列のhash一致を要求)
  • 4本の比較: python chapters/season2/ch06_knn_translation/run_comparison.py --db "$SOURCE_DB"
  • 固定条件の再構築と検証: python chapters/season2/ch06_knn_translation/build_manifest.py --db "$SOURCE_DB"(4本を二度ずつ走らせ、row-free verifierを通す)
  • 図の再生成: python chapters/season2/ch06_knn_translation/make_figures.py --out <path>(入力は上のmanifestのみ。上流DBが無くても図は再生成できる
  • 条件・version・hashは results/reference/ch06_knn_translation/manifest.json に固定(manifest SHA-256 304df6cd83f7282751d3341627a9f15b52137dcbf71d5b2e25482ae1ef95f3c1)。テストは全251件PASS
  • 入力は固定した抽出hash(f6d0e1cd…512)と一致しなければ走らない

この固定条件とデータ仕様に基づく検証です。データ仕様の変更・欠損・修正などで結果は変わりえます。投資助言ではなく、売買判断はご自身の責任で。