feat: add testnet mode support and fix UDS order type classification
- Add BINANCE_TESTNET env var to switch between live/demo API keys - Add KLINE_INTERVAL env var (default 15m) for configurable candle interval - Pass testnet flag through to Exchange, DataStream, UDS, Notifier - Add demo mode in bot: forced LONG entry with fixed 0.5% SL / 2% TP - Fix UDS close_reason: use ot (original order type) field to correctly classify STOP_MARKET/TAKE_PROFIT_MARKET triggers (was MANUAL) - Add UDS raw event logging with ot field for debugging - Add backtest market context (BTC/ETH regime, L/S ratio per fold) - Separate testnet trade history to data/trade_history/testnet/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -701,6 +701,8 @@ class WalkForwardBacktester:
|
||||
"fold": i + 1,
|
||||
"train_period": f"{train_start.date()} ~ {train_end.date()}",
|
||||
"test_period": f"{test_start.date()} ~ {test_end.date()}",
|
||||
"test_start": test_start.isoformat(),
|
||||
"test_end": test_end.isoformat(),
|
||||
"summary": result["summary"],
|
||||
})
|
||||
|
||||
|
||||
59
src/bot.py
59
src/bot.py
@@ -58,7 +58,7 @@ class TradingBot:
|
||||
self.symbol = symbol or config.symbol
|
||||
self.strategy = config.get_symbol_params(self.symbol)
|
||||
self.exchange = BinanceFuturesClient(config, symbol=self.symbol)
|
||||
self.notifier = DiscordNotifier(config.discord_webhook_url)
|
||||
self.notifier = DiscordNotifier(config.discord_webhook_url, testnet=config.testnet)
|
||||
self.risk = risk or RiskManager(config)
|
||||
# 심볼별 모델 디렉토리. 없으면 기존 models/ 루트로 폴백
|
||||
symbol_model_dir = Path(f"models/{self.symbol.lower()}")
|
||||
@@ -88,7 +88,7 @@ class TradingBot:
|
||||
self._trade_history: list[dict] = [] # 최근 거래 이력 (net_pnl 기록)
|
||||
self.stream = MultiSymbolStream(
|
||||
symbols=[self.symbol] + config.correlation_symbols,
|
||||
interval="15m",
|
||||
interval=config.kline_interval,
|
||||
on_candle=self._on_candle_closed,
|
||||
)
|
||||
# 부팅 시 거래 이력 복원 및 킬스위치 소급 검증
|
||||
@@ -98,7 +98,11 @@ class TradingBot:
|
||||
# ── 킬스위치 ──────────────────────────────────────────────────────
|
||||
|
||||
def _trade_history_path(self) -> Path:
|
||||
return _TRADE_HISTORY_DIR / f"{self.symbol.lower()}.jsonl"
|
||||
base = _TRADE_HISTORY_DIR
|
||||
if self.config.testnet:
|
||||
base = base / "testnet"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base / f"{self.symbol.lower()}.jsonl"
|
||||
|
||||
def _restore_trade_history(self) -> None:
|
||||
"""부팅 시 파일 마지막 N줄만 읽어 거래 이력을 복원한다.
|
||||
@@ -327,6 +331,23 @@ class TradingBot:
|
||||
return change
|
||||
|
||||
async def process_candle(self, df, btc_df=None, eth_df=None):
|
||||
# Demo 모드: 시그널/필터 전부 우회, 포지션 없을 때만 1회 LONG 진입 (UDS 검증용)
|
||||
if self.config.testnet:
|
||||
ind = Indicators(df)
|
||||
df_with_indicators = ind.calculate_all()
|
||||
current_price = df_with_indicators["close"].iloc[-1]
|
||||
# 로컬 상태 + 바이낸스 포지션 모두 체크
|
||||
if self.current_trade_side is not None:
|
||||
logger.info(f"[{self.symbol}] [DEMO] 포지션 보유 중 (로컬) — SL/TP 대기 | 현재가: {current_price:.4f}")
|
||||
return
|
||||
position = await self.exchange.get_position()
|
||||
if position is not None:
|
||||
logger.info(f"[{self.symbol}] [DEMO] 포지션 보유 중 (바이낸스) — SL/TP 대기 | 현재가: {current_price:.4f}")
|
||||
return
|
||||
logger.info(f"[{self.symbol}] [DEMO] 강제 LONG 진입 | 현재가: {current_price:.4f}")
|
||||
await self._open_position("LONG", df_with_indicators)
|
||||
return
|
||||
|
||||
self.ml_filter.check_and_reload()
|
||||
|
||||
# 가격 수익률 계산 (oi_price_spread용)
|
||||
@@ -418,15 +439,26 @@ class TradingBot:
|
||||
balance=per_symbol_balance, price=price, leverage=self.config.leverage, margin_ratio=margin_ratio
|
||||
)
|
||||
logger.info(f"[{self.symbol}] 포지션 크기: 잔고={per_symbol_balance:.2f}/{balance:.2f} USDT, 증거금비율={margin_ratio:.1%}, 수량={quantity}")
|
||||
# df는 이미 calculate_all() 적용된 df_with_indicators이므로
|
||||
# Indicators를 재생성하지 않고 ATR을 직접 사용
|
||||
atr = df["atr"].iloc[-1]
|
||||
if signal == "LONG":
|
||||
stop_loss = price - atr * self.strategy.atr_sl_mult
|
||||
take_profit = price + atr * self.strategy.atr_tp_mult
|
||||
# Demo 모드: 고정 퍼센트 SL/TP (ATR이 너무 작아 즉시 트리거 방지)
|
||||
if self.config.testnet:
|
||||
sl_pct = 0.005 # 0.5%
|
||||
tp_pct = 0.02
|
||||
if signal == "LONG":
|
||||
stop_loss = price * (1 - sl_pct)
|
||||
take_profit = price * (1 + tp_pct)
|
||||
else:
|
||||
stop_loss = price * (1 + sl_pct)
|
||||
take_profit = price * (1 - tp_pct)
|
||||
else:
|
||||
stop_loss = price + atr * self.strategy.atr_sl_mult
|
||||
take_profit = price - atr * self.strategy.atr_tp_mult
|
||||
# df는 이미 calculate_all() 적용된 df_with_indicators이므로
|
||||
# Indicators를 재생성하지 않고 ATR을 직접 사용
|
||||
atr = df["atr"].iloc[-1]
|
||||
if signal == "LONG":
|
||||
stop_loss = price - atr * self.strategy.atr_sl_mult
|
||||
take_profit = price + atr * self.strategy.atr_tp_mult
|
||||
else:
|
||||
stop_loss = price + atr * self.strategy.atr_sl_mult
|
||||
take_profit = price - atr * self.strategy.atr_tp_mult
|
||||
|
||||
notional = quantity * price
|
||||
if quantity <= 0 or notional < self.exchange.MIN_NOTIONAL:
|
||||
@@ -755,6 +787,9 @@ class TradingBot:
|
||||
self._is_reentering = False
|
||||
|
||||
async def run(self):
|
||||
if self.config.testnet:
|
||||
logger.warning("⚠️ TESTNET MODE ENABLED — 실제 자금이 아닌 테스트넷에서 실행 중")
|
||||
|
||||
s = self.strategy
|
||||
logger.info(
|
||||
f"[{self.symbol}] 봇 시작, 레버리지 {self.config.leverage}x | "
|
||||
@@ -773,10 +808,12 @@ class TradingBot:
|
||||
self.stream.start(
|
||||
api_key=self.config.api_key,
|
||||
api_secret=self.config.api_secret,
|
||||
testnet=self.config.testnet,
|
||||
),
|
||||
user_stream.start(
|
||||
api_key=self.config.api_key,
|
||||
api_secret=self.config.api_secret,
|
||||
testnet=self.config.testnet,
|
||||
),
|
||||
self._position_monitor(),
|
||||
)
|
||||
|
||||
@@ -35,10 +35,18 @@ class Config:
|
||||
signal_threshold: int = 3
|
||||
adx_threshold: float = 25.0
|
||||
volume_multiplier: float = 2.5
|
||||
kline_interval: str = "15m"
|
||||
testnet: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
self.api_key = os.getenv("BINANCE_API_KEY", "")
|
||||
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
|
||||
self.testnet = os.getenv("BINANCE_TESTNET", "").lower() in ("true", "1", "yes")
|
||||
|
||||
if self.testnet:
|
||||
self.api_key = os.getenv("BINANCE_DEMO_API_KEY", "")
|
||||
self.api_secret = os.getenv("BINANCE_DEMO_API_SECRET", "")
|
||||
else:
|
||||
self.api_key = os.getenv("BINANCE_API_KEY", "")
|
||||
self.api_secret = os.getenv("BINANCE_API_SECRET", "")
|
||||
self.symbol = os.getenv("SYMBOL", "XRPUSDT")
|
||||
self.leverage = int(os.getenv("LEVERAGE", "10"))
|
||||
self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", "")
|
||||
@@ -52,6 +60,7 @@ class Config:
|
||||
self.signal_threshold = int(os.getenv("SIGNAL_THRESHOLD", "3"))
|
||||
self.adx_threshold = float(os.getenv("ADX_THRESHOLD", "25"))
|
||||
self.volume_multiplier = float(os.getenv("VOL_MULTIPLIER", "2.5"))
|
||||
self.kline_interval = os.getenv("KLINE_INTERVAL", "15m")
|
||||
|
||||
# symbols: SYMBOLS 환경변수 우선, 없으면 SYMBOL에서 변환
|
||||
symbols_env = os.getenv("SYMBOLS", "")
|
||||
|
||||
@@ -77,10 +77,11 @@ class KlineStream:
|
||||
})
|
||||
logger.info(f"과거 캔들 {len(self.buffer)}개 로드 완료 — 즉시 신호 계산 가능")
|
||||
|
||||
async def start(self, api_key: str, api_secret: str):
|
||||
async def start(self, api_key: str, api_secret: str, testnet: bool = False):
|
||||
client = await AsyncClient.create(
|
||||
api_key=api_key,
|
||||
api_secret=api_secret,
|
||||
demo=testnet,
|
||||
)
|
||||
await self._preload_history(client)
|
||||
bm = BinanceSocketManager(client)
|
||||
@@ -189,10 +190,11 @@ class MultiSymbolStream:
|
||||
self._preload_one(client, symbol, limit) for symbol in self.symbols
|
||||
])
|
||||
|
||||
async def start(self, api_key: str, api_secret: str):
|
||||
async def start(self, api_key: str, api_secret: str, testnet: bool = False):
|
||||
client = await AsyncClient.create(
|
||||
api_key=api_key,
|
||||
api_secret=api_secret,
|
||||
demo=testnet,
|
||||
)
|
||||
await self._preload_history(client)
|
||||
bm = BinanceSocketManager(client)
|
||||
|
||||
@@ -20,6 +20,7 @@ class BinanceFuturesClient:
|
||||
self.client = Client(
|
||||
api_key=config.api_key,
|
||||
api_secret=config.api_secret,
|
||||
demo=config.testnet,
|
||||
)
|
||||
self._qty_precision: int | None = None
|
||||
self._price_precision: int | None = None
|
||||
|
||||
@@ -6,12 +6,15 @@ from loguru import logger
|
||||
class DiscordNotifier:
|
||||
"""Discord 웹훅으로 거래 알림을 전송하는 노티파이어."""
|
||||
|
||||
def __init__(self, webhook_url: str):
|
||||
def __init__(self, webhook_url: str, testnet: bool = False):
|
||||
self.webhook_url = webhook_url
|
||||
self._enabled = bool(webhook_url)
|
||||
self._testnet = testnet
|
||||
|
||||
def _send(self, content: str) -> None:
|
||||
"""알림 전송. 이벤트 루프 내에서는 백그라운드 스레드로 실행하여 블로킹 방지."""
|
||||
if self._testnet:
|
||||
content = f"[TESTNET] {content}"
|
||||
if not self._enabled:
|
||||
logger.debug("Discord 웹훅 URL 미설정 - 알림 건너뜀")
|
||||
return
|
||||
|
||||
@@ -28,11 +28,11 @@ class UserDataStream:
|
||||
# 부분 체결 누적용: order_id → {rp, commission}
|
||||
self._partial_fills: dict[int, dict[str, float]] = {}
|
||||
|
||||
async def start(self, api_key: str, api_secret: str) -> None:
|
||||
async def start(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||
"""User Data Stream 메인 루프 — 봇 종료 시까지 실행."""
|
||||
await self._run_loop(api_key, api_secret)
|
||||
await self._run_loop(api_key, api_secret, testnet)
|
||||
|
||||
async def _run_loop(self, api_key: str, api_secret: str) -> None:
|
||||
async def _run_loop(self, api_key: str, api_secret: str, testnet: bool = False) -> None:
|
||||
"""연결 → 재연결 무한 루프.
|
||||
|
||||
매 재연결마다 AsyncClient + BinanceSocketManager를 새로 생성한다.
|
||||
@@ -44,6 +44,7 @@ class UserDataStream:
|
||||
client = await AsyncClient.create(
|
||||
api_key=api_key,
|
||||
api_secret=api_secret,
|
||||
demo=testnet,
|
||||
)
|
||||
try:
|
||||
bm = BinanceSocketManager(client)
|
||||
@@ -93,12 +94,19 @@ class UserDataStream:
|
||||
if order.get("s", "") != self._symbol:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"[{self._symbol}] UDS 원본: s={order.get('s')} o={order.get('o')} "
|
||||
f"ot={order.get('ot')} x={order.get('x')} X={order.get('X')} "
|
||||
f"R={order.get('R')} S={order.get('S')} ap={order.get('ap')} "
|
||||
f"rp={order.get('rp')}"
|
||||
)
|
||||
|
||||
# x: Execution Type — TRADE만 처리
|
||||
if order.get("x") != "TRADE":
|
||||
return
|
||||
|
||||
order_status = order.get("X", "")
|
||||
order_type = order.get("o", "")
|
||||
order_type = order.get("ot", order.get("o", ""))
|
||||
is_reduce = order.get("R", False)
|
||||
order_id = order.get("i", 0)
|
||||
|
||||
@@ -107,6 +115,12 @@ class UserDataStream:
|
||||
if not is_close:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"[{self._symbol}] 청산 주문 상세: "
|
||||
f"type={order_type}, status={order_status}, "
|
||||
f"reduce={is_reduce}, id={order_id}"
|
||||
)
|
||||
|
||||
fill_rp = float(order.get("rp", "0"))
|
||||
fill_commission = abs(float(order.get("n", "0")))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user