Bybit APIのTP/SL(利確・損切)を完全マスターするPython実装
Full/Partialモード・トレーリング・
triggerByの選び方

「TP/SLを設定したのに発動しなかった」「二重に決済された」——この2つの事故は、BybitのtpslModeとtriggerByの仕様を取り違えていることがほぼ原因です。この記事では発注時の同時指定と set_trading_stop の使い分け、FullとPartialの決定的な違い、階段利確、トレーリングストップ、そしてTP/SLが消えたことを検知して自己修復する実装までをサンプルコード8本でまとめます。

確認日
2026-07-30
API バージョン
Bybit V5
Python
3.10 以上
pybit
5.17.0

※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。

目次

  1. まず動かす|発注と同時にTP/SLを付ける
  2. 2つの設定方法とその違い
  3. 最重要|FullとPartialの違い
  4. triggerByの選び方|MarkPriceかLastPriceか
  5. 階段利確を実装する(Partialモード)
  6. トレーリングストップ|公式機能と自作ATRトレール
  7. TP/SLが本当に設定されているか照合する
  8. TP/SLが消えたら自己修復する
  9. 疑似OCO|条件注文2本の排他制御
  10. 現物のTP/SLは仕組みが違う
  11. TP/SL関連のエラーコード
  12. 実運用での注意点
  13. よくある疑問Q&A

この記事は記事01の client.py と記事13の symbol_rules.py を再利用します。

まず動かす|発注と同時にTP/SLを付ける

order_with_tpsl.pyサンプル1
# 本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
# 必ず testnet で実行してください。
from decimal import Decimal

from client import make_client
from symbol_rules import fetch_rules, round_to_step

c = make_client()
rules = fetch_rules(c.http, "BTCUSDT", "linear")

last = Decimal(
    c.http.get_tickers(category="linear", symbol="BTCUSDT")["result"]["list"][0]["lastPrice"]
)
tick = rules.tick_size

res = c.http.place_order(
    category="linear",
    symbol="BTCUSDT",
    side="Buy",
    orderType="Market",
    qty="0.001",
    # --- ここからTP/SL ---
    tpslMode="Full",                                        # ポジション全体に対して設定
    takeProfit=str(round_to_step(last * Decimal("1.02"), tick)),   # +2%
    stopLoss=str(round_to_step(last * Decimal("0.99"), tick)),     # -1%
    tpTriggerBy="LastPrice",                                # 明示するのが安全
    slTriggerBy="MarkPrice",                                # 損切はマーク価格推奨(後述)
    orderLinkId="demo-tpsl-01",
)

print(res["retCode"], res["retMsg"])
print("orderId:", res["result"]["orderId"])

# 設定されたかを必ず確認する(発注が通っても設定されない場合がある)
pos = c.http.get_positions(category="linear", symbol="BTCUSDT")["result"]["list"][0]
print(f"TP={pos['takeProfit']}  SL={pos['stopLoss']}  mode={pos['tpslMode']}")
発注成功とTP/SL設定成功は別

place_order が retCode=0 を返しても、TP/SLが期待どおり設定されたとは限りません。指値注文の場合、TP/SLは約定してポジションが建ってから反映されます。また価格が現在値に対して不正な側にあると、注文自体は通ってTP/SLだけ無効になる場合があります。

上のコードの最後2行——設定後に get_positions() で照合する——を必ず入れてください。この確認を省いた実装が「TP/SLを設定したのに発動しなかった」の最大の原因です。

2つの設定方法とその違い

方法タイミング使いどころ注意
place_order
(同時指定)
エントリーと同時 エントリー価格からの固定%でTP/SLを置く場合 指値なら約定後に反映。未約定の間は無防備
set_trading_stop
(後付け)
ポジション保有中いつでも 建値が確定してから計算する場合、トレール、階段利確 ポジションが無いと失敗(110033)

実務では後付け(set_trading_stop)のほうが扱いやすいです。理由は、実際の建値(avgPrice)が分かった後にTP/SLを計算できるためです。同時指定では発注時の想定価格を基準にするしかなく、スリッページで想定と建値がズレると、リスク量が意図と変わります。

tpsl.pyサンプル2
"""tpsl.py — 実際の建値からTP/SLを計算して後付けする
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

from symbol_rules import fetch_rules, round_to_step


class NoPosition(RuntimeError):
    pass


@dataclass
class TpSlResult:
    take_profit: Decimal
    stop_loss: Decimal
    entry: Decimal
    risk_pct: Decimal
    reward_pct: Decimal

    @property
    def rr(self) -> Decimal:
        """リスクリワード比。"""
        return self.reward_pct / self.risk_pct if self.risk_pct else Decimal(0)


def set_tpsl_from_entry(http, symbol: str, tp_pct: Decimal, sl_pct: Decimal,
                        category: str = "linear",
                        tp_trigger: str = "LastPrice",
                        sl_trigger: str = "MarkPrice") -> TpSlResult:
    """実際の建値(avgPrice)を基準に、指定%でTP/SLを設定する。"""
    res = http.get_positions(category=category, symbol=symbol)
    if res["retCode"] != 0:
        raise RuntimeError(f"get_positions失敗: {res['retMsg']}")

    positions = [p for p in res["result"]["list"] if Decimal(p["size"]) > 0]
    if not positions:
        raise NoPosition(f"{symbol}: ポジションがありません")

    pos = positions[0]
    entry = Decimal(pos["avgPrice"])
    is_long = pos["side"] == "Buy"
    tick = fetch_rules(http, symbol, category).tick_size

    if is_long:
        tp = entry * (1 + tp_pct / 100)
        sl = entry * (1 - sl_pct / 100)
    else:
        tp = entry * (1 - tp_pct / 100)
        sl = entry * (1 + sl_pct / 100)

    tp = round_to_step(tp, tick)
    sl = round_to_step(sl, tick)

    params = {
        "category": category,
        "symbol": symbol,
        "tpslMode": "Full",
        "takeProfit": str(tp),
        "stopLoss": str(sl),
        "tpTriggerBy": tp_trigger,
        "slTriggerBy": sl_trigger,
    }
    # ヘッジモードでは対象ポジションを明示する必要がある
    if int(pos.get("positionIdx", 0)) != 0:
        params["positionIdx"] = int(pos["positionIdx"])

    r = http.set_trading_stop(**params)
    # 34040 は「すでに同じ値が設定済み」を含む。実質成功として扱う
    if r["retCode"] not in (0, 34040):
        raise RuntimeError(f"set_trading_stop失敗: {r['retCode']} {r['retMsg']}")

    return TpSlResult(take_profit=tp, stop_loss=sl, entry=entry,
                      risk_pct=sl_pct, reward_pct=tp_pct)


def clear_tpsl(http, symbol: str, category: str = "linear") -> None:
    """TP/SLを解除する。0を指定するのが解除の意味になる。"""
    http.set_trading_stop(
        category=category, symbol=symbol, tpslMode="Full",
        takeProfit="0", stopLoss="0", trailingStop="0",
    )


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    result = set_tpsl_from_entry(c.http, "BTCUSDT",
                                tp_pct=Decimal("2"), sl_pct=Decimal("1"))
    print(f"建値 {result.entry}  TP {result.take_profit}  SL {result.stop_loss}  "
          f"RR {result.rr:.2f}")
「0」は解除を意味する

takeProfit="0" は「価格0で利確」ではなく「利確を解除する」という意味です。同様に trailingStop="0" でトレールが解除されます。設定を変更するつもりで意図せず0を渡すと、防御が全部外れた状態で放置されます。None と 0 と未指定の違いを意識してコードを書いてください。

最重要|FullとPartialの違い

tpslMode の理解が、この記事で最も重要な部分です。この2つは挙動が根本的に違います。

観点FullPartial
対象ポジション全体数量を指定した一部
数量指定不要(常に全量)tpSize / slSize が必要
注文タイプ成行のみ成行・指値の両方
指値決済不可tpLimitPrice で可能
設定できる本数TP・SL 各1つ合計20件まで(110061)
用途単純な全決済階段利確・分割決済
Fullモードでは指値決済ができない

「利確はメイカーで決済して手数料を節約したい」と考えて tpOrderType="Limit" を指定しても、Fullモードでは無効です(成行のみ対応)。指値で利確したいなら tpslMode="Partial" にして tpSize と tpLimitPrice を指定する必要があります。

これに気づかず「Limitを指定したのに成行で決済された」と悩むケースが多いポイントです。手数料の差は往復で数bpsですが、高頻度戦略では効いてきます。

モードの切り替えには制約がある

既にTP/SL・トレーリングストップが設定されている状態でモードを切り替えると、110054 / 110055 / 110056 で失敗します。切り替えるには先に既存のTP/SLを解除("0" を設定)してください。Partial では tpSize と slSize を同じ値にする必要があるという制約もあります(片方だけ小さくはできません)。

triggerByの選び方|MarkPriceかLastPriceか

triggerBy には3つの選択肢があります。これは好みではなく、リスク管理上の判断です。

triggerBy基準特徴推奨用途
LastPrice 最終取引価格 実際の約定価格。薄い板ではヒゲで動きやすい 利確(TP)
MarkPrice マーク価格
(清算判定と同じ)
指数を参照するため瞬間的なヒゲに強い 損切(SL)
IndexPrice 現物指数 その取引所の板から最も独立 単一取引所の異常を無視したい場合

損切をMarkPriceにする理由は明確です。清算判定はマーク価格で行われるため、SLをLastPriceにすると「マーク価格では清算されない水準なのに、最終価格のヒゲでSLだけ発動する」という損な決済が起こりえます。逆にSLがマーク価格基準なら、清算と同じ物差しでリスクを管理できます。

一方利確はLastPriceが自然です。実際に取引できる価格で利確したいためです。

trigger_compare.pyサンプル3
"""trigger_compare.py — LastPriceとMarkPriceの乖離を実測し、triggerByの影響を見る
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import pandas as pd
from pybit.unified_trading import HTTP

COLS_FULL = ["ts", "open", "high", "low", "close", "volume", "turnover"]
COLS_PRICE = ["ts", "open", "high", "low", "close"]


def _df(rows: list[list[str]]) -> pd.DataFrame:
    cols = COLS_FULL if rows and len(rows[0]) >= 7 else COLS_PRICE
    df = pd.DataFrame([r[: len(cols)] for r in rows], columns=cols).astype(float)
    df["ts"] = pd.to_datetime(df["ts"].astype("int64"), unit="ms", utc=True)
    return df.set_index("ts").sort_index()


def compare_triggers(symbol: str = "BTCUSDT", interval: str = "1",
                     limit: int = 1000) -> pd.DataFrame:
    """最終価格とマーク価格の「足内の最悪値」の差を測る。

    SLをLastPriceにした場合とMarkPriceにした場合で、
    どれだけ余計にヒゲを踏むかを推定できる。
    """
    http = HTTP(testnet=False)
    kw = dict(category="linear", symbol=symbol, interval=interval, limit=limit)

    last = _df(http.get_kline(**kw)["result"]["list"])
    mark = _df(http.get_mark_price_kline(**kw)["result"]["list"])

    out = pd.DataFrame({
        "last_low": last["low"], "mark_low": mark["low"],
        "last_high": last["high"], "mark_high": mark["high"],
        "last_close": last["close"], "mark_close": mark["close"],
    }).dropna()

    # ロングのSLはこの差だけ「余計に」踏まれる可能性がある
    out["long_extra_bps"] = (out["mark_low"] - out["last_low"]) / out["mark_low"] * 10_000
    # ショートのSLも同様
    out["short_extra_bps"] = (out["last_high"] - out["mark_high"]) / out["mark_high"] * 10_000
    return out


if __name__ == "__main__":
    for sym in ("BTCUSDT", "SOLUSDT", "DOGEUSDT"):
        df = compare_triggers(sym)
        le, se = df["long_extra_bps"], df["short_extra_bps"]
        print(f"\n{sym}  ({len(df)}本の1分足)")
        print(f"  ロング側の余分なヒゲ : 中央値 {le.median():+6.2f} bps  "
              f"95%点 {le.quantile(0.95):+6.2f} bps  最大 {le.max():+6.2f} bps")
        print(f"  ショート側          : 中央値 {se.median():+6.2f} bps  "
              f"95%点 {se.quantile(0.95):+6.2f} bps  最大 {se.max():+6.2f} bps")
        print(f"  -> SLをLastPriceにすると、最大でこのbps分だけ"
              f"早くSLに掛かる可能性がある")
数値で判断する

この計測を流動性の高い銘柄(BTCUSDT)と低い銘柄で比べると、乖離の大きさがまるで違うことが分かります。BTCではほぼ無視できる差でも、薄いアルトコインでは数十bpsに達することがあります。SLの幅が狭い戦略でアルトコインを扱うなら、slTriggerBy="MarkPrice" はほぼ必須です。

階段利確を実装する(Partialモード)

「30%を+1%で、30%を+2%で、残り40%をトレールで」という段階的な利確は、tpslMode="Partial" で実装します。

ladder_tp.pyサンプル4
"""ladder_tp.py — Partialモードによる階段利確
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

制約:
  - tpSize と slSize は同じ値にする必要がある
  - Partialモードで設定できるTP/SLは合計20件まで(110061)
  - 各段の数量合計がポジション量を超えると 110058
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

from symbol_rules import fetch_rules, floor_to_step, round_to_step


@dataclass
class Rung:
    """階段の1段。"""
    tp_pct: Decimal        # 建値からの利確幅(%)
    size_ratio: Decimal    # ポジション全体に対する比率
    limit: bool = False    # Trueなら指値決済(メイカー手数料)


DEFAULT_LADDER = [
    Rung(tp_pct=Decimal("1.0"), size_ratio=Decimal("0.3"), limit=True),
    Rung(tp_pct=Decimal("2.0"), size_ratio=Decimal("0.3"), limit=True),
    Rung(tp_pct=Decimal("3.5"), size_ratio=Decimal("0.4"), limit=False),
]


def set_ladder_tp(http, symbol: str, sl_pct: Decimal,
                  ladder: list[Rung] | None = None,
                  category: str = "linear") -> list[dict]:
    """建値を基準に階段利確を設定する。損切は各段と同数量で置く。"""
    ladder = ladder or DEFAULT_LADDER
    total_ratio = sum(r.size_ratio for r in ladder)
    if total_ratio > 1:
        raise ValueError(f"size_ratioの合計が1を超えています: {total_ratio}")
    if len(ladder) > 20:
        raise ValueError("Partialモードの上限は20件です")

    res = http.get_positions(category=category, symbol=symbol)
    positions = [p for p in res["result"]["list"] if Decimal(p["size"]) > 0]
    if not positions:
        raise RuntimeError(f"{symbol}: ポジションがありません")

    pos = positions[0]
    entry = Decimal(pos["avgPrice"])
    size = Decimal(pos["size"])
    is_long = pos["side"] == "Buy"
    rules = fetch_rules(http, symbol, category)

    sl_price = round_to_step(
        entry * (1 - sl_pct / 100) if is_long else entry * (1 + sl_pct / 100),
        rules.tick_size,
    )

    applied: list[dict] = []
    for i, rung in enumerate(ladder, 1):
        qty = floor_to_step(size * rung.size_ratio, rules.qty_step)
        if qty < rules.min_qty:
            print(f"  [skip] 第{i}段: 数量{qty}が最小{rules.min_qty}未満")
            continue

        tp_price = round_to_step(
            entry * (1 + rung.tp_pct / 100) if is_long
            else entry * (1 - rung.tp_pct / 100),
            rules.tick_size,
        )

        params = {
            "category": category,
            "symbol": symbol,
            "tpslMode": "Partial",
            "takeProfit": str(tp_price),
            "stopLoss": str(sl_price),
            "tpSize": str(qty),
            "slSize": str(qty),        # tpSizeと同じ値にする必要がある
            "tpTriggerBy": "LastPrice",
            "slTriggerBy": "MarkPrice",
        }
        if rung.limit:
            # 指値決済はPartialモードでのみ有効
            params["tpOrderType"] = "Limit"
            params["tpLimitPrice"] = str(tp_price)
        if int(pos.get("positionIdx", 0)) != 0:
            params["positionIdx"] = int(pos["positionIdx"])

        r = http.set_trading_stop(**params)
        ok = r["retCode"] in (0, 34040)
        status = "OK" if ok else f"NG retCode={r['retCode']} {r['retMsg']}"
        kind = "指値" if rung.limit else "成行"
        print(f"  第{i}段: TP={tp_price} qty={qty} {kind} -> {status}")
        applied.append({"rung": i, "tp": tp_price, "qty": qty, "ok": ok,
                        "retCode": r["retCode"], "retMsg": r["retMsg"]})
    return applied


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    print("階段利確を設定します")
    for row in set_ladder_tp(c.http, "BTCUSDT", sl_pct=Decimal("1.5")):
        print(row)

トレーリングストップ|公式機能と自作ATRトレール

Bybitには公式のトレーリングストップがあります。trailingStop は%ではなく価格の距離で指定します。

パラメータ意味
trailingStop価格の距離(例: BTCで "500" なら500USDT分の追従幅)。0で解除
activePriceトレールを起動する価格。この価格に到達してから追従が始まる
trailing.pyサンプル5
"""trailing.py — 公式トレーリングストップと、自作ATRトレールの2方式
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import time
from decimal import Decimal

import pandas as pd

from symbol_rules import fetch_rules, round_to_step


# ---- 方式1: 公式のトレーリングストップ --------------------------------
def set_official_trailing(http, symbol: str, distance: Decimal,
                          activate_at: Decimal | None = None,
                          category: str = "linear") -> dict:
    """取引所側のトレール機能を使う。距離は「価格」で指定する点に注意。"""
    rules = fetch_rules(http, symbol, category)
    params = {
        "category": category,
        "symbol": symbol,
        "tpslMode": "Full",
        "trailingStop": str(round_to_step(distance, rules.tick_size)),
    }
    if activate_at is not None:
        params["activePrice"] = str(round_to_step(activate_at, rules.tick_size))

    r = http.set_trading_stop(**params)
    if r["retCode"] not in (0, 34040):
        raise RuntimeError(f"トレール設定失敗: {r['retCode']} {r['retMsg']}")
    return r


def atr(http, symbol: str, period: int = 14, interval: str = "60",
        category: str = "linear") -> Decimal:
    """ATRを計算する(TA-Lib不要)。"""
    res = http.get_kline(category=category, symbol=symbol,
                         interval=interval, limit=period * 3)
    rows = list(reversed(res["result"]["list"]))      # 昇順に直す
    df = pd.DataFrame(rows, columns=["ts", "open", "high", "low", "close",
                                     "volume", "turnover"]).astype(float)
    prev_close = df["close"].shift(1)
    tr = pd.concat([
        df["high"] - df["low"],
        (df["high"] - prev_close).abs(),
        (df["low"] - prev_close).abs(),
    ], axis=1).max(axis=1)
    return Decimal(str(round(tr.rolling(period).mean().iloc[-1], 8)))


# ---- 方式2: 自作トレール(ATR × 係数でSLを引き上げ続ける) ------------
def atr_trail_loop(http, symbol: str, mult: Decimal = Decimal("2.0"),
                   interval: str = "60", period: int = 14,
                   poll_sec: float = 60.0, max_loops: int = 1440,
                   category: str = "linear") -> None:
    """ATRベースでSLを不利方向には動かさずに更新し続ける。

    公式トレールとの違い:
      - 追従幅をボラティリティに応じて可変にできる
      - 更新のたびにログを残せる(検証しやすい)
      - ただしbotが止まると追従も止まる(公式は取引所側で動き続ける)
    """
    rules = fetch_rules(http, symbol, category)
    best_sl: Decimal | None = None

    for _ in range(max_loops):
        res = http.get_positions(category=category, symbol=symbol)
        positions = [p for p in res["result"]["list"] if Decimal(p["size"]) > 0]
        if not positions:
            print("ポジションが無くなりました。トレールを終了します")
            return

        pos = positions[0]
        is_long = pos["side"] == "Buy"
        mark = Decimal(pos["markPrice"])
        a = atr(http, symbol, period, interval, category)
        gap = a * mult

        candidate = round_to_step(mark - gap if is_long else mark + gap,
                                  rules.tick_size)

        # 有利方向にしか動かさない(これがトレールの本質)
        improved = (best_sl is None
                    or (is_long and candidate > best_sl)
                    or (not is_long and candidate < best_sl))

        if improved:
            params = {
                "category": category, "symbol": symbol, "tpslMode": "Full",
                "stopLoss": str(candidate), "slTriggerBy": "MarkPrice",
            }
            if int(pos.get("positionIdx", 0)) != 0:
                params["positionIdx"] = int(pos["positionIdx"])

            r = http.set_trading_stop(**params)
            if r["retCode"] in (0, 34040):
                best_sl = candidate
                print(f"[trail] mark={mark} ATR={a:.2f} -> SL={candidate}")
            else:
                print(f"[warn] SL更新失敗: {r['retCode']} {r['retMsg']}")

        time.sleep(poll_sec)


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    a = atr(c.http, "BTCUSDT")
    print(f"ATR(14, 1h) = {a}")

    # 公式トレール: ATRの2倍を追従幅にする
    set_official_trailing(c.http, "BTCUSDT", distance=a * Decimal("2"))
    print("公式トレールを設定しました")

    # 自作トレール(こちらを使う場合は公式トレールを解除してから)
    # atr_trail_loop(c.http, "BTCUSDT")
公式トレールと自作トレールを併用しない

両方を同時に有効にすると、お互いのSLを上書きし合って予測不能な挙動になります。どちらか一方に決めてください。

判断基準はシンプルです。botが落ちてもSLに守られていたいなら公式(取引所側で動き続ける)、ボラティリティに応じた可変幅や複雑な条件が必要なら自作です。自作を選ぶ場合は、botが落ちたときの保険として広めの固定SLを公式側で別途置いておくという併用は有効です(この場合は自作側が公式SLより内側で動く設計にします)。

TP/SLが本当に設定されているか照合する

設定したつもりのTP/SLが実際には無い、という状態を検知します。これは定期実行すべき監視処理です。

tpsl_audit.pyサンプル6
"""tpsl_audit.py — 全ポジションのTP/SL設定を監査する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal


@dataclass
class AuditRow:
    symbol: str
    side: str
    size: Decimal
    entry: Decimal
    mark: Decimal
    take_profit: Decimal | None
    stop_loss: Decimal | None
    trailing: Decimal | None
    tpsl_mode: str

    @property
    def unprotected(self) -> bool:
        """損切が一切設定されていないポジション。最も危険な状態。"""
        return not self.stop_loss and not self.trailing

    @property
    def sl_distance_pct(self) -> Decimal | None:
        if not self.stop_loss:
            return None
        return abs(self.mark - self.stop_loss) / self.mark * 100

    @property
    def sl_wrong_side(self) -> bool:
        """SLが現在価格の逆側にある異常状態(設定ミス)。"""
        if not self.stop_loss:
            return False
        if self.side == "Buy":
            return self.stop_loss >= self.mark
        return self.stop_loss <= self.mark


def _dec(v) -> Decimal | None:
    if v in (None, "", "0"):
        return None
    return Decimal(str(v))


def audit_tpsl(http, category: str = "linear",
               settle_coin: str = "USDT") -> list[AuditRow]:
    res = http.get_positions(category=category, settleCoin=settle_coin)
    if res["retCode"] != 0:
        raise RuntimeError(f"get_positions失敗: {res['retMsg']}")

    rows: list[AuditRow] = []
    for p in res["result"]["list"]:
        size = Decimal(p["size"])
        if size <= 0:
            continue
        rows.append(AuditRow(
            symbol=p["symbol"], side=p["side"], size=size,
            entry=Decimal(p["avgPrice"]), mark=Decimal(p["markPrice"]),
            take_profit=_dec(p.get("takeProfit")),
            stop_loss=_dec(p.get("stopLoss")),
            trailing=_dec(p.get("trailingStop")),
            tpsl_mode=p.get("tpslMode", "?"),
        ))
    return rows


def report(rows: list[AuditRow]) -> list[AuditRow]:
    """問題のあるポジションを返す。"""
    problems: list[AuditRow] = []
    print(f"{'symbol':<12}{'side':<6}{'size':>12}{'SL距離':>10}  状態")
    print("-" * 62)
    for r in rows:
        flags = []
        if r.unprotected:
            flags.append("損切なし")
        if r.sl_wrong_side:
            flags.append("SLが逆側")
        if not r.take_profit and not r.trailing:
            flags.append("利確なし")
        if flags:
            problems.append(r)

        dist = f"{r.sl_distance_pct:.2f}%" if r.sl_distance_pct else "-"
        status = " / ".join(flags) if flags else "OK"
        print(f"{r.symbol:<12}{r.side:<6}{r.size:>12}{dist:>10}  {status}")

    if problems:
        print(f"\n[警告] {len(problems)}件のポジションに問題があります")
    return problems


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    report(audit_tpsl(c.http))

TP/SLが消えたら自己修復する

TP/SLは消えることがあります。手動介入、部分決済、モード切替の失敗、取引所側の処理など理由は様々です。監査で問題を見つけたら自動で再設定する仕組みまで作ると、運用が安定します。

tpsl_guardian.pyサンプル7
"""tpsl_guardian.py — 無防備なポジションを検知して自動でSLを設定する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

思想: 「損切のないポジションは存在してはいけない」を機械的に保証する。
"""
from __future__ import annotations

import time
from decimal import Decimal

from symbol_rules import fetch_rules, round_to_step
from tpsl_audit import AuditRow, audit_tpsl

# 緊急時に強制する広めの損切幅。戦略が意図した値ではなく「最後の壁」
EMERGENCY_SL_PCT = Decimal("3.0")


def enforce_stop_loss(http, row: AuditRow, sl_pct: Decimal = EMERGENCY_SL_PCT,
                      category: str = "linear") -> bool:
    """損切が無いポジションに、緊急用の損切を設定する。"""
    rules = fetch_rules(http, row.symbol, category)
    is_long = row.side == "Buy"

    # 建値ではなく現在のマーク価格を基準にする。
    # 既に大きく含み損の場合、建値基準では即時発動してしまうため。
    base = row.mark
    sl = round_to_step(
        base * (1 - sl_pct / 100) if is_long else base * (1 + sl_pct / 100),
        rules.tick_size,
    )

    r = http.set_trading_stop(
        category=category, symbol=row.symbol, tpslMode="Full",
        stopLoss=str(sl), slTriggerBy="MarkPrice",
    )
    ok = r["retCode"] in (0, 34040)
    label = "修復" if ok else "失敗"
    detail = "" if ok else f"  retCode={r['retCode']} {r['retMsg']}"
    print(f"[{label}] {row.symbol} {row.side} 緊急SL={sl} ({sl_pct}%){detail}")
    return ok


def guardian_loop(http, interval_sec: float = 60.0, max_loops: int = 10_000,
                  category: str = "linear", notify=None) -> None:
    """定期的にポジションを監査し、無防備なものを修復する。"""
    for _ in range(max_loops):
        try:
            rows = audit_tpsl(http, category)
            for row in rows:
                if row.unprotected:
                    msg = (f"{row.symbol} {row.side} size={row.size} に"
                           f"損切がありません。緊急SLを設定します")
                    print(f"[検知] {msg}")
                    if notify:
                        notify(msg)
                    enforce_stop_loss(http, row, category=category)
                elif row.sl_wrong_side:
                    msg = (f"{row.symbol} のSL({row.stop_loss})が"
                           f"現在価格({row.mark})の逆側にあります")
                    print(f"[異常] {msg}")
                    if notify:
                        notify(msg)
        except Exception as e:
            print(f"[error] 監査に失敗: {type(e).__name__}: {e}")

        time.sleep(interval_sec)


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    # notify に記事39の通知モジュールを渡すと、Discord等に飛ばせる
    guardian_loop(c.http, interval_sec=60)
緊急SLは「戦略の損切」とは別物

EMERGENCY_SL_PCT = 3.0 は戦略が意図した損切幅ではなく、「何かがおかしいときの最後の壁」です。戦略の損切より広く設定するのが正しく、狭くすると正常な運用を妨げます。また基準を建値ではなくマーク価格にしているのは、すでに大きな含み損を抱えている場合に建値基準だと即時発動してしまうためです。この種の「防御の防御」は、記事24のサーキットブレーカーと同じ思想です。

疑似OCO|条件注文2本の排他制御

TP/SLで足りず、「どちらか一方が約定したら他方をキャンセル」という制御が必要な場面があります(ブレイクアウトの両建て待ち受けなど)。V5には汎用のOCO注文がないため、自分で状態管理します。

pseudo_oco.pyサンプル8
"""pseudo_oco.py — 条件注文2本の排他制御(疑似OCO)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import time
from dataclasses import dataclass
from decimal import Decimal

from symbol_rules import fetch_rules, round_to_step

# 「終了状態」を意味するretCode。競合として正常に扱う
DONE_CODES = {110001, 110008, 110010}


@dataclass
class OcoLeg:
    link_id: str
    side: str
    trigger_price: Decimal


class PseudoOco:
    """上抜けで買い / 下抜けで売り の条件注文を張り、片方が発動したら他方を消す。"""

    def __init__(self, http, symbol: str, category: str = "linear"):
        self.http = http
        self.symbol = symbol
        self.category = category
        self.legs: list[OcoLeg] = []

    def place(self, upper: Decimal, lower: Decimal, qty: Decimal,
              tag: str = "oco") -> None:
        rules = fetch_rules(self.http, self.symbol, self.category)
        tick = rules.tick_size
        stamp = int(time.time())

        specs = [
            ("Buy", round_to_step(upper, tick), 1),    # triggerDirection 1 = 上昇
            ("Sell", round_to_step(lower, tick), 2),   # 2 = 下落
        ]
        for side, price, direction in specs:
            link_id = f"{tag}-{side[:1]}-{stamp}"
            r = self.http.place_order(
                category=self.category, symbol=self.symbol, side=side,
                orderType="Market", qty=str(qty),
                triggerPrice=str(price),
                triggerDirection=direction,
                triggerBy="LastPrice",
                orderLinkId=link_id,
            )
            if r["retCode"] != 0:
                raise RuntimeError(f"条件注文失敗({side}): {r['retCode']} {r['retMsg']}")
            self.legs.append(OcoLeg(link_id=link_id, side=side,
                                    trigger_price=price))
            print(f"[oco] {side} トリガー {price} を設置 ({link_id})")

    def _is_open(self, link_id: str) -> bool:
        res = self.http.get_open_orders(
            category=self.category, symbol=self.symbol, orderLinkId=link_id,
            orderFilter="StopOrder",
        )
        return bool(res["result"]["list"])

    def _cancel(self, link_id: str) -> None:
        r = self.http.cancel_order(category=self.category, symbol=self.symbol,
                                   orderLinkId=link_id)
        if r["retCode"] in (0, *DONE_CODES):
            print(f"[oco] {link_id} をキャンセルしました")
        else:
            print(f"[warn] キャンセル失敗 {link_id}: {r['retCode']} {r['retMsg']}")

    def watch(self, poll_sec: float = 2.0, timeout_sec: float = 3600) -> str | None:
        """どちらかが発動するまで監視し、発動した側のtagを返す。"""
        deadline = time.time() + timeout_sec

        while time.time() < deadline:
            still_open = {leg.link_id: self._is_open(leg.link_id)
                          for leg in self.legs}

            triggered = [lid for lid, open_ in still_open.items() if not open_]
            if triggered:
                fired = triggered[0]
                print(f"[oco] {fired} が発動しました")
                for leg in self.legs:
                    if leg.link_id != fired and still_open[leg.link_id]:
                        self._cancel(leg.link_id)
                return fired

            time.sleep(poll_sec)

        print("[oco] タイムアウト。両方をキャンセルします")
        for leg in self.legs:
            self._cancel(leg.link_id)
        return None


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    last = Decimal(
        c.http.get_tickers(category="linear", symbol="BTCUSDT")["result"]["list"][0]["lastPrice"]
    )

    oco = PseudoOco(c.http, "BTCUSDT")
    oco.place(upper=last * Decimal("1.005"), lower=last * Decimal("0.995"),
              qty=Decimal("0.001"))
    oco.watch(poll_sec=2.0, timeout_sec=600)
ポーリングよりWebSocketが正解

上の実装は分かりやすさのためRESTポーリングにしていますが、実運用ではprivate WebSocketの order トピックで発動を即座に受け取るべきです。ポーリング間隔の分だけ、反対側のキャンセルが遅れます(その間に両方発動する可能性があります)。イベント駆動版は記事27で扱います。

現物のTP/SLは仕組みが違う

set_trading_stopは現物では使えない

set_trading_stop の category に指定できるのは linear / inverse / option です。現物(spot)には「ポジション」という概念がないため、この方法は使えません。

現物でTP/SL相当のことをするには、place_order で orderFilter="tpslOrder" を指定した注文、または条件注文(triggerPrice 付き)を自分で管理します。上限は条件注文・TP/SL・通常注文の合計で500件(170810)です。現物の積立やグリッドを組む場合はこの違いを前提に設計してください(記事36)。

TP/SL関連のエラーコード

retCode意味原因と対処
34040値が既に設定済み/必須パラメータ不足同じ値の再設定なら実質成功として無視してよい。パラメータ不足の可能性もあるので、初回設定時は区別する
110033ポジションが無いset_trading_stop はポジション保有中のみ有効。約定を待ってから呼ぶ
110054
110055
110056
既存のTP/SL/トレールがありモード切替できない先に "0" を設定して解除してから切り替える
110057条件注文・指値注文にTP/SLパラメータが含まれている組み合わせられない指定をしている。条件注文とTP/SLを分けて設定する
110058残ポジション量が不足Partialの各段の数量合計がポジション量を超えている。合計比率を1以下に
110059部分約定済み注文のTP/SLは変更不可注文ではなくポジション側に set_trading_stop で設定する
110060Fullモードでは変更できない数量指定の変更をしようとしている。Partialに切り替える
110061Partialモードは20件まで階段の段数を減らす、または既存の設定を整理する
110003価格が許容範囲外TP/SLが現在価格の逆側にある、または離れすぎている

実運用での注意点

1. 「TP/SLがある」を前提にしない

取引所側のTP/SLは便利ですが、それだけに依存するのは危険です。設定が消える、モード切替で外れる、部分決済で数量が合わなくなる——いずれも実際に起きます。サンプル7のような監査・修復ループを別プロセスで常時動かし、「損切のないポジションは存在しない」を機械的に保証してください。

2. set_trading_stopのレート制限

set_trading_stop の上限は毎秒10リクエストが目安です。自作トレールを短い間隔で回すと、この上限に当たります。SLが実際に改善するときだけ更新する(サンプル5の improved 判定)ことで、リクエスト数を大幅に減らせます。レート制限の詳細は記事05で扱います。

3. バックテストでの再現

TP/SLをバックテストで再現する際、同じ足でTPとSLの両方に触れた場合の扱いが問題になります。OHLCデータだけでは「どちらが先に付いたか」が分かりません。必ず不利な側(SL)を先に約定させるのが正しい方針です。有利側を選ぶと成績が体系的に過大になります(記事31)。

参考リンク(一次情報)

よくある疑問Q&A

TP/SLは板に見えますか?他のトレーダーに読まれませんか?
Fullモードのポジション単位のTP/SLは、トリガーされるまで板には出ません(トリガー後に成行または指値として執行されます)。したがって発動前に他者から見えることはありません。ただしキリのいい価格に置くと、多くの参加者のストップが集まる水準になるという一般的な問題は残ります。ATRなど相場のボラティリティに基づいて置くほうが、集中を避けやすくなります。
ポジションを追加したらTP/SLはどうなりますか?
Fullモードのままなら、TP/SLは新しいポジション全量に適用されます。ただし建値(avgPrice)が変わるため、建値基準で計算していたTP/SL幅は意図とズレます。増し玉する戦略では、約定のたびに avgPrice を再取得してTP/SLを再計算・再設定してください(サンプル2の関数を約定イベントで呼ぶ形にします)。
SLが発動したのに約定価格が指定より悪いです
正常な挙動です。SLはトリガー価格に達した時点で成行注文が発注される仕組み(Fullモードは成行のみ)なので、そこから板を食って約定します。急変時には指定価格から大きく離れます。これを抑えたいなら、Partialモードで slOrderType="Limit" と slLimitPrice を使う方法がありますが、その場合は指値が約定せず損切できないリスクと引き換えになります。どちらのリスクを取るかの選択です。
tpSizeとslSizeを違う値にしたいのですが
Partialモードでは同じ値である必要があります。「利確は30%、損切は全量」をやりたい場合は、Partialで30%分のTP/SLペアを設定し、全量の損切は別途(Fullモードのポジション単位SL、あるいは条件注文)で管理する、という組み合わせになります。設計が複雑になるため、まずは「TP/SLは同数量ペア」という制約の中で戦略を組めないか検討してください。
トレーリングストップの距離は%で指定できませんか?
trailingStop は価格の距離で指定します。%で管理したい場合は、現在価格に%を掛けて価格差に変換してから渡してください。BTCで「1%」なら約1,180USDT(価格118,000のとき)です。価格水準が変わると同じ距離の意味が変わるため、長期保有するポジションでは定期的に再計算する必要があります。ATR基準(サンプル5)にするとこの問題が自然に解消されます。
ヘッジモードでTP/SLの設定が通りません
ヘッジモードでは positionIdx(1=Buy側、2=Sell側)の指定が必須です。省略するとどちらのポジションに設定すべきか決まらず失敗します。サンプル2以降のコードは get_positions() のレスポンスから positionIdx を引き継ぐ実装にしてあります。ポジションモードの扱いは記事20で詳しく扱います。
TP/SLの設定をWebSocketで確認できますか?
はい。private WebSocketの position トピックを購読すると、ポジションの変化に含めて takeProfit / stopLoss / trailingStop の現在値が届きます。RESTポーリングよりレート制限にやさしく、変化を即座に検知できるため、サンプル7のガーディアンをWebSocketベースに置き換えるのが実運用の正解です(記事27)。

注意事項・免責事項

投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。

当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。

当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。

当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。

当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。

当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。

当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。