Pythonで株の自動売買BOTを自作|二重発注しない設計

自動売買プログラムを自作する方法 基礎知識・戦略

「アルゴリズムトレード」は機関投資家のものというイメージがありますが、個人でもPythonで組めます。ただ、入門記事の多くは移動平均の計算とグラフ描画で終わっていて、そこから「実際に注文を出し続けるプログラム」までの距離が説明されていません。

この記事では、その距離を埋めます。シグナル生成・リスク管理・執行・状態管理を分けた、そのまま動くBOTの骨格を作ります。証券口座がなくても試せるように、ペーパートレード用の実装から始めて、実際の発注APIに差し替えられる形にします。

結論を先に書くと、自動売買で難しいのは戦略ではありません。プロセスが落ちても、二重に発注せず、正しい状態から再開できることです。ここを最初に作らないと、必ず事故ります。

自動売買を3つの部品に分ける

1つのスクリプトに全部書くと、必ず破綻します。役割で分けます。

部品責務やってはいけないこと
Strategy売買シグナルを出す発注する、金額を決める
Risk数量と可否を決める相場観を持つ
Broker注文を出す・約定を返す売買判断をする
Storeポジションと履歴を保存売買判断をする

この分離には実利があります。Strategyが発注を知らなければ、バックテストとライブで同じコードを使えます。「検証したものと違うロジックが動いていた」という最悪の事故が構造的に起きなくなります。

Broker:ペーパートレードから始める

まず注文を受け取るインターフェースを決めます。実装をペーパーと本番で差し替えられるようにしておきます。

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import datetime as dt

@dataclass
class Order:
    symbol: str
    side: str          # "BUY" or "SELL"
    qty: int
    client_id: str     # 二重発注を防ぐための自前の識別子

@dataclass
class Fill:
    order: Order
    price: float
    at: dt.datetime
    fee: float

class Broker(ABC):
    @abstractmethod
    def last_price(self, symbol: str) -> float: ...

    @abstractmethod
    def send(self, order: Order) -> Fill | None: ...

    @abstractmethod
    def cash(self) -> float: ...


class PaperBroker(Broker):
    """実際には発注せず、終値+スリッページで約定したことにする。"""

    def __init__(self, cash: float = 1_000_000, fee_rate: float = 0.0005,
                 slippage: float = 0.001):
        self._cash = cash
        self.fee_rate = fee_rate
        self.slippage = slippage
        self.prices: dict[str, float] = {}
        self.fills: list[Fill] = []
        self._seen: set[str] = set()

    def set_price(self, symbol: str, price: float) -> None:
        self.prices[symbol] = price

    def last_price(self, symbol: str) -> float:
        return self.prices[symbol]

    def cash(self) -> float:
        return self._cash

    def send(self, order: Order) -> Fill | None:
        if order.client_id in self._seen:     # 冪等性チェック
            print(f"重複注文を無視: {order.client_id}")
            return None
        self._seen.add(order.client_id)

        base = self.prices[order.symbol]
        # 買いは不利な方向(高く)、売りも不利な方向(安く)に滑る
        px = base * (1 + self.slippage) if order.side == "BUY" \
            else base * (1 - self.slippage)
        gross = px * order.qty
        fee = gross * self.fee_rate
        self._cash += -gross - fee if order.side == "BUY" else gross - fee

        fill = Fill(order, px, dt.datetime.now(), fee)
        self.fills.append(fill)
        return fill

スリッページを必ず不利な方向に入れるのがポイントです。終値ちょうどで約定する前提のペーパートレードは、実弾に移した瞬間に成績が落ちます。

client_idによる重複チェックも最初から入れておきます。実際の証券会社APIにはこの仕組みが無いことが多いので、自分側で持つ必要があります。

Strategy:シグナルだけを返す

戦略は「今日どうすべきか」だけを返します。金額も口座残高も知りません。

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

def load(symbol: str, period: str = "2y") -> pd.DataFrame:
    df = yf.download(symbol, period=period, auto_adjust=True, progress=False)
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)
    return df.dropna()

class MaCrossStrategy:
    """短期MAが長期MAを上抜けたら買い、下抜けたら手仕舞い。"""

    def __init__(self, short: int = 25, long: int = 75):
        self.short, self.long = short, long

    def signals(self, df: pd.DataFrame) -> pd.Series:
        c = df["Close"]
        ma_s = c.rolling(self.short).mean()
        ma_l = c.rolling(self.long).mean()

        want = (ma_s > ma_l).astype(int)      # 保有したい状態(1 or 0)
        # 当日の終値で判定したことを、翌日以降の行動に反映する
        return want.shift(1).fillna(0).astype(int)

    def decide(self, df: pd.DataFrame, holding: bool) -> str:
        want = int(self.signals(df).iloc[-1])
        if want == 1 and not holding:
            return "BUY"
        if want == 0 and holding:
            return "SELL"
        return "HOLD"

.shift(1)が命綱です。これが無いと、当日の終値を見て当日の終値で売買したことになります。バックテストの成績だけが良くなり、実運用でまったく再現しません。入門記事のサンプルコードで最も多い誤りがこれです。

そしてsignals()Series全体を返すようにしておくと、同じメソッドをバックテストでも使えます。ライブでは最終行だけを見ます。

Risk:数量と可否を決める

ここが戦略よりも成績を左右します。いくら賭けるかは、何を買うかより重要です。

@dataclass
class RiskConfig:
    risk_per_trade: float = 0.01      # 1トレードで許容する損失(資産比)
    stop_atr_mult: float = 2.0        # 損切り幅 = ATR × この倍率
    max_position_ratio: float = 0.3   # 1銘柄への最大配分
    max_daily_loss: float = 0.03      # 日次損失がこれを超えたら全停止
    unit: int = 100                   # 日本株の売買単位

def atr(df: pd.DataFrame, n: int = 14) -> float:
    h, l, c = df["High"], df["Low"], df["Close"]
    prev = c.shift(1)
    tr = pd.concat([h - l, (h - prev).abs(), (l - prev).abs()], axis=1).max(axis=1)
    return float(tr.rolling(n).mean().iloc[-1])

class RiskManager:
    def __init__(self, cfg: RiskConfig):
        self.cfg = cfg

    def size(self, equity: float, price: float, df: pd.DataFrame) -> int:
        """損切り幅から逆算して、単元株単位の数量を返す。"""
        stop_width = atr(df) * self.cfg.stop_atr_mult
        if not np.isfinite(stop_width) or stop_width <= 0:
            return 0

        qty_by_risk = (equity * self.cfg.risk_per_trade) / stop_width
        qty_by_cap = (equity * self.cfg.max_position_ratio) / price
        qty = int(min(qty_by_risk, qty_by_cap))
        return (qty // self.cfg.unit) * self.cfg.unit      # 単元に丸める

    def halted(self, equity: float, start_equity: float) -> bool:
        return (equity / start_equity - 1) <= -self.cfg.max_daily_loss

数量の決め方が肝です。「100株ずつ買う」ではなく、損切りにかかったときの損失額が資産の1%になる株数を計算します。値動きの荒い銘柄では自動的に少なくなり、穏やかな銘柄では多くなります。

日本株は単元株制度があるので、最後に100株単位へ切り捨てます。ここを忘れると発注エラーで止まります。1株の値段が高い銘柄では、計算結果が100株に満たずqty=0になることもあります。それも正しい挙動です(資金に対して大きすぎる)。

Store:落ちても再開できるようにする

変数にポジションを持つと、プロセスが落ちた瞬間にすべて失われます。次に起動したBOTは「ポジションなし」と誤認して、二重に買います。状態はファイルかDBに置きます。

import sqlite3
from pathlib import Path

class Store:
    def __init__(self, path: str = "bot.db"):
        self.con = sqlite3.connect(path)
        self.con.executescript("""
        CREATE TABLE IF NOT EXISTS positions(
            symbol TEXT PRIMARY KEY,
            qty INTEGER NOT NULL,
            avg_price REAL NOT NULL
        );
        CREATE TABLE IF NOT EXISTS fills(
            client_id TEXT PRIMARY KEY,
            symbol TEXT, side TEXT, qty INTEGER,
            price REAL, fee REAL, at TEXT
        );
        """)
        self.con.commit()

    def position(self, symbol: str) -> tuple[int, float]:
        row = self.con.execute(
            "SELECT qty, avg_price FROM positions WHERE symbol=?", (symbol,)
        ).fetchone()
        return (row[0], row[1]) if row else (0, 0.0)

    def already_done(self, client_id: str) -> bool:
        return self.con.execute(
            "SELECT 1 FROM fills WHERE client_id=?", (client_id,)
        ).fetchone() is not None

    def record(self, fill: Fill) -> None:
        o = fill.order
        qty, avg = self.position(o.symbol)
        if o.side == "BUY":
            new_qty = qty + o.qty
            new_avg = (qty * avg + o.qty * fill.price) / new_qty
        else:
            new_qty = qty - o.qty
            new_avg = avg if new_qty > 0 else 0.0

        with self.con:
            self.con.execute(
                "INSERT OR REPLACE INTO positions VALUES (?,?,?)",
                (o.symbol, new_qty, new_avg))
            self.con.execute(
                "INSERT OR IGNORE INTO fills VALUES (?,?,?,?,?,?,?)",
                (o.client_id, o.symbol, o.side, o.qty,
                 fill.price, fill.fee, fill.at.isoformat()))

client_idを主キーにしているのが要点です。同じIDの約定は物理的に二重登録できません。DBの制約でガードするのが、アプリのif文で頑張るより確実です。

メインループ:1日1回、冪等に

import logging
from zoneinfo import ZoneInfo

JST = ZoneInfo("Asia/Tokyo")
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.FileHandler("bot.log", encoding="utf-8"),
              logging.StreamHandler()],
)
log = logging.getLogger("bot")

class Bot:
    def __init__(self, symbols: list[str], broker: Broker,
                 strategy: MaCrossStrategy, risk: RiskManager, store: Store):
        self.symbols = symbols
        self.broker, self.strategy = broker, strategy
        self.risk, self.store = risk, store

    def run_once(self) -> None:
        today = dt.datetime.now(JST).date().isoformat()

        for symbol in self.symbols:
            try:
                self._handle(symbol, today)
            except Exception:
                # 1銘柄の失敗で全体を止めない
                log.exception("処理に失敗: %s", symbol)

    def _handle(self, symbol: str, today: str) -> None:
        df = load(symbol, "1y")
        last_bar = df.index[-1].date().isoformat()
        price = float(df["Close"].iloc[-1])
        self.broker.set_price(symbol, price) if isinstance(
            self.broker, PaperBroker) else None

        qty_held, _ = self.store.position(symbol)
        action = self.strategy.decide(df, holding=qty_held > 0)
        log.info("%s price=%.1f held=%d action=%s", symbol, price,
                 qty_held, action)

        if action == "HOLD":
            return

        # 「銘柄・日付・行動」で一意なIDを作る。同日に2回動かしても発注は1回
        client_id = f"{symbol}-{last_bar}-{action}"
        if self.store.already_done(client_id):
            log.info("本日処理済みのためスキップ: %s", client_id)
            return

        if action == "BUY":
            equity = self.broker.cash()
            qty = self.risk.size(equity, price, df)
            if qty <= 0:
                log.warning("%s 数量が0のため見送り", symbol)
                return
        else:
            qty = qty_held

        fill = self.broker.send(Order(symbol, action, qty, client_id))
        if fill:
            self.store.record(fill)
            log.info("約定 %s %s %d株 @%.1f", symbol, action, qty, fill.price)

client_idを「銘柄+足の日付+行動」で組み立てているのが、この設計の中心です。手動で2回実行しても、cronが重複起動しても、発注は1回しか通りません。datetime.now()ではなくデータの最終日を使うことで、日付をまたいだ再実行にも耐えます。

また、try/exceptを銘柄ごとに置いています。1銘柄のデータ取得が失敗しただけで、他の銘柄の損切りが実行されないのは困ります。

動かしてみる

if __name__ == "__main__":
    broker = PaperBroker(cash=1_000_000)
    bot = Bot(
        symbols=["7203.T", "6758.T", "8306.T"],
        broker=broker,
        strategy=MaCrossStrategy(25, 75),
        risk=RiskManager(RiskConfig()),
        store=Store("bot.db"),
    )
    bot.run_once()

    print(f"\n現金: {broker.cash():,.0f}円")
    for f in broker.fills:
        print(f"  {f.order.side} {f.order.symbol} {f.order.qty}株 "
              f"@{f.price:,.1f} 手数料{f.fee:,.0f}円")

2回続けて実行してみてください。2回目は「本日処理済み」でスキップされるはずです。これが確認できたら、土台はできています。

Windowsならタスクスケジューラ、LinuxならcronやsystemdタイマーでUTC 6:10(日本時間15:10)あたりに毎日実行します。大引け後に判断し、翌日の寄り付きで執行する運用が現実的です。

本番の証券会社APIに差し替える

Brokerを実装し直すだけで、他のコードは変わりません。日本株で個人が使える公式APIは、三菱UFJ eスマート証券(旧auカブコム証券)のkabuステーションAPIが現実的な選択肢です。

class KabuBroker(Broker):
    def __init__(self, base: str, api_password: str, trade_password: str):
        self.base, self.pw, self.trade_pw = base, api_password, trade_password
        self._token = None

    def send(self, order: Order) -> Fill | None:
        # 実装の要点:
        #  1) トークンは毎日失効するので自動再取得する
        #  2) タイムアウトしても再送しない。注文照会で実在を確認する
        #  3) 発注は秒間5件までなので間隔を空ける
        ...

実APIでいちばん危険なのはタイムアウト時の再送です。応答が返らなくても注文は通っているかもしれません。素朴にリトライすると同じ成行注文が複数入ります。詳細はkabuステーションAPIの記事にまとめました。

実弾に移すまでの手順

  1. バックテスト。Strategy.signals()を使って過去データで検証する。取引回数が数十回に満たないなら結論は出せない
  2. ペーパートレードを1ヶ月。本番と同じスケジュールで動かし、落ちないことを確認する。ここで必ず何かが壊れる
  3. 最小単元で実弾。100株だけで1ヶ月。ペーパーとの成績差がスリッページの実測値になる
  4. 段階的に増やす。いきなり全額入れない

2番を飛ばしたくなりますが、飛ばすと必ず後悔します。祝日、決算またぎ、システムメンテナンス、ネットワーク断。コードのバグではなく環境のほうが先に壊れることを、身をもって知ることになります。

よくある失敗

失敗結果対策
シグナルをshiftしない検証だけ好成績翌足で執行する前提を徹底
状態をメモリに持つ再起動で二重発注DBに永続化する
タイムアウトを再送同じ注文が複数照会してから判断
損切りがコード内だけBOT停止中に無防備証券会社側に逆指値を置く
固定株数で発注銘柄ごとにリスクが不均一ATRから逆算する
日次損失の上限がない悪い日に負け続ける閾値で全停止させる
ログを出さない原因が追えない判断と約定を全部残す

まとめ

自動売買プログラムの本体は、売買ルールではありません。落ちても壊れない土台です。

戦略を差し替えるのはStrategyクラスを書き換えるだけで済みます。しかし、二重発注や状態消失は設計を直さないと解決しません。だから順序が大事で、先に土台を作り、あとから戦略を載せるのが正解です。

まずはこの記事のコードをそのまま動かして、2回実行してもスキップされることを確認してください。そこから自分の戦略をStrategyに書けば、あとは同じ枠組みで回せます。

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