※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
この記事は記事01の client.py と記事13の symbol_rules.py を再利用します。
# 本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
# 必ず 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']}")
place_order が retCode=0 を返しても、TP/SLが期待どおり設定されたとは限りません。指値注文の場合、TP/SLは約定してポジションが建ってから反映されます。また価格が現在値に対して不正な側にあると、注文自体は通ってTP/SLだけ無効になる場合があります。
上のコードの最後2行——設定後に get_positions() で照合する——を必ず入れてください。この確認を省いた実装が「TP/SLを設定したのに発動しなかった」の最大の原因です。
| 方法 | タイミング | 使いどころ | 注意 |
|---|---|---|---|
| place_order (同時指定) |
エントリーと同時 | エントリー価格からの固定%でTP/SLを置く場合 | 指値なら約定後に反映。未約定の間は無防備 |
| set_trading_stop (後付け) |
ポジション保有中いつでも | 建値が確定してから計算する場合、トレール、階段利確 | ポジションが無いと失敗(110033) |
実務では後付け(set_trading_stop)のほうが扱いやすいです。理由は、実際の建値(avgPrice)が分かった後にTP/SLを計算できるためです。同時指定では発注時の想定価格を基準にするしかなく、スリッページで想定と建値がズレると、リスク量が意図と変わります。
"""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}")
takeProfit="0" は「価格0で利確」ではなく「利確を解除する」という意味です。同様に trailingStop="0" でトレールが解除されます。設定を変更するつもりで意図せず0を渡すと、防御が全部外れた状態で放置されます。None と 0 と未指定の違いを意識してコードを書いてください。
tpslMode の理解が、この記事で最も重要な部分です。この2つは挙動が根本的に違います。
| 観点 | Full | Partial |
|---|---|---|
| 対象 | ポジション全体 | 数量を指定した一部 |
| 数量指定 | 不要(常に全量) | tpSize / slSize が必要 |
| 注文タイプ | 成行のみ | 成行・指値の両方 |
| 指値決済 | 不可 | tpLimitPrice で可能 |
| 設定できる本数 | TP・SL 各1つ | 合計20件まで(110061) |
| 用途 | 単純な全決済 | 階段利確・分割決済 |
「利確はメイカーで決済して手数料を節約したい」と考えて tpOrderType="Limit" を指定しても、Fullモードでは無効です(成行のみ対応)。指値で利確したいなら tpslMode="Partial" にして tpSize と tpLimitPrice を指定する必要があります。
これに気づかず「Limitを指定したのに成行で決済された」と悩むケースが多いポイントです。手数料の差は往復で数bpsですが、高頻度戦略では効いてきます。
既にTP/SL・トレーリングストップが設定されている状態でモードを切り替えると、110054 / 110055 / 110056 で失敗します。切り替えるには先に既存のTP/SLを解除("0" を設定)してください。Partial では tpSize と slSize を同じ値にする必要があるという制約もあります(片方だけ小さくはできません)。
triggerBy には3つの選択肢があります。これは好みではなく、リスク管理上の判断です。
| triggerBy | 基準 | 特徴 | 推奨用途 |
|---|---|---|---|
| LastPrice | 最終取引価格 | 実際の約定価格。薄い板ではヒゲで動きやすい | 利確(TP) |
| MarkPrice | マーク価格 (清算判定と同じ) |
指数を参照するため瞬間的なヒゲに強い | 損切(SL) |
| IndexPrice | 現物指数 | その取引所の板から最も独立 | 単一取引所の異常を無視したい場合 |
損切をMarkPriceにする理由は明確です。清算判定はマーク価格で行われるため、SLをLastPriceにすると「マーク価格では清算されない水準なのに、最終価格のヒゲでSLだけ発動する」という損な決済が起こりえます。逆にSLがマーク価格基準なら、清算と同じ物差しでリスクを管理できます。
一方利確はLastPriceが自然です。実際に取引できる価格で利確したいためです。
"""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" はほぼ必須です。
「30%を+1%で、30%を+2%で、残り40%をトレールで」という段階的な利確は、tpslMode="Partial" で実装します。
"""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)
Bybitには公式のトレーリングストップがあります。trailingStop は%ではなく価格の距離で指定します。
| パラメータ | 意味 |
|---|---|
| trailingStop | 価格の距離(例: BTCで "500" なら500USDT分の追従幅)。0で解除 |
| activePrice | トレールを起動する価格。この価格に到達してから追従が始まる |
"""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が実際には無い、という状態を検知します。これは定期実行すべき監視処理です。
"""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は消えることがあります。手動介入、部分決済、モード切替の失敗、取引所側の処理など理由は様々です。監査で問題を見つけたら自動で再設定する仕組みまで作ると、運用が安定します。
"""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)
EMERGENCY_SL_PCT = 3.0 は戦略が意図した損切幅ではなく、「何かがおかしいときの最後の壁」です。戦略の損切より広く設定するのが正しく、狭くすると正常な運用を妨げます。また基準を建値ではなくマーク価格にしているのは、すでに大きな含み損を抱えている場合に建値基準だと即時発動してしまうためです。この種の「防御の防御」は、記事24のサーキットブレーカーと同じ思想です。
TP/SLで足りず、「どちらか一方が約定したら他方をキャンセル」という制御が必要な場面があります(ブレイクアウトの両建て待ち受けなど)。V5には汎用のOCO注文がないため、自分で状態管理します。
"""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)
上の実装は分かりやすさのためRESTポーリングにしていますが、実運用ではprivate WebSocketの order トピックで発動を即座に受け取るべきです。ポーリング間隔の分だけ、反対側のキャンセルが遅れます(その間に両方発動する可能性があります)。イベント駆動版は記事27で扱います。
set_trading_stop の category に指定できるのは linear / inverse / option です。現物(spot)には「ポジション」という概念がないため、この方法は使えません。
現物でTP/SL相当のことをするには、place_order で orderFilter="tpslOrder" を指定した注文、または条件注文(triggerPrice 付き)を自分で管理します。上限は条件注文・TP/SL・通常注文の合計で500件(170810)です。現物の積立やグリッドを組む場合はこの違いを前提に設計してください(記事36)。
| 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 で設定する |
| 110060 | Fullモードでは変更できない | 数量指定の変更をしようとしている。Partialに切り替える |
| 110061 | Partialモードは20件まで | 階段の段数を減らす、または既存の設定を整理する |
| 110003 | 価格が許容範囲外 | TP/SLが現在価格の逆側にある、または離れすぎている |
取引所側のTP/SLは便利ですが、それだけに依存するのは危険です。設定が消える、モード切替で外れる、部分決済で数量が合わなくなる——いずれも実際に起きます。サンプル7のような監査・修復ループを別プロセスで常時動かし、「損切のないポジションは存在しない」を機械的に保証してください。
set_trading_stop の上限は毎秒10リクエストが目安です。自作トレールを短い間隔で回すと、この上限に当たります。SLが実際に改善するときだけ更新する(サンプル5の improved 判定)ことで、リクエスト数を大幅に減らせます。レート制限の詳細は記事05で扱います。
TP/SLをバックテストで再現する際、同じ足でTPとSLの両方に触れた場合の扱いが問題になります。OHLCデータだけでは「どちらが先に付いたか」が分かりません。必ず不利な側(SL)を先に約定させるのが正しい方針です。有利側を選ぶと成績が体系的に過大になります(記事31)。
avgPrice)が変わるため、建値基準で計算していたTP/SL幅は意図とズレます。増し玉する戦略では、約定のたびに avgPrice を再取得してTP/SLを再計算・再設定してください(サンプル2の関数を約定イベントで呼ぶ形にします)。slOrderType="Limit" と slLimitPrice を使う方法がありますが、その場合は指値が約定せず損切できないリスクと引き換えになります。どちらのリスクを取るかの選択です。trailingStop は価格の距離で指定します。%で管理したい場合は、現在価格に%を掛けて価格差に変換してから渡してください。BTCで「1%」なら約1,180USDT(価格118,000のとき)です。価格水準が変わると同じ距離の意味が変わるため、長期保有するポジションでは定期的に再計算する必要があります。ATR基準(サンプル5)にするとこの問題が自然に解消されます。positionIdx(1=Buy側、2=Sell側)の指定が必須です。省略するとどちらのポジションに設定すべきか決まらず失敗します。サンプル2以降のコードは get_positions() のレスポンスから positionIdx を引き継ぐ実装にしてあります。ポジションモードの扱いは記事20で詳しく扱います。position トピックを購読すると、ポジションの変化に含めて takeProfit / stopLoss / trailingStop の現在値が届きます。RESTポーリングよりレート制限にやさしく、変化を即座に検知できるため、サンプル7のガーディアンをWebSocketベースに置き換えるのが実運用の正解です(記事27)。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。