Files
upbit_trader/upbit_short_ver1.py
2026-03-13 04:37:58 +09:00

1381 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Upbit Short Trading Bot Ver1 - 단타용 Upbit API 트레이딩 시스템
- Upbit Open API 사용 (wss://api.upbit.com/websocket/v1)
- 개미털기(눌림목) 전략 기반 단타 매매
- WebSocket 실시간 체결가 기반 (REST는 캔들 스캔/주문확인에만)
- 체결 기준 포지션 관리 (주문 UUID → state=='done' 폴링 후 반영)
- quant_bot.db 연동 (active_trades, trade_history, env_config, kv_store)
- kis_short_ver2.py 의 단타 전략을 Upbit API 로 변환
"""
import json
import time
import uuid
import random
import hashlib
import logging
import sqlite3
import datetime
import threading
from datetime import datetime as dt
from pathlib import Path
from typing import Dict, List, Optional
from urllib.parse import urlencode
import pandas as pd
import requests
# websocket-client 라이브러리 (pip install websocket-client)
try:
import websocket
_WS_AVAILABLE = True
except ImportError:
_WS_AVAILABLE = False
logging.warning("⚠️ websocket-client 미설치 → pip install websocket-client")
# ============================================================
# 로깅 설정
# ============================================================
logging.basicConfig(
format='[%(asctime)s] %(message)s',
datefmt='%H:%M:%S',
level=logging.INFO,
)
logger = logging.getLogger("UpbitShortBot")
LOG_RED = "\033[91m" # 탈락
LOG_YELLOW = "\033[93m" # 경고
LOG_GREEN = "\033[92m" # 통과
LOG_CYAN = "\033[96m" # 강조
LOG_RESET = "\033[0m"
SCRIPT_DIR = Path(__file__).resolve().parent
# ============================================================
# DB 모듈 (quant_bot.db 연동)
# ============================================================
class UpbitDB:
"""
quant_bot.db 연동 클래스 (기존 스키마 100% 존중)
- env_config : 전략 설정값 읽기
- kv_store : Upbit API 키 저장/읽기
- active_trades : 현재 보유 포지션 (재시작 복원용)
- trade_history : 매매 기록 (손익 통계)
- target_candidates: 스캔 후보 목록
"""
def __init__(self, db_path: str):
self.db_path = db_path
self._lock = threading.Lock()
self._init_upbit_keys()
def _conn(self):
"""SQLite 연결 (멀티스레드 안전, WAL 모드)"""
conn = sqlite3.connect(self.db_path, check_same_thread=False, timeout=15)
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _init_upbit_keys(self):
"""kv_store 에 Upbit API 키 슬롯이 없으면 빈값으로 초기화"""
with self._lock:
with self._conn() as conn:
conn.execute("INSERT OR IGNORE INTO kv_store (k, v) VALUES ('UPBIT_ACCESS_KEY', '')")
conn.execute("INSERT OR IGNORE INTO kv_store (k, v) VALUES ('UPBIT_SECRET_KEY', '')")
conn.execute("INSERT OR IGNORE INTO kv_store (k, v) VALUES ('UPBIT_SCAN_INTERVAL_SEC', '60')")
conn.execute("INSERT OR IGNORE INTO kv_store (k, v) VALUES ('UPBIT_BUY_TOP_N', '2')")
conn.commit()
# ---- env_config 읽기 ----
def get_latest_env(self) -> dict:
"""env_config 최신 row → {컬럼명: 값} 딕셔너리"""
with self._lock:
with self._conn() as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1")
row = cur.fetchone()
return dict(row) if row else {}
# ---- kv_store ----
def get_kv(self, key: str, default: str = "") -> str:
with self._lock:
with self._conn() as conn:
cur = conn.execute("SELECT v FROM kv_store WHERE k=?", (key,))
row = cur.fetchone()
return row[0] if row and row[0] else default
def set_kv(self, key: str, value: str):
with self._lock:
with self._conn() as conn:
conn.execute("INSERT OR REPLACE INTO kv_store (k, v) VALUES (?, ?)", (key, value))
conn.commit()
# ---- active_trades ----
def load_active_trades(self) -> List[dict]:
"""재시작 시 보유 포지션 복원 (HOLDING 상태만)"""
with self._lock:
with self._conn() as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute("SELECT * FROM active_trades WHERE status='HOLDING'")
return [dict(r) for r in cur.fetchall()]
def upsert_trade(self, trade: dict):
"""
매수 체결 즉시 active_trades 에 원자적 저장
(재시작 시 포지션 복원 가능하도록)
"""
now = dt.now().strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
with self._conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO active_trades (
code, name, strategy,
avg_buy_price, current_price,
stop_price, target_price, max_price, atr_entry,
target_qty, current_qty, total_invested,
status, buy_date, updated_at,
rsi, volume_ratio, tail_length_pct
) VALUES (
:code, :name, :strategy,
:avg_buy_price, :current_price,
:stop_price, :target_price, :max_price, :atr_entry,
:target_qty, :current_qty, :total_invested,
:status, :buy_date, :updated_at,
:rsi, :volume_ratio, :tail_length_pct
)
""", {**trade, 'updated_at': now})
conn.commit()
def update_trade_max_price(self, code: str, current_price: float, max_price: float):
"""매도 체크 루프에서 고점 갱신 시 즉시 저장"""
now = dt.now().strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
with self._conn() as conn:
conn.execute(
"UPDATE active_trades SET current_price=?, max_price=?, updated_at=? WHERE code=?",
(current_price, max_price, now, code)
)
conn.commit()
def close_trade(self, code: str, sell_price: float, sell_reason: str):
"""
매도 체결 즉시: active_trades 제거 + trade_history 추가 (원자적)
이벤트 발생 즉시 저장 → 재시작 시 중복 매도 방지
"""
now = dt.now().strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
with self._conn() as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute("SELECT * FROM active_trades WHERE code=?", (code,))
row = cur.fetchone()
if not row:
return
row = dict(row)
buy_price = row['avg_buy_price']
qty = row['current_qty']
buy_date = row['buy_date']
profit_rate = (sell_price - buy_price) / buy_price * 100 if buy_price else 0
realized_pnl = (sell_price - buy_price) * qty
hold_minutes = 0
try:
hold_minutes = int((dt.now() - dt.strptime(buy_date, "%Y-%m-%d %H:%M:%S")).total_seconds() / 60)
except Exception:
pass
conn.execute("""
INSERT INTO trade_history (
code, name, strategy,
buy_price, sell_price, qty,
profit_rate, realized_pnl, hold_minutes,
buy_date, sell_date, sell_reason,
rsi, volume_ratio, tail_length_pct
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
code, row['name'], row.get('strategy', ''),
buy_price, sell_price, qty,
profit_rate, realized_pnl, hold_minutes,
buy_date, now, sell_reason,
row.get('rsi'), row.get('volume_ratio'), row.get('tail_length_pct'),
))
conn.execute("DELETE FROM active_trades WHERE code=?", (code,))
conn.commit()
# ---- target_candidates ----
def upsert_candidate(self, code: str, name: str, score: float, price: float):
"""스캔 후보 이벤트 즉시 저장"""
now = dt.now().strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
with self._conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO target_candidates (code, name, score, price, scan_time, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (code, name, score, price, now, now))
conn.commit()
def clear_old_candidates(self, hours: int = 2):
"""오래된 후보 정리"""
cutoff = (dt.now() - datetime.timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
with self._conn() as conn:
conn.execute("DELETE FROM target_candidates WHERE updated_at < ?", (cutoff,))
conn.commit()
# ---- 반켈리 승률 계산 ----
def calculate_half_kelly(self, lookback: int = 50) -> float:
"""최근 N건 거래 기준 Half-Kelly 비율 (5~50% 클리핑)"""
with self._lock:
with self._conn() as conn:
cur = conn.execute(
"SELECT profit_rate FROM trade_history ORDER BY id DESC LIMIT ?", (lookback,)
)
rows = cur.fetchall()
if not rows:
return 0.25 # 기본값
profits = [r[0] for r in rows]
wins = [p for p in profits if p > 0]
if not wins:
return 0.05
win_rate = len(wins) / len(profits)
avg_win = sum(wins) / len(wins)
losses = [abs(p) for p in profits if p < 0]
avg_loss = sum(losses) / len(losses) if losses else 1.0
b = avg_win / avg_loss if avg_loss else 1.0
kelly = (b * win_rate - (1 - win_rate)) / b if b else 0
return max(0.05, min(0.5, kelly / 2))
# ============================================================
# 전역 DB & env 헬퍼
# ============================================================
_db: Optional[UpbitDB] = None # run() 에서 초기화
def _env_raw(key: str, default: str = "") -> str:
env = _db.get_latest_env() if _db else {}
val = env.get(key)
if not val:
val = _db.get_kv(key) if _db else ""
if not val:
val = default
if isinstance(val, str) and "#" in val:
val = val.split("#")[0].strip()
return str(val) if val is not None else str(default)
def get_env_float(key: str, default: float) -> float:
try:
return float(_env_raw(key, str(default)))
except (ValueError, TypeError):
return default
def get_env_int(key: str, default: int) -> int:
try:
return int(float(_env_raw(key, str(default))))
except (ValueError, TypeError):
return default
def get_env_bool(key: str, default: bool = False) -> bool:
return _env_raw(key, str(default)).lower() in ("true", "1", "yes")
def get_env_str(key: str, default: str = "") -> str:
return _env_raw(key, default)
# ============================================================
# Mattermost 알림
# ============================================================
def send_mm(msg: str):
"""Mattermost 알림 전송 (실패 시 로그만 남기고 봇 계속 실행)"""
try:
server = get_env_str("MM_SERVER_URL", "")
token = get_env_str("MM_BOT_TOKEN_", "")
channel = get_env_str("MATTERMOST_CHANNEL", "upbit")
if not server or not token:
return
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
r = requests.get(f"{server}/api/v4/channels/name/{channel}", headers=headers, timeout=5)
if r.status_code != 200:
return
channel_id = r.json().get("id", "")
requests.post(
f"{server}/api/v4/posts",
headers=headers,
json={"channel_id": channel_id, "message": msg},
timeout=5,
)
except Exception as e:
logger.debug(f"MM 발송 실패: {e}")
# ============================================================
# WebSocket 실시간 체결가 캐시
# ============================================================
class UpbitWSPriceCache:
"""
Upbit WebSocket 실시간 체결가 캐시
- URL : wss://api.upbit.com/websocket/v1
- 타입 : trade (체결 틱) → SIMPLE 포맷
- 캐시 : {market: {price, volume, timestamp}}
- 자동 재연결: 끊기면 5초 후 재시도
- 구독 갱신: subscribe()/unsubscribe() 호출 즉시 서버에 반영
[SIMPLE 포맷 주요 필드]
cd : 마켓코드 (KRW-BTC 등)
tp : 체결가 (trade price)
tv : 체결량 (trade volume)
tms : 체결 타임스탬프 (ms)
"""
WS_URL = "wss://api.upbit.com/websocket/v1"
def __init__(self):
self._cache: Dict[str, dict] = {} # {market: {price, volume, timestamp}}
self._lock = threading.Lock()
self._codes = set() # 구독 중인 마켓 코드
self._ws: Optional["websocket.WebSocketApp"] = None
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self.is_active = False
# ---- 구독 관리 ----
def subscribe(self, *markets: str):
"""마켓 추가 구독 → 즉시 서버에 재구독 메시지 전송"""
with self._lock:
before = len(self._codes)
self._codes.update(markets)
changed = len(self._codes) != before
if changed and self._ws and self.is_active:
self._send_subscribe()
def unsubscribe(self, *markets: str):
"""마켓 구독 해제 → 즉시 서버에 재구독 메시지 전송"""
with self._lock:
self._codes.difference_update(markets)
# 캐시에서도 제거
for m in markets:
self._cache.pop(m, None)
if self._ws and self.is_active:
self._send_subscribe()
def _send_subscribe(self):
"""현재 구독 목록으로 WebSocket 구독 메시지 전송"""
with self._lock:
codes = list(self._codes)
if not codes or not self._ws:
return
payload = json.dumps([
{"ticket": str(uuid.uuid4())},
{"type": "trade", "codes": codes, "isOnlyRealtime": True},
{"format": "SIMPLE"},
])
try:
self._ws.send(payload)
logger.info(f"[WS] 구독 갱신: {len(codes)}개 마켓")
except Exception as e:
logger.warning(f"[WS] 구독 전송 실패: {e}")
# ---- 가격 조회 ----
def get_price(self, market: str, max_age_sec: float = 8.0) -> Optional[dict]:
"""
캐시에서 현재 체결가 반환
- max_age_sec 이내 수신분만 유효 (오래된 건 REST fallback 유도)
- 미구독/만료면 None 반환
"""
with self._lock:
data = self._cache.get(market)
if not data:
return None
if time.time() - data['timestamp'] > max_age_sec:
return None
return data
# ---- WebSocket 콜백 ----
def _on_open(self, ws):
self.is_active = True
logger.info("[WS] Upbit WebSocket 연결 완료")
self._send_subscribe()
def _on_message(self, ws, raw):
"""
체결 틱 수신 → price_cache 갱신
- SIMPLE 포맷: {"ty":"trade","cd":"KRW-BTC","tp":50000000.0,...}
"""
try:
# Upbit 은 바이너리(UTF-8)로 올 때도 있음
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
data = json.loads(raw)
market = data.get("cd")
price = data.get("tp") # 체결가
volume = data.get("tv") # 체결량
if market and price:
with self._lock:
self._cache[market] = {
"price": float(price),
"volume": float(volume) if volume else 0.0,
"timestamp": time.time(),
}
except Exception as e:
logger.debug(f"[WS] 메시지 파싱 오류: {e}")
def _on_error(self, ws, error):
logger.warning(f"[WS] 오류: {error}")
self.is_active = False
def _on_close(self, ws, code, msg):
logger.warning(f"[WS] 연결 종료 (code={code})")
self.is_active = False
# ---- 시작/종료 ----
def start(self, initial_markets: list = None):
"""WebSocket 백그라운드 스레드 시작"""
if not _WS_AVAILABLE:
logger.error("websocket-client 미설치 → WebSocket 비활성화")
return
if initial_markets:
with self._lock:
self._codes.update(initial_markets)
self._stop.clear()
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="UpbitWS")
self._thread.start()
logger.info("[WS] WebSocket 스레드 시작")
def stop(self):
"""WebSocket 종료"""
self._stop.set()
if self._ws:
try:
self._ws.close()
except Exception:
pass
self.is_active = False
logger.info("[WS] WebSocket 종료")
def _run_loop(self):
"""WebSocket 자동 재연결 루프 (5초 간격)"""
while not self._stop.is_set():
try:
self._ws = websocket.WebSocketApp(
self.WS_URL,
on_open=self._on_open,
on_message=self._on_message,
on_error=self._on_error,
on_close=self._on_close,
)
# ping/pong 으로 연결 유지
self._ws.run_forever(ping_interval=30, ping_timeout=10)
except Exception as e:
logger.warning(f"[WS] run_forever 예외: {e}")
finally:
self.is_active = False
if not self._stop.is_set():
logger.info("[WS] 5초 후 재연결...")
time.sleep(5)
# ============================================================
# Upbit REST API 클라이언트
# ============================================================
class UpbitClient:
"""
Upbit REST API 클라이언트
- Exchange API (주문/계좌): JWT 인증 필요
- Quotation API (시세/캔들): 인증 불필요
- HTTP 429 자동 재시도 (점진적 대기)
[레이트리밋]
- 주문 API: 분당 200회
- 시세 API: 분당 600회
→ 요청 간 0.12초 갭 유지 (안전 마진)
"""
BASE = "https://api.upbit.com/v1"
def __init__(self, access_key: str, secret_key: str):
self.access_key = access_key
self.secret_key = secret_key
self._last_t = 0.0
self._last_order_error = None
def _throttle(self, gap: float = 0.12):
"""요청 간격 조절 (레이트리밋 방지)"""
elapsed = time.time() - self._last_t
if elapsed < gap:
time.sleep(gap - elapsed)
self._last_t = time.time()
# ---- JWT 인증 ----
def _make_jwt(self, query_string: str = "") -> str:
try:
import jwt as pyjwt
except ImportError:
raise RuntimeError("PyJWT 필요: pip install PyJWT")
payload = {"access_key": self.access_key, "nonce": str(uuid.uuid4())}
if query_string:
qh = hashlib.sha512(query_string.encode()).hexdigest()
payload["query_hash"] = qh
payload["query_hash_alg"] = "SHA512"
return pyjwt.encode(payload, self.secret_key, algorithm="HS256")
def _auth_headers(self, qs: str = "") -> dict:
return {"Authorization": f"Bearer {self._make_jwt(qs)}"}
# ---- 공통 GET / POST ----
def _get(self, path: str, params: dict = None, auth: bool = False, retries: int = 5) -> Optional[dict]:
self._throttle()
url = f"{self.BASE}{path}"
qs = urlencode(params, doseq=True) if params else ""
hdrs = self._auth_headers(qs) if auth else {}
for attempt in range(retries):
try:
r = requests.get(url, params=params, headers=hdrs, timeout=10)
if r.status_code == 429:
wait = 1.0 + attempt
logger.warning(f"⏳ REST 429 → {wait:.0f}초 대기 (attempt {attempt+1})")
time.sleep(wait)
continue
if r.status_code == 200:
return r.json()
logger.warning(f"[REST] GET {path}{r.status_code}: {r.text[:200]}")
return None
except requests.RequestException as e:
wait = (2 ** attempt) + random.uniform(0.3, 1.0)
logger.warning(f"⚠️ 네트워크 오류 ({attempt+1}/{retries}): {e}{wait:.1f}")
time.sleep(wait)
return None
def _post(self, path: str, body: dict, retries: int = 3) -> Optional[dict]:
self._throttle()
url = f"{self.BASE}{path}"
qs = urlencode(body, doseq=True)
hdrs = {**self._auth_headers(qs), "Content-Type": "application/json"}
for attempt in range(retries):
try:
r = requests.post(url, json=body, headers=hdrs, timeout=10)
if r.status_code == 429:
wait = 3.0 + attempt * 2
logger.warning(f"⏳ REST 429 → {wait:.0f}초 대기 (attempt {attempt+1})")
time.sleep(wait)
continue
data = r.json()
if r.status_code in (200, 201):
return data
self._last_order_error = data
logger.warning(f"[REST] POST {path}{r.status_code}: {data}")
return None
except requests.RequestException as e:
wait = (2 ** attempt) + random.uniform(0.3, 1.0)
logger.warning(f"⚠️ 네트워크 오류 ({attempt+1}/{retries}): {e}")
time.sleep(wait)
return None
# ---- 계좌 ----
def get_accounts(self) -> list:
return self._get("/accounts", auth=True) or []
def get_krw_balance(self) -> float:
"""가용 KRW 잔고 반환"""
for acc in self.get_accounts():
if acc.get("currency") == "KRW":
# balance: 전체 잔고, locked: 주문 중 금액
return float(acc.get("balance", 0))
return 0.0
# ---- 시세 (Quotation) ----
def get_market_list(self) -> list:
return self._get("/market/all") or []
def get_ticker(self, markets: list) -> list:
return self._get("/ticker", params={"markets": ",".join(markets)}) or []
def get_candles_minutes(self, market: str, unit: int = 3, count: int = 50) -> list:
"""
분봉 조회 (unit: 1,3,5,10,15,30,60,240)
응답: 최신 봉이 인덱스 0 (시간 역순) → candles_to_df() 에서 정렬
"""
return self._get(f"/candles/minutes/{unit}",
params={"market": market, "count": count}) or []
def get_candles_days(self, market: str, count: int = 30) -> list:
return self._get("/candles/days", params={"market": market, "count": count}) or []
# ---- 주문 ----
def order_market_buy(self, market: str, price_krw: float) -> Optional[str]:
"""
시장가 매수 주문 (금액 기준)
Returns: 주문 UUID → wait_for_fill() 에 전달
"""
body = {
"market": market,
"side": "bid",
"price": str(int(price_krw)),
"ord_type": "price", # 금액 지정 시장가 매수
}
result = self._post("/orders", body)
return result.get("uuid") if result else None
def order_market_sell(self, market: str, volume: float) -> Optional[str]:
"""
시장가 매도 주문 (수량 기준)
Returns: 주문 UUID → wait_for_fill() 에 전달
"""
body = {
"market": market,
"side": "ask",
"volume": str(volume),
"ord_type": "market", # 수량 지정 시장가 매도
}
result = self._post("/orders", body)
return result.get("uuid") if result else None
def wait_for_fill(self, order_uuid: str, timeout_sec: int = 20, poll: float = 0.5) -> Optional[dict]:
"""
★ 체결 확인 (폴링)
- state == 'done' 이 될 때까지 REST 폴링
- 실제 체결가(avg_price) / 체결량(filled_volume) 반환
- timeout_sec 초 내 미체결 시 None 반환
Upbit 주문 상태:
wait : 대기 (미체결)
watch : 예약 (예약주문)
done : 완료 (체결 완료)
cancel : 취소
"""
deadline = time.time() + timeout_sec
while time.time() < deadline:
data = self._get("/order", params={"uuid": order_uuid}, auth=True)
if not data:
time.sleep(poll)
continue
state = data.get("state")
if state == "done":
# trades 배열로 정확한 평균 체결가 계산
trades = data.get("trades", [])
if trades:
total_vol = sum(float(t["volume"]) for t in trades)
total_cost = sum(float(t["price"]) * float(t["volume"]) for t in trades)
avg_price = total_cost / total_vol if total_vol else 0.0
paid_fee = sum(float(t.get("funds", 0)) * 0.0005 for t in trades)
else:
# trades 없으면 API 필드로 fallback
avg_price = float(data.get("avg_price") or data.get("price", 0))
total_vol = float(data.get("executed_volume", 0))
paid_fee = float(data.get("paid_fee", 0))
return {
"avg_price": avg_price,
"filled_volume": total_vol,
"paid_fee": paid_fee,
}
if state == "cancel":
logger.warning(f"[체결확인] 주문 취소됨: {order_uuid}")
return None
time.sleep(poll)
logger.warning(f"[체결확인] 타임아웃 ({timeout_sec}s): {order_uuid}")
return None
def cancel_order(self, order_uuid: str) -> bool:
hdrs = self._auth_headers(f"uuid={order_uuid}")
try:
r = requests.delete(f"{self.BASE}/order",
params={"uuid": order_uuid},
headers=hdrs, timeout=10)
return r.status_code == 200
except Exception:
return False
# ============================================================
# 지표 계산 유틸
# ============================================================
def candles_to_df(candles: list) -> pd.DataFrame:
"""
Upbit 분봉/일봉 리스트 → DataFrame (시간 오름차순)
Upbit 응답은 최신 봉이 인덱스 0 (역순) → sort 필수
"""
if not candles:
return pd.DataFrame()
df = pd.DataFrame(candles).rename(columns={
"opening_price": "open",
"high_price": "high",
"low_price": "low",
"trade_price": "close",
"candle_acc_trade_volume": "volume",
"candle_date_time_kst": "datetime",
})
for col in ("open", "high", "low", "close", "volume"):
if col in df.columns:
df[col] = df[col].astype(float)
if "datetime" in df.columns:
df = df.sort_values("datetime").reset_index(drop=True)
return df
def calc_rsi(close: pd.Series, period: int = 14) -> float:
"""RSI 계산 (마지막 값 반환). 데이터 부족 시 50 반환"""
if len(close) < period + 1:
return 50.0
delta = close.diff()
gain = delta.where(delta > 0, 0.0).rolling(period).mean()
loss = (-delta.where(delta < 0, 0.0)).rolling(period).mean()
rs = gain / loss.replace(0, float("nan"))
rsi = 100 - (100 / (1 + rs))
val = rsi.iloc[-1]
return float(val) if not pd.isna(val) else 50.0
def calc_atr(df: pd.DataFrame, period: int = 14) -> float:
"""ATR (Average True Range) 계산. 데이터 부족 시 0 반환"""
if df.empty or len(df) < period + 1:
return 0.0
h, l, c = df["high"], df["low"], df["close"]
tr = pd.concat([(h - l), (h - c.shift(1)).abs(), (l - c.shift(1)).abs()], axis=1).max(axis=1)
val = tr.rolling(period).mean().iloc[-1]
return float(val) if not pd.isna(val) else 0.0
# ============================================================
# 단타 트레이더 (메인 클래스)
# ============================================================
class UpbitShortTrader:
"""
Upbit 단타 트레이더 (개미털기/눌림목 전략)
[아키텍처]
┌─────────────────────────────────────────────┐
│ WebSocket 스레드 (daemon) │
│ wss://api.upbit.com/websocket/v1 │
│ → trade 체결 틱 수신 → price_cache 업데이트 │
└───────────────────┬─────────────────────────┘
│ (캐시 읽기, 잠금 없음)
┌───────────────────▼─────────────────────────┐
│ 메인 루프 (2초 주기) │
│ ① 매도 신호 체크 → WS 캐시 우선, REST fallback│
│ ② 매수 스캔 (60초마다) → REST 3분봉 │
└───────────────────┬─────────────────────────┘
┌───────────────────▼─────────────────────────┐
│ 주문 실행 → REST POST /orders │
│ 체결 확인 → REST GET /order (UUID 폴링) │
│ ★ state=='done' 확인 후에만 포지션 반영 │
└─────────────────────────────────────────────┘
"""
def __init__(self):
global _db
_db = UpbitDB(str(SCRIPT_DIR / "quant_bot.db"))
self.db = _db
# API 키 (kv_store 우선, env_config fallback)
access_key = self.db.get_kv("UPBIT_ACCESS_KEY") or get_env_str("UPBIT_ACCESS_KEY", "")
secret_key = self.db.get_kv("UPBIT_SECRET_KEY") or get_env_str("UPBIT_SECRET_KEY", "")
if not access_key or not secret_key:
logger.warning("⚠️ Upbit API 키 미설정 → kv_store 에 UPBIT_ACCESS_KEY/UPBIT_SECRET_KEY 저장 필요")
self.client = UpbitClient(access_key, secret_key)
self.ws_cache = UpbitWSPriceCache()
# 보유 포지션 메모리 {code: holding_dict}
self.holdings: Dict[str, dict] = {}
# 최근 매도 쿨다운 {code: timestamp}
self.recently_sold: Dict[str, float] = {}
# 설정 로드
self._load_settings()
# DB 에서 보유 포지션 복원 (재시작 시)
self._restore_holdings()
def _load_settings(self):
"""DB env_config 에서 전략 파라미터 로드 (모두 get_env_* 로 하드코딩 금지)"""
self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.02)
self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05)
self.max_stocks = get_env_int("MAX_STOCKS", 5)
self.min_drop_rate = get_env_float("MIN_DROP_RATE", 0.03)
self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO", 0.30)
self.rsi_overheat = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0)
self.tail_ratio_min = get_env_float("TAIL_RATIO_MIN", 1.5)
self.tail_pct_min = get_env_float("TAIL_PCT_MIN", 0.003)
self.shoulder_cut_pct = get_env_float("SHOULDER_CUT_PCT", 0.03)
self.shoulder_min_high = get_env_float("SHOULDER_MIN_HIGH_PCT", 0.01)
self.scalp_up_mult = get_env_float("SCALP_ATR_UP_MULT", 1.0)
self.scalp_down_mult = get_env_float("SCALP_ATR_DOWN_MULT", 0.2)
self.scalp_drop_mult = get_env_float("SCALP_ATR_DROP_MULT", 1.0)
self.reentry_cooldown = get_env_int("REENTRY_COOLDOWN_SEC", 300)
self.round_trip_cost = get_env_float("ROUND_TRIP_COST_PCT", 0.0005) # Upbit 수수료 0.05%
logger.info(
f"⚙️ 설정 로드 | 손절={self.stop_loss_pct*100:.1f}% "
f"| 익절={self.take_profit_pct*100:.1f}% "
f"| 최대종목={self.max_stocks}"
)
def _restore_holdings(self):
"""재시작 시 DB active_trades → 메모리 복원 + WS 재구독"""
rows = self.db.load_active_trades()
for r in rows:
code = r["code"]
self.holdings[code] = {
"name": r["name"],
"buy_price": r["avg_buy_price"],
"qty": r["current_qty"],
"buy_time": r["buy_date"],
"max_price": r.get("max_price") or r["avg_buy_price"],
"stop_price": r.get("stop_price") or r["avg_buy_price"] * (1 + self.stop_loss_pct),
"target_price": r.get("target_price") or r["avg_buy_price"] * (1 + self.take_profit_pct),
"atr_entry": r.get("atr_entry") or 0.0,
"rsi": r.get("rsi") or 50.0,
"volume_ratio": r.get("volume_ratio") or 1.0,
"tail_length_pct": r.get("tail_length_pct") or 0.0,
}
if rows:
logger.info(f"♻️ 보유 포지션 복원: {list(self.holdings.keys())}")
# --------------------------------------------------------
# 매수 스캔 (REST 캔들 기반, 60초 주기)
# --------------------------------------------------------
def scan_buy_candidates(self, market_list: list) -> list:
"""
전체 KRW 마켓 순회 → 개미털기(눌림목) 조건 필터링
[조건 순서]
1. 낙폭: 시가 대비 저가 낙폭 ≥ MIN_DROP_RATE
2. 회복률: 저가→현재가 회복률 [MIN_RECOVERY_RATIO ~ MAX_RECOVERY_RATIO]
3. 꼬리봉: 밑꼬리/몸통 비율 ≥ TAIL_RATIO_MIN, 꼬리% ≥ TAIL_PCT_MIN
4. 고점 추격 방지: 현재가 < 당일고점 × HIGH_PRICE_CHASE_THRESHOLD
5. RSI: < RSI_OVERHEAT_THRESHOLD
6. 점수 계산 후 내림차순 정렬
"""
candidates = []
for market in market_list:
if market in self.holdings:
continue # 이미 보유 중
# 재진입 쿨다운
if market in self.recently_sold:
if time.time() - self.recently_sold[market] < self.reentry_cooldown:
continue
try:
# 3분봉 50개 (약 2.5시간)
candles = self.client.get_candles_minutes(market, unit=3, count=50)
if not candles or len(candles) < 20:
continue
df = candles_to_df(candles)
current_price = df["close"].iloc[-1]
day_open = df["open"].iloc[0]
day_high = df["high"].max()
# 0인 저가(비정상 봉) 제외
valid_lows = df["low"][df["low"] > 0]
day_low = float(valid_lows.min()) if not valid_lows.empty else df["low"].min()
day_range = day_high - day_low
# [1] 낙폭 필터
drop_rate = (day_open - day_low) / day_open if day_open > 0 else 0
if drop_rate < self.min_drop_rate:
logger.info(f"{LOG_YELLOW}[탈락-낙폭] {market}: {drop_rate*100:.1f}% < {self.min_drop_rate*100:.1f}%{LOG_RESET}")
continue
# [2] 회복률 필터
recovery = (current_price - day_low) / day_range if day_range > 0 else 0
max_rec = get_env_float("MAX_RECOVERY_RATIO", 0.8)
if not (self.min_recovery_ratio <= recovery <= max_rec):
logger.info(f"{LOG_YELLOW}[탈락-회복] {market}: {recovery*100:.1f}%{LOG_RESET}")
continue
# [3] 망치봉 꼬리 계산 (마지막 봉 기준)
last = df.iloc[-1]
c_open, c_close = last["open"], last["close"]
c_low, c_high = last["low"], last["high"]
body_top = max(c_open, c_close)
body_bottom = min(c_open, c_close)
body_len = max(body_top - body_bottom, 1.0)
tail_len = max(body_bottom - c_low, 0.0)
tail_ratio = tail_len / body_len
tail_pct = tail_len / c_low if c_low > 0 else 0
# 마지막 봉에 꼬리가 없으면 최근 5봉 재탐색 (장외 도지 대응)
if tail_len <= 0 and len(df) >= 2:
for idx in range(len(df) - 2, max(-1, len(df) - 6), -1):
c = df.iloc[idx]
bt = max(c["open"], c["close"])
bb = min(c["open"], c["close"])
bl = max(bt - bb, 1.0)
tl = max(bb - c["low"], 0.0)
if tl > 0:
tail_ratio = tl / bl
tail_pct = tl / c["low"] if c["low"] > 0 else 0
tail_len = tl
break
if tail_ratio < self.tail_ratio_min or tail_pct < self.tail_pct_min:
logger.info(f"{LOG_YELLOW}[탈락-꼬리] {market}: 비율={tail_ratio:.2f}, pct={tail_pct*100:.2f}%{LOG_RESET}")
continue
# [4] 고점 추격 방지
hc_thr = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96)
if current_price >= day_high * hc_thr:
logger.info(f"{LOG_YELLOW}[탈락-고점추격] {market}{LOG_RESET}")
continue
# [5] RSI 과열 방지
rsi = calc_rsi(df["close"])
if rsi >= self.rsi_overheat:
logger.info(f"{LOG_YELLOW}[탈락-RSI] {market}: {rsi:.1f}{LOG_RESET}")
continue
# [6] 거래량 비율
avg_vol = df["volume"].mean()
last_vol = df["volume"].iloc[-1]
volume_ratio = last_vol / avg_vol if avg_vol > 0 else 1.0
# [7] ATR / 점수
atr = calc_atr(df)
score = get_env_float("TAIL_SCORE_BASE", 5.0) + \
tail_ratio * get_env_float("TAIL_SCORE_RATIO_MULT", 2.0)
candidates.append({
"code": market,
"name": market.replace("KRW-", ""),
"price": current_price,
"score": score,
"atr": atr,
"rsi": rsi,
"volume_ratio": volume_ratio,
"tail_ratio": tail_ratio,
"tail_pct": tail_pct,
"recovery": recovery,
"drop_rate": drop_rate,
})
logger.info(
f"{LOG_GREEN}🎯 [후보] {market} | 가격:{current_price:,.0f} "
f"| 꼬리:{tail_ratio:.1f}x | RSI:{rsi:.1f} | 점수:{score:.1f}{LOG_RESET}"
)
# DB 후보 즉시 저장
self.db.upsert_candidate(market, market.replace("KRW-", ""), score, current_price)
except Exception as e:
logger.debug(f"[스캔] {market} 오류: {e}")
# 마켓 간 레이트리밋 방지
time.sleep(random.uniform(0.12, 0.20))
candidates.sort(key=lambda x: x["score"], reverse=True)
return candidates
# --------------------------------------------------------
# 매도 신호 체크 (WebSocket 캐시 기반, 2초 주기)
# --------------------------------------------------------
def check_sell_signals(self) -> list:
"""
보유 포지션 매도 신호 체크
- 가격 소스: WebSocket 캐시 우선 (max_age=8초), 만료 시 REST fallback
- 주문이 아닌 신호만 생성 → execute_sell() 에서 실제 주문/체결 확인
[매도 우선순위]
1. 어깨매도 : 고점 대비 SHOULDER_CUT_PCT 이상 하락 (수익 보존)
2. ATR 스캘핑: 고점→본절 복귀 또는 고점→ATR 하락
3. 목표가 달성
4. 손절 (% 또는 원화 기준)
"""
if not self.holdings:
return []
signals = []
min_hold = get_env_float("MIN_HOLD_AFTER_BUY_SEC", 10.0)
for code, h in list(self.holdings.items()):
try:
# 최소 보유 시간 체크 (체결 직후 잔고 반영 딜레이 대응)
if h.get("buy_time"):
buy_dt = dt.strptime(h["buy_time"], "%Y-%m-%d %H:%M:%S")
if (dt.now() - buy_dt).total_seconds() < min_hold:
continue
# ★ 현재가: WebSocket 캐시 우선 → REST fallback
ws_data = self.ws_cache.get_price(code, max_age_sec=8.0)
if ws_data:
current_price = ws_data["price"]
_src = "WS"
else:
# WebSocket 미연결 or 데이터 만료 → REST
tickers = self.client.get_ticker([code])
if not tickers:
continue
current_price = float(tickers[0]["trade_price"])
_src = "REST"
if current_price <= 0:
continue
buy_price = h["buy_price"]
qty = h["qty"]
max_price = h.get("max_price", buy_price)
# 고점 갱신 (즉시 DB 저장)
if current_price > max_price:
max_price = current_price
self.holdings[code]["max_price"] = max_price
self.db.update_trade_max_price(code, current_price, max_price)
profit_pct = (current_price - buy_price) / buy_price if buy_price else 0
profit_krw = (current_price - buy_price) * qty
hours_held = 0.0
if h.get("buy_time"):
buy_dt = dt.strptime(h["buy_time"], "%Y-%m-%d %H:%M:%S")
hours_held = (dt.now() - buy_dt).total_seconds() / 3600
atr = h.get("atr_entry", 0) or buy_price * 0.01
stop_price = h.get("stop_price", buy_price * (1 + self.stop_loss_pct))
target_price = h.get("target_price", buy_price * (1 + self.take_profit_pct))
sell_reason = None
# [1] 어깨매도: 고점이 진입가 대비 충분히 올랐을 때만 적용
drop_from_high = (max_price - current_price) / max_price if max_price > 0 else 0
high_above_entry = (max_price - buy_price) / buy_price if buy_price > 0 else 0
# 수수료 반영 손익분기 하한 자동 계산
min_high_required = (1 + self.round_trip_cost + get_env_float("SHOULDER_MIN_NET_PCT", 0.001)) / \
(1 - self.shoulder_cut_pct) - 1 if (1 - self.shoulder_cut_pct) > 0 else 0.01
eff_shoulder_min = max(self.shoulder_min_high, min_high_required)
if drop_from_high >= self.shoulder_cut_pct and high_above_entry >= eff_shoulder_min:
sell_reason = f"어깨매도(고점-{drop_from_high*100:.1f}%)"
# [2] ATR 스캘핑: 고점 달성 후 본절 근처로 내려오면 매도
if not sell_reason and atr > 0:
if max_price >= buy_price + atr * self.scalp_up_mult and \
current_price <= buy_price + atr * self.scalp_down_mult:
sell_reason = "스캘핑_본절사수"
if not sell_reason and \
current_price < (max_price - atr * self.scalp_drop_mult) and profit_pct > 0:
sell_reason = "스캘핑_익절보존"
# [3] 목표가 달성
if not sell_reason and current_price >= target_price:
sell_reason = f"목표달성({profit_pct*100:+.2f}%)"
# [4] 손절 (% 기준)
if not sell_reason and (current_price <= stop_price or profit_pct <= self.stop_loss_pct):
sell_reason = f"손절({profit_pct*100:.2f}%)"
# [5] 원화 손실 한도 (금액 기준 손절)
max_loss_krw = get_env_float("MAX_LOSS_PER_TRADE_KRW", 50000)
if not sell_reason and profit_krw <= -max_loss_krw:
sell_reason = f"금액손절({profit_krw:+,.0f}원)"
if sell_reason:
signals.append({
"code": code,
"name": h["name"],
"reason": sell_reason,
"profit_pct": profit_pct,
"qty": qty,
"price": current_price,
"_src": _src,
})
logger.info(
f"💸 [매도신호/{_src}] {h['name']}({code}): "
f"{sell_reason} | {profit_pct*100:+.2f}%"
)
except Exception as e:
logger.error(f"[매도체크] {code} 오류: {e}")
return signals
# --------------------------------------------------------
# 매수 실행 (★ 체결 기준)
# --------------------------------------------------------
def execute_buy(self, candidate: dict) -> bool:
"""
매수 주문 → 체결 확인 → holdings & DB 반영
[체결 기준 플로우]
1. order_market_buy() → UUID 수령
2. wait_for_fill(UUID) → state=='done' 폴링
3. 실제 avg_price / filled_volume 으로 포지션 등록
4. DB active_trades 즉시 저장 (재시작 복원용)
5. WS 구독 추가 → 이후 매도 체크는 WS 기반
"""
code = candidate["code"]
name = candidate["name"]
price = candidate["price"]
if code in self.holdings:
logger.warning(f"⚠️ [{name}] 이미 보유 → 스킵")
return False
if len(self.holdings) >= self.max_stocks:
logger.warning(f"⚠️ 최대 종목 수 초과 ({self.max_stocks})")
return False
cash = self.client.get_krw_balance()
slot = get_env_float("SLOT_MONEY_DEFAULT", 100000)
if cash < slot * 1.05:
logger.warning(f"⚠️ [{name}] 잔고 부족: {cash:,.0f}원 < {slot*1.05:,.0f}")
return False
amount = min(slot, cash * 0.90)
# 주문
logger.info(f"🟡 [매수주문] {name}({code}) {amount:,.0f}원 시장가")
order_uuid = self.client.order_market_buy(code, amount)
if not order_uuid:
logger.warning(f"❌ [{name}] 매수 주문 실패: {self.client._last_order_error}")
return False
# ★ 체결 확인 (주문 UUID로 REST 폴링)
fill = self.client.wait_for_fill(order_uuid, timeout_sec=20)
if not fill or fill["filled_volume"] <= 0:
logger.warning(f"❌ [{name}] 매수 체결 미확인 (UUID={order_uuid})")
# 미체결이면 주문 취소 시도
self.client.cancel_order(order_uuid)
return False
filled_price = fill["avg_price"]
filled_volume = fill["filled_volume"]
# ATR 기반 손절가/목표가 계산
atr = candidate.get("atr", filled_price * 0.01)
if atr > 0:
stop_price = filled_price - atr * get_env_float("STOP_ATR_MULTIPLIER_TAIL", 2.5)
target_price = filled_price + atr * get_env_float("TARGET_ATR_MULTIPLIER_TAIL", 7.0)
else:
stop_price = filled_price * (1 + self.stop_loss_pct)
target_price = filled_price * (1 + self.take_profit_pct)
buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S")
# 메모리 포지션 등록
self.holdings[code] = {
"name": name,
"buy_price": filled_price,
"qty": filled_volume,
"buy_time": buy_time,
"max_price": filled_price,
"stop_price": stop_price,
"target_price": target_price,
"atr_entry": atr,
"rsi": candidate.get("rsi", 50.0),
"volume_ratio": candidate.get("volume_ratio", 1.0),
"tail_length_pct": candidate.get("tail_pct", 0.0) * 100,
}
# ★ DB 즉시 저장 (이벤트 발생 즉시 → 재시작 복원 가능)
self.db.upsert_trade({
"code": code,
"name": name,
"strategy": "UPBIT_TAIL_CATCH_3M",
"avg_buy_price": filled_price,
"current_price": filled_price,
"stop_price": stop_price,
"target_price": target_price,
"max_price": filled_price,
"atr_entry": atr,
"target_qty": filled_volume,
"current_qty": filled_volume,
"total_invested": filled_price * filled_volume,
"status": "HOLDING",
"buy_date": buy_time,
"rsi": candidate.get("rsi", 50.0),
"volume_ratio": candidate.get("volume_ratio", 1.0),
"tail_length_pct": candidate.get("tail_pct", 0.0) * 100,
})
# WS 구독 추가 → 이후 매도 체크는 WS 기반
self.ws_cache.subscribe(code)
logger.info(
f"✅ [매수체결] {name}({code}): "
f"{filled_price:,.4f}× {filled_volume:.6f} "
f"| 손절={stop_price:,.4f} / 목표={target_price:,.4f}"
)
send_mm(
f"🟢 **매수체결** {name}({code})\n"
f"체결가: {filled_price:,.4f}원 | 수량: {filled_volume:.6f}\n"
f"손절: {stop_price:,.4f} / 목표: {target_price:,.4f}\n"
f"보유: {len(self.holdings)}종목"
)
return True
# --------------------------------------------------------
# 매도 실행 (★ 체결 기준)
# --------------------------------------------------------
def execute_sell(self, signal: dict) -> bool:
"""
매도 주문 → 체결 확인 → holdings & DB 반영
[체결 기준 플로우]
1. order_market_sell() → UUID 수령
2. wait_for_fill(UUID) → state=='done' 폴링
3. 실제 avg_price 로 손익 계산 후 DB 이동
4. DB: active_trades 삭제 + trade_history 추가 (원자적)
5. WS 구독 해제
"""
code = signal["code"]
name = signal["name"]
qty = signal["qty"]
reason = signal["reason"]
if code not in self.holdings:
logger.warning(f"⚠️ [{name}] 보유 목록에 없음 → 스킵")
return False
logger.info(f"🟡 [매도주문] {name}({code}) {qty:.6f}개 시장가 | 사유: {reason}")
order_uuid = self.client.order_market_sell(code, qty)
if not order_uuid:
logger.warning(f"❌ [{name}] 매도 주문 실패: {self.client._last_order_error}")
return False
# ★ 체결 확인 (주문 UUID로 REST 폴링)
fill = self.client.wait_for_fill(order_uuid, timeout_sec=20)
if not fill or fill["filled_volume"] <= 0:
logger.warning(f"❌ [{name}] 매도 체결 미확인 (UUID={order_uuid})")
return False
sell_price = fill["avg_price"]
buy_price = self.holdings[code]["buy_price"]
profit_pct = (sell_price - buy_price) / buy_price if buy_price else 0
profit_krw = (sell_price - buy_price) * fill["filled_volume"]
fee = fill.get("paid_fee", 0)
# ★ DB 즉시 이동 (active_trades → trade_history, 원자적)
self.db.close_trade(code, sell_price, reason)
# 메모리에서 제거
del self.holdings[code]
# 재진입 쿨다운 기록
self.recently_sold[code] = time.time()
# WS 구독 해제 (불필요한 데이터 수신 차단)
self.ws_cache.unsubscribe(code)
logger.info(
f"✅ [매도체결] {name}({code}): "
f"{sell_price:,.4f}× {fill['filled_volume']:.6f} "
f"| {profit_pct*100:+.2f}% / {profit_krw:+,.0f}원 | 사유: {reason}"
)
send_mm(
f"🔴 **매도체결** {name}({code})\n"
f"체결가: {sell_price:,.4f}원 | 사유: {reason}\n"
f"수익률: {profit_pct*100:+.2f}% | 손익: {profit_krw:+,.0f}원 | 수수료: {fee:,.0f}\n"
f"보유: {len(self.holdings)}종목"
)
return True
# --------------------------------------------------------
# 메인 루프
# --------------------------------------------------------
def run(self):
"""
메인 루프
[흐름]
1. 전체 KRW 마켓 목록 로드
2. WebSocket 스레드 시작 (보유 종목 즉시 구독)
3. 매도 루프 (2초 주기) → WS 캐시 기반
4. 매수 스캔 (UPBIT_SCAN_INTERVAL_SEC 주기) → REST 캔들
"""
logger.info("🚀 Upbit 단타 트레이더 시작!")
# KRW 마켓 목록 로드
all_markets = [
m["market"] for m in self.client.get_market_list()
if m["market"].startswith("KRW-")
]
logger.info(f"📋 KRW 마켓 {len(all_markets)}개 로드")
# WebSocket 시작 (보유 종목 즉시 구독)
self.ws_cache.start(initial_markets=list(self.holdings.keys()))
time.sleep(2) # WS 연결 안정화 대기
last_scan_time = 0.0
scan_interval_sec = get_env_int("UPBIT_SCAN_INTERVAL_SEC", 60)
try:
while True:
now = time.time()
# ① 매도 신호 체크 (2초 주기, WS 캐시 기반)
sell_signals = self.check_sell_signals()
for sig in sell_signals:
self.execute_sell(sig)
# ② 매수 스캔 (scan_interval 주기, REST 기반)
if now - last_scan_time >= scan_interval_sec:
last_scan_time = now
self.db.clear_old_candidates()
if len(self.holdings) < self.max_stocks:
logger.info(f"🔍 매수 스캔 시작 ({len(all_markets)}개 마켓)")
candidates = self.scan_buy_candidates(all_markets)
logger.info(f"🎯 후보 {len(candidates)}개 발견")
# 상위 N개 후보 매수 시도
top_n = get_env_int("UPBIT_BUY_TOP_N", 2)
for cand in candidates[:top_n]:
if len(self.holdings) >= self.max_stocks:
break
# 매수 직전 WS 최신가로 가격 갱신 (슬리피지 최소화)
ws_p = self.ws_cache.get_price(cand["code"], max_age_sec=5.0)
if ws_p:
cand["price"] = ws_p["price"]
self.execute_buy(cand)
# ③ 2초 대기 (매도 루프 주기)
time.sleep(2)
except KeyboardInterrupt:
logger.info("⛔ 사용자 종료 요청")
except Exception as e:
logger.exception(f"❌ 메인 루프 예외: {e}")
send_mm(f"🚨 **Upbit 봇 비정상 종료**: {e}")
finally:
self.ws_cache.stop()
logger.info("🛑 Upbit 단타 트레이더 종료")
# ============================================================
# 진입점
# ============================================================
if __name__ == "__main__":
trader = UpbitShortTrader()
trader.run()