1381 lines
56 KiB
Python
1381 lines
56 KiB
Python
"""
|
||
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()
|