30分ブレークアウト戦略(ロング限定)を銘柄を変えてバックテストしてみたら納得

investment

30分ブレークアウト戦略とは、寄り付き後30分の最高値を超えたらロング、最安値を下回ったらショートという単純な戦略。手動デイトレだとそもそも30分も待てずにすぐポジションを取ってしまって負けることが多い。

まずトヨタでバックテストやってみた。全然勝てない。やっぱりこんな単純な戦法では勝てないんだと思って、いくつかの銘柄でやってみてびっくり。プラスの銘柄の方が多い。特にイビデンのシャープレシオが高い。トヨタは実トレードでも勝てないけど、バックテストでも勝てない。トヨタは難しいのかもしれない。手を出してはいけない銘柄であることをこのバックテストは示しているのかも。今回試してみた銘柄は値嵩株ばっかりでデイトレできないけど、手ごろな価格としてソシオネクストがバックテストで勝ってる。ORD30m戦略ではないけど実トレードでソシオネクストは負け続けているから、バックテストがどれだけ信頼できるかいつか実トレードでORD30m戦略を試してみたい。

OpeningRangeBreakout_30m_optimize.py(現時点でロングのみ)

/

import yfinance as yf
import pandas as pd
from backtesting import Backtest, Strategy
from datetime import datetime, time, timedelta
import pytz

# =============================
# 30分ブレークアウト(ORBO 30m)
# =============================
# 概要:
# ・市場オープン(09:00)〜09:30の高値・安値を Opening Range(OR) とする
# ・09:30以降、上抜けでロング(ORHブレイク)。ストップは ORL。任意でTP=R倍
# ・同日中は 1 回の新規のみ(デフォルト)。約定は Backtesting.py 仕様で次バー寄り。
# ・資金上限クリップ/未約定抑制/立ち上がり検出を実装
#
# 使い方:
#   python OpeningRangeBreakout_30m.py  (スクリプト単体実行)
#   下の __main__ の設定を必要に応じて変更
#
# 依存:
#   pip install yfinance backtesting pandas pytz

JST = pytz.timezone("Asia/Tokyo")

class ORB30(Strategy):
    # === パラメータ ===
    MARKET_OPEN = time(9, 0)     # 東証オープン
    OR_MINUTES = 30              # オープニングレンジの分数
    NO_NEW_AFTER = time(14, 30)  # この時刻以降は新規を出さない

    use_fixed_size = False       # True: 固定枚数で発注 / False: リスク一定
    fixed_size = 100             # 固定発注サイズ

    risk_per_trade = 0.01        # 口座の1%を1トレードの最大リスクに
    tp_R = 1.5                   # 利確は R=1.5 倍(0 なら TP なし)
    one_entry_per_day = True     # 同日中は1回のみエントリー
    eod_flatten = True                 # 引け前に必ず手仕舞い
    eod_close_time = time(14, 55)      # 例: 14:55でクローズ

    # === 内部状態 ===
    def init(self):
        self.current_day = None
        self.orh = None
        self.orl = None
        self.or_ready = False
        self.entry_done_today = False
        self.entry_ok_prev = False
        self.last_entry_bar = -999999
        # ログ用
        self.afford_clip = 0  # 資金クリップ回数
        self.afford_block = 0 # 資金不足で size<1 になりブロック回数

    # ユーティリティ: 現在バーのJSTローカル時刻
    def _jst_dt(self):
        # Backtesting は pandas の index をそのまま保持
        # self.data.index[-1] は tz-naive のことがあるので、強制的にJSTとして扱う
        idx = self.data.index[-1]
        if isinstance(idx, pd.Timestamp):
            if idx.tzinfo is None:
                return idx.tz_localize(JST)
            return idx.tz_convert(JST)
        # 念の為
        return pd.Timestamp(idx).tz_localize(JST)

    def _time_of_day(self):
        return self._jst_dt().time()

    def _date(self):
        return self._jst_dt().date()

    def _within_or_window(self, t: time) -> bool:
        # 09:00 <= t < 09:30
        start = self.MARKET_OPEN
        end = (datetime.combine(datetime.today(), start) + timedelta(minutes=self.OR_MINUTES)).time()
        return (t >= start) and (t < end)

    def _after_or_window(self, t: time) -> bool:
        start = self.MARKET_OPEN
        end = (datetime.combine(datetime.today(), start) + timedelta(minutes=self.OR_MINUTES)).time()
        return t >= end

    def next(self):
        t = self._time_of_day()
        d = self._date()

        # === 日付が変わったら初期化 ===
        if self.current_day != d:
            self.current_day = d
            self.orh = None
            self.orl = None
            self.or_ready = False
            self.entry_done_today = False
            # 念のための安全装置(万一、前日から持ち越していたら即手仕舞い)
            if self.position:
                self.position.close()
                return


        price = float(self.data.Close[-1])
        high  = float(self.data.High[-1])
        low   = float(self.data.Low[-1])

        # === OR 構築 ===
        if self._within_or_window(t):
            if self.orh is None or high > self.orh:
                self.orh = high
            if self.orl is None or low < self.orl:
                self.orl = low
            self.or_ready = False
            return  # OR 構築中は発注しない

        # OR 完了フラグ
        if (not self.or_ready) and (self.orh is not None) and (self.orl is not None) and self._after_or_window(t):
            self.or_ready = True

        # === 既存ポジの管理(SL/TP は発注時に指定するのでここでは何もしない) ===
        # if self.position:
        #     return

        # 2) デイトレ化の核心:引け前に必ずクローズ
        if self.position and self.eod_flatten and t >= self.eod_close_time:
            self.position.close()
            return


        # === 新規エントリー ===
        # 時間制限
        if t >= self.NO_NEW_AFTER:
            return
        if not self.or_ready:
            return
        if self.one_entry_per_day and self.entry_done_today:
            return

        # エントリー条件(上抜け): 現バー終値で ORH を越えて確定
        entry_ok = (self.data.Close[-1] > self.orh)

        # 立ち上がり検出(連続真の連打を防ぐ)
        edge = entry_ok and (not self.entry_ok_prev)
        self.entry_ok_prev = entry_ok
        if not edge:
            return

        # 未約定の重複を抑制
        if self.orders:
            return

        # --- サイジング ---
        # リスク = 現在価格 - ORL(逆行したら損切り)
        risk = max(price - self.orl, 1e-6)
        if self.use_fixed_size:
            size = int(self.fixed_size)
        else:
            size = int((self.equity * self.risk_per_trade) / risk)
        if size < 1:
            size = 1

        # 資金上限クリップ(現物前提 / 手数料は考慮外)
        max_affordable = int(self.equity // max(price, 1e-9))
        if size > max_affordable:
            self.afford_clip += 1
            size = max_affordable
        if size < 1:
            self.afford_block += 1
            return

        # SL/TP 設定
        sl_price = self.orl  # ORL で損切り
        if self.tp_R and self.tp_R > 0:
            tp_price = price + self.tp_R * (price - sl_price)
        else:
            tp_price = None

        # 発注
        if tp_price is not None:
            self.buy(size=size, sl=sl_price, tp=tp_price)
        else:
            self.buy(size=size, sl=sl_price)

        self.entry_done_today = True

# =============================
# データ取得 & バックテスト実行
# =============================
def load_data(ticker: str = "7203.T", period: str = "60d", interval: str = "5m") -> pd.DataFrame:
    """
    yfinance の列体系(単一/二重)に頑強に対応し、
    Backtesting.py が期待する単一レベルの ['Open','High','Low','Close','Volume'] 列へ正規化する。
    さらに空データや重複インデックスも整える。
    """
    df = yf.download(
        ticker,
        period=period,
        interval=interval,
        auto_adjust=False,
        actions=False,
        group_by="column",   # 可能なら単一列
        progress=False,
    )

    if df is None or len(df) == 0:
        raise ValueError("No data returned from yfinance. Try a shorter 'period' or different 'interval'.")

    # --- 列正規化 ---
    def _ensure_ohlcv_columns(_df: pd.DataFrame) -> pd.DataFrame:
        # すでに単一レベルで Open などが揃っているならそのまま
        base = {"Open", "High", "Low", "Close"}
        if not isinstance(_df.columns, pd.MultiIndex):
            cols = set(map(str, _df.columns))
            # 小文字化対応
            if base.issubset(cols):
                return _df
            if base.issubset({c.capitalize() for c in cols}):
                _df.columns = [c.capitalize() for c in _df.columns]
                return _df

        # MultiIndex の場合:OHLCV が level=0 にいるケースが多い
        if isinstance(_df.columns, pd.MultiIndex):
            lv0 = list(map(str, _df.columns.get_level_values(0)))
            lv1 = list(map(str, _df.columns.get_level_values(1)))
            if base.issubset(set(lv0)):
                _df.columns = _df.columns.get_level_values(0)
                return _df
            if base.issubset(set(lv1)):
                _df.columns = _df.columns.get_level_values(1)
                return _df
            # それでもダメなら、各タプルの先頭を採用
            try:
                _df.columns = [t[0] if isinstance(t, tuple) and len(t) > 0 else str(t) for t in _df.columns]
                return _df
            except Exception:
                pass
        return _df

    df = _ensure_ohlcv_columns(df)

    # 余計な列を落とし、欠けている列がないか検証
    keep = ["Open", "High", "Low", "Close", "Volume"]
    # Volume が存在しない市場/足でも動くように、まずは可能な列だけ選択
    present = [c for c in keep if c in df.columns]
    df = df[present].copy()

    # Open/High/Low/Close が揃っているか最終確認
    required = {"Open", "High", "Low", "Close"}
    if not required.issubset(set(df.columns)):
        raise ValueError(f"Columns after normalization: {list(df.columns)} — Missing one of {sorted(required)}")

    # 欠損・重複・順序
    df = df.dropna()
    if df.index.has_duplicates:
        df = df[~df.index.duplicated(keep='last')]
    df = df.sort_index()

    return df



def normalize_heatmap(hm):
    """Backtesting.pyの版差対策:SeriesでもDataFrameでもtp_Rとmetricを取り出す"""
    if hm is None:
        return None, None
    if isinstance(hm, pd.Series):
        metric = hm.name or "Sharpe Ratio"
        df = hm.rename(metric).reset_index()
        df.columns = ['tp_R', metric]
        return df, metric
    # DataFrame
    metric_candidates = [c for c in hm.columns
                         if c.lower().replace(' ','') in ('sharperatio','sharpe')]
    metric = metric_candidates[0] if metric_candidates else hm.columns[-1]
    df = hm.reset_index()
    if 'tp_R' not in df.columns and df.index.name:
        df = df.rename(columns={df.index.name: 'tp_R'})
    return df, metric


brand_names = {
    "1570.T": "Nikkei NF",
    "6526.T": "Sociotech",
    "7203.T": "Toyota",
    "6857.T": "Advantest",
    "6146.T": "Disco",
    "4062.T": "Ibiden",
    "6920.T": "LaserTech",
    "5803.T": "Fujikura",
    "8035.T": "Tokyo Electron",
    "6758.T": "SONY group",
    "9984.T": "Softbank group",
#    "AAPL":   "Apple Inc.",
    }


TICKERS = ["1570.T", "7203.T", "8306.T","6526.T","4062.T","6920.T","5803.T","8035.T","9984.T"]
PERIOD   = "60d"
INTERVAL = "5m"

param_grid = [1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0]  # tp_R 候補
summary_rows = []

#for tk in TICKERS:
for tk,name in brand_names.items():
    print(f"\n=== {tk} {name} ===")
    try:
        df = load_data(tk, PERIOD, INTERVAL)
        bt = Backtest(df, ORB30, cash=1_000_000, commission=0.0005, exclusive_orders=True)

        # 最適化(Sharpe基準を例に)
        res = bt.optimize(
            tp_R=param_grid,
            maximize='Sharpe Ratio',
            constraint=lambda p: p.tp_R > 0,
            return_heatmap=True
        )
        stats, heatmap = res if isinstance(res, tuple) else (res, None)
        best_tp = stats._strategy.tp_R

        # 全試行の表を整形して保存
        hm_df, metric_col = normalize_heatmap(heatmap)
        if hm_df is not None:
            hm_df.sort_values(metric_col, ascending=False).to_csv(f"{tk}_opt_heatmap.csv", index=False)

        # ベスト設定の詳細
        best_trades = stats['_trades']
        best_trades.to_csv(f"{tk}_trades_best.csv", index=False)

        # ベスト設定で再実行→プロット保存(GUI不要)
        _ = bt.run(tp_R=best_tp)
        bt.plot(filename=f"{tk}_orb30_best.html", open_browser=False)

        # 要点をサマリー行に
        summary_rows.append({
            "Ticker": tk,
            "name":name,
            "Best tp_R": best_tp,
            "Sharpe": stats.get('Sharpe Ratio', None),
            "Return%": stats.get('Return [%]', None),
            "MaxDD%": stats.get('Max. Drawdown [%]', None),
            "#Trades": stats.get('# Trades', None),
            "PF": stats.get('Profit Factor', None)
        })

        print(f"  -> Best tp_R={best_tp}, Sharpe={stats.get('Sharpe Ratio')}, Return%={stats.get('Return [%]')}")
        print(f"  Saved: {tk}_opt_heatmap.csv, {tk}_trades_best.csv, {tk}_orb30_best.html")

    except Exception as e:
        print(f"  [ERROR] {tk}: {e}")

# 銘柄横断サマリー
summary = pd.DataFrame(summary_rows)
summary = summary.sort_values("Sharpe", ascending=False)
print("\n=== Summary ===")
print(summary)
summary.to_csv("summary_by_ticker.csv", index=False)
print("Saved summary_by_ticker.csv")

ちなみに、デイトレで負け続けて口座のお金が無くなってしまった。新規建て余力がゼロなので1日信用でデイトレできないので、日経NFの現物株1株でトレードしてる。昨日400円負け、今日も400円負け。どちらも逆指値に差さってしまった。

これまでどうしても勝てない銘柄、というか大負けした銘柄に三菱重工があるので、ORB30m戦略をバックテストしてみた。最悪の結果になった。実トレードでORB30戦略をやったことはないけど、デイトレで勝てないのはこういうことなのかね。

   Ticker   name          Best tp_R  Sharpe   Return%    MaxDD%   #Trades PF
11 7011.T Mitsubishi Heavy Ind. 3.0 -2.947328 -4.434254 -4.782149 22 0.381313

YouTubeで日経平均が4万4千円のころ売りサインが出ているという動画をいくつも出している人がいたけど、4万4千円が天井だと考える人が多くて空売りが爆増したから、ショートカバーで5万円近くまで上昇したのかな?
以前にもS&P500に売りサインという動画が何個か出たときは確かに下がったけどしばらくしてまた上昇して高値を更新してる。そもそも投資信託って長期投資が前提のはずだと思うのに売りサインが出たから売るというものではないはず。
YouTubeの売りサイン、買いサイン動画に騙されてはいけない。

コメント

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