브랜치 분리 방식: A / B / C

A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
2026-05-05 21:04:17 +09:00
parent c2b2b711e0
commit f61c471aac
58 changed files with 803502 additions and 1430 deletions

View File

@@ -201,6 +201,8 @@ ENV_CONFIG_KEYS = (
"USE_RANDOM_SPLIT", "FORCE_MARKET_OPEN", "FORCE_BUY_TEST", "TOTAL_DEPOSIT",
# POP/LOCK·금액 손절 관련 추가 키
"ROUND_TRIP_COST_PCT", "POP_NET_PCT", "LOCK_NET_PCT", "MAX_LOSS_PER_TRADE_KRW",
# 금액손실컷 발동 최소 하락률(%): 이 하락률 미만이면 슬리피지/흔들림으로 간주하고 금액손실컷 미발동 (꼬리잡기)
"MIN_DROP_PCT_FOR_LOSS_CUT",
# 한투 API 관련 키 추가 (실전/모의 계좌 분리)
"KIS_APP_KEY_REAL", "KIS_APP_SECRET_REAL",
"KIS_APP_KEY_MOCK", "KIS_APP_SECRET_MOCK",
@@ -271,6 +273,7 @@ ENV_CONFIG_KEYS = (
"SCALP_GAP_RETRY_SEC",
# 스캘핑 전용 재진입 쿨다운(초): 매도 후 같은 종목 N초 동안 재매수 차단 (기본 600=10분)
# 백테스트와 동일 파라미터로 맞추려면 DB에 값 저장 후 봇/백테스트 모두 이 값 사용
"SCALP_REQUIRE_REVERSAL_CANDLE", # true=직전 음봉+현재 양봉 필수, false=RSI 과매도만으로 진입 허용
"SCALP_COOLDOWN_SEC",
# 트레일링 발동 최소 수익률(%): 고점이 매수가 대비 이 이상 올라야 트레일링 활성화
# 0.5%면 수수료(~0.21%) 뺀 나머지만 이익 → 1.5 이상 권장
@@ -278,6 +281,13 @@ ENV_CONFIG_KEYS = (
# 트레일링/본절사수 후 최소 순이익(%): 수수료+세금 위에 이 값만큼 추가 마진 확보
# 0이면 본절(수수료 이후 0원), 0.2면 최소 +0.2% 순이익 보장
"SCALP_MIN_PROFIT_PCT",
# 스캘핑 전용 방어로직 (꼬리잡기와 값 분리: HIGH_PRICE_CHASE_THRESHOLD / MIN_PRICE_TAIL 등과 별도)
"SCALP_HIGH_PRICE_CHASE_THRESHOLD", # 고점추격 방지: 당일고가 대비 비율 이하면 진입 허용 (0.96 등)
"SCALP_MAX_DAILY_CHANGE_PCT", # 급등주 필터: 당일 등락률 이 값 초과 시 진입 금지 (%)
"SCALP_MIN_PRICE", # 최소 가격(원): 이 값 미만 종목 진입 금지
"SCALP_MAX_LOSS_PER_TRADE_KRW", # 1회 최대 손실(원): 종목당 손실 상한
"SCALP_MIN_DROP_PCT_FOR_LOSS_CUT", # 금액손실컷 발동 최소 하락률(%): 이 미만은 흔들림으로 미발동
"SCALP_USE_DEFENSE_FILTERS", # true/false: 스캘핑 진입 방어 필터(낙폭/고점추격/급등/최소가격) ON/OFF
# ── 단타봇(kis_short_ver2) 전용 키 ───────────────────────────────────
# 켈리 공식 사용 여부 (true=켈리 적용, false=고정 비중)
"USE_KELLY_FORMULA",
@@ -338,6 +348,75 @@ ENV_CONFIG_KEYS = (
# USE_THEME_HEAT_FILTER : true=활성 (기본 false)
# THEME_HEAT_RSI_MAX : 테마 평균 RSI 이 값 초과면 진입 차단 (기본 72)
"USE_THEME_HEAT_FILTER", "THEME_HEAT_RSI_MAX",
# ── kis_trader 통합봇 전용 키 (전략 스위치 / 유니버스 / 랭킹) ────────
# 전략 on/off
"STRATEGY_SCALP_ENABLED", "STRATEGY_SHORT_ENABLED", "STRATEGY_BREAKOUT_ENABLED",
# 중복매매 정책: block(기본)=같은 종목 동시 양봇 차단 / allow=허용
"STRATEGY_SAME_CODE_POLICY",
# 유니버스 소스: ranking(기본) | condition
"SCALP_UNIVERSE_SOURCE", "SHORT_UNIVERSE_SOURCE", "BREAKOUT_UNIVERSE_SOURCE",
# 랭킹 정렬/크기 — sort: volume|trading_value|strength|fluct_up|fluct_down
"RANK_SCALP_SORT", "RANK_SCALP_LIMIT",
"RANK_SHORT_SORT", "RANK_SHORT_LIMIT",
"RANK_BREAKOUT_SORT", "RANK_BREAKOUT_LIMIT",
# 랭킹 폴링 주기(초) — 기본 10초 (모의 TPS 5건/s 대비 안전, 시간당 720회)
"RANKING_POLL_INTERVAL_SEC",
# 조건검색 파라미터
"KIS_HTS_ID", "CONDITION_POLL_INTERVAL_SEC",
"CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ",
"CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ",
"CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ",
# 유니버스 히스토리(백테스트용) — 기본 true, 배치 INSERT 로 DB 부담 최소화
"UNIVERSE_HISTORY_SAVE",
# WS 갭보정 파라미터 (KIS_FALLBACK 은 기본 false — 모의서버 500 폭탄 회피)
"WS_GAP_FILL_OFF_HOURS", "WS_GAP_FILL_LIMIT", "WS_TIMEFRAMES",
"WS_GAP_FILL_KIS_FALLBACK",
# 종목당 최대 매수금액 하드캡 (손절%가 작으면 포지션 사이즈 폭주 방지)
"MAX_BUY_AMOUNT_PER_STOCK",
"SCALP_MAX_BUY_AMOUNT", "SHORT_MAX_BUY_AMOUNT", "TAIL_MAX_BUY_AMOUNT",
# KIS REST 안정화 파라미터
"KIS_MIN_INTERVAL_SEC", "KIS_REST_MAX_RETRIES",
"KIS_REST_BACKOFF_CAP_SEC", "KIS_REST_TIMEOUT_SEC",
# ── 시장 급락 서킷브레이커 (KOSPI/KOSDAQ 지수 폭락 시 신규 매수 차단) ──
# 거래소 공식 서킷브레이커는 -8% 이지만, 봇 보호용으로 더 빨리(-2%) 반응.
# PANIC 모드 시 매수만 차단, 매도는 평소처럼 동작 (포지션 정리 가능).
"MARKET_GUARD_ENABLED", # 활성화 토글 (true/false). 백테스트 못하므로 운영 후 켜기
"MARKET_GUARD_5MIN_DROP_PCT", # 5분 내 -N% 하락 시 PANIC 진입 (기본 2.0)
"MARKET_GUARD_DAILY_DROP_PCT", # 일중 누적 -N% 하락 시 PANIC 진입 (기본 3.0)
"MARKET_GUARD_RECOVERY_PCT", # PANIC 중 5분 +N% 반등 시 자동 해제 (기본 1.0)
"MARKET_GUARD_INDEX_CODE", # 감시 지수 ("0001"=KOSPI, "1001"=KOSDAQ, "both"=둘 다)
"MARKET_GUARD_POLL_SEC", # 지수 폴링 주기 초 (기본 30)
# ── 전략별 후보 하드캡 (WS 구독 41 한도 보호 + 매수 체크 회전율 보장) ─
# cond/ranking 매니저가 폭주해도 본 전략은 상위 N개만 처리. 0=무제한.
# 권장: 세 전략 합산 ≤ 30 (영구구독 + 보유 여유 11종목 확보).
"SCALP_CAND_LIMIT", "SHORT_CAND_LIMIT", "BREAKOUT_CAND_LIMIT",
# ── 전략별 매매 시간대 (HHMM 정수, 930=09:30, 1500=15:00) ──────────
# 미설정 시 글로벌 TIME_START/TIME_END → 기본 900~1530.
# 예: SCALP는 변동성 큰 9시 직후 회피하고 09:30~14:30만 → SCALP_TIME_START=930, SCALP_TIME_END=1430
"SCALP_TIME_START", "SCALP_TIME_END",
"SHORT_TIME_START", "SHORT_TIME_END",
"BREAKOUT_TIME_START", "BREAKOUT_TIME_END",
# ETN/ETF/스팩/우선주 자동 제외 토글 (기본 true) — 후보 매니저 결과를 한 번 더 거른다.
"EXCLUDE_NON_STOCK",
# ── SCALP 진입 모드 토글 (reversal | momentum) ───────────────────
# reversal : 기존 RSI 과매도 V자 반전(원본 로직).
# momentum : 키움 'scalp' 조건검색(갭상승+신고가 돌파+거래량 폭증)으로
# 걸러진 종목군을 따라붙기. 봇은 모멘텀 살아있음/끝물 아님/
# 양봉 마무리/거래량 평균 초과만 검증.
"SCALP_MODE",
# SCALP momentum 모드 전용 파라미터
# mom_rsi_min ≤ RSI3 ≤ mom_rsi_max 만 진입 (모멘텀 살아있고 과열은 아님).
"SCALP_MOM_RSI_MIN", "SCALP_MOM_RSI_MAX",
# 직전 N봉 평균 거래량 × 배수 이상이어야 진입.
"SCALP_MOM_VOL_MULT", "SCALP_MOM_VOL_WIN",
# momentum 모드 진입 마감 시각(HHMM, 기본 1430). 종가 후폭풍 회피용.
"SCALP_MOM_TIME_END_HM",
# SCALP 일일 진입 횟수 (momentum은 회전 빠르므로 5 정도 권장)
"SCALP_MAX_DAILY",
# ── 전략별 동시 보유 종목 한도 (기존 글로벌 MAX_STOCKS 분리) ─────────
# 미설정(0/빈값) 시 글로벌 MAX_STOCKS 로 폴백 → 구버전 호환.
# 권장: 합계 ≤ MAX_STOCKS (계좌 슬롯 분산), 예: 3+2+2=7.
"SCALP_MAX_STOCKS", "SHORT_MAX_STOCKS", "BREAKOUT_MAX_STOCKS",
)
@@ -345,7 +424,14 @@ class TradeDB:
"""
트레이딩 봇용 MariaDB 데이터베이스 관리 클래스.
기존 SQLite 인터페이스와 100% 호환 (db_path 인수는 무시됨).
※ 호출자가 매번 ``TradeDB()`` → ``db.close()`` 패턴으로 쓰므로,
DDL(`CREATE TABLE IF NOT EXISTS`) 과 INFO 로그는 **프로세스당 1회만** 실행한다.
(내부 클래스 변수 ``_tables_created`` 가드)
"""
_tables_created = False # 프로세스 내 테이블 생성 1회만
_tables_lock = threading.Lock() # 멀티스레드 race 방지
def __init__(self, db_path="quant_bot.db"):
"""
Args:
@@ -353,8 +439,21 @@ class TradeDB:
"""
self.db_path = db_path # 호환용 보존
self.conn = _MariaDBConn()
self._create_tables()
logger.info("✅ TradeDB 초기화 완료: MariaDB %s:%s/%s", _DB_HOST, _DB_PORT, _DB_NAME)
# 테이블 생성은 첫 인스턴스에서만 — 매 호출마다 21개 DDL 폭탄 방지
with TradeDB._tables_lock:
if not TradeDB._tables_created:
self._create_tables()
logger.info(
"✅ TradeDB 초기화 완료: MariaDB %s:%s/%s",
_DB_HOST, _DB_PORT, _DB_NAME,
)
TradeDB._tables_created = True
else:
# 이후 인스턴스는 조용히 — 같은 정보 매초 찍어 로그 오염 방지
logger.debug(
"TradeDB 인스턴스 재생성 (테이블 스킵): %s:%s/%s",
_DB_HOST, _DB_PORT, _DB_NAME,
)
def _create_tables(self):
"""DB 테이블 생성 (없을 경우)"""
@@ -469,6 +568,24 @@ class TradeDB:
) CHARACTER SET utf8mb4
""")
# 6-2. 매수 후보군 이력 (5분마다 쌓아서 백테스트 시 '실제 그 시각 유니버스' 사용 가능)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS target_candidates_history (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
slot_key VARCHAR(12) NOT NULL,
scan_time VARCHAR(30) NOT NULL,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
score DOUBLE NOT NULL DEFAULT 0,
price DOUBLE NOT NULL DEFAULT 0,
market CHAR(1) DEFAULT 'Q',
sector VARCHAR(100),
theme VARCHAR(100),
INDEX idx_slot (slot_key),
INDEX idx_scan (scan_time)
) CHARACTER SET utf8mb4
""")
# 7. 종목 메타데이터 (테마·섹터·시장구분) — 스캐너가 채움, 조인 분석 용
# market: 'K'=KOSPI, 'Q'=KOSDAQ, 'E'=ETF/기타
# sector: 업종명 (KIS bstp_kor_isnm, 예: '반도체')
@@ -624,6 +741,27 @@ class TradeDB:
logger.info(f"📌 target_candidates.{col} 컬럼 추가")
except Exception as e:
logger.debug(f"migrate target_candidates theme cols: {e}")
# ── target_candidates_history (후보 이력, 백테스트용) ──────────────────
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS target_candidates_history (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
slot_key VARCHAR(12) NOT NULL,
scan_time VARCHAR(30) NOT NULL,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
score DOUBLE NOT NULL DEFAULT 0,
price DOUBLE NOT NULL DEFAULT 0,
market CHAR(1) DEFAULT 'Q',
sector VARCHAR(100),
theme VARCHAR(100),
INDEX idx_slot (slot_key),
INDEX idx_scan (scan_time)
) CHARACTER SET utf8mb4
""")
logger.info("📌 target_candidates_history 테이블 확인/생성")
except Exception as e:
logger.warning(f"migrate target_candidates_history 실패(이력 미적재 가능): {e}")
def _migrate_env_config_to_columns(self):
"""env_config가 예전 JSON 컬럼(snapshot_json)이면 컬럼 스키마로 이전"""
@@ -1227,6 +1365,31 @@ class TradeDB:
"""
try:
scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 5분 슬롯 키 (백테스트 유니버스 이력 조회용): 2026-03-18 15:55:21 → 202603181555
_now = datetime.datetime.now()
slot_min = (_now.hour * 60 + _now.minute) // 5 * 5
slot_key = _now.strftime("%Y%m%d") + "%02d%02d" % (slot_min // 60, slot_min % 60)
# 이력 테이블 없으면 생성 시도 (마이그레이션 누락/실패 시 자동 보정)
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS target_candidates_history (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
slot_key VARCHAR(12) NOT NULL,
scan_time VARCHAR(30) NOT NULL,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
score DOUBLE NOT NULL DEFAULT 0,
price DOUBLE NOT NULL DEFAULT 0,
market CHAR(1) DEFAULT 'Q',
sector VARCHAR(100),
theme VARCHAR(100),
INDEX idx_slot (slot_key),
INDEX idx_scan (scan_time)
) CHARACTER SET utf8mb4
""")
except Exception as e:
logger.debug("target_candidates_history CREATE 확인: %s", e)
# stock_meta 캐시: 테마/섹터가 없는 항목을 DB에서 보완
meta_cache: Dict[str, Dict] = {}
@@ -1271,8 +1434,14 @@ class TradeDB:
sector,
theme,
))
# [DEPRECATED] 구봇(kiwoom_universe_scanner 등) 의 5분 슬롯 이력 적재는 제거됨.
# 신봇(kis_trader/**) 은 RankingManager/ConditionSearchManager 가
# ``insert_condition_universe_snapshot()`` 으로 초단위 event_time 스냅샷을
# 기록한다. 구봇이 우발적으로 실행되더라도 이력 혼선을 일으키지 않도록
# 이 경로에서의 history 적재는 의도적으로 하지 않는다.
# (과거 코드: INSERT INTO target_candidates_history ...)
logger.info(f"✅ 매수 후보군 DB 저장: {len(candidates)}")
logger.info(f"✅ 매수 후보군 DB 저장: {len(candidates)} (slot_key={slot_key}, 이력 적재 안함)")
return True
except Exception as e:
@@ -1383,6 +1552,41 @@ class TradeDB:
logger.error(f"❌ 후보군 조회 실패: {e}")
return []
def get_universe_history_for_backtest(
self, start_ymd: str, end_ymd: str
) -> Dict[str, List[str]]:
"""
target_candidates_history에서 기간 내 5분별 유니버스 조회.
백테스트 시 '실제 그 시각에 봇이 보던 후보'로 검사할 때 사용.
Args:
start_ymd: 시작일 YYYYMMDD
end_ymd: 종료일 YYYYMMDD
Returns:
slot_key(YYYYMMDDHHMM) → 해당 슬롯 후보 코드 리스트 (점수 순 유지)
"""
try:
start_key = start_ymd + "0000"
end_key = end_ymd + "2359"
rows = self.conn.execute("""
SELECT slot_key, code
FROM target_candidates_history
WHERE slot_key >= %s AND slot_key <= %s
ORDER BY slot_key, score DESC
""", (start_key, end_key)).fetchall()
out: Dict[str, List[str]] = {}
for r in rows:
# 백테스트 엔진 _slot_key()는 항상 str → 조회 키도 str 통일 (DB 드라이버에 따라 int 올 수 있음)
sk = str(r["slot_key"])
if sk not in out:
out[sk] = []
out[sk].append(r["code"])
return out
except Exception as e:
logger.debug(f"후보 이력 조회 실패: {e}")
return {}
# ------------------------------------------------------------------
# stock_meta 테마/섹터 메타데이터 헬퍼
# ------------------------------------------------------------------
@@ -1876,7 +2080,7 @@ class TradeDB:
logger.error("cleanup_old_ws_candles 실패: %s", e)
def close(self):
"""DB 연결 종료"""
"""DB 연결 종료 (호출자가 매번 close 하므로 로그는 DEBUG 로 강등)."""
if self.conn:
self.conn.close()
logger.info("🔒 DB 연결 종료")
logger.debug("🔒 DB 연결 종료")