Files
kis_bot/kis_scalping_ver1.py
2026-03-17 12:33:30 +09:00

1659 lines
81 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.
"""
kis_scalping_ver1.py — KIS WebSocket 기반 초단타(스캘핑) 봇
════════════════════════════════════════════════════════════
전략: RSI 과매도(<SCALP_RSI_OVERSOLD) 되돌림 + 꼬리잡기 필터
ver2(꼬리잡기) 대비 핵심 차이점
────────────────────────────────
1. 봉 데이터 소스
- ver2: 5분마다 REST get_minute_chart 폴링
- ver3: WebSocket 틱 → CandleAggregator → ws_candles DB
REST 호출은 재접속 갭 보정 시에만 발생
2. 매수 신호
- ver2: 3분봉 망치봉(꼬리) + 낙폭/회복 조건
- ver3: 1분봉 RSI(3/5) 과매도 → 다음 봉 되돌림 확인 후 진입
+ 당일 낙폭/회복 필터 (ver2 로직 재사용)
3. 종목 소스
- 키움봇(kiwoom_trader)이 target_candidates DB에 넣은 종목을 구독
- 장 시작 후 SCALP_MARKET_OPEN_WAIT_MIN(기본 5분) 뒤부터 매매
REST 호출 횟수 (장 중 기준)
────────────────────────────
- 인증 토큰: 1회/12시간
- WS approval_key: 1회/23시간
- get_minute_chart: 재접속 시 종목당 1회 (정상 운영 시 0회)
- inquire_price: 진입 직전 1회/신호 + 일일 시고저 보정
- get_investor_trend: 진입 직전 1회/신호
- 주문: 트리거 시만
자가 검증
──────────
1. 손절/예외 처리: check_sell_signals 에서 stop_price / take_profit 확인 ✅
2. API 429/슬리피지: _safe_sleep + SafeRequest 상속 ✅
3. 기존 로직 100% 동일 기능: ver2 execute_buy/sell 그대로 재사용 ✅
"""
from __future__ import annotations
import asyncio
import datetime
import json
import logging
import os
import random
import threading
import time
from datetime import datetime as dt
from pathlib import Path
from typing import Dict, List, Optional
import pandas as pd
import requests
from database import TradeDB, ENV_CONFIG_KEYS
# WebSocket + CandleAggregator + 키움 분봉 갭보정 유틸
try:
from kis_ws import (
KISWebSocketPriceCache, CandleAggregator,
get_kiwoom_candles_df, _get_kiwoom_creds,
)
_KIS_WS_AVAILABLE = True
except ImportError:
_KIS_WS_AVAILABLE = False
# ML 예측 (선택적)
try:
from ml_predictor import MLPredictor
ML_AVAILABLE = True
except ImportError:
ML_AVAILABLE = False
# RiskManager (선택적)
try:
from risk_manager import RiskManager
RISK_MANAGER_AVAILABLE = True
except ImportError:
RISK_MANAGER_AVAILABLE = False
# ── 로깅 ──────────────────────────────────────────────────────────────
logging.basicConfig(
format="[%(asctime)s] %(message)s",
datefmt="%H:%M:%S",
level=logging.INFO,
)
logger = logging.getLogger("ScalpBot")
LOG_RED = "\033[91m"
LOG_YELLOW = "\033[93m"
LOG_GREEN = "\033[92m"
LOG_CYAN = "\033[96m"
LOG_RESET = "\033[0m"
# ── DB 초기화 (MariaDB — 접속 정보는 database.py 모듈 상수 또는 환경변수로 설정) ──
SCRIPT_DIR = Path(__file__).resolve().parent
db = TradeDB() # db_path 인수 무시됨, MariaDB 192.168.0.141 직접 연결
# ── 환경변수 헬퍼 ─────────────────────────────────────────────────────
def get_env_from_db(key, default=""):
env_data = db.get_latest_env()
if env_data and env_data.get("snapshot"):
return env_data["snapshot"].get(key, default)
return default
def get_env_float(key, default):
value = get_env_from_db(key, str(default))
if isinstance(value, str) and "#" in value:
value = value.split("#")[0].strip()
try:
return float(value) if value else default
except (ValueError, TypeError):
return default
def get_env_int(key, default):
value = get_env_from_db(key, str(default))
if isinstance(value, str) and "#" in value:
value = value.split("#")[0].strip()
try:
return int(value) if value else default
except (ValueError, TypeError):
return default
def get_env_bool(key, default=False):
value = get_env_from_db(key, str(default)).lower()
return value in ("true", "1", "yes")
# ── Mattermost 설정 ───────────────────────────────────────────────────
MM_SERVER_URL = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org")
MM_BOT_TOKEN = get_env_from_db("MM_BOT_TOKEN_", "").strip()
MM_CONFIG_FILE = SCRIPT_DIR / "mm_config.json"
MM_CHANNEL_DEFAULT = get_env_from_db("MATTERMOST_CHANNEL", "scalping")
MM_CHANNEL_SCALP = get_env_from_db("KIS_SCALP_MM_CHANNEL", MM_CHANNEL_DEFAULT)
# ── Gemini ───────────────────────────────────────────────────────────
try:
import google.genai as genai
GEMINI_AVAILABLE = True
except ImportError:
GEMINI_AVAILABLE = False
GEMINI_API_KEY = get_env_from_db("GEMINI_API_KEY", "").strip()
gemini_client = None
if GEMINI_API_KEY and GEMINI_AVAILABLE:
try:
gemini_client = genai.Client(api_key=GEMINI_API_KEY)
except Exception:
gemini_client = None
# ── 토큰 캐시 ─────────────────────────────────────────────────────────
KIS_TOKEN_CACHE_PATH_MOCK = SCRIPT_DIR / ".kis_token_cache_mock.json"
KIS_TOKEN_CACHE_PATH_REAL = SCRIPT_DIR / ".kis_token_cache_real.json"
KIS_TOKEN_EXPIRE_MARGIN_SEC = 60
def _parse_kis_token_expired(expired_str):
if not expired_str or not isinstance(expired_str, str):
return None
s = expired_str.strip().replace("T", " ")[:19]
try:
return dt.strptime(s, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def _load_kis_token_cache(mock):
path = KIS_TOKEN_CACHE_PATH_MOCK if mock else KIS_TOKEN_CACHE_PATH_REAL
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
cache = json.load(f)
if cache.get("mock") != mock:
return None
token = cache.get("access_token")
expired_dt = _parse_kis_token_expired(
cache.get("access_token_token_expired") or cache.get("expires_at")
)
if not token or not expired_dt:
return None
margin = datetime.timedelta(seconds=KIS_TOKEN_EXPIRE_MARGIN_SEC)
if dt.now() >= expired_dt - margin:
logger.info("한투 토큰 만료 임박/만료 → 재발급")
return None
logger.info("한투 토큰 캐시 재사용 (만료: %s)", expired_dt.strftime("%H:%M:%S"))
return {"access_token": token, "expires_at": expired_dt.isoformat()}
except Exception:
return None
def _save_kis_token_cache(mock, token, expired_str):
path = KIS_TOKEN_CACHE_PATH_MOCK if mock else KIS_TOKEN_CACHE_PATH_REAL
try:
with open(path, "w", encoding="utf-8") as f:
json.dump({"mock": mock, "access_token": token,
"access_token_token_expired": expired_str}, f)
except Exception as e:
logger.warning("한투 토큰 캐시 저장 실패: %s", e)
# ══════════════════════════════════════════════════════════════════════
# KIS REST 클라이언트 (ver2 에서 직접 이식 — 구조 동일)
# ══════════════════════════════════════════════════════════════════════
class KISClient:
"""한국투자증권 REST API 클라이언트. ver2 KISClient 와 동일 구조."""
def __init__(self, app_key, app_secret, account_no, account_code, mock=True):
self.app_key = app_key
self.app_secret = app_secret
self.account_no = account_no
self.account_code = account_code
self.mock = mock
self.base_url = (
"https://openapivts.koreainvestment.com:29443"
if mock else
"https://openapi.koreainvestment.com:9443"
)
self._token: Optional[str] = None
self._token_lock = __import__("threading").Lock()
# API 호출 간 최소 간격 (429 방지)
self._last_call_ts: float = 0.0
self._min_interval: float = 0.22 # 초 (초당 ~4건 안전 마진)
# 마지막 매도 오류 캐시 (execute_sell 에서 사용)
self._last_sell_msg_cd: Optional[str] = None
self._last_sell_msg1: Optional[str] = None
self._init_token()
def _init_token(self):
cache = _load_kis_token_cache(self.mock)
if cache:
self._token = cache["access_token"]
else:
try:
from kis_token_manager import ensure_token
if ensure_token(self.mock):
cache = _load_kis_token_cache(self.mock)
self._token = cache["access_token"] if cache else None
except Exception as e:
logger.warning("kis_token_manager 발급 실패: %s", e)
self._token = None
def _issue_token(self) -> Optional[str]:
try:
r = requests.post(
f"{self.base_url}/oauth2/tokenP",
json={"grant_type": "client_credentials",
"appkey": self.app_key, "appsecret": self.app_secret},
timeout=10,
)
j = r.json()
token = j.get("access_token")
if token:
_save_kis_token_cache(self.mock, token,
j.get("access_token_token_expired", ""))
logger.info("✅ 한투 토큰 발급 완료")
return token
except Exception as e:
logger.error("❌ 한투 토큰 발급 실패: %s", e)
return None
def _throttle(self):
"""
API 호출 전 처리:
1. 토큰 만료 10분 전 선제 갱신 (KisTokenManager)
2. 최소 호출 간격 대기 (429 방지)
"""
# 토큰 갱신 체크 (유효하면 메모리 반환, 만료 임박 시만 재발급)
try:
from kis_token_manager import KisTokenManager
fresh = KisTokenManager.instance(is_mock=self.mock).get_token()
if fresh:
self._token = fresh
except Exception:
pass
elapsed = time.time() - self._last_call_ts
if elapsed < self._min_interval:
time.sleep(self._min_interval - elapsed)
self._last_call_ts = time.time()
def _get(self, path, tr_id, params, retry=5):
"""GET 요청 + EGW00201 자동 재시도."""
self._throttle()
headers = {
"authorization": f"Bearer {self._token}",
"appkey": self.app_key,
"appsecret": self.app_secret,
"tr_id": tr_id,
"custtype": "P",
}
url = self.base_url + path
for attempt in range(1, retry + 1):
try:
r = requests.get(url, headers=headers, params=params, timeout=10)
if r.status_code == 200:
j = r.json()
if j.get("msg_cd") == "EGW00201":
wait = 1.5
logger.warning(
"⏳ API 초당거래 초과 (EGW00201) GET %s -> %.1f초 대기 후 재시도 (%d/%d) msg1=%s",
path, wait, attempt, retry, j.get("msg1", ""),
)
time.sleep(wait)
continue
return r
except requests.exceptions.Timeout:
logger.warning("⏰ GET %s 타임아웃 (%d/%d)", path, attempt, retry)
time.sleep(1.0)
return requests.Response()
def _post(self, path, tr_id, body, retry=3):
"""POST 요청 + EGW00201 자동 재시도."""
self._throttle()
headers = {
"authorization": f"Bearer {self._token}",
"appkey": self.app_key,
"appsecret": self.app_secret,
"tr_id": tr_id,
"content-type": "application/json; charset=utf-8",
"custtype": "P",
}
url = self.base_url + path
for attempt in range(1, retry + 1):
try:
r = requests.post(url, headers=headers, json=body, timeout=10)
if r.status_code == 200:
j = r.json()
if j.get("msg_cd") == "EGW00201":
wait = 1.5
logger.warning(
"⏳ API 초당거래 초과 (EGW00201) POST %s -> %.1f초 대기 (%d/%d)",
path, wait, attempt, retry,
)
time.sleep(wait)
continue
return r
except requests.exceptions.Timeout:
logger.warning("⏰ POST %s 타임아웃 (%d/%d)", path, attempt, retry)
time.sleep(1.0)
return requests.Response()
def inquire_price(self, code: str) -> Optional[dict]:
"""현재가 조회 [v1_국내주식-007]"""
r = self._get(
"/uapi/domestic-stock/v1/quotations/inquire-price",
"FHKST01010100",
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
)
if r.status_code != 200:
return None
j = r.json()
return j.get("output") if j.get("rt_cd") == "0" else None
def get_minute_chart(self, code: str, period: str = "1", limit: int = 100) -> pd.DataFrame:
"""
분봉 차트 조회 [v1_국내주식-017] — 재접속 갭 보정용.
스캘핑봇은 이 함수를 정상 운영 중에는 호출하지 않음.
"""
path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
tr_id = "FHKST03010200"
try:
end_dt = dt.now()
start_dt = end_dt - datetime.timedelta(days=1)
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code,
"FID_INPUT_HOUR_1": start_dt.strftime("%Y%m%d"),
"FID_INPUT_HOUR_2": end_dt.strftime("%Y%m%d"),
"FID_PW_DATA_INCU_YN": "Y",
"FID_ETC_CLS_CODE": "",
}
r = self._get(path, tr_id, params)
if r.status_code != 200:
return pd.DataFrame()
j = r.json()
if j.get("rt_cd") != "0":
return pd.DataFrame()
data = j.get("output2", [])
if not data:
return pd.DataFrame()
rows = []
for item in data:
try:
date_str = str(item.get("stck_bsop_date", "") or "")
time_str = str(item.get("stck_cntg_hour", "") or "000000")
rows.append({
"time": date_str + time_str[:4], # YYYYMMDDHHMM
"open": abs(float(item.get("stck_oprc", 0))),
"high": abs(float(item.get("stck_hgpr", 0))),
"low": abs(float(item.get("stck_lwpr", 0))),
"close": abs(float(item.get("stck_clpr", 0))),
"volume": int(item.get("acml_vol", 0)),
})
except Exception:
continue
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows)
df = df.sort_values("time").reset_index(drop=True)
return df.tail(limit)
except Exception as e:
logger.error("분봉 조회 실패(%s): %s", code, e)
return pd.DataFrame()
def get_investor_trend(self, code: str, days: int = 3) -> Optional[dict]:
"""투자자 동향 조회 (수급 확인용)"""
try:
r = self._get(
"/uapi/domestic-stock/v1/quotations/inquire-investor",
"FHKST01010900",
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code,
"FID_INPUT_DATE_1": (dt.now() - datetime.timedelta(days=days)).strftime("%Y%m%d"),
"FID_INPUT_DATE_2": dt.now().strftime("%Y%m%d"),
"FID_PERIOD_DIV_CODE": "D"},
)
if r.status_code != 200:
return None
j = r.json()
if j.get("rt_cd") != "0":
return None
output = j.get("output", [])
if not output:
return None
foreign_net = sum(int(str(o.get("frgn_ntby_qty", 0)).replace(",", ""))
for o in output if o)
org_net = sum(int(str(o.get("orgn_ntby_qty", 0)).replace(",", ""))
for o in output if o)
return {"foreign_net_buy": foreign_net, "org_net_buy": org_net}
except Exception as e:
logger.debug("투자자 동향 조회 실패(%s): %s", code, e)
return None
def get_account_balance(self) -> Optional[dict]:
"""계좌 잔고 조회"""
tr_id = "VTTC8434R" if self.mock else "TTTC8434R"
try:
r = self._get(
"/uapi/domestic-stock/v1/trading/inquire-balance",
tr_id,
{"CANO": self.account_no, "ACNT_PRDT_CD": self.account_code,
"AFHR_FLPR_YN": "N", "OFL_YN": "N", "INQR_DVSN": "01",
"UNPR_DVSN": "01", "FUND_STTL_ICLD_YN": "N",
"FNCG_AMT_AUTO_RDPT_YN": "N", "PRCS_DVSN": "00",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": ""},
)
if r.status_code != 200:
return None
j = r.json()
return j if j.get("rt_cd") == "0" else None
except Exception as e:
logger.error("계좌 잔고 조회 실패: %s", e)
return None
def buy_order(self, code: str, qty: int, price: int = 0,
order_type: str = "01") -> bool:
"""매수 주문 (01=시장가, 00=지정가)"""
tr_id = "VTTC0802U" if self.mock else "TTTC0802U"
path = "/uapi/domestic-stock/v1/trading/order-cash"
try:
body = {
"CANO": self.account_no, "ACNT_PRDT_CD": self.account_code,
"PDNO": code, "ORD_DVSN": order_type,
"ORD_QTY": str(qty), "ORD_UNPR": str(price),
}
r = self._post(path, tr_id, body)
if r.status_code != 200:
return False
j = r.json()
if j.get("rt_cd") == "0":
logger.info("✅ 매수주문 성공: %s %d주 @ %d", code, qty, price)
return True
logger.error("❌ 매수주문 실패: %s | msg=%s", code, j.get("msg1", ""))
return False
except Exception as e:
logger.error("매수 주문 예외(%s): %s", code, e)
return False
def sell_order(self, code: str, qty: int, price: int = 0,
order_type: str = "01") -> bool:
"""매도 주문 (01=시장가, 00=지정가)"""
tr_id = "VTTC0801U" if self.mock else "TTTC0801U"
path = "/uapi/domestic-stock/v1/trading/order-cash"
try:
body = {
"CANO": self.account_no, "ACNT_PRDT_CD": self.account_code,
"PDNO": code, "ORD_DVSN": order_type,
"ORD_QTY": str(qty), "ORD_UNPR": str(price),
}
r = self._post(path, tr_id, body)
if r.status_code != 200:
return False
j = r.json()
if j.get("rt_cd") == "0":
return True
self._last_sell_msg_cd = j.get("msg_cd", "")
self._last_sell_msg1 = j.get("msg1", "")
logger.error("❌ 매도주문 실패: %s | msg=%s", code, self._last_sell_msg1)
return False
except Exception as e:
self._last_sell_msg_cd = None
self._last_sell_msg1 = None
logger.error("매도 주문 예외(%s): %s", code, e)
return False
def sell_market_order(self, code: str, qty: int) -> bool:
return self.sell_order(code, qty, price=0, order_type="01")
# ══════════════════════════════════════════════════════════════════════
# 알림 헬퍼
# ══════════════════════════════════════════════════════════════════════
def _load_mm_channel_id(channel_alias: str) -> Optional[str]:
try:
if MM_CONFIG_FILE.exists():
with open(MM_CONFIG_FILE, "r", encoding="utf-8") as f:
cfg = json.load(f)
return cfg.get("channels", {}).get(channel_alias)
except Exception:
pass
return None
def msg_mm(text: str, channel_alias: str = None) -> None:
"""Mattermost 알림 전송."""
alias = channel_alias or MM_CHANNEL_SCALP
cid = _load_mm_channel_id(alias)
if not cid or not MM_BOT_TOKEN:
logger.debug("[MM 알림 스킵] channel=%s cid=%s", alias, cid)
return
try:
requests.post(
f"{MM_SERVER_URL}/api/v4/posts",
headers={"Authorization": f"Bearer {MM_BOT_TOKEN}",
"Content-Type": "application/json"},
json={"channel_id": cid, "message": text},
timeout=5,
)
except Exception as e:
logger.debug("MM 알림 실패: %s", e)
# ══════════════════════════════════════════════════════════════════════
# 스캘핑 봇 메인 클래스
# ══════════════════════════════════════════════════════════════════════
class ScalpingBotV1:
"""
WebSocket 봉 집계 기반 초단타 스캘핑 봇.
매수 조건 (AND 조건)
─────────────────────
1. 장 시작 후 SCALP_MARKET_OPEN_WAIT_MIN 분 경과
2. 당일 낙폭 >= MIN_DROP_RATE (ver2 동일)
3. 저점 회복률 >= MIN_RECOVERY_RATIO_SHORT (ver2 동일)
4. RSI(3) <= SCALP_RSI_OVERSOLD (과매도 구간)
5. 이전 봉 close < open (하락) → 현재 봉 close > open (반등 시작)
6. MA5 위에 있거나, 현재봉 거래량 >= 평균 거래량 × VOLUME_AVG_MULTIPLIER
7. RSI(3) >= SCALP_RSI_OVERBOUGHT 시 진입 금지 (고점 추격 방지)
매도 조건 (ver2 check_sell_signals 와 동일)
────────────────────────────────────────────
- 손절: 현재가 <= stop_price
- 익절: 현재가 >= target_price
- 숄더컷, 퀵프로핏, ATR 트레일링 스탑
"""
def __init__(self):
# ── KIS 인증 정보 (DB 에서 로드) ─────────────────────────
is_mock = get_env_bool("KIS_MOCK", True)
if is_mock:
app_key = get_env_from_db("KIS_APP_KEY_MOCK", "")
app_secret = get_env_from_db("KIS_APP_SECRET_MOCK", "")
account_no = get_env_from_db("KIS_ACCOUNT_NO_MOCK", "")
account_code = get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "01")
else:
app_key = get_env_from_db("KIS_APP_KEY_REAL", "")
app_secret = get_env_from_db("KIS_APP_SECRET_REAL", "")
account_no = get_env_from_db("KIS_ACCOUNT_NO_REAL", "")
account_code = get_env_from_db("KIS_ACCOUNT_CODE_REAL", "01")
self.client = KISClient(app_key, app_secret, account_no, account_code, mock=is_mock)
self.db = db
self.mm_channel = MM_CHANNEL_SCALP
# ── 보유 종목 & 상태 ─────────────────────────────────────
self.holdings: Dict[str, dict] = {}
self.untradable_skip_set: set = set()
self.recently_sold: dict = {}
self._sell_backoff: dict = {}
self.today_date = dt.now().strftime("%Y-%m-%d")
# 갭보정 실패 재시도 대기열: 신규 구독 시 갭보정이 실패한 종목 코드 집합
# check_buy_signal_scalp에서 봉부족 감지 시 자동으로 백그라운드 재갭보정 트리거
self._gap_retry_queue: set = set()
# 봉부족 종목별 마지막 재갭보정 시각 (같은 종목 30초 내 중복 재시도 방지)
self._gap_retry_ts: dict = {}
# ── 자산 추적 ─────────────────────────────────────────────
self.current_cash = 0.0
self.current_total_asset = 0.0
self.start_day_asset = 0.0
# ── 리포트 플래그 ─────────────────────────────────────────
self.morning_report_sent = False
self.closing_report_sent = False
# ── 설정 로드 ─────────────────────────────────────────────
self.reload_config()
# ── DB 에서 활성 포지션 복구 (SCALP_ 전략만) ──────────────
active_trades = self.db.get_active_trades(strategy_prefix="SCALP")
for code, trade in active_trades.items():
self.holdings[code] = {
"buy_price": trade.get("avg_buy_price", 0),
"qty": trade.get("current_qty", 0),
"stop_price": trade.get("stop_price", 0),
"target_price": trade.get("target_price", 0),
"max_price": trade.get("max_price", 0),
"atr_entry": trade.get("atr_entry", 0),
"buy_time": trade.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")),
"name": trade.get("name", code),
"size_class": trade.get("size_class", ""),
}
# ── WebSocket + CandleAggregator 초기화 ──────────────────
self.ws_cache: Optional[KISWebSocketPriceCache] = None
self.candle_agg: Optional[CandleAggregator] = None
self._init_websocket()
# ── ML / RiskManager (선택적) ─────────────────────────────
self.ml_predictor = None
if ML_AVAILABLE and get_env_bool("USE_ML_SIGNAL", False):
try:
self.ml_predictor = MLPredictor()
except Exception as e:
logger.warning("ML 초기화 실패: %s", e)
self.risk_manager = None
if RISK_MANAGER_AVAILABLE:
try:
self.risk_manager = RiskManager(
risk_pct_per_trade = get_env_float("RISK_PCT_PER_TRADE", 0.015),
max_position_pct = get_env_float("MAX_POSITION_PCT", 0.03),
min_position_amount = get_env_int("MIN_POSITION_AMOUNT", 20000),
use_kelly = get_env_bool("USE_KELLY", False),
kelly_multiplier = get_env_float("KELLY_MULTIPLIER", 0.25),
slot_base_amount_cap= get_env_int("SLOT_BASE_AMOUNT_CAP", 0),
# ── 무조건 깔고 가는 MAX_LOSS 기반 투자 상한 ─────────
max_loss_per_trade_krw=get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000),
stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.03),
# ── 사이즈 클래스별 비율 (DB에서 주입) ───────────────
size_small_ratio = get_env_float("SIZE_CLASS_SMALL_RATIO", 0.70),
size_mid_ratio = get_env_float("SIZE_CLASS_MID_RATIO", 0.85),
)
except Exception as e:
logger.warning("RiskManager 초기화 실패: %s", e)
# ── 백그라운드 태스크 핸들 ────────────────────────────────
self._report_task = None
logger.info("🚀 ScalpingBotV1 초기화 완료 (mock=%s, holdings=%d)",
is_mock, len(self.holdings))
# ------------------------------------------------------------------
# WebSocket + CandleAggregator 초기화
# ------------------------------------------------------------------
def _init_websocket(self):
"""WebSocket 시작 → CandleAggregator 연결 → 종목 구독 → 갭 보정."""
if not _KIS_WS_AVAILABLE:
logger.warning("⚠️ kis_ws 모듈 없음 → WebSocket 비활성")
return
try:
# [수정] 웹소켓은 데이터 수신용이므로 무조건 실전(Real) 서버를 타도록 하이브리드 구성
ws_app_key = get_env_from_db("KIS_APP_KEY_REAL", "")
ws_app_secret = get_env_from_db("KIS_APP_SECRET_REAL", "")
self.ws_cache = KISWebSocketPriceCache(
app_key = ws_app_key if ws_app_key else self.client.app_key,
app_secret = ws_app_secret if ws_app_secret else self.client.app_secret,
is_mock = False, # 실전 서버 강제 접속
)
# CandleAggregator 생성 후 WS 에 연결
# 1분봉(주전략) + 3분봉 + 15분봉 + 60분봉 — 나중에 다른 전략 필터로 활용 가능
tf = get_env_int("SCALP_CANDLE_TIMEFRAME", 1)
self.candle_agg = CandleAggregator(db=self.db, timeframes=[tf, 3, 15, 60])
self.ws_cache.attach_candle_aggregator(self.candle_agg)
ws_ok = self.ws_cache.start()
if not ws_ok:
logger.warning("⚠️ WebSocket 시작 실패 → 스캘핑봇은 WS 없이 동작 불가")
self.ws_cache = None
self.candle_agg = None
return
# 기존 보유 종목 구독
for code in list(self.holdings.keys()):
self.ws_cache.subscribe(code)
# 키움봇 후보 종목도 미리 구독
candidates = self.db.get_target_candidates()
for c in candidates:
code = c.get("code") or c.get("stk_cd", "")
if code:
self.ws_cache.subscribe(code)
# ── 영구 구독 ETF: 시장 방향 필터용 (유니버스 변경과 무관하게 항상 유지) ──
# KOSPI(069500), KOSDAQ(229200) 지수 ETF의 60분봉 RSI → 상승장/하락장 판단
perm_raw = get_env_from_db("PERMANENT_WS_CODES", "069500,229200")
self._permanent_ws_codes: set = {
c.strip() for c in str(perm_raw).split(",") if c.strip()
}
for code in sorted(self._permanent_ws_codes):
self.ws_cache.subscribe(code)
logger.info("📡 [영구구독] %s (시장방향 ETF)", code)
logger.info("✅ WebSocket + CandleAggregator 활성 (%d종목 구독)",
len(self.ws_cache._subscribed))
# WS 연결 성공 시마다 갭보정 자동 실행 등록
# (장 시간 첫 연결 시 REST 분봉 로드 → 봉부족 해소)
# 새벽 자동재연결 시에는 kis_ws 내부에서 is_market_hours() 체크 후 스킵
self.ws_cache.set_on_connected_callback(self._fill_all_gaps)
# 시작 시 즉시 한 번 실행 (장 중 재시작 대비)
self._fill_all_gaps()
except Exception as e:
logger.warning("⚠️ WebSocket 초기화 예외: %s", e)
self.ws_cache = None
self.candle_agg = None
def _fill_all_gaps(self):
"""
봇 시작 시 또는 WS 재접속 시 모든 구독 종목의 봉 갭을 REST 로 보정.
RAM 버퍼(_confirmed)에 즉시 적재 → 다음 매수 체크에 즉시 반영.
DB는 백그라운드 큐로 비동기 저장.
▶ 키움 우선 전략:
- 키움 ka10080 은 1회 호출에 최대 900봉(≈6개월치) 제공 → 장 초반에도 즉시 봉 확보 가능
- KIS get_minute_chart 는 당일봉만 제공 → 장 시작 직후 봉 부족 → 키움 우선
- 키움 키 없으면 KIS fallback (1분/3분봉만, 15/60분봉은 KIS 지원 안 함)
"""
if not self.candle_agg or not self.ws_cache:
return
# ── 중복 실행 방지 ─────────────────────────────────────────────
if getattr(self, '_gap_filling', False):
logger.debug("갭보정 이미 진행 중 → 스킵")
return
self._gap_filling = True
try:
main_tf = get_env_int("SCALP_CANDLE_TIMEFRAME", 1)
limit = get_env_int("SCALP_GAP_FILL_LIMIT", 120)
with self.ws_cache._sub_lock:
codes = set(self.ws_cache._subscribed)
# ── 키움 크레덴셜 조회 ────────────────────────────────────
kw_key, kw_secret, kw_mock = _get_kiwoom_creds(self.db)
use_kiwoom = bool(kw_key and kw_secret)
logger.info(
"🔧 [갭보정] %d종목 분봉 로드 시작 (tfs=%s, limit=%d, kiwoom=%s)",
len(codes), self.candle_agg.timeframes, limit, "" if use_kiwoom else "❌→KIS fallback",
)
for code in sorted(codes):
for tf in self.candle_agg.timeframes:
df = None
# 키움 우선: 어제 봉 포함, 장 초반에도 봉 바로 확보
# ※ 토큰은 _get_kiwoom_token_cached()가 23시간 캐시 → au10001 한도 방지
if use_kiwoom:
try:
df = get_kiwoom_candles_df(
code, tf, kw_key, kw_secret,
is_mock=kw_mock, n=limit,
)
except Exception as e:
logger.debug("키움 갭보정 실패 (%s %dM): %s", code, tf, e)
# KIS fallback: 당일봉만 → 1분/3분봉에만 유효
if (df is None or df.empty) and tf <= 3:
try:
df = self.client.get_minute_chart(
code, period=str(tf), limit=limit
)
except Exception as e:
logger.debug("KIS 갭보정 실패 (%s %dM): %s", code, tf, e)
if df is not None and not df.empty:
self.candle_agg.fill_gap_from_rest(code, tf, df)
# 같은 종목 내 timeframe 전환: 짧은 딜레이 (차트 API 레이트리밋)
time.sleep(random.uniform(0.2, 0.4))
# 종목 간 딜레이: 조금 더 길게
time.sleep(random.uniform(0.3, 0.6))
finally:
self._gap_filling = False
# ------------------------------------------------------------------
# 설정 리로드
# ------------------------------------------------------------------
def reload_config(self):
"""DB 설정 실시간 반영 (메인 루프마다 호출)."""
# ── 스캘핑 전용 손익절: 꼬리잡기(STOP_LOSS_PCT/TAKE_PROFIT_PCT)와 분리 ──
# SCALP_STOP_LOSS_PCT : 스캘핑 손절 % (양수, 기본 1.5%)
# 1분봉 초단타에서 -4% 손절은 너무 넓어 자금이 묶임 → 1.5%로 타이트하게 설정
# SCALP_TAKE_PROFIT_PCT: 스캘핑 익절 % (양수, 기본 1.5%)
# +5% 익절은 1분봉에서 거의 도달 불가 → 1.5%로 줄여 회전율 극대화
scalp_sl = get_env_float("SCALP_STOP_LOSS_PCT", 0.015)
self.scalp_stop_loss_pct = -abs(scalp_sl) # 음수로 통일 (ex: -0.015)
self.scalp_take_profit_pct = get_env_float("SCALP_TAKE_PROFIT_PCT", 0.015)
# 스캘핑 낙폭 기준: 꼬리잡기 MIN_DROP_RATE와 독립
# MIN_DROP_RATE(3%) 그대로 쓰면 1분봉 소형주에선 걸러지는 종목이 너무 많아짐
# SCALP_MIN_DROP_RATE 기본 1.5%로 완화 → 타점 빈도 증가
self.scalp_min_drop_rate = get_env_float("SCALP_MIN_DROP_RATE", 0.015)
# 글로벌 손절/익절 (호환 유지 - check_sell_signals 등에서 참조)
self.stop_loss_pct = self.scalp_stop_loss_pct
self.take_profit_pct = self.scalp_take_profit_pct
self.max_stocks = get_env_int("MAX_STOCKS", 3)
self.min_drop_rate = self.scalp_min_drop_rate # 하위호환
# 일일 회복률: 스캘핑에선 사용하지 않음 (RSI 과매도와 모순). 설정만 유지.
self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO_SHORT", 0.5)
# 스캘핑 전용
# RSI 과매도: 이 값 이하 봉이 나오면 되돌림 후보 (기본 25)
self.rsi_oversold = get_env_float("SCALP_RSI_OVERSOLD", 25.0)
# RSI 과매수: 이 값 이상이면 고점 추격 방지 진입 금지 (기본 75)
self.rsi_overbought = get_env_float("SCALP_RSI_OVERBOUGHT", 75.0)
# 봉 단위 (분)
self.candle_tf = get_env_int("SCALP_CANDLE_TIMEFRAME", 1)
# 장 시작 후 대기 (분)
self.open_wait_min = get_env_int("SCALP_MARKET_OPEN_WAIT_MIN", 5)
# 포지션 크기
self.slot_money = get_env_float("SLOT_MONEY_DEFAULT", 300000)
self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0)
# ATR 매도 배수
self.atr_up_mult = get_env_float("SCALP_ATR_UP_MULT", 1.5)
self.atr_down_mult = get_env_float("SCALP_ATR_DOWN_MULT", 0.8)
# 피뢰침/급등 필터
self.high_chase_thr = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96)
self.max_daily_chg = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0)
# 거래량 배율 필터
self.vol_multiplier = get_env_float("VOLUME_AVG_MULTIPLIER", 1.5)
# ML
self.use_ml_signal = get_env_bool("USE_ML_SIGNAL", False)
self.ml_min_prob = get_env_float("ML_MIN_PROBABILITY", 0.55)
# 최소 가격
self.min_price = get_env_float("MIN_PRICE_TAIL", 1000.0)
# ------------------------------------------------------------------
# 장 상태 체크
# ------------------------------------------------------------------
def check_market_status(self) -> bool:
"""장 중 여부 + 장 시작 후 워밍업 대기 확인."""
now = dt.now()
h, m = now.hour, now.minute
# 장외 시간
if not ((9 <= h < 15) or (h == 15 and m <= 30)):
return False
if get_env_bool("FORCE_MARKET_OPEN", False):
return True
# 장 시작(9:00) 후 open_wait_min 분 대기
market_start = now.replace(hour=9, minute=0, second=0, microsecond=0)
elapsed_min = (now - market_start).total_seconds() / 60
if elapsed_min < self.open_wait_min:
logger.info("⏳ 장 시작 후 %.0f분 경과 (워밍업 대기: %d분)",
elapsed_min, self.open_wait_min)
return False
return True
# ------------------------------------------------------------------
# 구독 관리 (새 후보 추가/구독 종목 정리)
# ------------------------------------------------------------------
def _sync_subscriptions(self, candidates: list):
"""
target_candidates DB 목록과 WS 구독 목록을 동기화.
- 새 종목 → subscribe + 갭 보정
- 유니버스에서 빠진 종목(보유 중 아닌 것) → unsubscribe + RAM 정리
※ 영구 구독 ETF(_permanent_ws_codes)는 절대 해제하지 않음 (시장 방향 필터용)
"""
if not self.ws_cache:
return
new_codes = {c.get("code") or c.get("stk_cd", "") for c in candidates if c}
new_codes.discard("")
# 현재 보유 종목은 매도 완료 전까지 반드시 유지
new_codes |= set(self.holdings.keys())
# 영구 구독 ETF는 유니버스와 무관하게 항상 유지
new_codes |= getattr(self, '_permanent_ws_codes', set())
with self.ws_cache._sub_lock:
current_subs = set(self.ws_cache._subscribed)
# ── 구독 해제: 유니버스에서 빠진 종목 ─────────────────────────
# 보유 중 종목은 매도 감시를 위해 구독 유지
for code in sorted(current_subs - new_codes):
self.ws_cache.unsubscribe(code)
if self.candle_agg:
self.candle_agg.remove_code(code)
# ── 신규 구독: 유니버스에 새로 들어온 종목 ─────────────────────
kw_key, kw_secret, kw_mock = _get_kiwoom_creds(self.db)
use_kiwoom = bool(kw_key and kw_secret)
for code in sorted(new_codes - current_subs):
self.ws_cache.subscribe(code)
# 구독 즉시 갭 보정 (봉 버퍼 없는 신규 종목) — 키움 우선
if not self.candle_agg:
continue
lim = get_env_int("SCALP_GAP_FILL_LIMIT", 120)
ok = False
for tf in self.candle_agg.timeframes:
df = None
if use_kiwoom:
try:
df = get_kiwoom_candles_df(
code, tf, kw_key, kw_secret,
is_mock=kw_mock, n=lim,
)
except Exception as e:
logger.debug("키움 신규갭보정 실패 (%s %dM): %s", code, tf, e)
if (df is None or df.empty) and tf <= 3:
try:
df = self.client.get_minute_chart(
code, period=str(tf), limit=lim
)
except Exception as e:
logger.debug("KIS 신규갭보정 실패 (%s %dM): %s", code, tf, e)
if df is not None and not df.empty:
self.candle_agg.fill_gap_from_rest(code, tf, df)
ok = True
# tf 간 딜레이 (차트 API, 토큰은 캐시 재사용)
time.sleep(random.uniform(0.2, 0.4))
if ok:
logger.info("🔧 [신규갭보정] %s: 모든 tf 로드 완료", code)
else:
logger.warning("⚠️ [신규갭보정실패] %s: 데이터 없음 → 재시도 대기열 등록", code)
self._gap_retry_queue.add(code)
# ------------------------------------------------------------------
# 매수 신호: RSI 과매도 되돌림
# ------------------------------------------------------------------
def check_buy_signal_scalp(self, code: str, name: str) -> Optional[dict]:
"""
[스캘핑 진입 조건 — 1분봉 초단타 특화]
1. RAM 버퍼에서 최근 확정 봉 조회 (REST 없음)
2. RSI(3) <= SCALP_RSI_OVERSOLD: 단기 과매도 구간 진입
3. 이전 봉 음봉 → 최신 확정 봉 양봉: 되돌림(Retracement) 시작 신호
4. 당일 낙폭 필터 (SCALP_MIN_DROP_RATE 기준, 꼬리잡기보다 완화)
※ 일일 회복률(50%) 필터는 제거 — RSI<25(폭락직후)와 모순이라 매수 기회를 모두 죽임
5. 피뢰침/급등 필터
6. 거래량 필터 (VOLUME_AVG_MULTIPLIER)
7. 수급 수집 (ML 피처용, 진입 필터로 사용하지 않음)
─ 당일 시고저: WS 캐시 우선 → REST fallback (API 과부하 방지)
"""
try:
# [테스트용] FORCE_BUY_TEST=true 시 조건 무시
if get_env_bool("FORCE_BUY_TEST", False):
ws_d = self.ws_cache.get_price(code) if self.ws_cache else None
px = 0.0
if ws_d:
px = abs(float(str(ws_d.get("stck_prpr", 0))))
if px <= 0:
pd_ = self.client.inquire_price(code)
if pd_:
px = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", "")))
if px > 0:
return {"code": code, "name": name, "price": px,
"score": 5.0, "entry_features": {}}
return None
# ── 0. 시장 방향 필터 (USE_MARKET_REGIME_FILTER=true 시 활성) ──
# KODEX200(069500)/KOSDAQ150(229200) 60분봉 RSI로 상승장 확인
# 하락장(ETF RSI < MARKET_REGIME_MIN_RSI)이면 롱 진입 차단
if get_env_bool("USE_MARKET_REGIME_FILTER", False):
min_rsi = get_env_float("MARKET_REGIME_MIN_RSI", 48.0)
regime = self.db.get_market_regime(tf=60)
if not regime.get("is_bull") or regime.get("avg_rsi", 50) < min_rsi:
logger.debug(
"[시장필터] %s %s: 하락장 차단 (ETF RSI=%.1f < %.1f)",
code, name, regime.get("avg_rsi", 0), min_rsi,
)
return None
# ── 0b. 테마 과열 필터 (USE_THEME_HEAT_FILTER=true 시 활성) ──
# 이 종목 테마의 60분봉 RSI 평균이 THEME_HEAT_RSI_MAX 초과면 차단
# "테마 전체가 과열인데 내가 지금 진입하면 상투 잡기"
if get_env_bool("USE_THEME_HEAT_FILTER", False):
heat_max = get_env_float("THEME_HEAT_RSI_MAX", 72.0)
meta = self.db.get_stock_meta(code)
if meta and meta.get("theme"):
momentum = self.db.get_theme_momentum(meta["theme"], tf=60)
if momentum.get("count", 0) >= 3 and momentum.get("avg_rsi3", 0) > heat_max:
logger.debug(
"[테마필터] %s %s: 테마(%s) 과열 차단 (RSI=%.1f > %.1f)",
code, name, meta["theme"],
momentum["avg_rsi3"], heat_max,
)
return None
# ── 1. RAM 버퍼에서 확정 봉 조회 (DB 조회 없음, 즉시 반영) ──
# fill_gap_from_rest() 가 RAM(_confirmed)에 즉시 쓰므로
# 갭보정 직후에도 봉을 사용 가능 (DB 큐 플러시 대기 불필요)
if self.candle_agg:
candles = self.candle_agg.get_candles(code, self.candle_tf, n=50)
else:
# WebSocket 비활성 시 DB fallback
candles = self.db.get_ws_candles(code, self.candle_tf,
limit=50, confirmed_only=True)
if len(candles) < 5:
logger.info("%s🔍 [탈락-봉부족] %s %s: 확정봉 %d개 (최소 5개 필요)%s",
LOG_YELLOW, name, code, len(candles), LOG_RESET)
# ── 봉부족 감지 → 백그라운드 재갭보정 트리거 ──────────────
# 갭보정이 처음에 실패했거나 누락된 경우 즉시 재시도
# 같은 종목 30초 내 중복 재시도 방지 (API 과부하 방지)
now_ts = time.time()
last_retry = self._gap_retry_ts.get(code, 0)
retry_interval = get_env_int("SCALP_GAP_RETRY_SEC", 30)
if now_ts - last_retry > retry_interval:
self._gap_retry_ts[code] = now_ts
def _retry_gap(c=code):
try:
tf = self.candle_tf
lim = get_env_int("SCALP_GAP_FILL_LIMIT", 100)
df = self.client.get_minute_chart(c, period=str(tf), limit=lim)
if df is not None and not df.empty and self.candle_agg:
self.candle_agg.fill_gap_from_rest(c, tf, df)
logger.info("🔧 [봉부족 재갭보정 완료] %s: %d행 로드", c, len(df))
else:
logger.warning("⚠️ [봉부족 재갭보정 실패] %s: 데이터 없음", c)
except Exception as ex:
logger.warning("⚠️ [봉부족 재갭보정 오류] %s: %s", c, ex)
threading.Thread(target=_retry_gap, daemon=True,
name=f"gap-retry-{code}").start()
return None
latest = candles[-1] # 가장 최신 확정 봉
prev = candles[-2] # 그 전 봉
curr_price = float(latest["close"])
if curr_price < self.min_price:
return None
# ── 2. RSI 과열/과매도 체크 ───────────────────────────────
# RSI(상대강도지수): 단기간 얼마나 오르고 내렸는지 나타내는 지표.
# SCALP_RSI_OVERSOLD(기본 25) 이하: 단기 극과매도 → 되돌림 가능성 ↑
rsi3 = latest.get("rsi_3")
if rsi3 is None:
logger.info("%s🔍 [탈락-RSI없음] %s %s: RSI 미계산 (봉 축적 중)%s",
LOG_YELLOW, name, code, LOG_RESET)
return None
rsi3 = float(rsi3)
# RSI 0.0 은 "극과매도"가 아니라 "봉 데이터 부족으로 계산 불가"
# → 신뢰할 수 없으므로 진입 차단
if rsi3 <= 0.0:
logger.info("%s🔍 [탈락-RSI무효] %s %s: RSI3=0.0 (봉 부족, 계산 불가)%s",
LOG_YELLOW, name, code, LOG_RESET)
return None
if rsi3 > self.rsi_overbought:
logger.info("%s🔍 [탈락-RSI과열] %s %s: RSI3=%.1f > %.0f%s",
LOG_YELLOW, name, code, rsi3, self.rsi_overbought, LOG_RESET)
return None
if rsi3 > self.rsi_oversold:
# 과매도 구간 아님 → 진입 기회 아님
logger.info("%s🔍 [탈락-RSI조건] %s %s: RSI3=%.1f (과매도<%.0f 아님)%s",
LOG_YELLOW, name, code, rsi3, self.rsi_oversold, LOG_RESET)
return None
# ── 3. 되돌림(Retracement) 봉 확인 ──────────────────────
# 되돌림: 하락하던 주가가 방향을 틀어 상승 전환하는 찰나의 타이밍
# 이전 봉 음봉 + 현재 봉 양봉 → 전환 신호 확인
prev_bearish = float(prev["close"]) < float(prev["open"])
curr_bullish = float(latest["close"]) > float(latest["open"])
if not (prev_bearish and curr_bullish):
logger.info("%s🔍 [탈락-되돌림없음] %s %s: prev_bear=%s curr_bull=%s%s",
LOG_YELLOW, name, code, prev_bearish, curr_bullish, LOG_RESET)
return None
# ── 4. 당일 시고저 확보 (WS 캐시 우선 → REST fallback) ──
# H0STCNT0 체결 틱에 stck_oprc/hgpr/lwpr 포함 → REST 없이 바로 사용
current_price = curr_price
day_open = day_high = day_low = 0.0
ws_d = self.ws_cache.get_price(code) if self.ws_cache else None
if ws_d:
current_price = abs(float(str(ws_d.get("stck_prpr", curr_price)).replace(",", ""))) or curr_price
day_open = abs(float(str(ws_d.get("stck_oprc", 0)).replace(",", "")))
day_high = abs(float(str(ws_d.get("stck_hgpr", 0)).replace(",", "")))
day_low = abs(float(str(ws_d.get("stck_lwpr", 0)).replace(",", "")))
# WS 캐시 없거나 시고저 미수신 시에만 REST 호출 (API 과부하 방지)
if day_open <= 0 or day_low <= 0:
price_data = self.client.inquire_price(code)
if not price_data:
return None
current_price = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) or current_price
day_open = abs(float(str(price_data.get("stck_oprc", 0)).replace(",", "")))
day_high = abs(float(str(price_data.get("stck_hgpr", 0)).replace(",", "")))
day_low = abs(float(str(price_data.get("stck_lwpr", 0)).replace(",", "")))
if day_open <= 0 or day_low <= 0 or current_price <= 0:
return None
# ── 낙폭 필터 (SCALP_MIN_DROP_RATE, 기본 1.5%) ──
# 꼬리잡기 MIN_DROP_RATE(3%)보다 절반으로 완화 → 1분봉 타점 빈도 증가
drop_rate = (day_open - day_low) / day_open if day_open > 0 else 0
if drop_rate < self.scalp_min_drop_rate:
logger.info("%s🔍 [탈락-낙폭] %s %s: %.2f%% < %.1f%%(SCALP_MIN_DROP_RATE)%s",
LOG_YELLOW, name, code,
drop_rate * 100, self.scalp_min_drop_rate * 100, LOG_RESET)
return None
# ── [핵심 변경] 일일 회복률 필터 제거 ─────────────────────
# 기존: recovery_pos_day >= MIN_RECOVERY_RATIO_SHORT(50%) 조건이 있었음
# 문제: RSI<25(방금 폭락)이면 당연히 회복률도 낮음 → 동시 충족 불가 → 매수 기회 0
# 스캘핑은 '폭락 직후 되돌림' 포착이 목적이므로 일일 회복률 체크 불필요
# ── 5. 피뢰침 / 급등 필터 ────────────────────────────────
if current_price >= day_high * self.high_chase_thr:
logger.info("%s🔍 [탈락-고점추격] %s %s: 현재가 %.0f ≥ 고가 %.0f × %.2f%s",
LOG_YELLOW, name, code,
current_price, day_high, self.high_chase_thr, LOG_RESET)
return None
if day_low > 0:
# 당일 변동폭이 너무 크면 이미 급등주 → 스캘핑 회수 어려움
daily_chg_pct = (day_high - day_low) / day_low * 100
if daily_chg_pct > self.max_daily_chg:
logger.info("%s🔍 [탈락-피뢰침 급등주] %s %s: 일일 변동폭 %.1f%% > %.0f%%%s",
LOG_YELLOW, name, code, daily_chg_pct, self.max_daily_chg, LOG_RESET)
return None
# ── 6. 거래량 필터 ────────────────────────────────────────
# 현재 봉 거래량이 최근 N봉 평균 대비 VOLUME_AVG_MULTIPLIER 배 이상이어야
# 단순 노이즈가 아닌 실제 수급 참여 신호로 판단
volumes = [float(c.get("volume", 0)) for c in candles]
avg_vol = sum(volumes[:-1]) / max(len(volumes) - 1, 1)
curr_vol = float(latest.get("volume", 0))
if avg_vol > 0 and curr_vol < avg_vol * self.vol_multiplier:
logger.info("%s🔍 [탈락-거래량] %s %s: %.0f < 평균%.0f × %.1f%s",
LOG_YELLOW, name, code,
curr_vol, avg_vol, self.vol_multiplier, LOG_RESET)
return None
# ── 7. 수급 수집 (ML 피처용, 진입 필터로 미사용) ──────────
investor_trend = self.client.get_investor_trend(code, days=3)
entry_features = {
"rsi": rsi3,
"volume_ratio": curr_vol / avg_vol if avg_vol > 0 else 1.0,
"drop_rate": drop_rate, # 당일 낙폭 (ML 학습용)
"tail_length_pct": 0.0,
"ma5_gap_pct": None,
"ma20_gap_pct": None,
"foreign_net_buy": investor_trend.get("foreign_net_buy", 0) if investor_trend else 0,
"institution_net_buy": investor_trend.get("org_net_buy", 0) if investor_trend else 0,
"market_hour": dt.now().hour,
}
# ── ML 승률 필터 (설정 시) ────────────────────────────────
if self.use_ml_signal and self.ml_predictor:
try:
ml_feats = {k: (v if v is not None else 0.0)
for k, v in entry_features.items()}
ml_prob = self.ml_predictor.predict_win_probability(ml_feats)
if ml_prob < self.ml_min_prob:
logger.info("%s🔍 [탈락-ML] %s %s: %.2f%% < %.0f%%%s",
LOG_YELLOW, name, code,
ml_prob * 100, self.ml_min_prob * 100, LOG_RESET)
return None
except Exception:
pass
# 과매도가 깊을수록(RSI 낮을수록) 점수 상승 → 우선 매수
score = 5.0 + (self.rsi_oversold - rsi3) / 5.0
logger.info(
"%s🎯 [스캘핑 시그널] %s | 가격:%.0f | RSI3:%.1f | 낙폭:%.1f%% | 거래량:%.1fx%s",
LOG_CYAN, name, current_price, rsi3,
drop_rate * 100, curr_vol / max(avg_vol, 1), LOG_RESET,
)
return {
"code": code,
"name": name,
"price": current_price,
"score": score,
"entry_features": entry_features,
}
except Exception as e:
logger.info("%s🔍 [탈락-예외] %s %s: %s%s",
LOG_YELLOW, name, code, e, LOG_RESET)
return None
# ------------------------------------------------------------------
# 매수 실행 (ver2 execute_buy 동일 구조)
# ------------------------------------------------------------------
def execute_buy(self, signal: dict) -> bool:
"""매수 주문 실행 + DB 저장 + WS 구독."""
code = signal["code"]
name = signal["name"]
price = signal["price"]
if price <= 0:
return False
# ── 포지션 크기 계산 (원화 손실 한도 역산) ────────────────────────────
# MAX_LOSS_PER_TRADE_KRW(원) ÷ |SCALP_STOP_LOSS_PCT| = 최대 투자 가능 금액
# 스캘핑 손절 1.5% 기준 예: 손실한도 20만원 → 투자상한 = 200,000 / 0.015 = 13,333,333원
# SLOT_MONEY_DEFAULT 가 더 낮으면 그쪽 사용 (포지션 과집중 방지)
max_loss_krw = get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000)
# 스캘핑 전용 손절 비율 (SCALP_STOP_LOSS_PCT, 기본 1.5%)
scalp_sl_pct = abs(self.scalp_stop_loss_pct) # 양수로 처리
if max_loss_krw > 0 and scalp_sl_pct > 0:
invest_limit = max_loss_krw / scalp_sl_pct
invest_amount = min(invest_limit, self.slot_money)
else:
invest_amount = self.slot_money
qty = max(1, int(invest_amount / price))
if qty <= 0:
return False
# 스캘핑 전용 타이트한 손절/익절가 (SCALP_STOP/TAKE_PROFIT_PCT)
# 꼬리잡기의 STOP_LOSS_PCT(-4%), TAKE_PROFIT_PCT(+5%)와 완전 분리
stop_price = price * (1 + self.scalp_stop_loss_pct) # ex: -1.5%
target_price = price * (1 + self.scalp_take_profit_pct) # ex: +1.5%
ok = self.client.buy_order(code, qty, order_type="01") # 시장가
if not ok:
return False
now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S")
holding = {
"buy_price": price,
"qty": qty,
"stop_price": stop_price,
"target_price": target_price,
"max_price": price,
"atr_entry": 0.0,
"buy_time": now_str,
"name": name,
"size_class": "",
}
self.holdings[code] = holding
# DB 저장 (upsert_trade 는 trade_data dict 방식)
self.db.upsert_trade({
"code": code,
"name": name,
"strategy": "SCALP_RSI_REVERSAL",
"avg_buy_price": price,
"current_price": price,
"stop_price": stop_price,
"target_price": target_price,
"max_price": price,
"atr_entry": 0.0,
"target_qty": qty,
"current_qty": qty,
"total_invested": price * qty,
"status": "HOLDING",
"buy_date": now_str,
"entry_features": signal["entry_features"],
})
# WS 구독 (이미 구독 중이면 무시됨)
if self.ws_cache:
self.ws_cache.subscribe(code)
rsi_val = signal["entry_features"].get("rsi", 0)
msg = (f"🛒 **[스캘핑 매수]** {name}({code})\n"
f"매수가: {price:,.0f}× {qty}주 = {price*qty:,.0f}\n"
f"손절: {stop_price:,.0f}원(-{abs(self.scalp_stop_loss_pct)*100:.1f}%) | "
f"익절: {target_price:,.0f}원(+{self.scalp_take_profit_pct*100:.1f}%)\n"
f"RSI3: {rsi_val:.1f}")
msg_mm(msg)
logger.info("✅ [매수체결] %s %s @ %d× %d주 | 손절-%.1f%% 익절+%.1f%%",
name, code, int(price), qty,
abs(self.scalp_stop_loss_pct) * 100, self.scalp_take_profit_pct * 100)
return True
# ------------------------------------------------------------------
# 매도 신호 체크 (ver2 check_sell_signals 와 동일 원리)
# ------------------------------------------------------------------
def check_sell_signals(self) -> List[dict]:
"""
보유 종목 순회 → 손절/익절/ATR 조건 체크.
현재가: WebSocket 캐시 우선, 없으면 REST fallback.
"""
signals = []
now = dt.now()
for code, holding in list(self.holdings.items()):
try:
name = holding.get("name", code)
buy_price = float(holding.get("buy_price", 0))
qty = int(holding.get("qty", 0))
stop_price = float(holding.get("stop_price", 0))
target_price = float(holding.get("target_price", 0))
max_price = float(holding.get("max_price", buy_price))
if qty <= 0 or buy_price <= 0:
continue
# 매도 백오프 중이면 스킵
backoff_until = self._sell_backoff.get(code, 0)
if time.time() < backoff_until:
continue
# 현재가 조회: WS 캐시 → REST fallback
current_price = 0.0
if self.ws_cache and self.ws_cache.is_active:
pd_ = self.ws_cache.get_price(code)
if pd_:
current_price = abs(float(str(pd_.get("stck_prpr", 0))))
if current_price <= 0:
pd_ = self.client.inquire_price(code)
if pd_:
current_price = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", "")))
if current_price <= 0:
continue
# max_price 갱신
if current_price > max_price:
max_price = current_price
holding["max_price"] = max_price
self.db.upsert_trade({
"code": code,
"name": holding.get("name", code),
"avg_buy_price": buy_price,
"current_price": current_price,
"max_price": max_price,
"target_qty": qty,
"current_qty": qty,
"status": "HOLDING",
"buy_date": holding.get("buy_time", now.strftime("%Y-%m-%d %H:%M:%S")),
})
profit_pct = (current_price - buy_price) / buy_price # 가격 변화율 (수수료 전)
profit_val = (current_price - buy_price) * qty # 가격 손익 원화 (수수료 전)
# ── 본절가(breakeven) 계산 ──────────────────────────────
# 왕복 수수료 + 세금 + 최소 마진을 합산한 최소 보장 라인
# FEE_RATE_PCT : 위탁수수료 매수/매도 각각 (기본 0.015%)
# SELL_TAX_RATE_PCT: 증권거래세 매도 시만 (기본 0.18%)
# SCALP_MIN_PROFIT_PCT: 수수료 위 최소 순이익 마진 (기본 0.2%)
# breakeven = 매수가 × (1 + 수수료×2 + 세금 + 최소마진)
_fee = get_env_float("FEE_RATE_PCT", 0.015) / 100
_tax = get_env_float("SELL_TAX_RATE_PCT", 0.18) / 100
_min_margin = get_env_float("SCALP_MIN_PROFIT_PCT", 0.2) / 100
breakeven_pct = _fee * 2 + _tax + _min_margin
breakeven_price = buy_price * (1 + breakeven_pct)
reason = None
# [1] 손절 (% 기준)
if current_price <= stop_price:
reason = f"손절 ({profit_pct*100:.2f}%)"
# [2] 금액 손실컷 (원화 기준) — 고가 종목에서 % 손절 이전에 원화 손실이 터지는 경우 대비
# 예) 10만원짜리 20주 보유, 손절 -3% → 20만원 손실 → 설정값 초과 시 먼저 컷
elif profit_val <= -get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000):
reason = f"금액손실컷 ({profit_val:,.0f}원)"
# [3] 익절 (% 기준)
elif current_price >= target_price:
reason = f"익절 ({profit_pct*100:.2f}%)"
# [4] 본절사수 — 한 번 의미 있게 올랐던 종목이 수수료 라인까지 내려오면
# 트레일링 전에 본절+0.2%에서 먼저 청산 (마이너스 방지)
# 조건: 고점이 본절가 이상이었던 적 있음 + 현재가가 본절가로 회귀
elif max_price >= breakeven_price and current_price <= breakeven_price:
net_pct = profit_pct - breakeven_pct + _min_margin
reason = f"본절사수 (순익≈{net_pct*100:+.2f}%)"
# [5] ATR 트레일링 스탑 (고점 대비 하락)
# 발동 조건: 고점이 매수가 대비 본절가 이상 올라야 함
# → 수수료 방어 라인을 넘긴 수익에서만 트레일링 작동
elif max_price >= breakeven_price:
drop_from_high = (max_price - current_price) / max_price
if drop_from_high >= self.atr_down_mult * 0.01:
# 트레일링 발동이어도 현재가가 본절 이하면 본절사수로 전환
# (위 [4]에서 먼저 걸리므로 여기는 본절 이상에서만 도달)
reason = f"트레일링스탑 고점대비-{drop_from_high*100:.1f}%"
if reason:
signals.append({
"code": code,
"name": name,
"current_price": current_price,
"qty": qty,
"buy_price": buy_price,
"profit_pct": profit_pct,
"reason": reason,
})
except Exception as e:
logger.error("매도 신호 체크 오류(%s): %s", code, e)
return signals
# ------------------------------------------------------------------
# 매도 실행
# ------------------------------------------------------------------
def execute_sell(self, signal: dict):
"""매도 주문 실행 + DB 업데이트 + WS 구독 해제."""
code = signal["code"]
name = signal["name"]
current_price = signal["current_price"]
qty = signal["qty"]
buy_price = signal["buy_price"]
profit_pct = signal["profit_pct"]
reason = signal["reason"]
# 메시지에 사용할 손익·보유시간 미리 계산 (holdings 삭제 전)
# ── 수수료 계산 (env/DB 에서 비율 로드) ─────────────────────────
# FEE_RATE_PCT : 위탁수수료 (매수/매도 각각, 기본 0.015%)
# SELL_TAX_RATE_PCT: 증권거래세 (매도 시만 부과, 기본 0.18%)
# 왕복 총비용 = buy_price×qty×fee + sell_price×qty×(fee + tax)
fee_rate = get_env_float("FEE_RATE_PCT", 0.015) / 100
tax_rate = get_env_float("SELL_TAX_RATE_PCT", 0.18) / 100
total_fee = (buy_price * qty * fee_rate
+ current_price * qty * (fee_rate + tax_rate))
gross_pnl = (current_price - buy_price) * qty # 수수료 제외 손익
realized_pnl = gross_pnl - total_fee # 수수료 반영 순손익
buy_time_str = (self.holdings.get(code) or {}).get("buy_time",
dt.now().strftime("%Y-%m-%d %H:%M:%S"))
try:
hold_min = int((dt.now() - dt.strptime(buy_time_str, "%Y-%m-%d %H:%M:%S")).total_seconds() / 60)
except Exception:
hold_min = 0
ok = self.client.sell_market_order(code, qty)
if not ok:
msg_cd = self.client._last_sell_msg_cd or ""
msg1 = self.client._last_sell_msg1 or ""
# 영업일 아님/장 외 시간 오류 → 백오프
if msg_cd in ("APBK0013", "APBK0962", "40910000"):
backoff = get_env_int("SELL_FAILURE_BACKOFF_SEC", 1800)
self._sell_backoff[code] = time.time() + backoff
logger.warning("⏳ [매도백오프] %s %s: %s%d초 대기",
name, code, msg_cd, backoff)
# ── 잔고 없음: 계좌에 포지션이 없는데 로컬 DB·메모리에만 남은 경우 ──
# 모의투자 계정 초기화, 수동 취소 등으로 실제 잔고가 사라진 경우
# 로컬 holdings·active_trades를 강제 정리해 무한 재시도 방지
elif "잔고" in msg1 or "보유" in msg1 or "APBK3020" in msg_cd:
logger.warning("⚠️ [유령잔고 정리] %s %s: 브로커 잔고 없음 → 로컬 기록 강제 삭제",
name, code)
self.db.close_trade(code=code, sell_price=0,
sell_reason="잔고없음(강제정리)",
strategy="SCALP_RSI_REVERSAL")
self.holdings.pop(code, None)
return
# DB: active_trades → trade_history 이동 (strategy 지정 → 스캘핑 row만 삭제)
# realized_pnl_override: 수수료·거래세 이미 차감된 순손익을 DB에 저장
self.db.close_trade(
code=code,
sell_price=current_price,
sell_reason=reason,
strategy="SCALP_RSI_REVERSAL",
realized_pnl_override=realized_pnl,
)
if code in self.holdings:
del self.holdings[code]
# WS 구독 해제 (재매수 대기 종목은 계속 구독)
self.recently_sold[code] = time.time()
# 당일 확정 손익: 스캘핑(SCALP_) 전략만 필터 → 꼬리잡기 손익 혼합 방지
try:
today_trades = self.db.get_trades_by_date(self.today_date)
day_pnl = sum(
(t.get("realized_pnl") or 0)
for t in today_trades
if str(t.get("strategy", "")).startswith("SCALP")
)
except Exception:
day_pnl = 0
# 수수료 반영 순수익률
net_pct = realized_pnl / (buy_price * qty) if buy_price * qty > 0 else 0
emoji = "🔴" if realized_pnl < 0 else "🟢"
msg = (f"{emoji} **[스캘핑 매도]** {name}({code})\n"
f"{current_price:,.0f}× {qty:,}주 | {reason} | "
f"수익률 {net_pct*100:+.2f}% (실현 {realized_pnl:+,.0f}원 / 수수료 -{total_fee:,.0f}원)\n"
f"보유: {hold_min}분 | 보유 {len(self.holdings)}종목\n"
f"당일손익 {day_pnl:+,.0f}")
msg_mm(msg)
logger.info("%s [매도체결] %s %s @ %d%+.2f%% (수수료 -%,.0f원) (%s)%s",
LOG_GREEN if realized_pnl >= 0 else LOG_RED,
name, code, int(current_price), net_pct * 100, total_fee, reason, LOG_RESET)
# ------------------------------------------------------------------
# 매인 루프
# ------------------------------------------------------------------
def run(self):
"""메인 루프 진입점."""
asyncio.run(self._run_async())
async def _run_async(self):
"""비동기 루프: 리포트 태스크 + 동기 매매 루프."""
self._report_task = asyncio.create_task(self._report_scheduler())
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._sync_trading_loop)
def _sync_trading_loop(self):
"""동기 매매 루프."""
logger.info("📈 스캘핑 매매 루프 시작")
last_cleanup_day = ""
while True:
try:
self.reload_config()
now = dt.now()
today_str = now.strftime("%Y-%m-%d")
# 날짜 변경 처리
if today_str != self.today_date:
self.today_date = today_str
self.untradable_skip_set.clear()
self.morning_report_sent = False
self.closing_report_sent = False
logger.info("📅 날짜 변경: %s", today_str)
# ws_candles 오래된 봉 정리 (1일 1회)
if today_str != last_cleanup_day:
keep = get_env_int("SCALP_CANDLE_KEEP_DAYS", 3)
self.db.cleanup_old_ws_candles(keep_days=keep)
last_cleanup_day = today_str
# 장 외 시간: 보유 없으면 슬립
if not self.check_market_status():
time.sleep(30)
continue
# ── 매도 우선 ─────────────────────────────────────────
sell_signals = self.check_sell_signals()
for sig in sell_signals:
self.execute_sell(sig)
# ── 후보 종목 동기화 (새 종목 구독) ──────────────────
candidates = self.db.get_target_candidates()
self._sync_subscriptions(candidates)
# ── 매수 체크 ─────────────────────────────────────────
active_cnt = len(self.holdings)
if candidates and active_cnt < self.max_stocks:
logger.info("🔍 [매수체크] 후보 %d개 순회 (보유 %d/%d)",
len(candidates), active_cnt, self.max_stocks)
for c in candidates:
code = c.get("code") or c.get("stk_cd", "")
name = c.get("name") or c.get("stk_nm", code)
if not code or code in self.holdings:
continue
if code in self.untradable_skip_set:
continue
# 재진입 쿨다운 (스캘핑 전용 SCALP_COOLDOWN_SEC → 없으면 REENTRY_COOLDOWN_SEC 폴백)
reentry_cd = get_env_int("SCALP_COOLDOWN_SEC", None) or get_env_int("REENTRY_COOLDOWN_SEC", 300)
elapsed = time.time() - self.recently_sold.get(code, 0)
if elapsed < reentry_cd:
remaining = int(reentry_cd - elapsed)
logger.info("⏳ [재진입차단] %s %s%d초 남음",
name, code, remaining)
continue
signal = self.check_buy_signal_scalp(code, name)
if signal:
ok = self.execute_buy(signal)
if ok:
time.sleep(random.uniform(1, 2))
break
time.sleep(random.uniform(0.3, 0.8))
continue
time.sleep(random.uniform(0.2, 0.5))
# 스캘핑 루프: 짧은 대기 (1분봉 집계 시 1~2초면 충분)
time.sleep(random.uniform(1, 2))
except KeyboardInterrupt:
logger.info("⏹ 봇 종료 (KeyboardInterrupt)")
if self._report_task:
self._report_task.cancel()
break
except Exception as e:
logger.error("❌ 루프 에러: %s", e)
time.sleep(5)
# ------------------------------------------------------------------
# 리포트 스케줄러
# ------------------------------------------------------------------
async def _report_scheduler(self):
"""장 시작(9:05) / 마감(15:35) 리포트 전송."""
while True:
try:
now = dt.now()
if (now.hour == 9 and now.minute == 5 and not self.morning_report_sent):
self._send_report("morning")
self.morning_report_sent = True
elif (now.hour == 15 and now.minute == 35 and not self.closing_report_sent):
self._send_report("closing")
self.closing_report_sent = True
except Exception as e:
logger.error("리포트 스케줄러 오류: %s", e)
await asyncio.sleep(30)
def _send_report(self, report_type: str):
"""간단한 보유 현황 리포트 전송."""
try:
now_str = dt.now().strftime("%Y-%m-%d %H:%M")
title = "🌅 장 시작 보유현황" if report_type == "morning" else "📊 마감 스캘핑 리포트"
lines = [f"**{title}** ({now_str})\n"]
if self.holdings:
for code, h in self.holdings.items():
price_data = self.client.inquire_price(code)
curr = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) \
if price_data else h.get("buy_price", 0)
pnl_pct = (curr - h["buy_price"]) / h["buy_price"] * 100 if h["buy_price"] > 0 else 0
lines.append(f"- {h['name']}({code}): {curr:,.0f}{pnl_pct:+.1f}%")
time.sleep(0.2)
else:
lines.append("- 보유 종목 없음")
# 오늘 매매 결과 — 스캘핑(SCALP_) 전략만 집계
today_yyyymmdd = dt.now().strftime("%Y%m%d")
all_today = self.db.get_trades_by_date(today_yyyymmdd)
history = [t for t in all_today
if str(t.get("strategy", "")).startswith("SCALP")]
if history:
wins = [t for t in history if (t.get("profit_rate") or 0) >= 0]
total_pnl = sum((t.get("realized_pnl") or 0) for t in history)
lines.append(f"\n오늘 매매: {len(history)}건 | 승{len(wins)}/패{len(history)-len(wins)} | "
f"손익 {total_pnl:+,.0f}")
msg_mm("\n".join(lines))
except Exception as e:
logger.error("리포트 전송 실패: %s", e)
# ══════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
bot = ScalpingBotV1()
bot.run()