ランダムウォークへの挑戦㉒|Q3の境界をずらしても優位性は残るのか(2026/8/30)

未分類

はじめに

前回は、Relative Band DistanceのQ3について、

時間が変わっても優位性が残っているのか

を検証しました。

ランダムウォークへの挑戦㉑|Q3の優位性は時間が変わっても残るのか(2026/8/29)
はじめに前回は、Relative Band Distanceごとの利益分布を分析しました。その結果、特にQ3とQ4で利益の作り方がかなり違うことが分かりました。Q3は、PF:1.412中央値:+271.15上位5%の勝ち取引を除外してもPF…

その結果、Q3のPFは、

Period1  1.273
Period2  1.659
Period3  1.435
Period4  1.401

となり、4期間すべてでPF1を上回りました。

さらに年別でも、

12年間中10年間でPF1以上

となりました。

これまでの分析を整理すると、

第19回
Q3のPFが最も高かった

↓

第20回
大勝ちを除外してもQ3は比較的強かった

↓

第21回
時間を分割してもQ3の優位性が残った

というところまで来ました。

Relative Band Distance Q3は、かなり面白い条件になってきています。

しかし、まだこのまま零号機へ組み込むことはできません。

理由は、

Q3という区切り自体が、過去データから作られたものだから

です。

今回は、いよいよこの問題を調べます。


Q3は「0.44~0.88」だった

これまで使用してきたRelative Band Distanceは、

Relative Band Distance
=
BAND_DISTANCE ÷ ATR

です。

つまり、

ボリンジャーバンドからどれくらい離れてエントリーしたか

を、そのときのボラティリティで正規化しています。

第19回で全取引を四分位に分けたところ、Q3はおおよそ、

0.44 ~ 0.88

となりました。

そして、この範囲のPFが、

1.412

と最も高くなりました。

一見すると、

ではRelative Band Distanceが0.44~0.88のときだけエントリーすればいい

となります。

しかし、これは少し危険です。


「0.44」という数字に意味はあるのか

例えば、本当に相場の構造としてQ3付近に優位性があるのであれば、

0.40 ~ 0.84
0.42 ~ 0.86
0.44 ~ 0.88
0.46 ~ 0.90
0.48 ~ 0.92

のように条件を少し動かしても、ある程度似た成績になるはずです。

一方で、

0.43 ~ 0.87 → PF 1.05
0.44 ~ 0.88 → PF 1.41
0.45 ~ 0.89 → PF 0.98

のように、わずかな違いだけで成績が急変するのであれば、

0.44~0.88という条件を過去データに合わせ込んだだけ

かもしれません。

これはバックテストでは非常に重要な問題です。

条件を細かく調整していけば、過去データ上だけ成績の良いポイントはいくらでも見つかります。

しかし、その一点だけが突出している条件は、未来でも再現するとは限りません。

今回探したいのは、

最高PFになる一点

ではありません。

少し条件を変えても比較的良い成績が続く範囲

です。


「山」ではなく「台地」を探す

イメージとしては、

PF

1.5             ▲
               / \
1.4           /   \
1.3 _________/     \_________

       Relative Band Distance

という鋭い山よりも、

PF

1.5
1.4        ┌────────┐
1.3 _______│        │_______
           └────────┘

       Relative Band Distance

という形の方が好ましいです。

前者は、

ある特定のパラメータだけが偶然良かった

可能性があります。

後者は、

その周辺一帯に何らかの相場的な特徴が存在する

可能性があります。

自動売買のパラメータ探索では、最高値そのものより、

良い成績が広い範囲に分布しているか

を見る方が重要だと考えています。

今回はこの「台地」が存在するかを調べます。


今回調べること

今回は、Relative Band Distanceの下限と上限を少しずつ変化させます。

基準となるQ3は、

下限 0.44
上限 0.88

です。

ここから下限を、

0.30
0.32
0.34
...
0.60

と動かします。

上限についても、

0.70
0.72
0.74
...
1.10

と動かします。

そして、それぞれの組み合わせについて、

  • 取引数
  • 勝率
  • 総損益
  • 平均損益
  • 中央値
  • Profit Factor

を計算します。

例えば、

0.40 ~ 0.80
0.40 ~ 0.82
0.40 ~ 0.84
...

0.42 ~ 0.80
0.42 ~ 0.82
0.42 ~ 0.84
...

という大量の組み合わせを一気に調べます。


ただし「最高PFランキング」だけは見ない

今回注意したいことがあります。

すべての組み合わせを計算すると、

PFが一番高い条件はどこか

を見たくなります。

しかし、これだけでは意味がありません。

例えば、

0.52~0.74
取引数:47
PF:2.30

という条件があったとしても、

取引数が少ないために偶然PFが高くなった可能性があります。

一方、

0.40~0.92
取引数:380
PF:1.35

であれば、PF自体は低くてもこちらの方が信頼できるかもしれません。

そこで今回は、

PFと取引数を同時に見る

ことにします。


最低取引数を設定する

今回は、

MIN_TRADES = 100

として、

100回未満の組み合わせは主要な評価対象から外す

ことにしました。

100回という数字自体に絶対的な意味があるわけではありません。

ただ、数十回しかない条件のPF2やPF3を追いかけ始めると、過剰最適化へ向かいやすくなります。

今回は、

ある程度のサンプル数を確保した上で、広い範囲に優位性が存在するか

を優先します。


Relative Band Distanceを再計算する

今回も、

analysis_df["relative_band_distance"] = (
    analysis_df["entry_band_distance_pips"]
    /
    analysis_df["entry_atr14_pips"]
)

としてRelative Band Distanceを計算します。

ただし、今回は四分位には分けません。

前回までは、

pd.qcut()

を使ってQ1~Q4を作っていました。

今回はこれを使わず、

Relative Band Distanceを連続値のまま扱う

のがポイントです。


下限と上限を総当たりする

下限候補を、

LOWER_VALUES = np.arange(
    0.30,
    0.62,
    0.02
)

上限候補を、

UPPER_VALUES = np.arange(
    0.70,
    1.12,
    0.02
)

としました。

その組み合わせについて、

lower <= relative_band_distance <= upper

を満たす取引だけを抽出します。

例えば、

mask = (
    (analysis_df["relative_band_distance"] >= lower)
    &
    (analysis_df["relative_band_distance"] <= upper)
)

です。

これをすべての組み合わせについて繰り返します。


Profit Factorを計算する

Profit Factorはこれまでと同じく、

総利益 ÷ 総損失

です。

コードでは、

gross_profit = selected.loc[
    selected["profit"] > 0,
    "profit"
].sum()

gross_loss = -selected.loc[
    selected["profit"] < 0,
    "profit"
].sum()

profit_factor = (
    gross_profit / gross_loss
    if gross_loss > 0
    else np.nan
)

とします。

これで、

下限
上限
取引数
勝率
総損益
平均損益
中央値
PF

が1行ずつ記録されます。


ヒートマップで見る

大量の数字をCSVで見るだけでは分かりにくいため、今回はヒートマップも作ります。

横軸を、

上限

縦軸を、

下限

色を、

Profit Factor

とします。

これによって、

Q3周辺にPFの高い領域がまとまって存在するか

を見ることができます。

今回もっとも見たいグラフです。

もし、

0.42~0.84
0.42~0.86
0.42~0.88
0.44~0.84
0.44~0.86
0.44~0.88
0.46~0.86
0.46~0.88

など広い範囲でPF1.3~1.4程度が維持されているなら、

Q3は偶然切り取られた区間ではなく、その周辺に比較的広い優位性がある

という仮説が強くなります。

逆に、

0.44~0.88

だけが突然突出していれば、かなり警戒すべきです。


元のQ3も比較対象として表示する

今回のコードでは、

BASE_LOWER = 0.44
BASE_UPPER = 0.88

として、元のQ3付近の条件も別途計算します。

これによって、

Q3相当条件

取引数
勝率
総損益
平均損益
中央値
PF

を確認できます。

ただし、第19回で使った0.44~0.88は四分位点を丸めて表記した数字なので、

今回の0.44~0.88と第19回Q3の取引集合が完全に一致するとは限らない

点には注意が必要です。

今回の目的はPF1.412を完全再現することではありません。

その周辺に成績の良い範囲が存在するか

を見ることです。


さらに「下限固定」「上限固定」でも見る

二次元のヒートマップだけでは少し分かりにくいため、もう2つグラフを作ります。

まず、

上限を0.88に固定

して、下限だけを変化させます。

0.30~0.88
0.32~0.88
0.34~0.88
...

という分析です。

逆に、

下限を0.44に固定

して、

0.44~0.70
0.44~0.72
0.44~0.74
...

と上限だけを動かします。

これによって、

どちら側の境界に成績が敏感なのか

も確認できます。

これは実際にEAへフィルターを実装するときにも重要です。


今回特に見たいポイント

今回、最高PFそのものより注目したいのは次の3点です。

① Q3周辺でPF1以上が広く続いているか

0.44~0.88だけではなく、その周辺にもPF1以上の組み合わせが多ければプラス材料です。

② PF1.3前後の領域がまとまって存在するか

一つだけPF1.5より、

1.32
1.37
1.40
1.35
1.31

のような領域が続いている方が、今回の目的には合っています。

③ 取引数が十分残るか

フィルターを厳しくしてPFを上げても、

1,600取引
↓
100取引

となれば、零号機の性格そのものが変わります。

PFだけでなく、

どれくらいの取引機会を捨てることになるのか

も見ていきます。


分析結果

まず、元のQ3に相当する、

0.44 ~ 0.88

の条件を確認しました。

結果は、

取引数:441
勝率:59.86%
総損益:+60,876円
平均損益:+138円
中央値:+271円
PF:1.397

となりました。

第19回で四分位として計算したQ3のPF1.412とほぼ同じ水準です。

丸めた境界値を直接指定しているため完全には一致しませんが、

0.44~0.88付近に優位性が存在する

というこれまでの分析結果は再確認できました。

しかし、今回さらに興味深かったのはここからです。

Q3だけが突出していたわけではなかった

全組み合わせをヒートマップにすると、Relative Band Distanceの条件を多少変えても、広い範囲でPF1を超えていることが分かりました。

最低100取引以上ある351通りの組み合わせについて見ると、

PF 1.0以上:349通り
PF 1.2以上:338通り
PF 1.3以上:318通り
PF 1.4以上:225通り
PF 1.5以上:137通り
PF 1.6以上:56通り

となりました。

割合にすると、

PF1.0以上:約99.4%
PF1.2以上:約96.3%
PF1.3以上:約90.6%
PF1.4以上:約64.1%
PF1.5以上:約39.0%
PF1.6以上:約16.0%

です。

これはかなり重要です。

仮に0.44~0.88という一点だけがPF1.4で、その周囲がPF1前後まで急落していれば、

Q3という区切りに過剰適合していた

可能性を疑う必要がありました。

しかし今回の結果はむしろ逆でした。

広い範囲にわたってPF1.3~1.6程度の領域が続いています。

つまり、今回探していた、

「尖った山」ではなく「台地」

にかなり近い結果です。


下限0.44には強く依存していない

次に、上限を0.88に固定して、下限だけを動かしました。

結果を見ると、

下限0.30 → PF 1.397
0.32 → 1.510
0.34 → 1.508
0.36 → 1.394
0.38 → 1.356
0.40 → 1.387
0.42 → 1.394
0.44 → 1.397
0.46 → 1.454
0.48 → 1.430
0.50 → 1.422
0.52 → 1.386

となっています。

0.54だけPF1.193まで一時的に落ちていますが、それ以外は概ねPF1.35~1.50付近です。

ここから分かるのは、

0.44という下限値自体に特別な意味があるわけではなさそう

ということです。

例えば、

0.40~0.88
0.42~0.88
0.44~0.88
0.46~0.88
0.48~0.88
0.50~0.88

と多少条件を変えても、PFは大きく崩れていません。

むしろ0.46~0.88ではPF1.454となっています。

したがって、

Q3の下限0.44を数百分の一単位で最適化する必要はなさそう

です。

これは過剰最適化を避けるという意味では良い結果です。


一方で、上限側にはかなり面白い変化があった

さらに興味深いのが、下限を0.44に固定して上限を動かした結果です。

0.70~0.88付近では、

0.44~0.70 → PF 1.325
0.44~0.74 → PF 1.390
0.44~0.80 → PF 1.365
0.44~0.86 → PF 1.387
0.44~0.88 → PF 1.397

と、PF1.3~1.4程度でした。

ところが、0.88を超えたあたりからPFが上昇し始めます。

0.44~0.90 → PF 1.448
0.44~0.94 → PF 1.443
0.44~0.96 → PF 1.470
0.44~0.98 → PF 1.496
0.44~1.00 → PF 1.639

特に、

0.44~1.00

では、

取引数:534
総損益:+117,305円
平均損益:+220円
中央値:+262円
PF:1.639

となりました。

これは元のQ3、

0.44~0.88
PF 1.397
総損益 +60,876円

と比較するとかなり大きな違いです。

上限を0.88から1.00までわずかに広げただけで、

取引数
441 → 534

総損益
+60,876円 → +117,305円

PF
1.397 → 1.639

となりました。

取引数を減らしてPFだけを高くしたわけではありません。

むしろ、

取引数を増やしながらPFも上昇している

のがポイントです。


本当に面白いのは「1.00」という境界かもしれない

ヒートマップを見ると、特に目立つのが、

Upper Threshold = 1.00

付近です。

この縦方向にPFの高い領域が広く形成されています。

例えばPF上位条件を見ると、

0.60~1.00
取引数344
PF 1.861

0.58~1.00
取引数361
PF 1.778

0.56~1.00
取引数381
PF 1.722

0.50~1.00
取引数452
PF 1.708

0.48~1.00
取引数481
PF 1.695

0.46~1.00
取引数503
PF 1.708

となっています。

さらに興味深いのが、

0.34~1.00
取引数675
PF 1.691

です。

かなり条件を広げてもPF1.69を維持しています。

これは、

ある極端に狭い範囲だけが強い

という結果ではありません。

むしろ、

Relative Band Distanceの上限1.00付近を境に、かなり広い下限設定で良好な成績が出ている

ように見えます。


「1.00」には意味があるのか

Relative Band Distanceは、

Band Distance ÷ ATR

です。

したがって、

Relative Band Distance = 1.00

とは、

ボリンジャーバンドからの距離が、おおよそATR1個分

という意味になります。

これは少し面白い結果です。

これまでQ3という統計的な区切りを追っていましたが、今回初めて、

ATR1個分

という比較的解釈しやすい水準が候補として浮かび上がりました。

つまり零号機では、

バンドを抜けているものの、ATR1個分以上離れすぎていない位置

が比較的良い可能性があります。

もちろん、今回だけで、

1.00が相場の真の境界

と結論づけることはできません。

1.00というきれいな数字が見えたからこそ、逆に慎重になる必要があります。


最高PFは0.60~1.00だった

今回の探索範囲の中で、最もPFが高かった条件は、

0.60~1.00

でした。

結果は、

取引数:344
勝率:53.49%
総損益:+94,287円
平均損益:+274円
中央値:+117円
PF:1.861

です。

PFだけを見ると非常に魅力的です。

ただし、これをそのまま、

最適値は0.60~1.00

とするつもりはありません。

その理由は今回ずっと見てきたとおり、

最高値を採用すること自体が過剰最適化につながる

からです。

むしろ興味深いのは、

0.46~1.00
0.48~1.00
0.50~1.00
0.56~1.00
0.58~1.00
0.60~1.00

など、周囲にもPF1.7前後の条件が多数存在することです。

こちらの方が今回の分析では重要です。


Q3仮説を少し修正する必要がありそう

これまで私は、

Relative Band DistanceのQ3が強い

と考えてきました。

しかし、今回の結果を見ると、少し表現を変えた方が良さそうです。

もしかすると本当に存在しているのは、

Q3という区間

ではなく、

バンドからある程度離れているが、ATR1個分を大きく超えていない領域

の優位性なのかもしれません。

つまり今回の分析によって、

Q3が強い

という統計上の発見から、

Relative Band Distance 1.0付近に
何らかの構造が存在するのではないか

という、もう少し具体的な仮説へ進むことができました。

これはかなり大きな変化です。


ただし、ここでフィルターを採用してはいけない

結果だけを見ると、

Relative Band Distance
0.4~1.0

あたりを零号機に入れたくなります。

しかし、まだ一つ大きな問題があります。

今回も、

同じ過去データを使って条件を探索している

からです。

0.30~0.60、

0.70~1.12、

という大量の組み合わせを試した結果として、

1.00付近が良い

と発見しています。

つまり、1.00という数字もまた、

過去データを見たから発見できた数字

です。

これをそのまま同じデータで評価して、

PF1.64だから強い

としてしまうと、探索と評価を同じデータで行うことになります。

これは避けなければなりません。


次回

発見した「1.00」は未来でも通用するのか

次回は、今回見つかった、

Relative Band Distanceの上限1.00付近

について、

時間的に分割しても優位性が残るのか

を改めて検証します。

第21回では、

0.44~0.88

というQ3について時間安定性を調べました。

しかし今回、

0.44~1.00

や、

0.46~1.00
0.50~1.00
0.60~1.00

など、より強い候補が見つかりました。

そこで次回は、例えばデータを時系列で、

前半50%
後半50%

または、

Period1
Period2
Period3
Period4

に分割します。

そして、

0.44~0.88
0.44~1.00
0.50~1.00
0.60~1.00

などの条件について、

取引数
PF
総損益
平均損益
中央値

を比較します。

特に確認したいのは、

0.60~1.00という最高PF条件が、すべての期間で強いのか

です。

もし、

Period1 PF 2.5
Period2 PF 2.2
Period3 PF 0.9
Period4 PF 0.8

のような結果であれば、全期間PF1.86という数字は一部期間に依存している可能性があります。

一方で、

Period1 PF 1.6
Period2 PF 1.9
Period3 PF 1.7
Period4 PF 1.5

のように安定していれば、

Relative Band Distance 0.6~1.0付近

はかなり有力なフィルター候補になります。

さらに、

0.44~1.00

のような広めの条件と、

0.60~1.00

という狭い条件を比較することで、

PFを最大化する狭い条件

と、

多少PFは下がっても取引数と安定性を確保できる条件

のどちらを採用するべきかも考えたいと思います。


次回予告

今回の分析では、

Q3の境界は偶然ではなさそう

というところから、さらに、

Relative Band Distance 1.00付近に興味深い境界が存在する

という新しい仮説が見えてきました。

しかしこれは、過去データを探索して見つけた数字です。

次回は、

「0.44~1.00」「0.60~1.00」は時代が変わっても強いのか

を検証します。

最高PFを追いかけるのではなく、

時間が変わっても残る頑丈な条件

を探します。


今回のコード全文

ファイル名:

relative_band_threshold_analysis.py
コード全文を見る
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt


# ============================================================
# 設定
# ============================================================

INPUT_FILE = "results/trade_base.csv"

OUTPUT_DIR = "results"

os.makedirs(
    OUTPUT_DIR,
    exist_ok=True
)


OUTPUT_CSV = os.path.join(
    OUTPUT_DIR,
    "relative_band_threshold_analysis.csv"
)

OUTPUT_FILTERED_CSV = os.path.join(
    OUTPUT_DIR,
    "relative_band_threshold_filtered.csv"
)

OUTPUT_HEATMAP = os.path.join(
    OUTPUT_DIR,
    "relative_band_pf_heatmap.png"
)

OUTPUT_LOWER_GRAPH = os.path.join(
    OUTPUT_DIR,
    "relative_band_lower_sensitivity.png"
)

OUTPUT_UPPER_GRAPH = os.path.join(
    OUTPUT_DIR,
    "relative_band_upper_sensitivity.png"
)

OUTPUT_TOP_CSV = os.path.join(
    OUTPUT_DIR,
    "relative_band_threshold_top.csv"
)


# 元Q3のおおよその境界
BASE_LOWER = 0.44
BASE_UPPER = 0.88


# 探索範囲
LOWER_VALUES = np.round(
    np.arange(
        0.30,
        0.62,
        0.02
    ),
    2
)

UPPER_VALUES = np.round(
    np.arange(
        0.70,
        1.12,
        0.02
    ),
    2
)


# 最低取引数
MIN_TRADES = 100


# ============================================================
# データ読み込み
# ============================================================

df = pd.read_csv(
    INPUT_FILE
)


print(
    "========================================"
)

print(
    "RELATIVE BAND THRESHOLD ANALYSIS"
)

print(
    "========================================"
)

print(
    f"\n読み込み件数: {len(df)}"
)


# ============================================================
# 必要列確認
# ============================================================

required_columns = [
    "entry_band_distance_pips",
    "entry_atr14_pips",
    "profit"
]


missing_columns = [
    column
    for column in required_columns
    if column not in df.columns
]


if missing_columns:

    raise ValueError(
        "必要な列がありません: "
        + ", ".join(missing_columns)
    )


# ============================================================
# 数値変換
# ============================================================

for column in required_columns:

    df[column] = pd.to_numeric(
        df[column],
        errors="coerce"
    )


# ============================================================
# 分析対象データ
# ============================================================

analysis_df = df[
    df[
        "entry_band_distance_pips"
    ].notna()
    &
    df[
        "entry_atr14_pips"
    ].notna()
    &
    df[
        "profit"
    ].notna()
    &
    (
        df[
            "entry_atr14_pips"
        ]
        > 0
    )
].copy()


if analysis_df.empty:

    raise ValueError(
        "分析対象データがありません。"
    )


# ============================================================
# Relative Band Distance
# ============================================================

analysis_df[
    "relative_band_distance"
] = (
    analysis_df[
        "entry_band_distance_pips"
    ]
    /
    analysis_df[
        "entry_atr14_pips"
    ]
)


analysis_df[
    "relative_band_distance"
] = (
    analysis_df[
        "relative_band_distance"
    ]
    .replace(
        [
            np.inf,
            -np.inf
        ],
        np.nan
    )
)


analysis_df = analysis_df[
    analysis_df[
        "relative_band_distance"
    ].notna()
].copy()


print(
    f"分析対象件数: {len(analysis_df)}"
)


print(
    "\nRelative Band Distance 基本統計量"
)

print(
    analysis_df[
        "relative_band_distance"
    ].describe()
)


# ============================================================
# 集計関数
# ============================================================

def calculate_metrics(
    selected,
    lower,
    upper
):

    trade_count = len(
        selected
    )

    if trade_count == 0:

        return {
            "lower": lower,
            "upper": upper,
            "trade_count": 0,
            "win_rate": np.nan,
            "total_profit": np.nan,
            "average_profit": np.nan,
            "median_profit": np.nan,
            "gross_profit": np.nan,
            "gross_loss": np.nan,
            "profit_factor": np.nan
        }


    win_count = (
        selected[
            "profit"
        ]
        > 0
    ).sum()


    win_rate = (
        win_count
        /
        trade_count
        *
        100
    )


    total_profit = (
        selected[
            "profit"
        ].sum()
    )


    average_profit = (
        selected[
            "profit"
        ].mean()
    )


    median_profit = (
        selected[
            "profit"
        ].median()
    )


    gross_profit = (
        selected.loc[
            selected[
                "profit"
            ]
            > 0,
            "profit"
        ]
        .sum()
    )


    gross_loss = -(
        selected.loc[
            selected[
                "profit"
            ]
            < 0,
            "profit"
        ]
        .sum()
    )


    if gross_loss > 0:

        profit_factor = (
            gross_profit
            /
            gross_loss
        )

    else:

        profit_factor = np.nan


    return {
        "lower": lower,
        "upper": upper,
        "trade_count": trade_count,
        "win_rate": win_rate,
        "total_profit": total_profit,
        "average_profit": average_profit,
        "median_profit": median_profit,
        "gross_profit": gross_profit,
        "gross_loss": gross_loss,
        "profit_factor": profit_factor
    }


# ============================================================
# 全組み合わせを分析
# ============================================================

results = []


for lower in LOWER_VALUES:

    for upper in UPPER_VALUES:

        if lower >= upper:

            continue


        mask = (
            (
                analysis_df[
                    "relative_band_distance"
                ]
                >= lower
            )
            &
            (
                analysis_df[
                    "relative_band_distance"
                ]
                <= upper
            )
        )


        selected = analysis_df[
            mask
        ]


        metrics = calculate_metrics(
            selected,
            lower,
            upper
        )


        results.append(
            metrics
        )


result_df = pd.DataFrame(
    results
)


# ============================================================
# CSV保存
# ============================================================

result_df.to_csv(
    OUTPUT_CSV,
    index=False,
    encoding="utf-8-sig"
)


print(
    f"\n保存: {OUTPUT_CSV}"
)


# ============================================================
# 最低取引数以上に限定
# ============================================================

filtered_df = result_df[
    result_df[
        "trade_count"
    ]
    >= MIN_TRADES
].copy()


filtered_df.to_csv(
    OUTPUT_FILTERED_CSV,
    index=False,
    encoding="utf-8-sig"
)


print(
    f"保存: {OUTPUT_FILTERED_CSV}"
)


# ============================================================
# PF上位条件
# ============================================================

top_df = (
    filtered_df
    .sort_values(
        "profit_factor",
        ascending=False
    )
    .head(
        30
    )
    .copy()
)


top_df.to_csv(
    OUTPUT_TOP_CSV,
    index=False,
    encoding="utf-8-sig"
)


print(
    f"保存: {OUTPUT_TOP_CSV}"
)


print(
    "\n========================================"
)

print(
    f"PF上位条件"
    f"(取引数{MIN_TRADES}以上)"
)

print(
    "========================================"
)


print(
    top_df[
        [
            "lower",
            "upper",
            "trade_count",
            "win_rate",
            "total_profit",
            "median_profit",
            "profit_factor"
        ]
    ]
    .head(
        20
    )
    .to_string(
        index=False
    )
)


# ============================================================
# 元Q3相当条件
# ============================================================

base_mask = (
    (
        analysis_df[
            "relative_band_distance"
        ]
        >= BASE_LOWER
    )
    &
    (
        analysis_df[
            "relative_band_distance"
        ]
        <= BASE_UPPER
    )
)


base_df = analysis_df[
    base_mask
]


base_metrics = calculate_metrics(
    base_df,
    BASE_LOWER,
    BASE_UPPER
)


print(
    "\n========================================"
)

print(
    "元Q3相当条件"
)

print(
    "========================================"
)


for key, value in base_metrics.items():

    print(
        f"{key}: {value}"
    )


# ============================================================
# PF 1以上 / 1.2以上 / 1.3以上の条件数
# ============================================================

print(
    "\n========================================"
)

print(
    "安定領域確認"
)

print(
    "========================================"
)


valid_count = len(
    filtered_df
)


pf_100_count = (
    filtered_df[
        "profit_factor"
    ]
    >= 1.0
).sum()


pf_120_count = (
    filtered_df[
        "profit_factor"
    ]
    >= 1.2
).sum()


pf_130_count = (
    filtered_df[
        "profit_factor"
    ]
    >= 1.3
).sum()


print(
    f"評価対象組み合わせ数: "
    f"{valid_count}"
)

print(
    f"PF >= 1.0: "
    f"{pf_100_count}"
)

print(
    f"PF >= 1.2: "
    f"{pf_120_count}"
)

print(
    f"PF >= 1.3: "
    f"{pf_130_count}"
)


if valid_count > 0:

    print(
        f"PF1以上割合: "
        f"{pf_100_count / valid_count * 100:.1f}%"
    )

    print(
        f"PF1.2以上割合: "
        f"{pf_120_count / valid_count * 100:.1f}%"
    )

    print(
        f"PF1.3以上割合: "
        f"{pf_130_count / valid_count * 100:.1f}%"
    )


# ============================================================
# Q3近傍だけを確認
# ============================================================

near_q3_df = filtered_df[
    (
        filtered_df[
            "lower"
        ]
        >= 0.38
    )
    &
    (
        filtered_df[
            "lower"
        ]
        <= 0.50
    )
    &
    (
        filtered_df[
            "upper"
        ]
        >= 0.82
    )
    &
    (
        filtered_df[
            "upper"
        ]
        <= 0.94
    )
].copy()


print(
    "\n========================================"
)

print(
    "Q3近傍"
)

print(
    "========================================"
)


print(
    near_q3_df[
        [
            "lower",
            "upper",
            "trade_count",
            "total_profit",
            "median_profit",
            "profit_factor"
        ]
    ]
    .sort_values(
        [
            "lower",
            "upper"
        ]
    )
    .to_string(
        index=False
    )
)


# ============================================================
# ヒートマップ用データ
# ============================================================

heatmap_df = (
    filtered_df
    .pivot(
        index="lower",
        columns="upper",
        values="profit_factor"
    )
    .sort_index(
        ascending=False
    )
)


# ============================================================
# PFヒートマップ
# ============================================================

fig, ax = plt.subplots(
    figsize=(
        14,
        9
    )
)


image = ax.imshow(
    heatmap_df.values,
    aspect="auto",
    interpolation="nearest"
)


ax.set_title(
    "Profit Factor Heatmap\n"
    "Relative Band Distance Threshold"
)

ax.set_xlabel(
    "Upper Threshold"
)

ax.set_ylabel(
    "Lower Threshold"
)


ax.set_xticks(
    np.arange(
        len(
            heatmap_df.columns
        )
    )
)

ax.set_xticklabels(
    [
        f"{value:.2f}"
        for value
        in heatmap_df.columns
    ],
    rotation=90
)


ax.set_yticks(
    np.arange(
        len(
            heatmap_df.index
        )
    )
)

ax.set_yticklabels(
    [
        f"{value:.2f}"
        for value
        in heatmap_df.index
    ]
)


colorbar = fig.colorbar(
    image,
    ax=ax
)

colorbar.set_label(
    "Profit Factor"
)


plt.tight_layout()


plt.savefig(
    OUTPUT_HEATMAP,
    dpi=150
)


plt.close()


print(
    f"\n保存: {OUTPUT_HEATMAP}"
)


# ============================================================
# 上限を元Q3付近に固定し下限を動かす
# ============================================================

fixed_upper_candidates = result_df[
    np.isclose(
        result_df[
            "upper"
        ],
        BASE_UPPER
    )
].copy()


fixed_upper_candidates = (
    fixed_upper_candidates
    .sort_values(
        "lower"
    )
)


plt.figure(
    figsize=(
        11,
        6
    )
)


plt.plot(
    fixed_upper_candidates[
        "lower"
    ],
    fixed_upper_candidates[
        "profit_factor"
    ],
    marker="o"
)


plt.axhline(
    1.0,
    linestyle="--"
)


plt.axvline(
    BASE_LOWER,
    linestyle="--"
)


plt.title(
    f"Lower Threshold Sensitivity "
    f"(Upper = {BASE_UPPER:.2f})"
)

plt.xlabel(
    "Lower Threshold"
)

plt.ylabel(
    "Profit Factor"
)

plt.grid()


plt.tight_layout()


plt.savefig(
    OUTPUT_LOWER_GRAPH,
    dpi=150
)


plt.close()


print(
    f"保存: {OUTPUT_LOWER_GRAPH}"
)


# ============================================================
# 下限を元Q3付近に固定し上限を動かす
# ============================================================

fixed_lower_candidates = result_df[
    np.isclose(
        result_df[
            "lower"
        ],
        BASE_LOWER
    )
].copy()


fixed_lower_candidates = (
    fixed_lower_candidates
    .sort_values(
        "upper"
    )
)


plt.figure(
    figsize=(
        11,
        6
    )
)


plt.plot(
    fixed_lower_candidates[
        "upper"
    ],
    fixed_lower_candidates[
        "profit_factor"
    ],
    marker="o"
)


plt.axhline(
    1.0,
    linestyle="--"
)


plt.axvline(
    BASE_UPPER,
    linestyle="--"
)


plt.title(
    f"Upper Threshold Sensitivity "
    f"(Lower = {BASE_LOWER:.2f})"
)

plt.xlabel(
    "Upper Threshold"
)

plt.ylabel(
    "Profit Factor"
)

plt.grid()


plt.tight_layout()


plt.savefig(
    OUTPUT_UPPER_GRAPH,
    dpi=150
)


plt.close()


print(
    f"保存: {OUTPUT_UPPER_GRAPH}"
)


# ============================================================
# 最終表示
# ============================================================

print(
    "\n========================================"
)

print(
    "分析完了"
)

print(
    "========================================"
)

print(
    "\n出力ファイル:"
)

print(
    OUTPUT_CSV
)

print(
    OUTPUT_FILTERED_CSV
)

print(
    OUTPUT_TOP_CSV
)

print(
    OUTPUT_HEATMAP
)

print(
    OUTPUT_LOWER_GRAPH
)

print(
    OUTPUT_UPPER_GRAPH
)

出力されるファイル

今回のコードを実行すると、resultsフォルダに次のファイルが出力されます。

relative_band_threshold_analysis.csv

relative_band_threshold_filtered.csv

relative_band_threshold_top.csv

relative_band_pf_heatmap.png

relative_band_lower_sensitivity.png

relative_band_upper_sensitivity.png

特に次回確認するのは、

relative_band_threshold_analysis.csv
relative_band_pf_heatmap.png
relative_band_lower_sensitivity.png
relative_band_upper_sensitivity.png

です。

ここから、

Q3が本当に広い優位性を持つゾーンなのか

を判断していきます。

関連記事

ランダムウォークへの挑戦㉓|最高PFは本当に強いのか。Relative Band Distanceを時間分割で比較する(2026/8/30)
ランダムウォークへの挑戦㉒|Q3の境界をずらしても優位性は残るのか(2026/8/30)
ランダムウォークへの挑戦㉑|Q3の優位性は時間が変わっても残るのか(2026/8/29)
ランダムウォークへの挑戦⑳|零号機の利益は「一部の大勝ち」に支えられているのか(2026/8/20)
ランダムウォークへの挑戦⑲|BAND_DISTANCEをATRで割って「相対的なブレイクの強さ」を分析する(2026/8/19)
ランダムウォークへの挑戦⑱|バンドからの距離で零号機のエントリー位置を分析する(2026/8/19)
ランダムウォークへの挑戦⑰|ATR×BB幅で零号機の「得意な相場」を16種類に分解する(2026/8/14)
ランダムウォークへの挑戦⑯|ボリンジャーバンドの幅で零号機の得意な相場を探る(2026/8/11)
ランダムウォークへの挑戦⑮|零号機は「荒れた相場」に強いのか ― ATRと取引成績の関係をPythonで分析する(2026/8/9)
ランダムウォークへの挑戦⑭ | 零号機の「記憶」をPythonへ渡す ― trade_log.csvを分析用データに整える(2026/8/5)
ランダムウォークへの挑戦⑬ | 零号機に「記憶」を与える ― mql4でエントリーから決済までの相場環境を記録する(2026/8/2)
ランダムウォークへの挑戦⑫ | Chapter2「学習」始動 ー零号機は何を学ぶべきなのかー(2026/8/2)
ランダムウォークへの挑戦⑪ | 零号機の性格診断──Chapter1「観測」 完結(2026/8/1)
ランダムウォークへの挑戦⑩ | Pythonで資産推移とドローダウンを分析する(2026/8/1)
ランダムウォークへの挑戦⑨ | Pythonで取引の連勝・連敗を分析する(2026/7/28)
ランダムウォークへの挑戦⑧ | Pythonでポジションの保有時間別に成績を分析する(2026/7/26)
ランダムウォークへの挑戦⑦ | Pythonでエントリー曜日と決済曜日から零号機を分析する(2026/7/23)
ランダムウォークへの挑戦⑥ | Pythonでエントリー時間と決済時間から損益を分析する(2026/7/22)
ランダムウォークへの挑戦⑤ | Pythonで月別損益をグラフ化する(2026/7/5)
ランダムウォークへの挑戦④ |Pythonで「零号機」の基本成績を分析する(2026/6/11)
ランダムウォークへの挑戦③ | 環境設定(2026/5/26)
ランダムウォークへの挑戦➁ | 小目標3つ設定(2026/5/25)
ランダムウォークへの挑戦① | はじめに(2026/5/25)

コメント

タイトルとURLをコピーしました