feat: add algo order compatibility and orphan order cleanup
- 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>
This commit is contained in:
80
src/bot.py
80
src/bot.py
@@ -78,6 +78,10 @@ class TradingBot:
|
||||
self._entry_quantity: float | None = None
|
||||
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
|
||||
self._entry_time_ms: int | None = None # 포지션 진입 시각 (ms, SYNC PnL 범위 제한용)
|
||||
self._sl_order_id: int | None = None # SL 주문 ID (고아 주문 취소용)
|
||||
self._tp_order_id: int | None = None # TP 주문 ID (고아 주문 취소용)
|
||||
self._sl_price: float | None = None # SL 가격 (가격 기반 close_reason 판별용)
|
||||
self._tp_price: float | None = None # TP 가격 (가격 기반 close_reason 판별용)
|
||||
self._close_event = asyncio.Event() # 콜백 청산 완료 대기용
|
||||
self._close_lock = asyncio.Lock() # 청산 처리 원자성 보장 (C3 fix)
|
||||
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
||||
@@ -236,6 +240,13 @@ class TradingBot:
|
||||
open_orders = await self.exchange.get_open_orders()
|
||||
has_sl = any(o.get("type") == "STOP_MARKET" for o in open_orders)
|
||||
has_tp = any(o.get("type") == "TAKE_PROFIT_MARKET" for o in open_orders)
|
||||
# 오픈 주문에서 SL/TP 가격 복원 (가격 기반 close_reason 판별용)
|
||||
for o in open_orders:
|
||||
otype = o.get("type", "")
|
||||
if otype == "STOP_MARKET":
|
||||
self._sl_price = float(o.get("stopPrice", 0))
|
||||
elif otype == "TAKE_PROFIT_MARKET":
|
||||
self._tp_price = float(o.get("stopPrice", 0))
|
||||
if has_sl and has_tp:
|
||||
return
|
||||
missing = []
|
||||
@@ -398,6 +409,8 @@ class TradingBot:
|
||||
self.current_trade_side = None
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
if not await self.risk.can_open_new_position(self.symbol, raw_signal):
|
||||
logger.info(f"[{self.symbol}] 포지션 오픈 불가")
|
||||
return
|
||||
@@ -485,6 +498,8 @@ class TradingBot:
|
||||
self._entry_price = price
|
||||
self._entry_quantity = quantity
|
||||
self._entry_time_ms = int(time.time() * 1000)
|
||||
self._sl_price = stop_loss
|
||||
self._tp_price = take_profit
|
||||
self.notifier.notify_open(
|
||||
symbol=self.symbol,
|
||||
side=signal,
|
||||
@@ -527,22 +542,26 @@ class TradingBot:
|
||||
for attempt in range(1, self._SL_TP_MAX_RETRIES + 1):
|
||||
try:
|
||||
if not sl_placed:
|
||||
await self.exchange.place_order(
|
||||
sl_result = await self.exchange.place_order(
|
||||
side=sl_side,
|
||||
quantity=quantity,
|
||||
order_type="STOP_MARKET",
|
||||
stop_price=self.exchange._round_price(stop_loss),
|
||||
reduce_only=True,
|
||||
)
|
||||
self._sl_order_id = sl_result.get("orderId") or sl_result.get("algoId")
|
||||
logger.info(f"[{self.symbol}] SL 주문 배치: id={self._sl_order_id}")
|
||||
sl_placed = True
|
||||
if not tp_placed:
|
||||
await self.exchange.place_order(
|
||||
tp_result = await self.exchange.place_order(
|
||||
side=sl_side,
|
||||
quantity=quantity,
|
||||
order_type="TAKE_PROFIT_MARKET",
|
||||
stop_price=self.exchange._round_price(take_profit),
|
||||
reduce_only=True,
|
||||
)
|
||||
self._tp_order_id = tp_result.get("orderId") or tp_result.get("algoId")
|
||||
logger.info(f"[{self.symbol}] TP 주문 배치: id={self._tp_order_id}")
|
||||
tp_placed = True
|
||||
return # 둘 다 성공
|
||||
except Exception as e:
|
||||
@@ -567,6 +586,8 @@ class TradingBot:
|
||||
self.current_trade_side = None
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
self.notifier.notify_info(
|
||||
f"🚨 [{self.symbol}] SL/TP 배치 실패 → 긴급 청산 완료"
|
||||
)
|
||||
@@ -579,6 +600,28 @@ class TradingBot:
|
||||
f"🔴 [{self.symbol}] 긴급 청산 실패! 수동 청산 필요: {e}"
|
||||
)
|
||||
|
||||
async def _cancel_remaining_orders(self, reason: str = "") -> None:
|
||||
"""잔여 SL/TP 고아 주문을 저장된 주문 ID로 직접 취소한다."""
|
||||
ctx = f" ({reason})" if reason else ""
|
||||
cancelled = 0
|
||||
for label, oid in [("SL", self._sl_order_id), ("TP", self._tp_order_id)]:
|
||||
if oid is None:
|
||||
continue
|
||||
try:
|
||||
result = await self.exchange.cancel_order(oid)
|
||||
logger.info(
|
||||
f"[{self.symbol}] {label} 주문 취소 완료{ctx}: "
|
||||
f"id={oid} → status={result.get('status', 'N/A')}"
|
||||
)
|
||||
cancelled += 1
|
||||
except Exception as e:
|
||||
# 이미 체결/취소된 주문이면 무시
|
||||
logger.debug(f"[{self.symbol}] {label} 주문 취소 스킵{ctx}: id={oid}: {e}")
|
||||
self._sl_order_id = None
|
||||
self._tp_order_id = None
|
||||
if cancelled == 0:
|
||||
logger.info(f"[{self.symbol}] 취소할 잔여 주문 없음{ctx}")
|
||||
|
||||
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
||||
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
|
||||
if self._entry_price is None or self._entry_quantity is None or self.current_trade_side is None:
|
||||
@@ -601,6 +644,17 @@ class TradingBot:
|
||||
self._close_event.set()
|
||||
return
|
||||
|
||||
# 실전 API에서 algo order는 ot=MARKET로 오므로 MANUAL로 판별됨
|
||||
# → SL/TP 가격과 exit_price 비교로 재판별
|
||||
if close_reason == "MANUAL" and self._sl_price and self._tp_price:
|
||||
sl_dist = abs(exit_price - self._sl_price)
|
||||
tp_dist = abs(exit_price - self._tp_price)
|
||||
close_reason = "SL" if sl_dist < tp_dist else "TP"
|
||||
logger.info(
|
||||
f"[{self.symbol}] close_reason 재판별: MANUAL → {close_reason} "
|
||||
f"(exit={exit_price:.4f}, SL={self._sl_price:.4f}, TP={self._tp_price:.4f})"
|
||||
)
|
||||
|
||||
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
||||
diff = net_pnl - estimated_pnl
|
||||
|
||||
@@ -632,11 +686,16 @@ class TradingBot:
|
||||
if self._is_reentering:
|
||||
return
|
||||
|
||||
# 잔여 SL/TP 고아 주문 취소
|
||||
await self._cancel_remaining_orders("UDS 청산 콜백")
|
||||
|
||||
# Flat 상태로 초기화
|
||||
self.current_trade_side = None
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
self._entry_time_ms = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
|
||||
_MONITOR_INTERVAL = 300 # 5분
|
||||
|
||||
@@ -698,10 +757,14 @@ class TradingBot:
|
||||
)
|
||||
self._append_trade(net_pnl, "SYNC")
|
||||
self._check_kill_switch()
|
||||
# 잔여 SL/TP 주문 취소
|
||||
await self._cancel_remaining_orders("SYNC 폴백")
|
||||
self.current_trade_side = None
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
self._entry_time_ms = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
self._close_event.set()
|
||||
continue
|
||||
except Exception as e:
|
||||
@@ -760,6 +823,11 @@ class TradingBot:
|
||||
self._entry_price = None
|
||||
self._entry_quantity = None
|
||||
self._entry_time_ms = None
|
||||
self._sl_price = None
|
||||
self._tp_price = None
|
||||
|
||||
# 잔여 SL/TP 주문 취소 확인 (_close_position에서 cancel_all 호출하지만 검증)
|
||||
await self._cancel_remaining_orders("재진입 전 검증")
|
||||
|
||||
if self._killed:
|
||||
logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)")
|
||||
@@ -799,6 +867,14 @@ class TradingBot:
|
||||
await self._recover_position()
|
||||
await self._init_oi_history()
|
||||
|
||||
# 봇 시작 시 포지션 없으면 고아 주문 정리 (저장된 ID 없으므로 cancel_all 사용)
|
||||
if self.current_trade_side is None:
|
||||
try:
|
||||
result = await self.exchange.cancel_all_orders()
|
||||
logger.info(f"[{self.symbol}] 봇 시작 — cancel_all_orders 응답: {result}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.symbol}] 봇 시작 — 주문 취소 실패: {e}")
|
||||
|
||||
user_stream = UserDataStream(
|
||||
symbol=self.symbol,
|
||||
on_order_filled=self._on_position_closed,
|
||||
|
||||
@@ -171,18 +171,54 @@ class BinanceFuturesClient:
|
||||
return None
|
||||
|
||||
async def get_open_orders(self) -> list[dict]:
|
||||
"""현재 심볼의 오픈 주문 목록을 조회한다."""
|
||||
return await self._run_api(
|
||||
"""현재 심볼의 오픈 주문 + algo 주문을 병합 반환한다."""
|
||||
orders = await self._run_api(
|
||||
lambda: self.client.futures_get_open_orders(symbol=self.symbol),
|
||||
)
|
||||
try:
|
||||
algo_orders = await self._run_api(
|
||||
lambda: self.client.futures_get_open_algo_orders(symbol=self.symbol)
|
||||
)
|
||||
for ao in algo_orders.get("orders", []):
|
||||
orders.append({
|
||||
"orderId": ao.get("algoId"),
|
||||
"type": ao.get("orderType"),
|
||||
"stopPrice": ao.get("triggerPrice"),
|
||||
"side": ao.get("side"),
|
||||
"status": ao.get("algoStatus"),
|
||||
"_is_algo": True,
|
||||
})
|
||||
except Exception:
|
||||
pass # algo 주문 없으면 실패 가능
|
||||
return orders
|
||||
|
||||
async def cancel_all_orders(self):
|
||||
"""오픈 주문을 모두 취소한다."""
|
||||
"""일반 주문 + algo 주문을 모두 취소한다."""
|
||||
await self._run_api(
|
||||
lambda: self.client.futures_cancel_all_open_orders(
|
||||
symbol=self.symbol
|
||||
),
|
||||
)
|
||||
try:
|
||||
await self._run_api(
|
||||
lambda: self.client.futures_cancel_all_algo_open_orders(symbol=self.symbol)
|
||||
)
|
||||
except Exception:
|
||||
pass # algo 주문 없으면 실패 가능
|
||||
|
||||
async def cancel_order(self, order_id: int):
|
||||
"""개별 주문을 취소한다. 일반 주문 실패 시 algo 주문으로 재시도."""
|
||||
try:
|
||||
return await self._run_api(
|
||||
lambda: self.client.futures_cancel_order(
|
||||
symbol=self.symbol, orderId=order_id
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# Algo order (데모 API의 조건부 주문) 취소 시도
|
||||
return await self._run_api(
|
||||
lambda: self.client.futures_cancel_algo_order(algoId=order_id),
|
||||
)
|
||||
|
||||
async def get_recent_income(self, limit: int = 5, start_time: int | None = None) -> tuple[list[dict], list[dict]]:
|
||||
"""최근 REALIZED_PNL + COMMISSION 내역을 조회한다.
|
||||
|
||||
Reference in New Issue
Block a user