- exchange.py: cancel_all_orders() now cancels both standard and algo orders - exchange.py: get_open_orders() merges standard + algo orders - exchange.py: cancel_order() falls back to algo cancel on failure - bot.py: store SL/TP prices for price-based close_reason re-determination - bot.py: add _cancel_remaining_orders() for orphan SL/TP cleanup - bot.py: re-classify MANUAL close_reason as SL/TP via price comparison - bot.py: cancel orphan orders on startup when no position exists - tests: fix env setup for testnet config and ML filter mocking - docs: add backtest market context and algo order fix design specs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
231 lines
8.2 KiB
Python
231 lines
8.2 KiB
Python
"""실전 API SL/TP 콜백 검증 스크립트.
|
|
|
|
검증 항목:
|
|
1. SL/TP 주문 응답에 orderId vs algoId 확인
|
|
2. SL 트리거 시 UDS 콜백의 o, ot 필드 값
|
|
3. futures_cancel_order(orderId=...)로 TP 취소 가능 여부
|
|
|
|
사용법:
|
|
1. 바이낸스 앱/웹에서 XRPUSDT 소액 LONG 포지션 수동 진입
|
|
2. python scripts/verify_prod_api.py 실행
|
|
→ 자동으로 SL/TP 배치 + UDS 리스닝
|
|
3. SL이 트리거되면 콜백 로그 확인 + TP 자동 취소 시도
|
|
|
|
환경변수: BINANCE_API_KEY, BINANCE_API_SECRET (실전 키)
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from dotenv import load_dotenv
|
|
from binance import AsyncClient, BinanceSocketManager
|
|
from loguru import logger
|
|
|
|
from src.exchange import BinanceFuturesClient
|
|
from src.config import Config
|
|
|
|
# .env에서 실전 키 로드 (BINANCE_TESTNET이 설정되어 있으면 해제)
|
|
os.environ.pop("BINANCE_TESTNET", None)
|
|
load_dotenv()
|
|
|
|
SYMBOL = "XRPUSDT"
|
|
|
|
|
|
async def main():
|
|
api_key = os.getenv("BINANCE_API_KEY", "")
|
|
api_secret = os.getenv("BINANCE_API_SECRET", "")
|
|
|
|
if not api_key or not api_secret:
|
|
logger.error("BINANCE_API_KEY / BINANCE_API_SECRET 환경변수 필요")
|
|
return
|
|
|
|
# Exchange 클라이언트 (실전)
|
|
config = Config()
|
|
config.testnet = False
|
|
config.api_key = api_key
|
|
config.api_secret = api_secret
|
|
config.symbol = SYMBOL
|
|
|
|
exchange = BinanceFuturesClient(config, symbol=SYMBOL)
|
|
|
|
# ── Step 1: 현재 포지션 확인 ──
|
|
position = await exchange.get_position()
|
|
if position is None:
|
|
logger.error(
|
|
f"[{SYMBOL}] 포지션 없음. 먼저 바이낸스 앱/웹에서 소액 포지션을 수동으로 진입하세요."
|
|
)
|
|
return
|
|
|
|
pos_amt = float(position["positionAmt"])
|
|
entry_price = float(position["entryPrice"])
|
|
mark_price = float(position.get("markPrice", entry_price))
|
|
side = "LONG" if pos_amt > 0 else "SHORT"
|
|
quantity = abs(pos_amt)
|
|
|
|
logger.info(f"[{SYMBOL}] 포지션 확인: {side} qty={quantity}, entry={entry_price}, mark={mark_price}")
|
|
|
|
# ── Step 2: 기존 오픈 주문 확인/정리 ──
|
|
open_orders = await exchange.get_open_orders()
|
|
if open_orders:
|
|
logger.info(f"[{SYMBOL}] 기존 오픈 주문 {len(open_orders)}개 — 전체 취소")
|
|
await exchange.cancel_all_orders()
|
|
await asyncio.sleep(1)
|
|
|
|
# ── Step 3: SL/TP 주문 배치 (현재가 기준 가까운 값) ──
|
|
# SL: 현재가에서 0.15% 떨어진 곳 (빨리 트리거되도록)
|
|
# TP: 현재가에서 2% 떨어진 곳 (트리거 안 되도록)
|
|
sl_side = "SELL" if side == "LONG" else "BUY"
|
|
|
|
if side == "LONG":
|
|
stop_loss = exchange._round_price(mark_price * 0.9985) # -0.15%
|
|
take_profit = exchange._round_price(mark_price * 1.02) # +2%
|
|
else:
|
|
stop_loss = exchange._round_price(mark_price * 1.0015) # +0.15%
|
|
take_profit = exchange._round_price(mark_price * 0.98) # -2%
|
|
|
|
logger.info(f"[{SYMBOL}] SL/TP 배치 예정: SL={stop_loss}, TP={take_profit}, side={sl_side}")
|
|
|
|
# SL 배치
|
|
sl_result = await exchange.place_order(
|
|
side=sl_side,
|
|
quantity=quantity,
|
|
order_type="STOP_MARKET",
|
|
stop_price=stop_loss,
|
|
reduce_only=True,
|
|
)
|
|
logger.success(f"[검증1] SL 주문 응답 전체:\n{json.dumps(sl_result, indent=2)}")
|
|
sl_order_id = sl_result.get("orderId")
|
|
sl_algo_id = sl_result.get("algoId")
|
|
logger.info(f" → orderId={sl_order_id}, algoId={sl_algo_id}")
|
|
|
|
# TP 배치
|
|
tp_result = await exchange.place_order(
|
|
side=sl_side,
|
|
quantity=quantity,
|
|
order_type="TAKE_PROFIT_MARKET",
|
|
stop_price=take_profit,
|
|
reduce_only=True,
|
|
)
|
|
logger.success(f"[검증1] TP 주문 응답 전체:\n{json.dumps(tp_result, indent=2)}")
|
|
tp_order_id = tp_result.get("orderId")
|
|
tp_algo_id = tp_result.get("algoId")
|
|
logger.info(f" → orderId={tp_order_id}, algoId={tp_algo_id}")
|
|
|
|
# ── Step 4: UDS 리스닝 — SL 트리거 대기 ──
|
|
logger.info(f"[{SYMBOL}] UDS 리스닝 시작 — SL 트리거 대기 중 (mark={mark_price}, SL={stop_loss})")
|
|
logger.info(" SL이 트리거되면 자동으로 TP 취소를 시도합니다.")
|
|
logger.info(" Ctrl+C로 중단 가능 (중단 시 잔여 주문 정리)")
|
|
|
|
sl_triggered = asyncio.Event()
|
|
|
|
async def on_uds_message(msg: dict):
|
|
if msg.get("e") != "ORDER_TRADE_UPDATE":
|
|
return
|
|
|
|
order = msg.get("o", {})
|
|
if order.get("s") != SYMBOL:
|
|
return
|
|
|
|
# 모든 이벤트 원본 로깅
|
|
logger.info(
|
|
f"[검증2] UDS 원본: "
|
|
f"s={order.get('s')} "
|
|
f"o={order.get('o')} "
|
|
f"ot={order.get('ot')} "
|
|
f"x={order.get('x')} "
|
|
f"X={order.get('X')} "
|
|
f"R={order.get('R')} "
|
|
f"S={order.get('S')} "
|
|
f"i={order.get('i')} "
|
|
f"ap={order.get('ap')} "
|
|
f"rp={order.get('rp')} "
|
|
f"n={order.get('n')}"
|
|
)
|
|
|
|
# FILLED된 SL 감지
|
|
if order.get("x") == "TRADE" and order.get("X") == "FILLED":
|
|
ot = order.get("ot", "")
|
|
if ot == "STOP_MARKET":
|
|
logger.success(
|
|
f"[검증2] SL FILLED 확인! "
|
|
f"o={order.get('o')}, ot={ot}, "
|
|
f"orderId={order.get('i')}, "
|
|
f"exit_price={order.get('ap')}, rp={order.get('rp')}"
|
|
)
|
|
sl_triggered.set()
|
|
|
|
# UDS 연결
|
|
client = await AsyncClient.create(
|
|
api_key=api_key,
|
|
api_secret=api_secret,
|
|
)
|
|
|
|
try:
|
|
bm = BinanceSocketManager(client)
|
|
async with bm.futures_user_socket() as stream:
|
|
logger.info("UDS 연결 완료")
|
|
|
|
while True:
|
|
try:
|
|
msg = await asyncio.wait_for(stream.recv(), timeout=1.0)
|
|
await on_uds_message(msg)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
|
|
if sl_triggered.is_set():
|
|
break
|
|
|
|
# ── Step 5: TP 취소 검증 ──
|
|
cancel_id = tp_order_id or tp_algo_id
|
|
logger.info(f"[검증3] TP 취소 시도: futures_cancel_order(orderId={cancel_id})")
|
|
|
|
try:
|
|
cancel_result = await exchange.cancel_order(cancel_id)
|
|
logger.success(f"[검증3] TP 취소 성공:\n{json.dumps(cancel_result, indent=2)}")
|
|
except Exception as e:
|
|
logger.error(f"[검증3] TP 취소 실패: {e}")
|
|
|
|
# cancel_all_orders 폴백
|
|
logger.info("[검증3] cancel_all_orders 폴백 시도")
|
|
try:
|
|
fallback_result = await exchange.cancel_all_orders()
|
|
logger.success(f"[검증3] cancel_all_orders 결과: {fallback_result}")
|
|
except Exception as e2:
|
|
logger.error(f"[검증3] cancel_all_orders도 실패: {e2}")
|
|
|
|
# 최종 오픈 주문 확인
|
|
remaining = await exchange.get_open_orders()
|
|
if remaining:
|
|
logger.warning(f"[검증3] 잔여 오픈 주문 {len(remaining)}개:")
|
|
for o in remaining:
|
|
logger.warning(f" id={o.get('orderId')}, type={o.get('type')}, status={o.get('status')}")
|
|
else:
|
|
logger.success("[검증3] 잔여 오픈 주문 없음 — 고아주문 없음 확인!")
|
|
|
|
except KeyboardInterrupt:
|
|
logger.info("중단 — 잔여 주문 정리 중...")
|
|
try:
|
|
await exchange.cancel_all_orders()
|
|
logger.info("잔여 주문 전체 취소 완료")
|
|
except Exception as e:
|
|
logger.warning(f"잔여 주문 취소 실패: {e}")
|
|
finally:
|
|
await client.close_connection()
|
|
|
|
# ── 결과 요약 ──
|
|
logger.info("=" * 60)
|
|
logger.info("검증 결과 요약")
|
|
logger.info("=" * 60)
|
|
logger.info(f"[1] SL orderId={sl_order_id}, algoId={sl_algo_id}")
|
|
logger.info(f"[1] TP orderId={tp_order_id}, algoId={tp_algo_id}")
|
|
logger.info(f"[2] SL 트리거 감지: {'YES' if sl_triggered.is_set() else 'NO (타임아웃/중단)'}")
|
|
logger.info(f"[3] 위 로그에서 TP 취소 성공 여부 확인")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|