Bybit APIの条件付き注文(ストップ・逆指値)実装ガイド
triggerBy / triggerDirection

triggerPrice を足すだけで条件注文になる——仕様上はそうですが、実装ではtriggerDirection の指定ミスで即エラー、あるいは意図と逆方向で発火します。さらに銘柄あたり10件の上限、トリガー後の指値が約定しないリスクという2つの落とし穴があります。この記事ではブレイクアウトbotを題材に、サンプルコード8本で条件注文を実務水準まで作り込みます。

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

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

目次

  1. 条件注文の仕組みと通常注文との違い
  2. triggerDirectionを自動判定する
  3. triggerByの選択とヒゲ誤発火
  4. 条件注文の一覧取得とキャンセル
  5. 10件上限とキュー管理
  6. ドンチャンブレイクアウトbot
  7. トリガー後の未約定フォールバック
  8. WebSocketで発火を検知する
  9. よくある疑問Q&A

条件注文の仕組みと通常注文との違い

条件注文(Conditional Order)はトリガー価格に到達するまで板に乗りません。取引所側で待機し、条件成立の瞬間に通常注文として板に投入されます。

項目通常注文条件注文
板への表示 即時に乗る(指値の場合) トリガーまで乗らない
証拠金の拘束 発注時に拘束 トリガー時に判定。資金不足なら発火しても失敗する
必須パラメータ category / symbol / side / orderType / qty 上記 + triggerPrice + triggerDirection
件数上限 未約定注文 500件/銘柄 10件/銘柄(別枠)
一覧取得 get_open_orders() 既定 orderFilter="StopOrder" が必要
対応カテゴリ linear / inverse / spot / option linear / inverse / spot(optionは非対応)
条件注文で最も多い事故

証拠金は発注時ではなくトリガー時に判定されます。「損切りの条件注文を置いてあるから安心」と考えていても、その時点で証拠金が不足していれば発火しても注文が通りません。条件注文をリスク管理の最終防衛線にしないでください。最終防衛線は記事14のポジション紐付けTP/SLと、記事20の自前監視の二重化です。

conditional_basic.pyサンプル1
"""conditional_basic.py — 条件付き成行・条件付き指値の発注
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  Stop Market : orderType="Market" + triggerPrice
                → 発火したら成行。必ず約定するが価格は保証されない。
  Stop Limit  : orderType="Limit" + price + triggerPrice
                → 発火したら指値。価格は守れるが約定しないことがある。

  triggerDirection:
    1 = 現在価格が triggerPrice まで「上昇」したら発火
    2 = 現在価格が triggerPrice まで「下落」したら発火
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

from client import make_client
from symbol_rules import fetch_rules


@dataclass(frozen=True)
class ConditionalResult:
    ok: bool
    order_id: str = ""
    order_link_id: str = ""
    ret_code: int = 0
    ret_msg: str = ""

    def line(self) -> str:
        if self.ok:
            return f"OK orderId={self.order_id[:12]}... link={self.order_link_id}"
        return f"NG retCode={self.ret_code} {self.ret_msg}"


def place_stop_market(http, symbol: str, side: str, qty: Decimal,
                      trigger_price: Decimal, trigger_direction: int,
                      *, category: str = "linear",
                      trigger_by: str = "LastPrice",
                      reduce_only: bool = False,
                      order_link_id: str = "") -> ConditionalResult:
    """条件付き成行注文(Stop Market)。"""
    rules = fetch_rules(http, symbol, category)
    params = {
        "category": category,
        "symbol": symbol,
        "side": side,
        "orderType": "Market",
        "qty": rules.format_qty(qty),
        "triggerPrice": rules.format_price(trigger_price),
        "triggerDirection": trigger_direction,
        "triggerBy": trigger_by,
        # orderFilter は linear/inverse では省略可(自動でStopOrder扱い)
        # spot では明示的に "StopOrder" が必要
    }
    if category == "spot":
        params["orderFilter"] = "StopOrder"
    if reduce_only:
        params["reduceOnly"] = True
    if order_link_id:
        params["orderLinkId"] = order_link_id

    r = http.place_order(**params)
    if r["retCode"] != 0:
        return ConditionalResult(False, ret_code=r["retCode"],
                                 ret_msg=r["retMsg"])
    return ConditionalResult(True,
                             order_id=r["result"]["orderId"],
                             order_link_id=r["result"].get("orderLinkId", ""))


def place_stop_limit(http, symbol: str, side: str, qty: Decimal,
                     trigger_price: Decimal, limit_price: Decimal,
                     trigger_direction: int, *,
                     category: str = "linear",
                     trigger_by: str = "LastPrice",
                     time_in_force: str = "GTC",
                     reduce_only: bool = False,
                     order_link_id: str = "") -> ConditionalResult:
    """条件付き指値注文(Stop Limit)。

    limit_price は trigger_price より不利な側に置くのが定石。
    買いブレイクなら trigger より少し上、売りなら少し下。
    同値に置くと、発火した瞬間には既に価格が通り過ぎていて約定しない。
    """
    rules = fetch_rules(http, symbol, category)
    params = {
        "category": category,
        "symbol": symbol,
        "side": side,
        "orderType": "Limit",
        "qty": rules.format_qty(qty),
        "price": rules.format_price(limit_price),
        "triggerPrice": rules.format_price(trigger_price),
        "triggerDirection": trigger_direction,
        "triggerBy": trigger_by,
        "timeInForce": time_in_force,
    }
    if category == "spot":
        params["orderFilter"] = "StopOrder"
    if reduce_only:
        params["reduceOnly"] = True
    if order_link_id:
        params["orderLinkId"] = order_link_id

    r = http.place_order(**params)
    if r["retCode"] != 0:
        return ConditionalResult(False, ret_code=r["retCode"],
                                 ret_msg=r["retMsg"])
    return ConditionalResult(True,
                             order_id=r["result"]["orderId"],
                             order_link_id=r["result"].get("orderLinkId", ""))


if __name__ == "__main__":
    c = make_client()   # 既定は testnet=True(記事04)
    symbol = "BTCUSDT"

    r = c.http.get_tickers(category="linear", symbol=symbol)
    last = Decimal(r["result"]["list"][0]["lastPrice"])
    print(f"現在価格: {last}")

    # 上ブレイクで買い(現在価格の +2%)
    up = last * Decimal("1.02")
    res = place_stop_market(c.http, symbol, "Buy", Decimal("0.001"),
                            up, trigger_direction=1)
    print(f"[Stop Market 買い @{up:.1f}] {res.line()}")

    # 下抜けで売り(現在価格の −2%)— 指値は trigger の少し下に置く
    down = last * Decimal("0.98")
    res = place_stop_limit(c.http, symbol, "Sell", Decimal("0.001"),
                           trigger_price=down,
                           limit_price=down * Decimal("0.999"),
                           trigger_direction=2)
    print(f"[Stop Limit 売り @{down:.1f}] {res.line()}")

    print("\n※ triggerDirection を間違えると retCode=10001 で拒否されるか、")
    print("  条件によっては即座に発火する。次節の自動判定を使うこと。")

triggerDirectionを自動判定する

triggerDirection は「現在価格から見てトリガー価格がどちら側にあるか」で機械的に決まります。手で指定する理由はありません。

trigger_direction.pyサンプル2
"""trigger_direction.py — triggerDirectionの自動判定と意図の検証
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  triggerDirection は現在価格との位置関係で一意に決まる:
    trigger > current  → 1(上昇して到達)
    trigger < current  → 2(下落して到達)

  さらに「その組み合わせが意図した戦略と整合しているか」も検証する。
  例: ロングの損切りは「下落して発火するSell」でなければおかしい。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from enum import Enum


class Intent(str, Enum):
    BREAKOUT_LONG = "上ブレイク買い"
    BREAKOUT_SHORT = "下ブレイク売り"
    STOP_LONG = "ロング損切り"
    STOP_SHORT = "ショート損切り"
    LIMIT_LONG = "押し目買い"
    LIMIT_SHORT = "戻り売り"


@dataclass(frozen=True)
class TriggerSpec:
    direction: int
    side: str
    reduce_only: bool
    intent: Intent

    def describe(self) -> str:
        arrow = "↑" if self.direction == 1 else "↓"
        ro = " (reduceOnly)" if self.reduce_only else ""
        return f"{self.intent.value}: {arrow}発火 / {self.side}{ro}"


def resolve_direction(current: Decimal, trigger: Decimal) -> int:
    """triggerDirection を機械的に決める。"""
    if trigger == current:
        raise ValueError(
            "triggerPrice が現在価格と同値です。"
            "即発火するか拒否されます(10001)")
    return 1 if trigger > current else 2


def build_spec(intent: Intent, current: Decimal,
               trigger: Decimal) -> TriggerSpec:
    """意図から side / reduceOnly / direction を導き、整合性を検証する。"""
    direction = resolve_direction(current, trigger)

    table = {
        # intent: (side, reduce_only, 期待するdirection)
        Intent.BREAKOUT_LONG:  ("Buy",  False, 1),
        Intent.BREAKOUT_SHORT: ("Sell", False, 2),
        Intent.STOP_LONG:      ("Sell", True,  2),
        Intent.STOP_SHORT:     ("Buy",  True,  1),
        Intent.LIMIT_LONG:     ("Buy",  False, 2),
        Intent.LIMIT_SHORT:    ("Sell", False, 1),
    }
    side, reduce_only, expected = table[intent]

    if direction != expected:
        raise ValueError(
            f"{intent.value} なのに triggerPrice({trigger}) が "
            f"現在価格({current}) の"
            f"{'上' if direction == 1 else '下'}にあります。"
            f"意図と価格の関係が矛盾しています")

    return TriggerSpec(direction, side, reduce_only, intent)


def validate_stop_limit(trigger: Decimal, limit: Decimal,
                        direction: int, side: str,
                        tolerance_bps: float = 1.0) -> list[str]:
    """Stop Limitの指値位置が現実的かを検査し、警告を返す。"""
    warnings: list[str] = []
    gap_bps = abs(limit - trigger) / trigger * Decimal("10000")

    if trigger == limit:
        warnings.append(
            "指値がトリガーと同値です。発火時には既に通過している可能性が高く、"
            "約定しないまま板に残ります")
    elif gap_bps < Decimal(str(tolerance_bps)):
        warnings.append(
            f"指値とトリガーの差が {gap_bps:.2f}bps しかありません。"
            "急変時は約定しない可能性があります")

    # 買いブレイク(direction=1, Buy)なら limit >= trigger であるべき
    if direction == 1 and side == "Buy" and limit < trigger:
        warnings.append(
            "上昇ブレイクの買いなのに、指値がトリガーより下にあります。"
            "発火時点で価格はトリガー以上なので約定しません")
    if direction == 2 and side == "Sell" and limit > trigger:
        warnings.append(
            "下落ブレイクの売りなのに、指値がトリガーより上にあります。"
            "発火時点で価格はトリガー以下なので約定しません")

    return warnings


if __name__ == "__main__":
    current = Decimal("120000")

    print(f"現在価格: {current}\n")
    cases = [
        (Intent.BREAKOUT_LONG,  Decimal("123000")),
        (Intent.BREAKOUT_SHORT, Decimal("117000")),
        (Intent.STOP_LONG,      Decimal("118000")),
        (Intent.STOP_SHORT,     Decimal("122000")),
        (Intent.LIMIT_LONG,     Decimal("116000")),
    ]
    for intent, trigger in cases:
        spec = build_spec(intent, current, trigger)
        print(f"  trigger={trigger}  {spec.describe()}")

    print("\n=== 意図と価格が矛盾する例 ===")
    try:
        build_spec(Intent.STOP_LONG, current, Decimal("125000"))
    except ValueError as e:
        print(f"  弾いた: {e}")

    print("\n=== Stop Limit の指値位置の検査 ===")
    for limit in (Decimal("123000"), Decimal("122900"), Decimal("123100")):
        w = validate_stop_limit(Decimal("123000"), limit, 1, "Buy")
        status = "問題なし" if not w else " / ".join(w)
        print(f"  trigger=123000 limit={limit}: {status}")

triggerByの選択とヒゲ誤発火

triggerBy は LastPrice / MarkPrice / IndexPrice の3つから選べます。この選択が誤発火の頻度を数倍変えるため、実データで比較します。

triggerBy性質誤発火推奨用途
LastPrice Bybit上の最終約定価格。既定値 多い。薄い時間帯のヒゲを拾う ブレイクアウトのエントリー
MarkPrice 清算判定に使う公正価格。平滑化されている 少ない 損切り(清算と同じ基準で揃う)
IndexPrice 複数現物取引所の加重平均 最も少ない 市場全体の水準を条件にしたい場合
trigger_by_compare.pyサンプル3
"""trigger_by_compare.py — triggerByによる誤発火の差を実データで測る
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  「LastPriceだとヒゲで刈られる」はよく言われるが、
  実際にどれくらい違うのかを数えたことのある人は少ない。
  過去のKlineで、各基準が何回トリガーを跨いだかを数える。
"""
from __future__ import annotations

from dataclasses import dataclass

import pandas as pd

from client import make_client
from kline_fetch import fetch_klines


@dataclass
class TriggerStats:
    source: str
    crossings: int
    false_crossings: int      # 跨いだが、N本後には元の側に戻っていた
    bars: int

    @property
    def false_rate(self) -> float:
        return self.false_crossings / self.crossings if self.crossings else 0.0

    def line(self) -> str:
        return (f"{self.source:<12}"
                f"発火 {self.crossings:>4}回  "
                f"うち戻り {self.false_crossings:>4}回  "
                f"({self.false_rate:>6.1%})")


def fetch_price_sources(http, symbol: str, interval: str,
                        start: str) -> dict[str, pd.DataFrame]:
    """Last / Mark / Index の3系列を取得する。

    Klineには専用エンドポイントが別々にある:
      get_kline           → 約定ベース(LastPrice相当)
      get_mark_price_kline→ マーク価格
      get_index_price_kline→ インデックス価格
    """
    out = {}
    out["LastPrice"] = fetch_klines(http, symbol, interval=interval,
                                    start=start, verbose=False)

    for name, fn in (("MarkPrice", http.get_mark_price_kline),
                     ("IndexPrice", http.get_index_price_kline)):
        try:
            out[name] = fetch_klines(http, symbol, interval=interval,
                                     start=start, verbose=False,
                                     fetcher=fn)
        except Exception as e:
            print(f"  [warn] {name} 取得失敗: {e}")
    return out


def count_crossings(bars: pd.DataFrame, level: float, direction: int,
                    revert_bars: int = 3) -> tuple[int, int]:
    """トリガー水準を跨いだ回数と、そのうち戻った回数を数える。

    direction=1: 高値が level 以上になったら発火
    direction=2: 安値が level 以下になったら発火
    """
    if direction == 1:
        hit = bars["high"] >= level
        reverted = bars["close"].shift(-revert_bars) < level
    else:
        hit = bars["low"] <= level
        reverted = bars["close"].shift(-revert_bars) > level

    # 連続した発火は1回として数える(同じ動きの継続)
    new_hit = hit & ~hit.shift(1, fill_value=False)
    crossings = int(new_hit.sum())
    false_hits = int((new_hit & reverted.fillna(False)).sum())
    return crossings, false_hits


def compare_sources(symbol: str = "BTCUSDT", interval: str = "5",
                    start: str = "2026-06-01",
                    offset_pct: float = 1.0,
                    direction: int = 2) -> list[TriggerStats]:
    """各価格ソースで、同じ%水準に置いた条件注文の発火回数を比較する。"""
    c = make_client()
    sources = fetch_price_sources(c.http, symbol, interval, start)

    base = sources["LastPrice"]
    anchor = float(base["close"].iloc[0])
    level = (anchor * (1 - offset_pct / 100) if direction == 2
             else anchor * (1 + offset_pct / 100))

    print(f"基準価格 {anchor:,.1f} → トリガー水準 {level:,.1f} "
          f"({'−' if direction == 2 else '+'}{offset_pct}%)\n")

    stats = []
    for name, df in sources.items():
        cross, false_ = count_crossings(df, level, direction)
        stats.append(TriggerStats(name, cross, false_, len(df)))
    return stats


if __name__ == "__main__":
    print("=== triggerBy別の発火回数比較(下方向のストップ) ===\n")
    for s in compare_sources(direction=2):
        print("  " + s.line())

    print("\n【解釈】")
    print("  『戻り』が多いほど、実質的にヒゲで刈られている。")
    print("  MarkPrice / IndexPrice は平滑化されているため、")
    print("  同じ水準でも発火回数が少なくなる傾向がある。")
    print("\n※ ただし発火が少ない = 良いとは限らない。")
    print("  損切りが遅れれば損失は大きくなる。")
    print("  『何を守りたいか』で選ぶこと:")
    print("    清算を避けたい      → MarkPrice(清算判定と同じ基準)")
    print("    実勢価格で切りたい  → LastPrice")
Klineの取得元を切り替える

サンプル3は fetch_klines() に fetcher= 引数を渡して取得元を差し替えています。記事07の fetch_klines() がこの引数に対応していない場合は、fetcher: Callable = None を追加し、None のとき http.get_kline を使うようにしてください。マーク価格・インデックス価格のKlineは出来高フィールドを持たない点にも注意します。

条件注文の一覧取得とキャンセル

get_open_orders() を素で呼ぶと条件注文が返ってきません。orderFilter を明示する必要があります。この仕様を知らないと「置いたはずの注文が消えた」と誤解します。

conditional_query.pyサンプル4
"""conditional_query.py — 条件注文の一覧取得・分類・一括キャンセル
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  orderFilter の値(linear/inverse/spot):
    "Order"      : 通常注文(既定)
    "StopOrder"  : 条件注文(ポジションに紐付かない独立した条件注文)
    "tpslOrder"  : ポジションに紐付くTP/SL(記事14の set_trading_stop 由来)

  つまり get_open_orders() を素で呼ぶと "Order" しか返らない。
  「注文が消えた」の大半はこれが原因。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

from client import make_client

ORDER_FILTERS = ("Order", "StopOrder", "tpslOrder")


@dataclass(frozen=True)
class OpenOrder:
    order_id: str
    order_link_id: str
    symbol: str
    side: str
    order_type: str
    qty: Decimal
    price: Decimal
    trigger_price: Decimal
    trigger_direction: int
    trigger_by: str
    reduce_only: bool
    status: str
    order_filter: str

    @property
    def is_conditional(self) -> bool:
        return self.trigger_price > 0

    def line(self) -> str:
        if self.is_conditional:
            arrow = "↑" if self.trigger_direction == 1 else "↓"
            trig = f" trig{arrow}{self.trigger_price} ({self.trigger_by})"
        else:
            trig = ""
        ro = " RO" if self.reduce_only else ""
        px = f"@{self.price}" if self.price > 0 else "@Market"
        return (f"[{self.order_filter:<10}] {self.symbol} {self.side} "
                f"{self.qty}{px}{trig}{ro} <{self.status}>")


def _to_decimal(value) -> Decimal:
    try:
        return Decimal(str(value)) if value not in (None, "") else Decimal(0)
    except Exception:
        return Decimal(0)


def fetch_open_orders(http, *, category: str = "linear",
                      symbol: str = "",
                      order_filter: str = "Order") -> list[OpenOrder]:
    """指定フィルタの未約定注文を全ページ取得する。"""
    orders: list[OpenOrder] = []
    cursor = ""

    while True:
        params = {"category": category, "limit": 50,
                  "orderFilter": order_filter}
        if symbol:
            params["symbol"] = symbol
        elif category in ("linear", "inverse"):
            # symbol 省略時は settleCoin か baseCoin が必要
            params["settleCoin"] = "USDT"
        if cursor:
            params["cursor"] = cursor

        r = http.get_open_orders(**params)
        if r["retCode"] != 0:
            raise RuntimeError(
                f"get_open_orders失敗 ({order_filter}): "
                f"{r['retCode']} {r['retMsg']}")

        for o in r["result"]["list"]:
            orders.append(OpenOrder(
                order_id=o["orderId"],
                order_link_id=o.get("orderLinkId", ""),
                symbol=o["symbol"],
                side=o["side"],
                order_type=o["orderType"],
                qty=_to_decimal(o.get("qty")),
                price=_to_decimal(o.get("price")),
                trigger_price=_to_decimal(o.get("triggerPrice")),
                trigger_direction=int(o.get("triggerDirection") or 0),
                trigger_by=o.get("triggerBy", ""),
                reduce_only=bool(o.get("reduceOnly")),
                status=o.get("orderStatus", ""),
                order_filter=order_filter,
            ))

        cursor = r["result"].get("nextPageCursor", "")
        if not cursor:
            break

    return orders


def fetch_all_kinds(http, *, category: str = "linear",
                    symbol: str = "") -> dict[str, list[OpenOrder]]:
    """3種類すべてを取得して辞書で返す。"""
    out: dict[str, list[OpenOrder]] = {}
    for f in ORDER_FILTERS:
        if category == "spot" and f == "tpslOrder":
            continue    # spot は tpslOrder 非対応
        try:
            out[f] = fetch_open_orders(http, category=category,
                                       symbol=symbol, order_filter=f)
        except RuntimeError as e:
            print(f"  [warn] {e}")
            out[f] = []
    return out


def cancel_conditional(http, symbol: str, *,
                       category: str = "linear") -> tuple[int, list[str]]:
    """条件注文だけを一括キャンセルする。

    cancel_all_orders() に orderFilter を渡すと種類を絞れる。
    渡さないと「通常注文だけ」が消え、条件注文が残る。
    """
    r = http.cancel_all_orders(category=category, symbol=symbol,
                               orderFilter="StopOrder")
    if r["retCode"] != 0:
        raise RuntimeError(f"一括キャンセル失敗: {r['retCode']} {r['retMsg']}")
    ids = [o["orderId"] for o in r["result"].get("list", [])]
    return len(ids), ids


if __name__ == "__main__":
    c = make_client()
    symbol = "BTCUSDT"

    kinds = fetch_all_kinds(c.http, symbol=symbol)

    total = sum(len(v) for v in kinds.values())
    print(f"=== {symbol} の未約定注文 合計 {total}件 ===\n")

    for name, orders in kinds.items():
        print(f"[orderFilter={name}] {len(orders)}件")
        for o in orders:
            print("   " + o.line())
        if not orders:
            print("   (なし)")
        print()

    print("※ get_open_orders() を素で呼ぶと orderFilter='Order' 相当で、")
    print("  条件注文とTP/SLは見えない。監視スクリプトで")
    print("  『注文が無い』と誤判定しないよう、3種類すべてを見ること。")

    # n, ids = cancel_conditional(c.http, symbol)
    # print(f"条件注文 {n}件をキャンセルしました")

10件上限とキュー管理

条件注文は銘柄あたり10件という別枠の上限を持ちます(通常の未約定注文500件とは独立)。グリッド的に多数の水準を仕込む設計は、この上限に必ずぶつかります。

conditional_queue.pyサンプル5
"""conditional_queue.py — 条件注文10件上限を守るキュー管理
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  上限に達すると retCode=10001 系のエラーで拒否される。
  「エラーが出たら諦める」ではなく、
  『現在価格に近い順に10件だけ板に出し、残りは待機させる』設計にする。

  価格が動いたら、待機列と発注済みを入れ替える(リバランス)。
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field
from decimal import Decimal

from client import make_client
from conditional_basic import place_stop_market
from conditional_query import fetch_open_orders

MAX_CONDITIONAL_PER_SYMBOL = 10


@dataclass
class PendingTrigger:
    """まだ発注していない条件注文の予定。"""
    symbol: str
    side: str
    qty: Decimal
    trigger_price: Decimal
    trigger_direction: int
    tag: str
    order_id: str = ""          # 発注済みならID が入る
    order_link_id: str = ""

    @property
    def is_live(self) -> bool:
        return bool(self.order_id)

    def distance_bps(self, current: Decimal) -> Decimal:
        return abs(self.trigger_price - current) / current * Decimal("10000")


@dataclass
class ConditionalQueue:
    """条件注文の待機列。上限を超えないよう自動で出し入れする。"""
    http: object
    symbol: str
    category: str = "linear"
    max_live: int = MAX_CONDITIONAL_PER_SYMBOL
    reserve: int = 2            # 損切り用に確保しておく枠
    items: list[PendingTrigger] = field(default_factory=list)

    @property
    def capacity(self) -> int:
        return max(0, self.max_live - self.reserve)

    def add(self, item: PendingTrigger) -> None:
        self.items.append(item)

    def _current_price(self) -> Decimal:
        r = self.http.get_tickers(category=self.category, symbol=self.symbol)
        return Decimal(r["result"]["list"][0]["lastPrice"])

    def _cancel(self, item: PendingTrigger) -> None:
        r = self.http.cancel_order(category=self.category,
                                   symbol=self.symbol,
                                   orderId=item.order_id)
        if r["retCode"] not in (0, 110001):   # 110001: 注文が存在しない
            print(f"  [warn] キャンセル失敗 {item.tag}: "
                  f"{r['retCode']} {r['retMsg']}")
        item.order_id = ""

    def _place(self, item: PendingTrigger) -> None:
        link = f"cq-{item.tag}-{int(time.time() * 1000) % 1_000_000}"
        res = place_stop_market(
            self.http, self.symbol, item.side, item.qty,
            item.trigger_price, item.trigger_direction,
            category=self.category, order_link_id=link)
        if res.ok:
            item.order_id = res.order_id
            item.order_link_id = res.order_link_id or link
        else:
            print(f"  [warn] 発注失敗 {item.tag}: {res.line()}")

    def sync(self, verbose: bool = True) -> dict:
        """現在価格に近い順に capacity 件だけ板に出す。"""
        current = self._current_price()

        ranked = sorted(self.items, key=lambda i: i.distance_bps(current))
        should_live = set(id(i) for i in ranked[:self.capacity])

        placed, cancelled = 0, 0
        for item in self.items:
            want = id(item) in should_live
            if want and not item.is_live:
                self._place(item)
                placed += 1
            elif not want and item.is_live:
                self._cancel(item)
                cancelled += 1

        live = sum(1 for i in self.items if i.is_live)
        if verbose:
            print(f"  現在価格 {current} → 発注 {placed} / 取消 {cancelled} "
                  f"/ 板上 {live}件(枠 {self.capacity})")
        return {"current": current, "placed": placed,
                "cancelled": cancelled, "live": live}

    def verify_against_exchange(self) -> list[str]:
        """取引所側の実数と手元の状態が一致しているか検証する。"""
        remote = fetch_open_orders(self.http, category=self.category,
                                   symbol=self.symbol,
                                   order_filter="StopOrder")
        remote_ids = {o.order_id for o in remote}
        local_ids = {i.order_id for i in self.items if i.is_live}

        problems = []
        for oid in local_ids - remote_ids:
            problems.append(f"手元では板上だが取引所に無い: {oid[:12]}...")
        for oid in remote_ids - local_ids:
            problems.append(f"取引所にあるが手元で未把握: {oid[:12]}...")
        if len(remote) > self.max_live:
            problems.append(
                f"条件注文が上限を超えている: {len(remote)} > {self.max_live}")
        return problems


if __name__ == "__main__":
    c = make_client()
    symbol = "BTCUSDT"

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

    q = ConditionalQueue(c.http, symbol)

    # ±0.5% 刻みで 上下 8 水準ずつ、合計16件を「予定」として登録
    for k in range(1, 9):
        up = last * (Decimal(1) + Decimal("0.005") * k)
        dn = last * (Decimal(1) - Decimal("0.005") * k)
        q.add(PendingTrigger(symbol, "Buy", Decimal("0.001"), up, 1, f"up{k}"))
        q.add(PendingTrigger(symbol, "Sell", Decimal("0.001"), dn, 2, f"dn{k}"))

    print(f"=== 予定 {len(q.items)}件 / 板に出せるのは {q.capacity}件 ===")
    q.sync()

    print("\n=== 取引所との整合性チェック ===")
    problems = q.verify_against_exchange()
    print("  問題なし" if not problems else "\n".join("  " + p for p in problems))

    print("\n※ reserve=2 は損切り用の枠。")
    print("  エントリー用の条件注文で10件すべてを埋めると、")
    print("  いざ損切りを置きたいときに発注できなくなる。")

ドンチャンブレイクアウトbot

breakout_bot.pyサンプル6
"""breakout_bot.py — ドンチャン高値/安値ブレイクの条件注文を毎足置き直す
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  設計の要点:
    - 毎足キャンセル+再発注ではなく amend_order で「価格だけ更新」する。
      レート消費が半分になり、注文IDが保たれるので追跡が単純になる。
    - ポジションを持っている間はエントリー注文を出さない。
    - ボラティリティ(記事12)でトリガーに余裕幅を持たせ、
      同値ブレイクの誤発火を減らす。
"""
from __future__ import annotations

import time
from dataclasses import dataclass
from decimal import Decimal

import pandas as pd

from client import make_client
from conditional_basic import place_stop_market
from conditional_query import fetch_open_orders
from kline_fetch import fetch_klines
from symbol_rules import fetch_rules


@dataclass(frozen=True)
class BreakoutConfig:
    symbol: str = "BTCUSDT"
    category: str = "linear"
    interval: str = "60"
    donchian: int = 20            # 高値/安値の参照本数
    buffer_atr: float = 0.25      # ATRの何倍を余裕幅にするか
    atr_period: int = 14
    qty: Decimal = Decimal("0.001")
    poll_sec: int = 60
    link_prefix: str = "brk"


@dataclass
class BreakoutState:
    long_order_id: str = ""
    short_order_id: str = ""
    long_trigger: Decimal = Decimal(0)
    short_trigger: Decimal = Decimal(0)


def compute_levels(bars: pd.DataFrame,
                   cfg: BreakoutConfig) -> tuple[Decimal, Decimal]:
    """ドンチャン水準にATRの余裕幅を足したトリガー価格を返す。"""
    from atr_tools import atr    # 記事12のサンプル6

    recent = bars.iloc[-(cfg.donchian + 1):-1]     # 未確定足を除外
    high = float(recent["high"].max())
    low = float(recent["low"].min())
    a = float(atr(bars, cfg.atr_period).iloc[-2])  # 確定足のATR

    up = Decimal(str(high + a * cfg.buffer_atr))
    dn = Decimal(str(low - a * cfg.buffer_atr))
    return up, dn


def has_position(http, cfg: BreakoutConfig) -> bool:
    r = http.get_positions(category=cfg.category, symbol=cfg.symbol)
    if r["retCode"] != 0:
        raise RuntimeError(f"get_positions失敗: {r['retCode']} {r['retMsg']}")
    return any(Decimal(p["size"] or "0") > 0 for p in r["result"]["list"])


def amend_trigger(http, cfg: BreakoutConfig, order_id: str,
                  new_trigger: Decimal) -> bool:
    """条件注文のトリガー価格だけを更新する。

    amend_order は triggerPrice の変更に対応している。
    キャンセル+再発注より高速で、レート消費も少ない。
    """
    rules = fetch_rules(http, cfg.symbol, cfg.category)
    r = http.amend_order(category=cfg.category, symbol=cfg.symbol,
                         orderId=order_id,
                         triggerPrice=rules.format_price(new_trigger))
    if r["retCode"] == 0:
        return True
    # 110001: 注文が存在しない(既に発火・キャンセル済み)
    if r["retCode"] in (110001, 110004):
        return False
    print(f"  [warn] amend失敗: {r['retCode']} {r['retMsg']}")
    return False


def cancel_all_entries(http, cfg: BreakoutConfig, state: BreakoutState) -> None:
    for attr in ("long_order_id", "short_order_id"):
        oid = getattr(state, attr)
        if not oid:
            continue
        r = http.cancel_order(category=cfg.category, symbol=cfg.symbol,
                              orderId=oid)
        if r["retCode"] not in (0, 110001):
            print(f"  [warn] キャンセル失敗: {r['retCode']} {r['retMsg']}")
        setattr(state, attr, "")


def step(http, cfg: BreakoutConfig, state: BreakoutState) -> None:
    """1周分の処理。"""
    bars = fetch_klines(http, cfg.symbol, interval=cfg.interval,
                        limit_bars=cfg.donchian + cfg.atr_period + 10,
                        verbose=False)
    up, dn = compute_levels(bars, cfg)
    state.long_trigger, state.short_trigger = up, dn

    if has_position(http, cfg):
        # ポジション保有中はエントリー注文を全て引き上げる
        if state.long_order_id or state.short_order_id:
            print("  ポジション保有中 → エントリー注文を撤去")
            cancel_all_entries(http, cfg, state)
        return

    stamp = int(time.time())
    for attr, side, trigger, direction in (
            ("long_order_id", "Buy", up, 1),
            ("short_order_id", "Sell", dn, 2)):
        oid = getattr(state, attr)
        if oid and amend_trigger(http, cfg, oid, trigger):
            continue
        # 未発注、または既に消えていたので新規発注
        res = place_stop_market(
            http, cfg.symbol, side, cfg.qty, trigger, direction,
            category=cfg.category,
            order_link_id=f"{cfg.link_prefix}-{side.lower()}-{stamp}")
        setattr(state, attr, res.order_id if res.ok else "")
        if not res.ok:
            print(f"  [warn] {side} 発注失敗: {res.line()}")

    print(f"  上ブレイク買い @{up:.1f} / 下ブレイク売り @{dn:.1f}")


def run(cfg: BreakoutConfig = BreakoutConfig()) -> None:
    c = make_client()
    state = BreakoutState()

    print(f"=== ドンチャンブレイクアウトbot ({cfg.symbol} {cfg.interval}分足) ===")
    print(f"  参照本数={cfg.donchian}  余裕幅=ATR×{cfg.buffer_atr}")
    print("  Ctrl+C で停止\n")

    try:
        while True:
            try:
                step(c.http, cfg, state)
            except Exception as e:
                print(f"  [error] {type(e).__name__}: {e}")
            time.sleep(cfg.poll_sec)
    except KeyboardInterrupt:
        print("\n停止します。エントリー注文を撤去...")
        cancel_all_entries(c.http, cfg, state)
        remaining = fetch_open_orders(c.http, category=cfg.category,
                                      symbol=cfg.symbol,
                                      order_filter="StopOrder")
        print(f"  残存条件注文: {len(remaining)}件")


if __name__ == "__main__":
    run()

    # 注意: このbotはエントリーのみを担当する。
    #   損切りは記事14の set_trading_stop でポジションに紐付けること。
    #   条件注文による損切りは、証拠金不足時に発火しても通らない。

トリガー後の未約定フォールバック

Stop Limit の最大のリスクは「発火したのに約定しない」ことです。急落でストップが発火したものの、指値が置き去りになり、損切りできないまま損失が拡大する——最も避けたい事故です。

stop_fallback.pyサンプル7
"""stop_fallback.py — Stop Limitが発火後に約定しなかった場合の成行フォールバック
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  発火 → 通常の指値注文に変わる → 約定しない、という状態を検知する。
  検知の手がかり:
    - 条件注文リスト(StopOrder)から消えた
    - 通常注文リスト(Order)に同じ orderId で現れた
    - その状態が猶予時間を超えて続いている

  猶予時間を過ぎたら、残数量を成行で処理する。
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field
from decimal import Decimal

from client import make_client
from conditional_query import fetch_open_orders
from symbol_rules import fetch_rules


@dataclass(frozen=True)
class FallbackConfig:
    category: str = "linear"
    grace_sec: float = 30.0        # 発火後、これだけ待って未約定なら成行
    poll_sec: float = 2.0
    max_slippage_bps: float = 100.0  # これ以上不利なら成行にせず警告のみ


@dataclass
class WatchedOrder:
    order_id: str
    symbol: str
    side: str
    reduce_only: bool
    triggered_at: float = 0.0
    resolved: bool = False
    note: str = ""


@dataclass
class StopWatcher:
    http: object
    cfg: FallbackConfig = field(default_factory=FallbackConfig)
    watched: dict[str, WatchedOrder] = field(default_factory=dict)

    def register(self, order_id: str, symbol: str, side: str,
                 reduce_only: bool = True) -> None:
        self.watched[order_id] = WatchedOrder(order_id, symbol, side,
                                              reduce_only)

    def _snapshot(self, symbol: str) -> tuple[dict, dict]:
        """条件注文と通常注文の現状を orderId で引ける形で返す。"""
        stop = {o.order_id: o for o in fetch_open_orders(
            self.http, category=self.cfg.category, symbol=symbol,
            order_filter="StopOrder")}
        normal = {o.order_id: o for o in fetch_open_orders(
            self.http, category=self.cfg.category, symbol=symbol,
            order_filter="Order")}
        return stop, normal

    def _leaves_qty(self, symbol: str, order_id: str) -> Decimal:
        """未約定の残数量を取得する。"""
        r = self.http.get_open_orders(category=self.cfg.category,
                                      symbol=symbol, orderId=order_id)
        if r["retCode"] != 0 or not r["result"]["list"]:
            return Decimal(0)
        o = r["result"]["list"][0]
        return Decimal(str(o.get("leavesQty") or "0"))

    def _market_close(self, w: WatchedOrder, qty: Decimal) -> bool:
        """残数量を成行で処理する。"""
        # まず未約定の指値をキャンセルしてから成行を出す。
        # 逆順にすると二重約定になる。
        r = self.http.cancel_order(category=self.cfg.category,
                                   symbol=w.symbol, orderId=w.order_id)
        if r["retCode"] not in (0, 110001):
            print(f"    キャンセル失敗: {r['retCode']} {r['retMsg']}")
            return False

        rules = fetch_rules(self.http, w.symbol, self.cfg.category)
        params = {
            "category": self.cfg.category, "symbol": w.symbol,
            "side": w.side, "orderType": "Market",
            "qty": rules.format_qty(qty),
        }
        if w.reduce_only:
            params["reduceOnly"] = True
        r = self.http.place_order(**params)
        if r["retCode"] != 0:
            print(f"    成行フォールバック失敗: {r['retCode']} {r['retMsg']}")
            return False
        print(f"    成行で処理: {qty} {w.side}")
        return True

    def check_once(self) -> list[str]:
        """1周分の監視。発生した事象のログを返す。"""
        events: list[str] = []
        now = time.time()
        symbols = {w.symbol for w in self.watched.values() if not w.resolved}

        for symbol in symbols:
            stop, normal = self._snapshot(symbol)

            for w in self.watched.values():
                if w.resolved or w.symbol != symbol:
                    continue

                in_stop = w.order_id in stop
                in_normal = w.order_id in normal

                if in_stop:
                    continue                    # まだ待機中

                if in_normal:
                    # 発火して通常注文になったが、まだ約定していない
                    if w.triggered_at == 0.0:
                        w.triggered_at = now
                        events.append(
                            f"発火検知 {w.order_id[:12]}... "
                            f"({self.cfg.grace_sec:.0f}秒の猶予を開始)")
                        continue

                    waited = now - w.triggered_at
                    if waited < self.cfg.grace_sec:
                        continue

                    qty = self._leaves_qty(symbol, w.order_id)
                    if qty <= 0:
                        w.resolved, w.note = True, "猶予中に約定"
                        continue

                    events.append(
                        f"未約定 {waited:.0f}秒 → 成行フォールバック "
                        f"({w.order_id[:12]}... 残 {qty})")
                    if self._market_close(w, qty):
                        w.resolved, w.note = True, "成行で処理"
                    continue

                # どちらにも無い = 全部約定した、またはキャンセルされた
                w.resolved = True
                w.note = "約定またはキャンセル済み"
                events.append(f"完了 {w.order_id[:12]}... ({w.note})")

        return events

    def run(self, timeout_sec: float = 600.0) -> None:
        deadline = time.time() + timeout_sec
        print(f"=== Stop Limit 監視開始 ({len(self.watched)}件) ===")

        while time.time() < deadline:
            if all(w.resolved for w in self.watched.values()):
                print("すべて解決しました。")
                break
            for line in self.check_once():
                print(f"  {line}")
            time.sleep(self.cfg.poll_sec)
        else:
            unresolved = [w for w in self.watched.values() if not w.resolved]
            print(f"タイムアウト。未解決 {len(unresolved)}件")

        print("\n=== 結果 ===")
        for w in self.watched.values():
            print(f"  {w.order_id[:12]}...: "
                  f"{w.note or '未解決'}")


if __name__ == "__main__":
    from conditional_basic import place_stop_limit

    c = make_client()
    symbol = "BTCUSDT"

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

    # わざと約定しにくい Stop Limit を置く(trigger と limit が同値)
    trigger = last * Decimal("0.995")
    res = place_stop_limit(c.http, symbol, "Sell", Decimal("0.001"),
                           trigger_price=trigger, limit_price=trigger,
                           trigger_direction=2, reduce_only=False)
    print(f"発注: {res.line()}")

    if res.ok:
        w = StopWatcher(c.http, FallbackConfig(grace_sec=15.0))
        w.register(res.order_id, symbol, "Sell", reduce_only=False)
        w.run(timeout_sec=120.0)

    print("\n※ この監視はRESTポーリング。より確実なのは次節のWebSocket。")
    print("  ただしWSが切れている間の取りこぼしに備え、")
    print("  RESTポーリングを冗長系として残す構成を勧める。")

WebSocketで発火を検知する

conditional_ws.pyサンプル8
"""conditional_ws.py — private WSのorderトピックで条件注文の発火を検知する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  条件注文のライフサイクル(orderStatus の遷移):
    Untriggered → Triggered → New → PartiallyFilled → Filled
                            ↘ Deactivated(トリガー前にキャンセル)
                            ↘ Rejected(発火したが証拠金不足など)

  ポーリングと違い、Triggered の瞬間を取りこぼさない。
  記事27のprivate WS基盤を前提とする。
"""
from __future__ import annotations

import json
import time
from collections import deque
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Callable

from pybit.unified_trading import WebSocket

from config import load_config

# 条件注文に関係する orderStatus
UNTRIGGERED = "Untriggered"
TRIGGERED = "Triggered"
TERMINAL = {"Filled", "Cancelled", "Rejected", "Deactivated"}


@dataclass
class OrderEvent:
    order_id: str
    order_link_id: str
    symbol: str
    side: str
    status: str
    trigger_price: Decimal
    price: Decimal
    qty: Decimal
    cum_exec_qty: Decimal
    leaves_qty: Decimal
    reject_reason: str
    received_at: float

    @property
    def is_conditional(self) -> bool:
        return self.trigger_price > 0

    def line(self) -> str:
        tag = "条件" if self.is_conditional else "通常"
        rej = f" [{self.reject_reason}]" if self.reject_reason not in ("", "EC_NoError") else ""
        return (f"[{tag}] {self.symbol} {self.side} {self.status} "
                f"約定 {self.cum_exec_qty}/{self.qty} 残 {self.leaves_qty}{rej}")


def _dec(value) -> Decimal:
    try:
        return Decimal(str(value)) if value not in (None, "") else Decimal(0)
    except Exception:
        return Decimal(0)


def parse_order(o: dict) -> OrderEvent:
    return OrderEvent(
        order_id=o.get("orderId", ""),
        order_link_id=o.get("orderLinkId", ""),
        symbol=o.get("symbol", ""),
        side=o.get("side", ""),
        status=o.get("orderStatus", ""),
        trigger_price=_dec(o.get("triggerPrice")),
        price=_dec(o.get("price")),
        qty=_dec(o.get("qty")),
        cum_exec_qty=_dec(o.get("cumExecQty")),
        leaves_qty=_dec(o.get("leavesQty")),
        reject_reason=o.get("rejectReason", ""),
        received_at=time.time(),
    )


@dataclass
class ConditionalMonitor:
    """条件注文の発火を検知して、後続処理をコールバックで呼ぶ。"""
    on_triggered: Callable[[OrderEvent], None] | None = None
    on_rejected: Callable[[OrderEvent], None] | None = None
    on_filled: Callable[[OrderEvent], None] | None = None
    history: deque = field(default_factory=lambda: deque(maxlen=500))
    _seen: dict = field(default_factory=dict)   # orderId → 直前のstatus

    def handle(self, message: dict) -> None:
        for o in message.get("data", []):
            ev = parse_order(o)
            if not ev.is_conditional:
                continue

            prev = self._seen.get(ev.order_id)
            self._seen[ev.order_id] = ev.status
            self.history.append(ev)

            if prev == ev.status:
                continue            # 同じ状態の再送はスキップ

            print(f"  {ev.line()}")

            if ev.status == TRIGGERED and self.on_triggered:
                self.on_triggered(ev)
            elif ev.status == "Rejected" and self.on_rejected:
                self.on_rejected(ev)
            elif ev.status == "Filled" and self.on_filled:
                self.on_filled(ev)

            if ev.status in TERMINAL:
                self._seen.pop(ev.order_id, None)

    def untriggered_count(self) -> int:
        return sum(1 for s in self._seen.values() if s == UNTRIGGERED)


def on_triggered_example(ev: OrderEvent) -> None:
    """発火したら、対になる注文をキャンセルする(OCO的な挙動)。"""
    print(f"    → 発火: {ev.symbol} {ev.side} @{ev.trigger_price}")
    print("      対の注文をキャンセルし、TP/SLを設定する処理をここに書く")


def on_rejected_example(ev: OrderEvent) -> None:
    """発火したのに拒否された = 証拠金不足などの深刻な状態。"""
    print(f"    → 拒否: {ev.reject_reason}")
    print("      アラート送信(記事39)と、bot停止(記事24)を検討")


def main() -> None:
    cfg = load_config()

    ws = WebSocket(
        testnet=cfg.testnet,
        demo=cfg.demo,
        channel_type="private",
        api_key=cfg.api_key,
        api_secret=cfg.api_secret,
        # ping_interval の既定は20秒。切断検知は記事27を参照
    )

    monitor = ConditionalMonitor(
        on_triggered=on_triggered_example,
        on_rejected=on_rejected_example,
    )

    ws.order_stream(callback=monitor.handle)

    print("=== 条件注文の発火を監視中 (Ctrl+C で停止) ===")
    print("  別のターミナルで conditional_basic.py を実行して")
    print("  条件注文を置き、価格が到達するのを待つ\n")

    try:
        while True:
            time.sleep(30)
            print(f"  [heartbeat] 待機中の条件注文: "
                  f"{monitor.untriggered_count()}件")
    except KeyboardInterrupt:
        print("\n停止します。")
        print(f"  受信イベント数: {len(monitor.history)}")


if __name__ == "__main__":
    main()

    # 注意: order トピックはスナップショットを送らない。
    #   接続時点で既に存在する注文は流れてこないため、
    #   起動直後に一度 REST で現状を取得して初期状態を作ること(記事27)。
参考リンク(一次情報)

よくある疑問Q&A

条件注文を出したのに get_open_orders に出てきません
orderFilter="StopOrder" を指定していないためです。既定値は "Order" で、条件注文は返りません。サンプル4の fetch_all_kinds() のように、"Order" / "StopOrder" / "tpslOrder" の3種類をすべて取得してください。
botの整合性チェックでこれを見落とすと、「未約定注文はゼロ」と誤判定して二重発注につながります。
triggerDirectionはどう決めればいいですか
現在価格とトリガー価格の位置関係で一意に決まります。手で指定する必要はありません。サンプル2の resolve_direction() を使ってください。
さらに build_spec() は「ロングの損切りなのにトリガーが現在価格より上」といった意図との矛盾も検出します。この検証を入れておくと、設定ファイルの符号ミスが本番前に見つかります。
損切りは条件注文とTP/SLのどちらで置くべきですか
ポジションの損切りは 記事14 の set_trading_stop(TP/SL)を使ってください。ポジションに紐付くため、ポジションが消えれば自動で消え、10件枠も消費しません。
条件注文はポジションを持っていない状態からのエントリー(ブレイクアウト)に向いています。両者を混同すると、決済後に孤児注文が残って意図しないポジションを持つ事故が起きます。
条件注文が10件を超えるとどうなりますか
発注が拒否されます(retCode は状況により 10001 系)。この上限は通常の未約定注文500件とは別枠で、銘柄ごとに数えられます。
グリッド的に多数の水準を仕込みたい場合は、サンプル5の ConditionalQueue のように現在価格に近い順に10件だけ板に出し、残りは手元で待機させる設計にします。reserve で損切り用の枠を確保しておくのが実務上の要点です。
Stop Limitが発火したのに約定しませんでした
指値がトリガーと同値、または不利な側に置かれていた可能性が高いです。発火した瞬間、価格は既にトリガーを通り過ぎています。買いブレイクならトリガーより少し上に、売りならトリガーより少し下に指値を置いてください。
サンプル2の validate_stop_limit() が発注前に警告します。それでも約定しないケースに備え、サンプル7の成行フォールバックを併用してください。
証拠金が足りないときに損切りは発火しますか
発火はしますが、注文は通りません。条件注文の証拠金判定は発注時ではなくトリガー時に行われるためです(rejectReason 付きで Rejected になります)。
これは条件注文を最終防衛線にできない決定的な理由です。記事14のポジション紐付けTP/SLと、記事24のサーキットブレーカーを組み合わせて多重化してください。サンプル8の on_rejected でこの状態を即座に検知できます。
毎足キャンセルして再発注するのは非効率ですか
はい。amend_order で triggerPrice だけを更新すれば1リクエストで済み、注文IDも保たれます(キャンセル+発注は2リクエスト)。サンプル6の amend_trigger() がその実装です。
記事05のレート制限の観点でも、複数銘柄を回すbotでは差が効いてきます。ただし amend が 110001 を返す場合は「既に発火・キャンセル済み」なので、新規発注にフォールバックする分岐が必要です。
triggerByをMarkPriceにすれば安全ですか
誤発火は減りますが、「安全」とは限りません。マーク価格は平滑化されているため、実勢価格が急落してもマーク価格の反応が遅れ、損切りが遅れることがあります。
清算はマーク価格で判定されるため、清算を避けたいならMarkPriceで基準を揃えるのが理にかないます。一方、実勢価格で確実に切りたいならLastPriceです。サンプル3で自分の銘柄・時間帯の実測値を見てから決めてください。

注意事項・免責事項

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

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

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

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

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

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

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