株式自動売買ツールでコツコツ利益確定するには?

株式自動売買ツールでコツコツ利益確定し大損を防ぐ資金管理 自動化・運用

株式自動売買ツールでコツコツ利益確定するには、利確回数を増やすのではなく、手数料・スリッページ控除後の期待値をプラスに保ち、1回の大損で過去の利益を吐き出さない設計が必要です。「1%上がったら利確」を繰り返すだけでは、勝率は高く見えても、急落時の5〜10%損失で資産が減ります。

この記事では、利益確定・損切り・ATRによる注文数量・総リスク上限・ドローダウン停止をPythonで実装します。さらに、同じ日足で利確と損切りの両方に触れた場合の扱い、未来参照、取引コスト、過剰最適化まで含め、実運用に近いバックテストへ整えます。

結論:「コツコツ利益」は小さな利確幅から生まれるのではありません。損失1単位に対して平均で何単位を回収できるか、連敗しても継続できる数量か、相場環境が崩れたら止まるか。この3点を自動化した結果として、損益曲線が安定します。

勝率より先に期待値を計算する

売買戦略の1取引あたり期待値は、次の式で表せます。

期待値 = 勝率 × 平均利益 - 負け率 × 平均損失 - 平均取引コスト

勝率80%、平均利益1%、平均損失5%、往復コスト0.2%なら、期待値は0.8×1% - 0.2×5% - 0.2% = -0.4%です。8回勝って2回負けるため「よく当たる」感覚はありますが、長く繰り返すほど資産が減る設計です。

戦略勝率平均利益平均損失コスト控除前期待値
小利大損型80%+1.0%-5.0%-0.20%
均衡型55%+2.0%-1.5%+0.43%
トレンド型40%+4.0%-1.5%+0.70%

利益確定を細かくすると勝率は上がりやすい一方、平均利益は小さくなります。損切りを遠くすると負ける回数は減りますが、平均損失は大きくなります。この関係を無視して勝率だけ最適化すると、小利大損へ近づきます。期待値の計算方法はPythonで売買ロジックの期待値を測る方法でも詳しく解説しています。

利益確定は固定率よりR倍数で設計する

R倍数とは、最初に許容した損失額を1Rとして損益を表す方法です。買値2,000円、損切り1,900円なら1株あたり100円が1Rです。利益確定2,200円は+2R、1,950円で撤退なら-0.5Rになります。銘柄価格やボラティリティが違っても比較できます。

  • 初期損切り:買値から2ATR下、または戦略が無効になる価格
  • 第1利確:+1Rで半分を売却し、元本リスクを軽くする
  • 残りの出口:+2R、移動平均割れ、ATRトレーリングのいずれか
  • 時間切れ:想定期間内に動かなければ資金を解放する

分割利確は心理的には扱いやすいものの、常に有利とは限りません。半分を+1Rで売ると、大きなトレンドに乗ったときの平均利益が下がります。一方、残り半分をトレーリングストップで伸ばせば、確定利益と上振れ余地を両立できます。バックテストで「全量+2R」と「半分+1R、残り追随」を同じ条件で比較します。

ATRで銘柄ごとの値動きを正規化する

同じ3%損切りでも、日々1%しか動かない銘柄には遠く、日々5%動く銘柄には近すぎます。ATR(Average True Range)を使うと、最近の値動きに合わせて損切り幅を変えられます。

from __future__ import annotations

import numpy as np
import pandas as pd
import yfinance as yf

def load_price(ticker: str, start: str = "2015-01-01") -> pd.DataFrame:
  df = yf.download(
    ticker,
    start=start,
    auto_adjust=True,
    progress=False
  )
  if isinstance(df.columns, pd.MultiIndex):
    df.columns = df.columns.get_level_values(0)
  return df[["Open", "High", "Low", "Close", "Volume"]].dropna()

def add_atr(df: pd.DataFrame, length: int = 14) -> pd.DataFrame:
  out = df.copy()
  prev_close = out["Close"].shift(1)
  true_range = pd.concat([
    out["High"] - out["Low"],
    (out["High"] - prev_close).abs(),
    (out["Low"] - prev_close).abs()
  ], axis=1).max(axis=1)
  out["ATR"] = true_range.ewm(
    alpha=1 / length,
    adjust=False,
    min_periods=length
  ).mean()
  out["ATR_Pct"] = out["ATR"] / out["Close"]
  return out

ATRは方向を示さず、価格変動の幅を測ります。窓開けもTrue Rangeへ反映するため、単純な高値−安値より損切り設計に適しています。ただし決算後のギャップは、逆指値価格を飛び越えて約定します。ATRが2%だから最大損失も2%に限定されるわけではありません。

1回の損失から注文数量を逆算する

コツコツ利益を残すには、銘柄選びより先に注文数量を決めます。運用資金100万円、1取引リスク0.5%なら許容損失は5,000円です。買値と損切りの差が50円なら100株、差が100円なら50株です。通常の日本株を100株単位で買えない場合は見送ります。

from dataclasses import dataclass
import math

@dataclass(frozen=True)
class PositionPlan:
  quantity: int
  entry: float
  stop: float
  target_1r: float
  target_2r: float
  risk_yen: float

def plan_position(
  equity: float,
  entry: float,
  atr: float,
  risk_pct: float = 0.005,
  stop_atr: float = 2.0,
  max_position_pct: float = 0.20,
  lot_size: int = 100
) -> PositionPlan:
  stop = entry - stop_atr * atr
  risk_per_share = entry - stop
  if risk_per_share <= 0:
    raise ValueError("損切り幅が不正です")

  qty_risk = math.floor((equity * risk_pct) / risk_per_share)
  qty_value = math.floor((equity * max_position_pct) / entry)
  quantity = (min(qty_risk, qty_value) // lot_size) * lot_size
  if quantity < lot_size:
    raise ValueError("リスク上限内で単元株を購入できません")

  return PositionPlan(
    quantity=quantity,
    entry=round(entry, 1),
    stop=round(stop, 1),
    target_1r=round(entry + risk_per_share, 1),
    target_2r=round(entry + 2 * risk_per_share, 1),
    risk_yen=round(quantity * risk_per_share, 0)
  )

「1取引0.5%」は絶対の正解ではなく、連敗とギャップを考慮した保守的な出発点です。10連敗しても単純計算で約5%の損失に収まり、検証を続ける余力を残せます。2%ずつなら10連敗で約18.3%、5%ずつなら約40.1%減ります。損失後は元本が減るため単純加算ではありません。

「コツコツ利確」戦略をPythonで実装する

エントリーは20日高値の上抜け、200日移動平均の上、20日平均出来高10万株以上とします。シグナルは大引け後に確定し、翌日の始値で買います。利益確定は+1Rで半分、残りは最高値−2ATRのトレーリングストップで追随します。

def add_signal(df: pd.DataFrame) -> pd.DataFrame:
  out = add_atr(df)
  out["SMA200"] = out["Close"].rolling(200).mean()
  out["High20"] = out["High"].rolling(20).max().shift(1)
  out["Volume20"] = out["Volume"].rolling(20).mean().shift(1)
  out["EntrySignal"] = (
    (out["Close"] > out["High20"])
    & (out["Close"] > out["SMA200"])
    & (out["SMA200"].diff(20) > 0)
    & (out["Volume20"] >= 100_000)
  )
  return out

@dataclass
class OpenPosition:
  entry_date: pd.Timestamp
  entry: float
  initial_qty: int
  remaining_qty: int
  initial_stop: float
  trailing_stop: float
  target_1r: float
  highest: float
  partial_done: bool = False

ポジションは、初期数量、残数量、初期ストップ、追随ストップ、最高値、分割利確済みかを保持します。再起動する本番システムでは、この状態をSQLiteへ保存します。メモリだけに持つと、PC再起動後に同じ銘柄を新規買いしたり、利確済み数量より多く売ったりします。

def backtest_scale_out(
  source: pd.DataFrame,
  initial_cash: float = 1_000_000,
  risk_pct: float = 0.005,
  cost_rate: float = 0.001
) -> tuple[pd.DataFrame, pd.DataFrame]:
  df = add_signal(source)
  cash = initial_cash
  position: OpenPosition | None = None
  trades: list[dict] = []
  equity_rows: list[dict] = []

  for i in range(201, len(df)):
    today = df.iloc[i]
    yesterday = df.iloc[i - 1]
    date = df.index[i]
    open_px = float(today["Open"])
    high_px = float(today["High"])
    low_px = float(today["Low"])
    close_px = float(today["Close"])

    if position is None and bool(yesterday["EntrySignal"]):
      try:
        plan = plan_position(
          equity=cash,
          entry=open_px,
          atr=float(yesterday["ATR"]),
          risk_pct=risk_pct
        )
      except ValueError:
        plan = None

      if plan:
        buy_px = open_px * (1 + cost_rate)
        cash -= buy_px * plan.quantity
        position = OpenPosition(
          entry_date=date,
          entry=buy_px,
          initial_qty=plan.quantity,
          remaining_qty=plan.quantity,
          initial_stop=plan.stop,
          trailing_stop=plan.stop,
          target_1r=plan.target_1r,
          highest=high_px
        )

    elif position is not None:
      # 寄付がストップを下回れば、ストップ価格ではなく寄付で約定
      stop_fill = min(open_px, position.trailing_stop)
      hit_stop = low_px <= position.trailing_stop
      hit_target = (
        not position.partial_done
        and high_px >= position.target_1r
      )

      # 同じ日足で両方に触れたら保守的にストップを先とする
      if hit_stop:
        sell_px = stop_fill * (1 - cost_rate)
        qty = position.remaining_qty
        cash += sell_px * qty
        trades.append({
          "entry_date": position.entry_date,
          "exit_date": date,
          "quantity": qty,
          "entry": position.entry,
          "exit": sell_px,
          "reason": "stop",
          "r_multiple": (
            (sell_px - position.entry)
            / (position.entry - position.initial_stop)
          )
        })
        position = None

      elif hit_target:
        qty = max(100, (position.initial_qty // 2 // 100) * 100)
        qty = min(qty, position.remaining_qty)
        sell_px = position.target_1r * (1 - cost_rate)
        cash += sell_px * qty
        position.remaining_qty -= qty
        position.partial_done = True
        # 部分利確後も、買値より不利な方向へストップを戻さない
        position.trailing_stop = max(
          position.trailing_stop,
          position.entry
        )
        trades.append({
          "entry_date": position.entry_date,
          "exit_date": date,
          "quantity": qty,
          "entry": position.entry,
          "exit": sell_px,
          "reason": "partial_1R",
          "r_multiple": (
            (sell_px - position.entry)
            / (position.entry - position.initial_stop)
          )
        })

      if position is not None:
        position.highest = max(position.highest, high_px)
        next_trailing = position.highest - 2 * float(today["ATR"])
        position.trailing_stop = max(
          position.trailing_stop,
          next_trailing
        )

    market_value = (
      position.remaining_qty * close_px
      if position is not None else 0.0
    )
    equity_rows.append({
      "date": date,
      "equity": cash + market_value
    })

  equity = pd.DataFrame(equity_rows).set_index("date")
  return equity, pd.DataFrame(trades)

このコードで最も重要なのは、同じ日の高値と安値が利確・損切りの両方へ到達したとき、都合よく利確を先にしていないことです。日足OHLCから値動きの順序は分からないため、損切りを先に扱います。より正確に判定したい場合は、その日だけ1分足を取得して順序を確認します。

また、窓開け下落では逆指値価格で売れるとは限りません。始値がストップより低いときは始値を約定候補にします。実際には流動性と板状況でさらに滑る可能性があります。バックテストへスリッページを入れる方法は取引コストを織り込む実装を参照してください。

損益をR倍数と実装ショートフォールで評価する

分割利確では1回のポジションから複数の売却行が生まれます。単純に行数で勝率を出すと、1つの取引を2勝と数える場合があります。entry_date単位で損益を集約してから評価します。

def performance_report(
  equity: pd.DataFrame,
  legs: pd.DataFrame
) -> dict:
  if equity.empty or legs.empty:
    return {}

  legs = legs.copy()
  legs["pnl"] = (
    (legs["exit"] - legs["entry"]) * legs["quantity"]
  )
  round_trips = legs.groupby("entry_date").agg(
    pnl=("pnl", "sum"),
    exit_date=("exit_date", "max"),
    legs=("reason", "count")
  )

  wins = round_trips.loc[round_trips["pnl"] > 0, "pnl"]
  losses = round_trips.loc[round_trips["pnl"] < 0, "pnl"]
  expectancy = round_trips["pnl"].mean()
  profit_factor = (
    wins.sum() / abs(losses.sum())
    if not losses.empty else np.inf
  )

  daily = equity["equity"].pct_change().fillna(0)
  drawdown = equity["equity"] / equity["equity"].cummax() - 1
  years = len(equity) / 252
  cagr = (
    equity["equity"].iloc[-1]
    / equity["equity"].iloc[0]
  ) ** (1 / years) - 1

  return {
    "trades": len(round_trips),
    "win_rate_pct": round((round_trips["pnl"] > 0).mean() * 100, 1),
    "expectancy_yen": round(expectancy, 0),
    "profit_factor": round(profit_factor, 2),
    "cagr_pct": round(cagr * 100, 2),
    "max_drawdown_pct": round(drawdown.min() * 100, 2),
    "sharpe": round(daily.mean() / daily.std() * np.sqrt(252), 2)
  }

df = load_price("7203.T")
equity, legs = backtest_scale_out(df)
print(performance_report(equity, legs))

CFA Instituteは、実際の運用結果と意思決定時価格で売買できた仮想結果との差を実装ショートフォールとして測る考え方を標準的な取引コスト評価に挙げています。個人の自動売買でも、バックテスト価格と実約定価格の差、注文拒否、未約定を記録すると、戦略の問題と執行の問題を分けられます。

指標何を見るか単独では分からないこと
期待値1取引あたり平均損益損失の並び順と資金枯渇
プロフィットファクター総利益÷総損失運用期間と資金効率
最大ドローダウンピークからの最大下落将来の最悪損失
Sharpe変動に対する平均収益非正規分布の尾部リスク
実装ショートフォール机上と実約定の差戦略自体の優位性

複数銘柄ではポートフォリオヒートを制限する

各銘柄を0.5%リスクにしても、10銘柄を同時保有すれば合計5%です。さらに銀行株を5銘柄持てば、別銘柄でも同じ金利ニュースで同時に下落します。個別リスクだけでなく、全建玉の初期損失額を合計した「ポートフォリオヒート」を制限します。

from dataclasses import dataclass

@dataclass(frozen=True)
class HoldingRisk:
  code: str
  sector: str
  quantity: int
  last_price: float
  stop_price: float

  @property
  def risk_yen(self) -> float:
    return max(self.last_price - self.stop_price, 0) * self.quantity

def can_add_position(
  equity: float,
  holdings: list[HoldingRisk],
  new_risk_yen: float,
  new_sector: str,
  max_heat_pct: float = 0.02,
  max_sector_pct: float = 0.01
) -> bool:
  total_heat = sum(h.risk_yen for h in holdings) + new_risk_yen
  sector_heat = (
    sum(h.risk_yen for h in holdings if h.sector == new_sector)
    + new_risk_yen
  )
  return (
    total_heat <= equity * max_heat_pct
    and sector_heat <= equity * max_sector_pct
  )

損切り価格は保証価格ではないため、ヒート2%でも実損失が2%に収まるとは限りません。決算またぎ、流動性の低い銘柄、同一セクターへの集中には別の上限が必要です。保有銘柄間の相関を定期的に計算し、高相関の建玉を同じリスクバケットとして扱います。

ドローダウンで自動的にサイズを落とす

戦略の期待値は一定ではありません。市場構造やボラティリティが変わり、過去に機能したルールが劣化します。資産ピークから5%下落したらリスクを半分、10%下落したら新規取引を停止するサーキットブレーカーを入れます。

def risk_multiplier(
  current_equity: float,
  peak_equity: float
) -> float:
  if peak_equity <= 0:
    raise ValueError("peak_equityは正数が必要です")
  drawdown = current_equity / peak_equity - 1

  if drawdown <= -0.10:
    return 0.0   # 新規停止
  if drawdown <= -0.05:
    return 0.5   # リスク半減
  return 1.0

base_risk_pct = 0.005
actual_risk_pct = base_risk_pct * risk_multiplier(
  current_equity=940_000,
  peak_equity=1_000_000
)
print(actual_risk_pct)  # 0.0025

50%下落すると元に戻るために100%の利益が必要です。深い損失ほど回復が非線形に難しくなるため、好調時にリスクを上げるより、不調時に早く下げるほうが生存に効きます。最大ドローダウンの計算はPythonで最大ドローダウンを測る方法も参考にしてください。

過剰最適化を避けて利益確定幅を決める

利確を0.8R、0.9R、1.0Rと細かく試し、過去最高になった0.93Rを採用しても、将来に再現する根拠はありません。試行回数が増えるほど、偶然よい設定を見つける確率が高まります。BaileyらのPBO研究は、バックテストで選んだ最良戦略がアウト・オブ・サンプルで平均以下になる確率を評価する枠組みを提示しています。

  • 最初に仮説と試すパラメータ数を記録する
  • 銘柄選定に当時取得できた情報だけを使う
  • 学習期間で選び、未使用の検証期間で評価する
  • 利確0.5R〜3Rの広い範囲を見て、安定領域を探す
  • 複数銘柄・上昇・下落・横ばい局面で再現性を見る
  • 最後にウォークフォワードとペーパートレードを行う
def time_splits(
  df: pd.DataFrame,
  train_years: int = 5,
  test_years: int = 1
):
  start = df.index.min()
  end = df.index.max()
  train_start = start

  while True:
    train_end = train_start + pd.DateOffset(years=train_years)
    test_end = train_end + pd.DateOffset(years=test_years)
    if test_end > end:
      break
    train = df.loc[train_start:train_end]
    test = df.loc[train_end:test_end]
    yield train, test
    train_start += pd.DateOffset(years=test_years)

検証期間ごとに最適値が大きく変わるなら、利確幅に安定した根拠がない可能性があります。ウォークフォワードの具体的な設計はバックテストの過学習を防ぐ方法で確認できます。

コツコツ利益を残すための運用ルール

項目開始時の例停止・見直し条件
1取引リスク資産の0.5%DD5%で半減
ポートフォリオヒート合計2%以下上限なら新規停止
第1利確+1Rで半分期待値悪化なら再検証
残りの出口最高値−2ATRストップを下げない
日次損失資産の1%以下到達後は当日停止
総ドローダウン10%未満を目標10%で新規停止・監査

これらの数字は利益を保証する基準ではなく、少額で検証を始めるための例です。戦略の取引頻度、ギャップ、銘柄分散、生活資金との分離によって適切な値は変わります。「月10%を安定」など高収益を保証する自動売買サービスには注意が必要です。SECやFINRAも、低リスクで高収益を保証する自動売買・AI取引の宣伝を警戒するよう注意喚起しています。

コツコツ利益確定する仕組みの中心は、頻繁に売るボタンではありません。期待値をR倍数で測り、ATRで数量を調整し、複数銘柄の総リスクを制限し、ドローダウン時に止まることです。利益確定はその一部にすぎません。

まずは1銘柄・100株未満の仮想売買から始め、最低でも数十取引の注文候補、仮想約定、実際の市場価格との差を記録してください。バックテスト、ペーパートレード、最小単元の順で進め、毎月の利益額よりも、ルール逸脱ゼロと想定損失内での運用を最初の成功条件にするのが現実的です。

参考資料

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