※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
条件注文(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 — 条件付き成行・条件付き指値の発注
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
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 は「現在価格から見てトリガー価格がどちら側にあるか」で機械的に決まります。手で指定する理由はありません。
"""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 は LastPrice / MarkPrice / IndexPrice の3つから選べます。この選択が誤発火の頻度を数倍変えるため、実データで比較します。
| triggerBy | 性質 | 誤発火 | 推奨用途 |
|---|---|---|---|
| LastPrice | Bybit上の最終約定価格。既定値 | 多い。薄い時間帯のヒゲを拾う | ブレイクアウトのエントリー |
| MarkPrice | 清算判定に使う公正価格。平滑化されている | 少ない | 損切り(清算と同じ基準で揃う) |
| IndexPrice | 複数現物取引所の加重平均 | 最も少ない | 市場全体の水準を条件にしたい場合 |
"""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")
サンプル3は fetch_klines() に fetcher= 引数を渡して取得元を差し替えています。記事07の fetch_klines() がこの引数に対応していない場合は、fetcher: Callable = None を追加し、None のとき http.get_kline を使うようにしてください。マーク価格・インデックス価格のKlineは出来高フィールドを持たない点にも注意します。
get_open_orders() を素で呼ぶと条件注文が返ってきません。orderFilter を明示する必要があります。この仕様を知らないと「置いたはずの注文が消えた」と誤解します。
"""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件という別枠の上限を持ちます(通常の未約定注文500件とは独立)。グリッド的に多数の水準を仕込む設計は、この上限に必ずぶつかります。
"""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(" いざ損切りを置きたいときに発注できなくなる。")
"""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 — 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ポーリングを冗長系として残す構成を勧める。")
"""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)。
orderFilter="StopOrder" を指定していないためです。既定値は "Order" で、条件注文は返りません。サンプル4の fetch_all_kinds() のように、"Order" / "StopOrder" / "tpslOrder" の3種類をすべて取得してください。resolve_direction() を使ってください。build_spec() は「ロングの損切りなのにトリガーが現在価格より上」といった意図との矛盾も検出します。この検証を入れておくと、設定ファイルの符号ミスが本番前に見つかります。set_trading_stop(TP/SL)を使ってください。ポジションに紐付くため、ポジションが消えれば自動で消え、10件枠も消費しません。retCode は状況により 10001 系)。この上限は通常の未約定注文500件とは別枠で、銘柄ごとに数えられます。ConditionalQueue のように現在価格に近い順に10件だけ板に出し、残りは手元で待機させる設計にします。reserve で損切り用の枠を確保しておくのが実務上の要点です。validate_stop_limit() が発注前に警告します。それでも約定しないケースに備え、サンプル7の成行フォールバックを併用してください。amend_order で triggerPrice だけを更新すれば1リクエストで済み、注文IDも保たれます(キャンセル+発注は2リクエスト)。サンプル6の amend_trigger() がその実装です。110001 を返す場合は「既に発火・キャンセル済み」なので、新規発注にフォールバックする分岐が必要です。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。