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:
@@ -149,3 +149,5 @@ All design documents and implementation plans are stored in `docs/plans/` with t
|
|||||||
| 2026-03-21 | `purged-gap-and-ablation` (plan) | Completed |
|
| 2026-03-21 | `purged-gap-and-ablation` (plan) | Completed |
|
||||||
| 2026-03-21 | `ml-validation-result` | ML OFF > ML ON 확정, SOL/DOGE/TRX 제외, XRP 단독 운영 |
|
| 2026-03-21 | `ml-validation-result` | ML OFF > ML ON 확정, SOL/DOGE/TRX 제외, XRP 단독 운영 |
|
||||||
| 2026-03-21 | `ml-validation-pipeline` (plan) | Completed |
|
| 2026-03-21 | `ml-validation-pipeline` (plan) | Completed |
|
||||||
|
| 2026-03-22 | `backtest-market-context` (design) | 설계 완료, 구현 대기 |
|
||||||
|
| 2026-03-22 | `testnet-uds-verification` (design) | 설계 완료, 구현 대기 |
|
||||||
|
|||||||
192
docs/plans/2026-03-22-backtest-market-context-design.md
Normal file
192
docs/plans/2026-03-22-backtest-market-context-design.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# 백테스트 시장 컨텍스트 리포트 설계
|
||||||
|
|
||||||
|
**일자**: 2026-03-22
|
||||||
|
**상태**: 설계 완료, 구현 대기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
Walk-Forward 백테스트 결과를 해석할 때, 각 폴드 기간의 시장 상황(BTC/ETH 추세, L/S ratio)을 함께 보여준다. **"왜 이 폴드에서 졌는가"**를 구조적으로 이해하기 위한 참조 데이터이며, 트레이딩 시그널이나 ML 피처로는 사용하지 않는다.
|
||||||
|
|
||||||
|
## 접근 방식
|
||||||
|
|
||||||
|
Walk-Forward 폴드 테이블 출력 직후에 시장 컨텍스트 테이블 2개(Market Regime + L/S Ratio)를 추가한다. 기존 `scripts/run_backtest.py`만 수정하며, 별도 CLI 명령어는 만들지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 소스
|
||||||
|
|
||||||
|
### 1. BTC/ETH 가격 데이터 (Market Regime)
|
||||||
|
|
||||||
|
- **소스**: XRP의 `data/xrpusdt/combined_15m.parquet`에 임베딩된 `close_btc`, `high_btc`, `low_btc`, `close_eth`, `high_eth`, `low_eth` 컬럼
|
||||||
|
- 별도 `data/btcusdt/combined_15m.parquet` 파일은 로컬/프로덕션 모두 **존재하지 않음**
|
||||||
|
- 백테스터가 이미 이 임베딩 컬럼을 로딩하므로 추가 데이터 fetch 불필요
|
||||||
|
- 폴드 기간별로 슬라이싱하여 수익률, ADX 계산
|
||||||
|
|
||||||
|
### 2. L/S Ratio 데이터
|
||||||
|
|
||||||
|
- **소스**: `data/{symbol}/ls_ratio_15m.parquet` (로컬 파일)
|
||||||
|
- **심볼**: XRPUSDT, BTCUSDT, ETHUSDT
|
||||||
|
- **주기**: 15m
|
||||||
|
- **컬럼**: `timestamp` (datetime64[ms, UTC]), `top_acct_ls_ratio` (float64), `global_ls_ratio` (float64)
|
||||||
|
|
||||||
|
#### 현재 데이터 상태
|
||||||
|
|
||||||
|
- L/S ratio collector는 운영 LXC(`10.1.10.24`)에서 가동 중 (commit `e2b0454`, 2026-03-22~)
|
||||||
|
- **프로덕션**: XRP/BTC/ETH 각 3건 (2026-03-22 13:15 ~ 13:45 UTC), 계속 축적 중
|
||||||
|
- **로컬**: XRP 2건, BTC 2건, ETH 2건 (로컬 collector 테스트 시 생성된 데이터)
|
||||||
|
- 과거 폴드(2025-06, 2025-09, 2025-12)에 대한 L/S ratio 데이터는 **존재하지 않음**
|
||||||
|
- Binance API는 최근 30일만 historical 제공 → 과거 데이터 복구 불가능
|
||||||
|
|
||||||
|
#### 데이터 동기화
|
||||||
|
|
||||||
|
구현 전 프로덕션 LXC에서 L/S ratio parquet 파일을 로컬로 복사해야 한다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp root@10.1.10.24:/root/cointrader/data/xrpusdt/ls_ratio_15m.parquet data/xrpusdt/
|
||||||
|
scp root@10.1.10.24:/root/cointrader/data/btcusdt/ls_ratio_15m.parquet data/btcusdt/
|
||||||
|
scp root@10.1.10.24:/root/cointrader/data/ethusdt/ls_ratio_15m.parquet data/ethusdt/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fallback 전략
|
||||||
|
|
||||||
|
1. **로컬 parquet 우선**: `data/{symbol}/ls_ratio_15m.parquet`에서 폴드 기간 데이터 조회
|
||||||
|
2. **파일 없거나 해당 기간 데이터 없으면 `N/A`**: 폴드의 L/S ratio 셀을 `N/A`로 표시
|
||||||
|
3. **전체 폴드가 N/A이면 L/S ratio 테이블 자체를 생략**: 불필요한 N/A 테이블을 출력하지 않음
|
||||||
|
4. **Binance API에서 실시간 fetch하지 않음**: 백테스트는 오프라인 재현 가능해야 함
|
||||||
|
5. **시간이 지나면 해결됨**: collector가 계속 수집하므로, 데이터 축적 후 백테스트에 자연스럽게 반영
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Market Regime 분류 기준
|
||||||
|
|
||||||
|
BTC ADX와 수익률 기반으로 **코드에 명확히 정의**하여 주관적 해석을 방지한다:
|
||||||
|
|
||||||
|
| 조건 | 라벨 |
|
||||||
|
|------|------|
|
||||||
|
| ADX ≥ 25 and return > 0 | 상승 추세 |
|
||||||
|
| ADX ≥ 25 and return < 0 | 하락 추세 |
|
||||||
|
| ADX < 25 | 횡보 |
|
||||||
|
|
||||||
|
- ADX는 폴드 기간 내 BTC 15m 캔들(`high_btc`, `low_btc`, `close_btc`)로 계산한 **기간 평균 ADX** (`pandas_ta.adx(length=14)` 사용)
|
||||||
|
- return은 폴드 시작가 대비 종료가의 **단순 수익률** (`close_btc`)
|
||||||
|
- 라벨 뒤에 `(BTC ADX {값:.0f})` 형태로 실제 수치 병기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
|
기존 폴드 테이블 바로 아래에 출력:
|
||||||
|
|
||||||
|
```
|
||||||
|
📊 Market Context per Fold
|
||||||
|
┌──────┬──────────────┬──────────────┬─────────────────────────────────┐
|
||||||
|
│ Fold │ BTC Return │ ETH Return │ Market Regime │
|
||||||
|
├──────┼──────────────┼──────────────┼─────────────────────────────────┤
|
||||||
|
│ 1 │ +12.3% │ +8.7% │ 상승 추세 (BTC ADX 32) │
|
||||||
|
│ 2 │ -2.1% │ -5.4% │ 횡보 (BTC ADX 18) │
|
||||||
|
│ 3 │ +25.6% │ +18.2% │ 상승 추세 (BTC ADX 41) │
|
||||||
|
└──────┴──────────────┴──────────────┴─────────────────────────────────┘
|
||||||
|
|
||||||
|
📊 L/S Ratio Context per Fold (period avg)
|
||||||
|
┌──────┬──────────────────┬──────────────────┬──────────────────┐
|
||||||
|
│ Fold │ XRP Top/Global │ BTC Top/Global │ ETH Top/Global │
|
||||||
|
├──────┼──────────────────┼──────────────────┼──────────────────┤
|
||||||
|
│ 1 │ N/A │ N/A │ N/A │
|
||||||
|
│ 2 │ N/A │ N/A │ N/A │
|
||||||
|
│ 3 │ 1.15 / 0.98 │ 0.95 / 1.02 │ 1.08 / 1.05 │
|
||||||
|
└──────┴──────────────────┴──────────────────┴──────────────────┘
|
||||||
|
→ Fold 1~2: L/S ratio 데이터 없음 (collector 가동 전)
|
||||||
|
→ Fold 3: 데이터 가용
|
||||||
|
```
|
||||||
|
|
||||||
|
**전체 폴드가 N/A인 경우** (현재 상태에서 과거 데이터만으로 백테스트하면):
|
||||||
|
|
||||||
|
```
|
||||||
|
📊 Market Context per Fold
|
||||||
|
┌──────┬──────────────┬──────────────┬─────────────────────────────────┐
|
||||||
|
│ Fold │ BTC Return │ ETH Return │ Market Regime │
|
||||||
|
├──────┼──────────────┼──────────────┼─────────────────────────────────┤
|
||||||
|
│ 1 │ +12.3% │ +8.7% │ 상승 추세 (BTC ADX 32) │
|
||||||
|
│ 2 │ -2.1% │ -5.4% │ 횡보 (BTC ADX 18) │
|
||||||
|
│ 3 │ +25.6% │ +18.2% │ 상승 추세 (BTC ADX 41) │
|
||||||
|
└──────┴──────────────┴──────────────┴─────────────────────────────────┘
|
||||||
|
ℹ️ L/S ratio 데이터 없음 — collector 데이터 축적 후 표시됩니다
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON 출력
|
||||||
|
|
||||||
|
walk-forward 결과 JSON에도 `market_context` 필드 추가:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folds": [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"test_period": "2025-06-07 ~ 2025-07-06",
|
||||||
|
"test_start": "2025-06-07T00:00:00",
|
||||||
|
"test_end": "2025-07-06T00:00:00",
|
||||||
|
"summary": { "..." : "..." },
|
||||||
|
"market_context": {
|
||||||
|
"btc_return_pct": 12.3,
|
||||||
|
"eth_return_pct": 8.7,
|
||||||
|
"btc_avg_adx": 32.1,
|
||||||
|
"market_regime": "상승 추세",
|
||||||
|
"ls_ratio": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fold": 3,
|
||||||
|
"test_period": "2026-03-01 ~ 2026-04-01",
|
||||||
|
"test_start": "2026-03-01T00:00:00",
|
||||||
|
"test_end": "2026-04-01T00:00:00",
|
||||||
|
"summary": { "..." : "..." },
|
||||||
|
"market_context": {
|
||||||
|
"btc_return_pct": 5.2,
|
||||||
|
"eth_return_pct": 3.1,
|
||||||
|
"btc_avg_adx": 28.5,
|
||||||
|
"market_regime": "상승 추세",
|
||||||
|
"ls_ratio": {
|
||||||
|
"xrp": { "top_acct_avg": 1.15, "global_avg": 0.98 },
|
||||||
|
"btc": { "top_acct_avg": 0.95, "global_avg": 1.02 },
|
||||||
|
"eth": { "top_acct_avg": 1.08, "global_avg": 1.05 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 대상 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 역할 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `scripts/run_backtest.py` | Modify | 시장 컨텍스트 계산 + 출력 함수 추가 |
|
||||||
|
| `src/backtester.py` | Modify (최소) | 폴드 결과에 `test_start`/`test_end`를 timestamp로 노출 (현재는 문자열 `test_period`만 있음) |
|
||||||
|
|
||||||
|
### 변경하지 않는 것
|
||||||
|
|
||||||
|
- `src/indicators.py` — ADX 계산은 `run_backtest.py` 내에서 `pandas_ta.adx()` 직접 사용
|
||||||
|
- `scripts/collect_ls_ratio.py` — 기존 collector 로직 변경 없음
|
||||||
|
- `src/ml_filter.py`, `src/ml_features.py` — ML 피처와 무관
|
||||||
|
- `scripts/fetch_history.py` — BTC/ETH 별도 fetch 불필요 (XRP parquet에 임베딩됨)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 전 선행 작업
|
||||||
|
|
||||||
|
1. ~~BTC/ETH 히스토리 데이터 fetch~~ → **불필요** (XRP parquet에 `close_btc`, `close_eth` 등 임베딩됨)
|
||||||
|
2. `backtester.py`에서 `test_start`/`test_end`를 timestamp로 노출하도록 수정
|
||||||
|
3. 프로덕션 LXC에서 L/S ratio parquet 파일 로컬 동기화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 범위 제한
|
||||||
|
|
||||||
|
- **참조 전용**: 시장 컨텍스트는 출력/리포트에만 사용. 트레이딩 로직에 영향 없음
|
||||||
|
- **오프라인 우선**: Binance API 호출 없음. 로컬 데이터만 사용
|
||||||
|
- **기존 테스트 영향 없음**: 출력 함수 추가이므로 기존 백테스트 로직 불변
|
||||||
|
- **L/S ratio 테이블 조건부 출력**: 전체 N/A이면 테이블 생략, 한 줄 안내 메시지만 출력
|
||||||
@@ -116,13 +116,11 @@ UDS의 close_reason 판별 로직은 유지하되, 콜백 시그니처에 `exit_
|
|||||||
|
|
||||||
현재 UDS에서 `ot`로 판별 → 실전에서 `ot=MARKET` → `close_reason="MANUAL"` → bot.py에서 가격 비교로 SL/TP 재판별. 이 흐름이 테스트넷에서도 안전 (테스트넷은 `ot=STOP_MARKET`이 오므로 재판별 자체가 불필요).
|
현재 UDS에서 `ot`로 판별 → 실전에서 `ot=MARKET` → `close_reason="MANUAL"` → bot.py에서 가격 비교로 SL/TP 재판별. 이 흐름이 테스트넷에서도 안전 (테스트넷은 `ot=STOP_MARKET`이 오므로 재판별 자체가 불필요).
|
||||||
|
|
||||||
### 4. 포지션 모니터 SYNC 경로
|
### 4. 포지션 모니터 SYNC 경로 — 이미 구현됨
|
||||||
|
|
||||||
`_position_monitor()`의 SYNC 폴백(line 669-722)에서 잔여주문 취소 누락 → 추가.
|
`_position_monitor()`의 SYNC 폴백에서 잔여주문 취소는 **이미 구현되어 있음**. 추가 수정 불필요.
|
||||||
```python
|
|
||||||
# 상태 동기화 후 잔여 주문 취소
|
> **참고**: `_place_sl_tp_with_retry()`의 algoId 저장도 이미 구현됨 (bot.py line 539, 550).
|
||||||
await self._cancel_remaining_orders("SYNC")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 테스트 계획
|
### 5. 테스트 계획
|
||||||
|
|
||||||
|
|||||||
230
scripts/verify_prod_api.py
Normal file
230
scripts/verify_prod_api.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""실전 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())
|
||||||
80
src/bot.py
80
src/bot.py
@@ -78,6 +78,10 @@ class TradingBot:
|
|||||||
self._entry_quantity: float | None = None
|
self._entry_quantity: float | None = None
|
||||||
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
|
self._is_reentering: bool = False # _close_and_reenter 중 콜백 상태 초기화 방지
|
||||||
self._entry_time_ms: int | None = None # 포지션 진입 시각 (ms, SYNC PnL 범위 제한용)
|
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_event = asyncio.Event() # 콜백 청산 완료 대기용
|
||||||
self._close_lock = asyncio.Lock() # 청산 처리 원자성 보장 (C3 fix)
|
self._close_lock = asyncio.Lock() # 청산 처리 원자성 보장 (C3 fix)
|
||||||
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
self._prev_oi: float | None = None # OI 변화율 계산용 이전 값
|
||||||
@@ -236,6 +240,13 @@ class TradingBot:
|
|||||||
open_orders = await self.exchange.get_open_orders()
|
open_orders = await self.exchange.get_open_orders()
|
||||||
has_sl = any(o.get("type") == "STOP_MARKET" for o in 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)
|
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:
|
if has_sl and has_tp:
|
||||||
return
|
return
|
||||||
missing = []
|
missing = []
|
||||||
@@ -398,6 +409,8 @@ class TradingBot:
|
|||||||
self.current_trade_side = None
|
self.current_trade_side = None
|
||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = 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):
|
if not await self.risk.can_open_new_position(self.symbol, raw_signal):
|
||||||
logger.info(f"[{self.symbol}] 포지션 오픈 불가")
|
logger.info(f"[{self.symbol}] 포지션 오픈 불가")
|
||||||
return
|
return
|
||||||
@@ -485,6 +498,8 @@ class TradingBot:
|
|||||||
self._entry_price = price
|
self._entry_price = price
|
||||||
self._entry_quantity = quantity
|
self._entry_quantity = quantity
|
||||||
self._entry_time_ms = int(time.time() * 1000)
|
self._entry_time_ms = int(time.time() * 1000)
|
||||||
|
self._sl_price = stop_loss
|
||||||
|
self._tp_price = take_profit
|
||||||
self.notifier.notify_open(
|
self.notifier.notify_open(
|
||||||
symbol=self.symbol,
|
symbol=self.symbol,
|
||||||
side=signal,
|
side=signal,
|
||||||
@@ -527,22 +542,26 @@ class TradingBot:
|
|||||||
for attempt in range(1, self._SL_TP_MAX_RETRIES + 1):
|
for attempt in range(1, self._SL_TP_MAX_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
if not sl_placed:
|
if not sl_placed:
|
||||||
await self.exchange.place_order(
|
sl_result = await self.exchange.place_order(
|
||||||
side=sl_side,
|
side=sl_side,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
order_type="STOP_MARKET",
|
order_type="STOP_MARKET",
|
||||||
stop_price=self.exchange._round_price(stop_loss),
|
stop_price=self.exchange._round_price(stop_loss),
|
||||||
reduce_only=True,
|
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
|
sl_placed = True
|
||||||
if not tp_placed:
|
if not tp_placed:
|
||||||
await self.exchange.place_order(
|
tp_result = await self.exchange.place_order(
|
||||||
side=sl_side,
|
side=sl_side,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
order_type="TAKE_PROFIT_MARKET",
|
order_type="TAKE_PROFIT_MARKET",
|
||||||
stop_price=self.exchange._round_price(take_profit),
|
stop_price=self.exchange._round_price(take_profit),
|
||||||
reduce_only=True,
|
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
|
tp_placed = True
|
||||||
return # 둘 다 성공
|
return # 둘 다 성공
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -567,6 +586,8 @@ class TradingBot:
|
|||||||
self.current_trade_side = None
|
self.current_trade_side = None
|
||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = None
|
self._entry_quantity = None
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
self.notifier.notify_info(
|
self.notifier.notify_info(
|
||||||
f"🚨 [{self.symbol}] SL/TP 배치 실패 → 긴급 청산 완료"
|
f"🚨 [{self.symbol}] SL/TP 배치 실패 → 긴급 청산 완료"
|
||||||
)
|
)
|
||||||
@@ -579,6 +600,28 @@ class TradingBot:
|
|||||||
f"🔴 [{self.symbol}] 긴급 청산 실패! 수동 청산 필요: {e}"
|
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:
|
def _calc_estimated_pnl(self, exit_price: float) -> float:
|
||||||
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
|
"""진입가·수량 기반 예상 PnL 계산 (수수료 미반영)."""
|
||||||
if self._entry_price is None or self._entry_quantity is None or self.current_trade_side is None:
|
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()
|
self._close_event.set()
|
||||||
return
|
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)
|
estimated_pnl = self._calc_estimated_pnl(exit_price)
|
||||||
diff = net_pnl - estimated_pnl
|
diff = net_pnl - estimated_pnl
|
||||||
|
|
||||||
@@ -632,11 +686,16 @@ class TradingBot:
|
|||||||
if self._is_reentering:
|
if self._is_reentering:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 잔여 SL/TP 고아 주문 취소
|
||||||
|
await self._cancel_remaining_orders("UDS 청산 콜백")
|
||||||
|
|
||||||
# Flat 상태로 초기화
|
# Flat 상태로 초기화
|
||||||
self.current_trade_side = None
|
self.current_trade_side = None
|
||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = None
|
self._entry_quantity = None
|
||||||
self._entry_time_ms = None
|
self._entry_time_ms = None
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
|
|
||||||
_MONITOR_INTERVAL = 300 # 5분
|
_MONITOR_INTERVAL = 300 # 5분
|
||||||
|
|
||||||
@@ -698,10 +757,14 @@ class TradingBot:
|
|||||||
)
|
)
|
||||||
self._append_trade(net_pnl, "SYNC")
|
self._append_trade(net_pnl, "SYNC")
|
||||||
self._check_kill_switch()
|
self._check_kill_switch()
|
||||||
|
# 잔여 SL/TP 주문 취소
|
||||||
|
await self._cancel_remaining_orders("SYNC 폴백")
|
||||||
self.current_trade_side = None
|
self.current_trade_side = None
|
||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = None
|
self._entry_quantity = None
|
||||||
self._entry_time_ms = None
|
self._entry_time_ms = None
|
||||||
|
self._sl_price = None
|
||||||
|
self._tp_price = None
|
||||||
self._close_event.set()
|
self._close_event.set()
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -760,6 +823,11 @@ class TradingBot:
|
|||||||
self._entry_price = None
|
self._entry_price = None
|
||||||
self._entry_quantity = None
|
self._entry_quantity = None
|
||||||
self._entry_time_ms = 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:
|
if self._killed:
|
||||||
logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)")
|
logger.info(f"[{self.symbol}] 킬스위치 활성 — 재진입 건너뜀 (청산만 수행)")
|
||||||
@@ -799,6 +867,14 @@ class TradingBot:
|
|||||||
await self._recover_position()
|
await self._recover_position()
|
||||||
await self._init_oi_history()
|
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(
|
user_stream = UserDataStream(
|
||||||
symbol=self.symbol,
|
symbol=self.symbol,
|
||||||
on_order_filled=self._on_position_closed,
|
on_order_filled=self._on_position_closed,
|
||||||
|
|||||||
@@ -171,18 +171,54 @@ class BinanceFuturesClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_open_orders(self) -> list[dict]:
|
async def get_open_orders(self) -> list[dict]:
|
||||||
"""현재 심볼의 오픈 주문 목록을 조회한다."""
|
"""현재 심볼의 오픈 주문 + algo 주문을 병합 반환한다."""
|
||||||
return await self._run_api(
|
orders = await self._run_api(
|
||||||
lambda: self.client.futures_get_open_orders(symbol=self.symbol),
|
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):
|
async def cancel_all_orders(self):
|
||||||
"""오픈 주문을 모두 취소한다."""
|
"""일반 주문 + algo 주문을 모두 취소한다."""
|
||||||
await self._run_api(
|
await self._run_api(
|
||||||
lambda: self.client.futures_cancel_all_open_orders(
|
lambda: self.client.futures_cancel_all_open_orders(
|
||||||
symbol=self.symbol
|
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]]:
|
async def get_recent_income(self, limit: int = 5, start_time: int | None = None) -> tuple[list[dict], list[dict]]:
|
||||||
"""최근 REALIZED_PNL + COMMISSION 내역을 조회한다.
|
"""최근 REALIZED_PNL + COMMISSION 내역을 조회한다.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ def config():
|
|||||||
"NOTION_TOKEN": "secret_test",
|
"NOTION_TOKEN": "secret_test",
|
||||||
"NOTION_DATABASE_ID": "db_test",
|
"NOTION_DATABASE_ID": "db_test",
|
||||||
"DISCORD_WEBHOOK_URL": "",
|
"DISCORD_WEBHOOK_URL": "",
|
||||||
|
"BINANCE_TESTNET": "false",
|
||||||
})
|
})
|
||||||
return Config()
|
return Config()
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ def sample_df():
|
|||||||
"low": close * 0.995,
|
"low": close * 0.995,
|
||||||
"close": close,
|
"close": close,
|
||||||
"volume": np.random.randint(100000, 1000000, n).astype(float),
|
"volume": np.random.randint(100000, 1000000, n).astype(float),
|
||||||
|
"atr": np.full(n, 0.005),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def test_no_model_should_enter_returns_true(tmp_path):
|
|||||||
assert f.should_enter(features) is True
|
assert f.should_enter(features) is True
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"NO_ML_FILTER": "false"})
|
||||||
def test_should_enter_above_threshold():
|
def test_should_enter_above_threshold():
|
||||||
"""확률 >= 0.60 이면 True"""
|
"""확률 >= 0.60 이면 True"""
|
||||||
f = MLFilter(threshold=0.60)
|
f = MLFilter(threshold=0.60)
|
||||||
@@ -40,6 +41,7 @@ def test_should_enter_above_threshold():
|
|||||||
assert f.should_enter(features) is True
|
assert f.should_enter(features) is True
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"NO_ML_FILTER": "false"})
|
||||||
def test_should_enter_below_threshold():
|
def test_should_enter_below_threshold():
|
||||||
"""확률 < 0.60 이면 False"""
|
"""확률 < 0.60 이면 False"""
|
||||||
f = MLFilter(threshold=0.60)
|
f = MLFilter(threshold=0.60)
|
||||||
|
|||||||
Reference in New Issue
Block a user