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:
21in7
2026-03-23 13:05:38 +09:00
parent 1135efc5be
commit 0ddd1f6764
9 changed files with 511 additions and 20 deletions

View File

@@ -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"],
})

View File

@@ -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(),
)

View File

@@ -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", "")

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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")))