Files
kis_trader/database.py
Hwang 78edb75e01 feat: Add new files and enhance backtesting functionality
Changes:
- Introduced new files for strategy definitions and study names.
- Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting.
- Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies.
- Added EOD configuration options in the database and parameter search files.

Impact:
- These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
2026-07-06 19:11:34 +09:00

3616 lines
170 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.
"""
트레이딩 봇 데이터베이스 관리 모듈
- MariaDB 기반 (pymysql) — SQLite 에서 이전
- 활성 트레이딩 관리 (active_trades)
- 매매 히스토리 관리 (trade_history)
MariaDB 접속 정보 우선순위:
1) 환경변수 DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME
2) 아래 DEFAULT 상수 (기본값)
"""
import json
import os
import datetime
import logging
import threading
from typing import Any, Dict, List, Optional, Tuple
try:
import pymysql
import pymysql.cursors
_PYMYSQL_AVAILABLE = True
except ImportError:
_PYMYSQL_AVAILABLE = False
logger = logging.getLogger("TradeDB")
# ── MariaDB 기본 접속 정보 (환경변수로 재정의 가능) ─────────────────
_DB_HOST = os.environ.get("DB_HOST", "192.168.0.141")
_DB_PORT = int(os.environ.get("DB_PORT", "3306"))
_DB_USER = os.environ.get("DB_USER", "jae")
_DB_PASS = os.environ.get("DB_PASS", "1234")
_DB_NAME = os.environ.get("DB_NAME", "kis_quant_db")
# ══════════════════════════════════════════════════════════════════════
# SQLite 호환 래퍼 — 기존 conn.execute() / with conn: 패턴 유지
# ══════════════════════════════════════════════════════════════════════
class _MariaDBConn:
"""
pymysql 연결을 sqlite3 인터페이스처럼 감싸는 래퍼.
- conn.execute(sql, params) → DictCursor 반환 (row['col'] 접근)
- with conn: ... → 자동커밋(autocommit=True) 이므로 no-op
- conn.commit() → 명시적 커밋 (autocommit=True라 호환)
- ? 플레이스홀더 → %s 자동 변환
- SQLite-only DDL 키워드 자동 변환
"""
# SQL 텍스트 자동 변환 규칙 (SQLite → MySQL)
_REPLACE_PAIRS = [
# DDL 키워드
("INTEGER PRIMARY KEY AUTOINCREMENT", "INT NOT NULL AUTO_INCREMENT PRIMARY KEY"),
("INTEGER PRIMARY KEY", "INT NOT NULL PRIMARY KEY"),
("AUTOINCREMENT", "AUTO_INCREMENT"),
# DML — SQLite 전용 구문
("INSERT OR REPLACE INTO", "REPLACE INTO"),
("last_insert_rowid()", "LAST_INSERT_ID()"),
# ON CONFLICT 처리: 단순 패턴 제거 후 ON DUPLICATE KEY UPDATE 로 수동 변환
# (복잡한 케이스는 각 메서드에서 직접 처리)
]
def __init__(self):
self._lock = threading.Lock()
self._conn = None
self._connect()
def _connect(self):
"""pymysql 연결 (재연결 포함)."""
if not _PYMYSQL_AVAILABLE:
raise ImportError(
"pymysql 미설치. 설치: pip install pymysql\n"
"또는: pip install PyMySQL"
)
self._conn = pymysql.connect(
host=_DB_HOST, port=_DB_PORT,
user=_DB_USER, password=_DB_PASS,
database=_DB_NAME,
charset="utf8mb4",
autocommit=True,
cursorclass=pymysql.cursors.DictCursor,
connect_timeout=10,
read_timeout=30,
write_timeout=30,
)
logger.debug("✅ MariaDB 연결 완료 (%s:%s/%s)", _DB_HOST, _DB_PORT, _DB_NAME)
def _ensure_connected(self):
"""연결 끊김 시 자동 재접속."""
try:
self._conn.ping(reconnect=True)
except Exception:
try:
self._connect()
except Exception as e:
logger.error("❌ MariaDB 재접속 실패: %s", e)
raise
@staticmethod
def _translate_sql(sql: str) -> str:
"""SQLite 전용 SQL 구문을 MySQL 호환으로 변환."""
for old, new in _MariaDBConn._REPLACE_PAIRS:
sql = sql.replace(old, new)
# ? → %s (파라미터 플레이스홀더)
# 단, 이미 %s 가 있는 경우 중복 변환 방지
if "?" in sql:
sql = sql.replace("?", "%s")
return sql
def execute(self, sql: str, params=None):
"""
SQL 실행. sqlite3.Connection.execute() 와 동일한 인터페이스.
반환값: DictCursor (fetchone/fetchall/lastrowid 사용 가능)
"""
sql = self._translate_sql(sql)
with self._lock:
self._ensure_connected()
cur = self._conn.cursor()
cur.execute(sql, params or ())
return cur
def __enter__(self):
"""with conn: 패턴 호환 (autocommit=True 이므로 실질적으로 no-op)."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
pass
def commit(self):
"""명시적 커밋 (autocommit=True 환경에서 호환성 유지용)."""
try:
with self._lock:
self._conn.commit()
except Exception:
pass
@property
def row_factory(self):
return None # DictCursor 가 이미 dict 반환, 호환용 stub
@row_factory.setter
def row_factory(self, _):
pass # sqlite3 호환용 stub (무시)
def close(self):
try:
self._conn.close()
except Exception:
pass
# ── information_schema 기반 컬럼 목록 조회 (PRAGMA 대체) ──────
def get_columns(self, table_name: str) -> list:
"""
PRAGMA table_info() 대체.
반환: 컬럼명 문자열 리스트
"""
try:
cur = self.execute(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s "
"ORDER BY ORDINAL_POSITION",
(_DB_NAME, table_name),
)
return [row["COLUMN_NAME"] for row in cur.fetchall()]
except Exception as e:
logger.debug("get_columns(%s) 실패: %s", table_name, e)
return []
# ML 학습용 진입 시점 피처 컬럼 (active_trades / trade_history 공통)
ML_ENTRY_FEATURE_COLUMNS = (
"rsi", "volume_ratio", "tail_length_pct", "ma5_gap_pct", "ma20_gap_pct",
"foreign_net_buy", "institution_net_buy", "market_hour",
)
# env_config 테이블 컬럼 (키 하나당 컬럼 하나, 추가/삭제 시 여기와 CREATE TABLE만 수정)
ENV_CONFIG_KEYS = (
"STOP_LOSS_PCT", "SHOULDER_CUT_PCT", "SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_NET_PCT", "STOP_ATR_MULTIPLIER_TAIL", "TARGET_ATR_MULTIPLIER_TAIL",
"TAIL_ATR_SL_MIN_PCT", "TAIL_ATR_SL_MAX_PCT", "TAIL_ATR_TP_MIN_PCT", "TAIL_ATR_TP_MAX_PCT",
"MAX_POSITION_PCT", "USE_SLOT_CAP", "SLOT_CAP_PCT", "MAX_STOCKS",
"USE_KELLY", "RISK_PCT_PER_TRADE", "MIN_POSITION_AMOUNT",
"USE_RISK_CHECK", "DAILY_STOP_LOSS_PCT", "CONSECUTIVE_LOSS_LIMIT",
# 일일 익절 목표 (마스터·총합) — env_config. 전략별은 {SID}_DAILY_PROFIT_* → config_*
"DAILY_PROFIT_TARGET_ENABLED",
"DAILY_PROFIT_TARGET_KRW",
"DAILY_PROFIT_TARGET_PCT",
"DAILY_PROFIT_TARGET_BUDGET_KRW",
"DAILY_PROFIT_HALT_NEW_BUYS",
"DAILY_PROFIT_NOTIFY_MM",
# 일일 누적손익 트레일 익절 (고점 대비 되돌림 시 신규매수 중단) — 마스터·총합
"DAILY_PROFIT_MODE",
"DAILY_PROFIT_TRAIL_TIERS",
"DAILY_PROFIT_TRAIL_DROP_PCT",
"DAILY_PROFIT_TRAIL_ARM_KRW",
"DAILY_PROFIT_TRAIL_ARM_PCT",
"USE_BAN_SYSTEM", "BAN_HOURS", "USE_STOCK_FILTER", "RSI_OVERHEAT_THRESHOLD",
"MIN_RECOVERY_RATIO", "MAX_RECOVERY_RATIO",
"USE_TWAP", "TWAP_MIN_SPLIT", "TWAP_MAX_SPLIT", "TWAP_MIN_DELAY", "TWAP_MAX_DELAY",
"USE_ML_SIGNAL", "ML_MIN_PROBABILITY", "USE_NEWS_ANALYSIS", "NEWS_ANALYSIS_HOUR", "NEWS_MAX_COUNT",
"USE_QUICK_PROFIT_PROTECTION", "HIGH_PRICE_CHASE_THRESHOLD", "MAX_DAILY_CHANGE_PCT",
"MA20_MAX_ABOVE_PCT", "VOLUME_AVG_MULTIPLIER", "CANDLE_OPEN_PRICE_BUFFER",
"MIN_CANDLE_LEN_TAIL", "MIN_PRICE_TAIL", "TAIL_SCORE_BASE", "TAIL_SCORE_RATIO_MULT",
"TAIL_RATIO_MIN", "TAIL_PCT_MIN", # 꼬리 비율/하락률 최소 (AI·봇 공통 기준)
"TAIL_SKIP_HTS_SCAN_DUPES", "TAIL_USE_INTRADAY_DROP", "TAIL_USE_MA20_FILTER",
"TAIL_USE_RSI_FILTER", "TAIL_USE_DAILY_RANGE_FILTER", "TAIL_USE_HIGH_CHASE_FILTER",
"TAIL_BAR_CHG_MIN_PCT", "TAIL_BAR_CHG_MAX_PCT", "TAIL_RSI_THRESHOLD",
"TAIL_PATTERN_HAMMER", "TAIL_PATTERN_PIN", "TAIL_PATTERN_ENGULFING",
"TAIL_PATTERN_PIERCING", "TAIL_PATTERN_HARAMI", "TAIL_PATTERN_DOJI",
"TAIL_PATTERN_MORNING_STAR",
"TAIL_PIN_CLOSE_UPPER_RATIO", "TAIL_PIN_MAX_UPPER_TAIL_RATIO",
"TAIL_ENGULF_MIN_BODY_RATIO", "TAIL_PIERCING_PENETRATE_RATIO",
"TAIL_HARAMI_MAX_BODY_RATIO", "TAIL_DOJI_BODY_MAX_RATIO",
"TAIL_MORNING_STAR_BODY_MAX_RATIO",
# 꼬리잡기 TAIL_* 통일 키 (레거시 MIN_DROP_RATE·STOP_LOSS_PCT 등 대체)
"TAIL_MIN_DROP_RATE", "TAIL_MIN_RECOVERY_RATIO", "TAIL_MAX_RECOVERY_3M",
"TAIL_STOP_LOSS_PCT", "TAIL_TAKE_PROFIT_PCT",
"TAIL_SHOULDER_MIN_HIGH_PCT", "TAIL_SHOULDER_CUT_PCT",
"TAIL_RATCHET_TIERS", "TAIL_MAX_HOLD_BARS",
"TAIL_TRAIL_PCT", "TAIL_TRAIL_ARM_PCT",
"TAIL_BACKTEST_USE_TICK_DB", "TAIL_BACKTEST_TICK_FALLBACK_OHLC",
"TAIL_STOP_ATR_MULT", "TAIL_TARGET_ATR_MULT",
"TAIL_MIN_PRICE", "TAIL_MAX_DAILY_CHG", "TAIL_MA20_MAX_ABOVE_PCT",
"TAIL_MAX_LOSS_KRW", "TAIL_MIN_DROP_FOR_LOSS_CUT",
"TAIL_RSI_PERIOD", "TAIL_COOLDOWN_SEC", "TAIL_MAX_DAILY",
"TAIL_HIGH_CHASE_THR", "TAIL_TIME_START", "TAIL_TIME_END",
"TAIL_MAX_STOCKS", "TAIL_TOTAL_BUDGET_KRW", "TAIL_SLOT_MONEY",
"TAIL_ENTRY_MODE", "TAIL_LIMIT_ATR_MULT", "TAIL_LIMIT_ANCHOR",
"TAIL_LIMIT_VALID_BARS", "TAIL_LIMIT_FILL_SLIP_PCT",
"TAIL_MIN_INVEST_RATIO_OF_SLOT",
"TAIL_PARAM_SEARCH_ENTRY_MODE",
"TAIL_GRID_FAST_MAX_DAILY_CHG", "TAIL_GRID_COARSE_MAX_DAILY_CHG",
"TAIL_GRID_FAST_LIMIT_ATR_MULT", "TAIL_GRID_COARSE_LIMIT_ATR_MULT",
# 더블 볼린저 DBBAND_* (config_dbband)
"DBBAND_BB_PERIOD", "DBBAND_BB_INNER_STD", "DBBAND_BB_OUTER_STD",
"DBBAND_TREND_MA_PERIOD", "DBBAND_USE_TREND_FILTER", "DBBAND_SIDE_MODE",
"DBBAND_ENTRY_VALID_BARS", "DBBAND_ENTRY_MODE",
"DBBAND_STOP_MODE", "DBBAND_STOP_BUFFER_PCT", "DBBAND_STOP_LOSS_PCT",
"DBBAND_TP_MODE", "DBBAND_TAKE_PROFIT_PCT", "DBBAND_RR_RATIO",
"DBBAND_EXIT_MODE",
"DBBAND_SHOULDER_MIN_HIGH_PCT", "DBBAND_SHOULDER_CUT_PCT",
"DBBAND_TRAIL_PCT", "DBBAND_TRAIL_ARM_PCT",
"DBBAND_TIME_START", "DBBAND_TIME_END", "DBBAND_COOLDOWN_SEC",
"DBBAND_MAX_DAILY", "DBBAND_MIN_PRICE",
"DBBAND_SLOT_MONEY", "DBBAND_MAX_STOCKS", "DBBAND_TOTAL_BUDGET_KRW",
"DBBAND_MAX_BUY_AMOUNT", "DBBAND_MAX_HOLD_BARS", "DBBAND_TIMEFRAME",
"DBBAND_MIN_INVEST_RATIO_OF_SLOT", "DBBAND_FORCE_EOD_EXIT",
"DBBAND_LIVE_MIN_CANDLES", "DBBAND_LIVE_SIGNAL_LOOKBACK_BARS",
"DBBAND_UNIVERSE_SOURCE", "DBBAND_CANDLE_FETCH_N",
"STRATEGY_DBBAND_ENABLED", "KIS_DBBAND_MM_CHANNEL",
"RANK_DBBAND_SORT", "RANK_DBBAND_LIMIT",
"CONDITION_DBBAND_NAME", "CONDITION_DBBAND_SEQ",
"DBBAND_GRID_BB_PERIOD0", "DBBAND_GRID_BB_PERIOD1",
"DBBAND_GRID_INNER_STD0", "DBBAND_GRID_INNER_STD1",
"DBBAND_GRID_OUTER_STD0", "DBBAND_GRID_OUTER_STD1",
"DBBAND_GRID_TREND_MA0", "DBBAND_GRID_TREND_MA1",
"DBBAND_GRID_SL0", "DBBAND_GRID_SL1",
"DBBAND_GRID_TP0", "DBBAND_GRID_TP1",
"DBBAND_GRID_RR0", "DBBAND_GRID_RR1",
"INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "SIZE_CLASS_LARGE_MIN", "SIZE_CLASS_MID_MIN",
# 단타 스캔 후보 점수 (전부 env/DB)
"SCAN_INVESTOR_NET_STRONG", "SCAN_INVESTOR_SCORE_STRONG", "SCAN_INVESTOR_SCORE_WEAK",
"SCAN_INVESTOR_BONUS_STRONG", "SCAN_INVESTOR_BONUS_WEAK",
"SCAN_VOLUME_BONUS_MIN", "SCAN_VOLUME_BONUS_POINT",
"SCAN_EXEC_STRENGTH_HIGH", "SCAN_EXEC_STRENGTH_MID", "SCAN_EXEC_BONUS_HIGH", "SCAN_EXEC_BONUS_MID",
"SCAN_SCORE_DROP_WEIGHT", "SCAN_SCORE_RECOVERY_WEIGHT",
"MIN_SCORE_DISPLAY", "MM_TOP_N",
"SLOT_MONEY_DEFAULT", "SLOT_BASE_AMOUNT_CAP",
"SIZE_CLASS_SMALL_RATIO", "SIZE_CLASS_MID_RATIO",
# 개미털기/유니버스 필터 추가 키
"SCAN_MIN_PREV_DAY_PCT", "UPDATE_UNIVERSE_MIN_CANDIDATES",
"UPDATE_UNIVERSE_MIN_SCORE", "UPDATE_UNIVERSE_FALLBACK_TOP_N", "UPDATE_UNIVERSE_TOP_LOG", "UPDATE_UNIVERSE_TOP_N",
"SCAN_INTERVAL_SEC", # 유니버스 스캔 주기(초), kiwoom_universe_scanner 전용
"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",
"KIS_ACCOUNT_NO_REAL", "KIS_ACCOUNT_CODE_REAL", # 실전 계좌 (KIS_MOCK=false 시 사용)
"KIS_ACCOUNT_NO_MOCK", "KIS_ACCOUNT_CODE_MOCK", # 모의 계좌 (KIS_MOCK=true 시 사용)
"KIS_MOCK",
# 단타 봇 전용 키
"TAKE_PROFIT_PCT", "MIN_DROP_RATE", "MIN_RECOVERY_RATIO_SHORT",
# 단타 매도 로직 (env/DB에서 수치 로드)
"SCALP_ATR_UP_MULT", "SCALP_ATR_DOWN_MULT", "SCALP_ATR_DROP_MULT",
"QUICK_PROFIT_PROTECT_HOURS", "QUICK_PROFIT_MAX_RATIO", "QUICK_PROFIT_CURRENT_MIN",
"MIN_HOLD_EARLY_TAKE_PCT", "MIN_HOLD_HIGH_PCT", "MIN_HOLD_DROP_FROM_HIGH",
"POST_HOLD_TAKE_PCT", "POST_HOLD_DROP_FROM_HIGH",
# 늘림목 봇 전용 키
"MAX_PER", "MAX_PEG", "MIN_GROWTH_PCT", "DCA_INTERVALS", "DCA_AMOUNTS",
# Mattermost 및 AI 리포트 관련 키
"MM_SERVER_URL", "MM_BOT_TOKEN_", "MATTERMOST_CHANNEL", "GEMINI_API_KEY",
"AI_JOURNAL_LINES", "ANTHROPIC_API_KEY", "CLAUDE_MODEL_ID", "CLAUDE_MAX_TOKENS",
"MM_BUTLER_CHANNEL",
# true=체결 알림을 전략 채널 + MATTERMOST_CHANNEL(통합) 양쪽 발송
"MM_DUAL_CHANNEL_ENABLED",
# OpenRouter API 연동용 키
"OPENROUTER_API_KEY", "OPENROUTER_MODEL_ID",
# 봇별 Mattermost 채널 — 전략별 config_* (KIS_LONG 은 홀딩봇 전용 → env_config)
"KIS_LONG_MM_CHANNEL",
# 롱 위시리스트/뉴스 리포트 전용 키
"LONG_DAILY_LOOKBACK_DAYS", "LONG_MA_SHORT_DAYS", "LONG_MA_LONG_DAYS",
"LONG_REPORT_AM_HOUR", "LONG_REPORT_AM_MIN",
"LONG_REPORT_PM_HOUR", "LONG_REPORT_PM_MIN",
"LONG_NEWS_ENABLED", "LONG_NEWS_INTERVAL_MIN",
"LONG_NEWS_ACTIVE_START_HOUR", "LONG_NEWS_ACTIVE_END_HOUR",
"LONG_ANALYSIS_DELAY_MIN_SEC", "LONG_ANALYSIS_DELAY_MAX_SEC",
# RSI 기간: 스윙=14(기본), 단타=5, 스캘핑=3 권장 (DB에서 실시간 변경 가능)
"RSI_PERIOD",
# KIS WebSocket 관련 키
"KIS_WS_URL_REAL", "KIS_WS_URL_MOCK", "KIS_WS_MOCK_ENABLED",
# 재진입 쿨다운: 매도 후 같은 종목 재매수를 N초 동안 차단 (반복매매 루프 방지)
"REENTRY_COOLDOWN_SEC",
# 꼬리잡기/단타 매수 허용 시간대 (HHMM 정수, 930=09:30, 1500=15:00) — 백테스트·실매 공통
"TIME_START", "TIME_END",
# 매도 실패 백오프: 영업일 아님·장외 시간 오류 시 N초 동안 재시도 금지 (API 낭비·차단 방지)
"SELL_FAILURE_BACKOFF_SEC",
# 실잔고 inquire-balance 캐시 TTL(초) — 매도 루프 내 N종목 1회 조회 공유
"BROKER_HOLDINGS_CACHE_TTL_SEC",
# 유령잔고(DB·메모리만 존재) 정리 후 동일 (전략,종목) 재시도 쿨다운(초)
"GHOST_POSITION_COOLDOWN_SEC",
# ── 스캘핑봇(kis_scalping_ver1) 전용 키 ──────────────────────────────
# RSI 과매도 임계값: 이 값 이하면 "과매도 → 되돌림 가능" 후보로 판단
"SCALP_RSI_OVERSOLD",
# RSI 과매수 임계값: 이 값 이상이면 신규 진입 금지 (고점 추격 방지)
"SCALP_RSI_OVERBOUGHT",
# 스캘핑봇이 사용할 봉 단위 (분): 1 또는 3 권장
"SCALP_CANDLE_TIMEFRAME",
# 장 시작 후 몇 분 뒤부터 매매 허용 (장 시작 직후 변동성 회피)
"SCALP_MARKET_OPEN_WAIT_MIN",
# WS 재접속 후 갭 보정에 사용할 REST 분봉 조회 캔들 수
"SCALP_GAP_FILL_LIMIT",
# 확정 봉 기준 최소 거래량: 이 값 미만인 봉은 유동성 부족으로 무시
"SCALP_MIN_VOLUME",
# 스캘핑·꼬리 MM alias → config_scalp / config_short
"KIS_SCALP_MM_CHANNEL",
"KIS_SHORT_MM_CHANNEL",
# 시스템 알림(시작·종료·장시작·장마감) 단일 MM alias (기본 default=키스명령봇)
"KIS_SYSTEM_MM_CHANNEL",
# 시작·종료 알림 추가 발송 MM alias (기본 stock — 재시작/종료 짝 확인용)
"KIS_LIFECYCLE_MM_CHANNEL",
# 미등록 보유분 일괄 시장가매도 시 종목 간 간격(초) — 429 방지
"BULK_SELL_INTERVAL_SEC",
# 수동매수 보호 종목코드(쉼표구분) — 일괄매도에서 무조건 제외
"MANUAL_HOLD_CODES",
# 장마감 후 봇 고아(active_trades 미기록) 자동 복구 ON/OFF
"ORPHAN_RECONCILE_ENABLED",
# ws_candles 자동 정리 보존 일수
"SCALP_CANDLE_KEEP_DAYS",
# ws_ticks 실시간 체결 틱 (RAM 링버퍼 + 배치 INSERT, C안)
"WS_TICK_SAVE_ENABLED",
"WS_TICK_BUFFER_MAX_PER_CODE",
"WS_TICK_DB_BATCH_SIZE",
"WS_TICK_DB_FLUSH_SEC",
"WS_TICK_WRITE_QUEUE_MAX",
"WS_TICK_KEEP_DAYS",
"WS_TICK_RECORD_SCOPE",
"WS_TICK_DEFAULT_MARKET",
"WS_TICK_DEFAULT_EXCHANGE",
"WS_TICK_DEFAULT_CURRENCY",
# ws_orderbook / ws_program — TRIGGER 호가·프로그램 스냅샷 (백테 재현)
"WS_ORDERBOOK_SAVE_ENABLED",
"WS_PROGRAM_SAVE_ENABLED",
"WS_TRIGGER_SNAPSHOT_DB_INTERVAL_SEC",
"WS_TRIGGER_SNAPSHOT_DB_BATCH_SIZE",
"WS_TRIGGER_SNAPSHOT_DB_FLUSH_SEC",
"WS_TRIGGER_SNAPSHOT_WRITE_QUEUE_MAX",
"WS_ORDERBOOK_KEEP_DAYS",
"WS_PROGRAM_KEEP_DAYS",
# TRIGGER 필터 판정 시점 스냅샷 (실매 RAM → DB, 백테 재생)
"WS_TRIGGER_EVAL_SAVE_ENABLED",
"WS_TRIGGER_EVAL_DB_BATCH_SIZE",
"WS_TRIGGER_EVAL_DB_FLUSH_SEC",
"WS_TRIGGER_EVAL_WRITE_QUEUE_MAX",
"BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT",
"BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT",
"MOMENTUM_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT",
"BREAKOUT_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT",
"TAIL_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT",
"BACKTEST_USE_TRIGGER_SNAPSHOT_DB",
"MOMENTUM_BACKTEST_USE_TRIGGER_SNAPSHOT_DB",
"MOMENTUM_BACKTEST_USE_TICK_EXIT",
"MOMENTUM_BACKTEST_USE_TICK_ENTRY",
"MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC",
"MOMENTUM_BACKTEST_POLL_MS",
"MOMENTUM_BACKTEST_SELL_SLIP_PCT",
"MOMENTUM_BACKTEST_BUY_SLIP_PCT",
"MOMENTUM_BACKTEST_UNIVERSE_STRICT",
"MOMENTUM_BACKTEST_UNIVERSE_STRICT_LAG_MIN",
"MOMENTUM_BACKTEST_LIVE_SCAN_QUEUE",
"MOMENTUM_BACKTEST_SCAN_SEC",
"MOMENTUM_BACKTEST_UNIVERSE_SCAN_AT",
"MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS",
"MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC",
"BREAKOUT_BACKTEST_USE_TRIGGER_SNAPSHOT_DB",
"TAIL_BACKTEST_USE_TRIGGER_SNAPSHOT_DB",
# 휩쏘(TRIGGER) 필터 — 공통 + 전략별
"WHIPSAW_FILTER_ENABLED",
"WHIPSAW_BACKTEST_OHLC_FALLBACK",
"WHIPSAW_MODE",
"WHIPSAW_SUBBAR_SEC",
"WHIPSAW_LOOKBACK_SEC",
"WHIPSAW_DIP_PCT",
"WHIPSAW_RECOVERY_TOL_PCT",
"WHIPSAW_MIN_SUBBARS",
"WHIPSAW_TICK_BUFFER_LIMIT",
"MOMENTUM_WHIPSAW_FILTER_ENABLED",
"MOMENTUM_WHIPSAW_MODE",
"MOMENTUM_WHIPSAW_SUBBAR_SEC",
"MOMENTUM_WHIPSAW_LOOKBACK_SEC",
"MOMENTUM_WHIPSAW_DIP_PCT",
"MOMENTUM_WHIPSAW_RECOVERY_TOL_PCT",
"BREAKOUT_WHIPSAW_FILTER_ENABLED",
"BREAKOUT_WHIPSAW_MODE",
"BREAKOUT_WHIPSAW_SUBBAR_SEC",
"BREAKOUT_WHIPSAW_LOOKBACK_SEC",
"BREAKOUT_WHIPSAW_DIP_PCT",
"BREAKOUT_WHIPSAW_RECOVERY_TOL_PCT",
"TAIL_WHIPSAW_FILTER_ENABLED",
"TAIL_WHIPSAW_MODE",
"TAIL_WHIPSAW_SUBBAR_SEC",
"TAIL_WHIPSAW_LOOKBACK_SEC",
"TAIL_WHIPSAW_DIP_PCT",
"TAIL_WHIPSAW_RECOVERY_TOL_PCT",
# 스캘핑 전용 손절 % (꼬리잡기 STOP_LOSS_PCT와 분리, 기본 1.5%)
# 1분봉 초단타: 손절이 넓으면(-4%) 자금이 묶여 회전율 0 → 타이트하게
"SCALP_STOP_LOSS_PCT",
# 스캘핑 전용 익절 % (꼬리잡기 TAKE_PROFIT_PCT와 분리, 기본 1.5%)
# 1분봉에서 +5% 익절은 도달 불가 → 박리다매 전략으로 1.5%씩 수익 적립
"SCALP_TAKE_PROFIT_PCT",
# 스캘핑 익절 상한·어깨컷 (꼬리와 동일 1순위 청산)
"SCALP_TP_MAX_PCT",
"SCALP_SHOULDER_MIN_HIGH_PCT", "SCALP_SHOULDER_CUT_PCT",
# 스캘핑 낙폭 필터 % (꼬리잡기 MIN_DROP_RATE와 분리, 기본 1.5%)
# 3% 기준은 1분봉 소형주에서 너무 엄격 → 1.5%로 완화해 타점 빈도 증가
"SCALP_MIN_DROP_RATE",
# 봉부족 감지 시 재갭보정 최소 간격(초): 같은 종목 중복 REST 호출 방지 (기본 30초)
"SCALP_GAP_RETRY_SEC",
# 스캘핑 전용 재진입 쿨다운(초): 매도 후 같은 종목 N초 동안 재매수 차단 (기본 600=10분)
# 백테스트와 동일 파라미터로 맞추려면 DB에 값 저장 후 봇/백테스트 모두 이 값 사용
"SCALP_REQUIRE_REVERSAL_CANDLE", # true=직전 음봉+현재 양봉 필수, false=RSI 과매도만으로 진입 허용
"SCALP_COOLDOWN_SEC",
# 트레일링 발동 최소 수익률(%): 고점이 매수가 대비 이 이상 올라야 트레일링 활성화
# 0.5%면 수수료(~0.21%) 뺀 나머지만 이익 → 1.5 이상 권장
"SCALP_TRAIL_TRIGGER_PCT",
# 트레일링/본절사수 후 최소 순이익(%): 수수료+세금 위에 이 값만큼 추가 마진 확보
# 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
# HTS C(MACD+Stoch 골든크로스) — TRIGGER 전용. true면 RSI reversal 대신 MACD 진입.
"SCALP_USE_MACD_CROSS",
"SCALP_MACD_FAST", "SCALP_MACD_SLOW", "SCALP_MACD_SIGNAL",
"SCALP_STOCH_K_PERIOD", "SCALP_STOCH_D_PERIOD", "SCALP_STOCH_SLOW",
# ── 단타봇(kis_short_ver2) 전용 키 ───────────────────────────────────
# 켈리 공식 사용 여부 (true=켈리 적용, false=고정 비중)
"USE_KELLY_FORMULA",
# 켈리 공식 적용 배수 (0.25 = Full Kelly의 25%, 과도한 베팅 방지)
"KELLY_MULTIPLIER",
# 시장가 IOC 주문 사용 여부 (실전: true=IOC, false=일반 시장가)
"USE_MARKET_IOC",
# 체결 확인 엄격 모드: true=모의도 fill 없으면 가정 체결 금지 (실전 훈련)
"STRICT_FILL_VERIFY",
# 주문 직후 체결 조회 대기(초) — 시장가
"ORDER_FILL_WAIT_SEC",
# 지정가 주문 직후 체결 조회 대기(초)
"LIMIT_ORDER_FILL_WAIT_SEC",
# heartbeat 미체결 재조회 간격(초)
"PENDING_FILL_POLL_INTERVAL_SEC",
# 미체결 재조회 시 get_execution 1회 대기(초)
"PENDING_FILL_POLL_SEC",
# (레거시) 매수 미체결 최대 대기 — PENDING_BUY_MAX_AGE_SEC 미설정 시 fallback
"PENDING_FILL_MAX_AGE_SEC",
# 매수 미체결 최대 대기(초) — 초과 시 취소·다음 신호 대기
"PENDING_BUY_MAX_AGE_SEC",
# 익절·지정가 매도 미체결 최대 대기(초)
"PENDING_SELL_MAX_AGE_SEC",
# 손절·장마감·긴급 매도 미체결 최대 대기(초) — 짧게, 만료 시 시장가 재주문
"PENDING_SELL_STOP_MAX_AGE_SEC",
# 손절 등 긴급 매도 만료 취소 후 즉시 시장가 재주문
"SELL_PENDING_REORDER_ON_EXPIRE",
# 동일 전략·종목 미체결 매수 있으면 재주문 스킵 (체결 API 지연 시 주문 폭주 방지)
"BUY_DEDUP_PENDING",
# 매수 부분체결 후 잔량 자동 취소 (지정가·비-IOC 시장가)
"AUTO_CANCEL_PARTIAL_BUY_REMAINDER",
# heartbeat poll_pending_fills: 당일 체결 1 REST 일괄 조회 (실매 전용)
"PENDING_POLL_BATCH_FETCH",
# 잔고 연속조회 최대 페이지 (1p=실전50/모의20종목) — 보유 많을 때 누락 방지
"BALANCE_MAX_PAGES",
# WebSocket 실시간 가격 캐시 유효기간(초): 이 시간 이상 지나면 REST 재조회
"KIS_PRICE_CACHE_TTL_SEC",
# WS 재접속 후 갭 보정에 사용할 REST 분봉 조회 캔들 수 (단타봇)
"SHORT_GAP_FILL_LIMIT",
# 매수 직후 최소 보유 시간(초): 이 기간 내 매도 신호 무시 (API 잔고 반영 지연 대응)
"MIN_HOLD_AFTER_BUY_SEC",
# 최소 보유 시간(시간): 이 기간 이전에는 손절 외 매도 금지 (꼬리잡기 전략 충분히 대기)
"MIN_HOLD_HOURS",
# 3개월 최대 회복 비율: 전고점 대비 이 비율 이상 회복한 종목은 추격 매수 제외
"MAX_RECOVERY_RATIO_3M",
# 유니버스 상위 N개 후보 경량 체크 (매수 후보 1차 필터)
"CANDIDATE_LIST_TOP_N_LIGHT",
# 매수 신호 체크 시 사용할 유니버스 최대 종목 수 (과부하 방지)
"SCAN_UNIVERSE_MAX_CODES",
# 꼬리 캔들 패턴 인식 시 과거 몇 봉까지 확인할지 (lookback)
"TAIL_CANDLE_LOOKBACK",
# ── 레거시 단일 계좌 키 (KIS_APP_KEY_REAL/MOCK 이전 버전 호환용) ──────
"KIS_APP_KEY", "KIS_APP_SECRET", "KIS_ACCOUNT_NO", "KIS_ACCOUNT_CODE",
# ── mm_butler 전용 키 ────────────────────────────────────────────────
# Gemini AI 모델 ID (gemini-2.5-flash, gemini-1.5-pro 등)
"GEMINI_MODEL_ID",
# MM 원격 명령 폴링 주기(초): 너무 짧으면 API 과부하
"MM_BUTLER_POLL_SEC",
# AI 소스 텍스트 최대 길이(문자): 초과 시 잘라서 전송 (토큰 비용 관리)
"AI_SOURCE_MAX_CHARS",
# ── 수수료·거래세 (전략 공통) ──────────────────────────────────────────
# FEE_RATE_PCT : 위탁수수료율 (매수/매도 각각, 기본 0.015%)
# SELL_TAX_RATE_PCT: 증권거래세율 (매도 시만 부과, 기본 0.18% ← 2025 코스피/코스닥 공통)
"FEE_RATE_PCT", "SELL_TAX_RATE_PCT",
# ── 키움증권 REST API 키 (60분봉 과거 데이터 수집 전용) ────────────────
# 실전/모의 분리 (KIS_MOCK 값에 따라 자동 선택)
# KIWOOM_APP_KEY_REAL : 키움증권 실전 앱키
# KIWOOM_APP_SECRET_REAL: 키움증권 실전 시크릿
# KIWOOM_APP_KEY_MOCK : 키움증권 모의 앱키 (mockapi.kiwoom.com)
# KIWOOM_APP_SECRET_MOCK: 키움증권 모의 시크릿
# KIWOOM_APP_KEY / KIWOOM_APP_SECRET: 레거시 (단일 키 호환용)
"KIWOOM_APP_KEY_REAL", "KIWOOM_APP_SECRET_REAL",
"KIWOOM_APP_KEY_MOCK", "KIWOOM_APP_SECRET_MOCK",
"KIWOOM_APP_KEY", "KIWOOM_APP_SECRET",
# ── WebSocket 영구 구독 종목 (시장 방향 필터용) ─────────────────────
# KOSPI/KOSDAQ 지수 ETF는 매매 후보와 무관하게 항상 구독 유지.
# 60분봉 RSI 로 상승장/하락장 체크 → 스캘핑/꼬리잡기 진입 방향 결정.
# 기본값: KODEX200(069500), KODEX KOSDAQ150(229200)
# 콤마 구분 코드: "069500,229200"
"PERMANENT_WS_CODES",
# ── 시장 방향 필터 (상승장에서만 롱 진입) ──────────────────────────
# USE_MARKET_REGIME_FILTER: true=활성, false=비활성 (기본 false)
# MARKET_REGIME_MIN_RSI : ETF 60분봉 RSI 이 값 이상이어야 롱 진입 허용 (기본 48)
"USE_MARKET_REGIME_FILTER", "MARKET_REGIME_MIN_RSI",
# ── 테마 과열 필터 (테마 전체가 과열이면 신규 진입 억제) ───────────
# USE_THEME_HEAT_FILTER : true=활성 (기본 false)
# THEME_HEAT_RSI_MAX : 테마 평균 RSI 이 값 초과면 진입 차단 (기본 72)
"USE_THEME_HEAT_FILTER", "THEME_HEAT_RSI_MAX",
# ── kis_trader 통합봇 전용 키 (전략 스위치 / 유니버스 / 랭킹) ────────
# 전략 on/off → config_{전략} (classify_config_key)
"STRATEGY_SCALP_ENABLED", "STRATEGY_SHORT_ENABLED", "STRATEGY_BREAKOUT_ENABLED",
"STRATEGY_RANGE_BREAK_ENABLED",
"STRATEGY_UPDOW_ENABLED",
# 중복매매 정책: allow(기본)=전략별 ODNO·active_trades 분리 / block=동일종목 1전략만
"STRATEGY_SAME_CODE_POLICY",
# 가용 예수금 캐시 (kv_store account.*) — 부족 시에만 매수 qty 축소
"ORDER_CASH_PCT", "ORDER_CASH_FEE_BUFFER", "ORDER_CASH_DIVIDE_BY_MAX_STOCKS",
"ACCOUNT_CASH_PERSIST_SEC",
# 라이브 진입 타이밍 = 백테 (신호봉 확정 → 다음 봉 시가)
"SCALP_LIVE_BACKTEST_ALIGN", "SCALP_LIVE_SIGNAL_LOOKBACK_BARS",
"BACKTEST_EXIT_CHECKS_PER_BAR",
"SHORT_LIVE_BACKTEST_ALIGN", "SHORT_LIVE_SIGNAL_LOOKBACK_BARS",
"MOMENTUM_LIVE_BACKTEST_ALIGN", "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS",
"BREAKOUT_LIVE_BACKTEST_ALIGN", "BREAKOUT_LIVE_SIGNAL_LOOKBACK_BARS",
"RANGE_BREAK_LIVE_BACKTEST_ALIGN", "RANGE_BREAK_LIVE_SIGNAL_LOOKBACK_BARS",
"BREAKOUT_ENTRY_MODE",
"BREAKOUT_INTRABAR_SLIPPAGE_PCT",
# 유니버스 소스: ranking(기본) | condition
"SCALP_UNIVERSE_SOURCE", "SHORT_UNIVERSE_SOURCE", "BREAKOUT_UNIVERSE_SOURCE",
"RANGE_BREAK_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",
"RANK_RANGE_BREAK_SORT", "RANK_RANGE_BREAK_LIMIT",
# 랭킹 폴링 주기(초) — 기본 10초 (모의 TPS 5건/s 대비 안전, 시간당 720회)
"RANKING_POLL_INTERVAL_SEC",
# 조건검색 파라미터
"KIS_HTS_ID", "CONDITION_POLL_INTERVAL_SEC",
# 조건검색 EXIT 유예 시간 — 한 번 빠진 종목을 N초간 universe 에 keep
# (단발성 EXIT/RE-ENTER 회전으로 WS 구독·캔들 데이터가 휘발되는 사이클 차단)
# 0 = 비활성(기존 동작), 권장 60
"CONDITION_EXIT_GRACE_SEC",
"CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ",
"CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ",
"CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ",
"CONDITION_RANGE_BREAK_NAME", "CONDITION_RANGE_BREAK_SEQ",
# 키움 조건식 seq (선택). 이름은 CONDITION_{SID}_NAME 공통 — UNIVERSE_SOURCE=kiwoom_condition 일 때만 사용.
"CONDITION_SHORT_KIWOOM_SEQ",
"CONDITION_BREAKOUT_KIWOOM_SEQ",
"CONDITION_MOMENTUM_KIWOOM_SEQ",
# (레거시) KIWOOM 전용 이름 — CONDITION_{SID}_NAME 과 다를 때만 설정
"CONDITION_SHORT_KIWOOM_NAME",
"CONDITION_BREAKOUT_KIWOOM_NAME",
"CONDITION_MOMENTUM_KIWOOM_NAME",
# 유니버스 히스토리(백테스트용) — 기본 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",
"WS_GAP_FILL_MAX_RETRIES", "WS_GAP_FILL_RETRY_DELAY_SEC",
"WS_GAP_BULK_REFILL_DEBOUNCE_SEC",
"WS_GAP_FILL_PRIORITY_TFS",
"WS_GAP_FILL_TF_SLEEP_MIN_SEC", "WS_GAP_FILL_TF_SLEEP_MAX_SEC",
"WS_GAP_FILL_CODE_SLEEP_MIN_SEC", "WS_GAP_FILL_CODE_SLEEP_MAX_SEC",
"WS_GAP_FILL_PHASE_PAUSE_SEC",
"WS_GAP_FILL_WORKERS",
"KIWOOM_CNSRREQ_GAP_MIN_SEC", "KIWOOM_CNSRREQ_GAP_MAX_SEC",
"KIWOOM_CNSRREQ_MAX_RETRIES", "KIWOOM_CNSRREQ_RETRY_SEC",
# 종목당 최대 매수금액 하드캡 (손절%가 작으면 포지션 사이즈 폭주 방지)
"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",
# 도메인별 REST 최소 호출 간격 (한투 유량: 실전 18건/초, 모의 1건/초)
"KIS_MIN_INTERVAL_SEC_MOCK", "KIS_MIN_INTERVAL_SEC_REAL",
# 스캔 루프 회전율 sleep (REST 유량과 별개 · WS 따라가기 속도 · 비우면 전략별 기본값)
"SCAN_REJECT_SLEEP_MIN", "SCAN_REJECT_SLEEP_MAX",
"SCAN_BUY_OK_SLEEP_MIN", "SCAN_BUY_OK_SLEEP_MAX",
"SCAN_BUY_FAIL_SLEEP_MIN", "SCAN_BUY_FAIL_SLEEP_MAX",
"STRATEGY_LOOP_SLEEP_MIN", "STRATEGY_LOOP_SLEEP_MAX",
# ── 시장 급락 서킷브레이커 (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)
"MARKET_GUARD_INDEX_CANDLE_KEEP_MIN", # 재시작 시 ws_candles MG* 1분봉 복원 개수 (기본 20)
"MARKET_GUARD_PERSIST_STATE", # kv_store PANIC 영속 (기본 true)
# ── 전략별 후보 하드캡 (WS 구독 41 한도 보호 + 매수 체크 회전율 보장) ─
# cond/ranking 매니저가 폭주해도 본 전략은 상위 N개만 처리. 0=무제한.
# 권장: 세 전략 합산 ≤ 30 (영구구독 + 보유 여유 11종목 확보).
"SCALP_CAND_LIMIT", "SHORT_CAND_LIMIT", "BREAKOUT_CAND_LIMIT",
"RANGE_BREAK_CAND_LIMIT",
# ── 전략별 매매 시간대 (HHMM 정수, 930=09:30, 1500=15:00) ──────────
# 꼬리(SHORT): config_short 의 TAIL_TIME_START/TAIL_TIME_END (SHORT_TIME_* 레거시 제거)
# 그 외 미설정 시 글로벌 TIME_START/TIME_END → 기본 900~1530.
"SCALP_TIME_START", "SCALP_TIME_END",
"BREAKOUT_TIME_START", "BREAKOUT_TIME_END",
# ── BREAKOUT(돌파) — HTS SCAN(널넬) + 봇 TRIGGER(엄격) ─────────────────
"BREAKOUT_LOOKBACK_MIN", "BREAKOUT_VOL_WIN", "BREAKOUT_VOL_MULT",
"BREAKOUT_PREV_CHG_MIN", "BREAKOUT_PREV_CHG_MAX",
"BREAKOUT_STOP_LOSS_PCT", "BREAKOUT_TAKE_PROFIT_PCT", "BREAKOUT_TRAIL_PCT",
"BREAKOUT_TRAIL_ARM_PCT",
"BREAKOUT_SHOULDER_MIN_HIGH_PCT", "BREAKOUT_SHOULDER_CUT_PCT",
"BREAKOUT_RATCHET_TIERS", "BREAKOUT_MAX_HOLD_BARS",
"BREAKOUT_EOD_ENABLED", "BREAKOUT_EOD_HM",
"MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM",
"TAIL_EOD_ENABLED", "TAIL_EOD_HM",
# ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정%)
"BREAKOUT_SL_MODE", "BREAKOUT_ATR_PERIOD", "BREAKOUT_ATR_SL_MULT",
"BREAKOUT_ATR_SL_MIN_PCT", "BREAKOUT_ATR_SL_MAX_PCT",
"BREAKOUT_GOLDEN_END_HM",
"BREAKOUT_GRID_COARSE_SHOULDER_SMIN", "BREAKOUT_GRID_COARSE_SHOULDER_CUT",
"BREAKOUT_GRID_FINE_SHOULDER_SMIN", "BREAKOUT_GRID_FINE_SHOULDER_CUT",
"BREAKOUT_GRID_FULL_SHOULDER_SMIN", "BREAKOUT_GRID_FULL_SHOULDER_CUT",
"BREAKOUT_MAX_DAILY_CHG", "BREAKOUT_MIN_PRICE",
"BREAKOUT_MIN_BAR_TRADE_VALUE_KRW",
"BREAKOUT_MIN_TURNOVER_1M_PCT", "BREAKOUT_SHARE_DENOM", "STOCK_SHARE_DENOM",
"BREAKOUT_USE_EMA_FILTER", "BREAKOUT_EMA_FAST_PERIOD", "BREAKOUT_EMA_SLOW_PERIOD",
"BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "BREAKOUT_SLOT_MONEY",
"BREAKOUT_MAX_DAILY", "BREAKOUT_COOLDOWN_SEC",
# ── RANGE_BREAK(박스권 돌파) — HTS momentum SCAN + 봇 TRIGGER ─────────────
"RANGE_BREAK_BOX_LOOKBACK_MIN", "RANGE_BREAK_BOX_MAX_WIDTH_PCT",
"RANGE_BREAK_BOX_MIN_WIDTH_PCT", "RANGE_BREAK_SETUP_VOL_MAX_MULT",
"RANGE_BREAK_SETUP_BEAR_BARS_MIN", "RANGE_BREAK_VOL_MULT", "RANGE_BREAK_VOL_WIN",
"RANGE_BREAK_VOL_BASELINE_WIN", "RANGE_BREAK_BREAK_MARGIN_PCT", "RANGE_BREAK_BODY_MIN_PCT",
"RANGE_BREAK_TIME_START", "RANGE_BREAK_TIME_END_HM",
"RANGE_BREAK_STOP_LOSS_PCT", "RANGE_BREAK_TAKE_PROFIT_PCT", "RANGE_BREAK_TRAIL_PCT",
"RANGE_BREAK_TRAIL_ARM_PCT", "RANGE_BREAK_SHOULDER_MIN_HIGH_PCT", "RANGE_BREAK_SHOULDER_CUT_PCT",
"RANGE_BREAK_MAX_HOLD_BARS", "RANGE_BREAK_MAX_DAILY", "RANGE_BREAK_COOLDOWN_SEC",
"RANGE_BREAK_MAX_DAILY_CHG", "RANGE_BREAK_MIN_PRICE", "RANGE_BREAK_HIGH_CHASE_THR",
"RANGE_BREAK_USE_HIGH_CHASE_FILTER", "RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW",
"RANGE_BREAK_SLOT_MONEY",
"RANGE_BREAK_MAX_BUY_AMOUNT", "RANGE_BREAK_MIN_INVEST_RATIO_OF_SLOT",
"RANGE_BREAK_FORCE_EOD_EXIT",
# 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",
# ── MOMENTUM 전략 (SCALP 와 분리된 독립 전략) ─────────────────────
# 키움 'scalp' 조건검색(갭상승+신고가) 후보를 따라붙기. SCALP=reversal 고정과 분리.
# 신규 키가 비어있으면 자동으로 기존 SCALP_MOM_*/SCALP_* 키로 fallback (운영 호환).
"STRATEGY_MOMENTUM_ENABLED",
# MM 체결 알림 alias (mm_config.json channels 키) → config_momentum / config_breakout / config_updow
"KIS_MOMENTUM_MM_CHANNEL",
"KIS_BREAKOUT_MM_CHANNEL",
"KIS_RANGE_BREAK_MM_CHANNEL",
"KIS_UPDOW_MM_CHANNEL",
"MOMENTUM_UNIVERSE_SOURCE",
"RANK_MOMENTUM_SORT", "RANK_MOMENTUM_LIMIT",
"CONDITION_MOMENTUM_NAME", "CONDITION_MOMENTUM_SEQ",
"MOMENTUM_CAND_LIMIT",
"MOMENTUM_TIME_START", "MOMENTUM_TIME_END",
"MOMENTUM_MAX_STOCKS",
"MOMENTUM_MAX_BUY_AMOUNT",
"MOMENTUM_MAX_DAILY",
"MOMENTUM_RSI_MIN", "MOMENTUM_RSI_MAX",
"MOMENTUM_VOL_MULT", "MOMENTUM_VOL_WIN",
"MOMENTUM_TIME_END_HM",
# 시가 대비 등락(%) 컷 — 파라서치 coarse 그리드 mom_max_from_open_pct 와 동일 의미
"MOMENTUM_MAX_FROM_OPEN_PCT", "MOMENTUM_MIN_FROM_OPEN_PCT",
"MOMENTUM_SKIP_HTS_SCAN_DUPES",
"MOMENTUM_STOP_LOSS_PCT", "MOMENTUM_TAKE_PROFIT_PCT",
"MOMENTUM_TP_MAX_PCT",
"MOMENTUM_SHOULDER_MIN_HIGH_PCT", "MOMENTUM_SHOULDER_CUT_PCT",
"MOMENTUM_MIN_DROP_RATE",
"MOMENTUM_SLOT_MONEY",
"MOMENTUM_MIN_PRICE",
"MOMENTUM_MAX_LOSS_PER_TRADE_KRW",
"MOMENTUM_MIN_DROP_PCT_FOR_LOSS_CUT",
"MOMENTUM_MIN_PROFIT_PCT",
"MOMENTUM_RATCHET_TIERS",
"MOMENTUM_TRAIL_PCT",
"MOMENTUM_TRAIL_ARM_PCT",
"MOMENTUM_MAX_HOLD_BARS",
"MOMENTUM_USE_DEFENSE_FILTERS",
"MOMENTUM_USE_HIGH_CHASE_FILTER",
"MOMENTUM_USE_DAILY_RANGE_FILTER",
"MOMENTUM_USE_EMA_FILTER",
"MOMENTUM_EMA_FAST_PERIOD",
"MOMENTUM_EMA_SLOW_PERIOD",
"MOMENTUM_USE_RSI_MAX_FILTER",
"MOMENTUM_PATTERN_BREAKOUT",
"MOMENTUM_PATTERN_PULLBACK",
"MOMENTUM_CHASE_LOOKBACK_MIN",
"MOMENTUM_PULLBACK_LOOKBACK_MIN",
"MOMENTUM_PULLBACK_MIN_PCT",
"MOMENTUM_PULLBACK_MAX_PCT",
"MOMENTUM_SETUP_VOL_MAX_MULT",
"MOMENTUM_SETUP_BEAR_BARS_MIN",
"MOMENTUM_HIGH_CHASE_THR",
"MOMENTUM_RSI_PERIOD",
"MOMENTUM_COOLDOWN_SEC",
"MOMENTUM_MIN_HOLD_SEC",
"MOMENTUM_FORCE_EOD_EXIT",
# ── UPDOW 전략 (직전봉 몸통 하락 → 다음봉 시가, kis_trader 독립 쓰레드) ─
"UPDOW_UNIVERSE_SOURCE",
"RANK_UPDOW_SORT", "RANK_UPDOW_LIMIT",
"CONDITION_UPDOW_NAME", "CONDITION_UPDOW_SEQ",
"UPDOW_CAND_LIMIT",
"UPDOW_TIME_START", "UPDOW_TIME_END",
"UPDOW_REENTRY_COOLDOWN_SEC",
"UPDOW_MIN_CANDLE_LEN", "MIN_CANDLE_LEN_UPDOW",
"UPDOW_CANDLE_FETCH_N",
"UPDOW_LIVE_MIN_CANDLES",
"UPDOW_MAX_LOSS_PER_TRADE_KRW",
"UPDOW_MAX_BUY_AMOUNT",
# ── 전략별 동시 보유 종목 한도 (기존 글로벌 MAX_STOCKS 분리) ─────────
# 미설정(0/빈값) 시 글로벌 MAX_STOCKS 로 폴백 → 구버전 호환.
# 권장: 합계 ≤ MAX_STOCKS (계좌 슬롯 분산), 예: 3+2+2=7.
"SCALP_MAX_STOCKS", "SHORT_MAX_STOCKS", "BREAKOUT_MAX_STOCKS",
"RANGE_BREAK_MAX_STOCKS", "UPDOW_MAX_STOCKS",
# 전략별 시각순 포트폴리오 총 운용한도 (동시 보유 매입금 합 상한)
"SHORT_TOTAL_BUDGET_KRW",
"SCALP_TOTAL_BUDGET_KRW",
"MOMENTUM_TOTAL_BUDGET_KRW",
"BREAKOUT_TOTAL_BUDGET_KRW",
"RANGE_BREAK_TOTAL_BUDGET_KRW",
"UPDOW_TOTAL_BUDGET_KRW",
# 일일 익절 목표 (서브·전략별) — split_env_keys → config_{strategy}
"SCALP_DAILY_PROFIT_TARGET_ENABLED",
"SCALP_DAILY_PROFIT_TARGET_KRW",
"SCALP_DAILY_PROFIT_TARGET_PCT",
"SCALP_DAILY_PROFIT_HALT_NEW_BUYS",
"SCALP_DAILY_PROFIT_MODE", "SCALP_DAILY_PROFIT_TRAIL_TIERS",
"SCALP_DAILY_PROFIT_TRAIL_DROP_PCT", "SCALP_DAILY_PROFIT_TRAIL_ARM_KRW", "SCALP_DAILY_PROFIT_TRAIL_ARM_PCT",
"SHORT_DAILY_PROFIT_TARGET_ENABLED",
"SHORT_DAILY_PROFIT_TARGET_KRW",
"SHORT_DAILY_PROFIT_TARGET_PCT",
"SHORT_DAILY_PROFIT_HALT_NEW_BUYS",
"SHORT_DAILY_PROFIT_MODE", "SHORT_DAILY_PROFIT_TRAIL_TIERS",
"SHORT_DAILY_PROFIT_TRAIL_DROP_PCT", "SHORT_DAILY_PROFIT_TRAIL_ARM_KRW", "SHORT_DAILY_PROFIT_TRAIL_ARM_PCT",
"MOMENTUM_DAILY_PROFIT_TARGET_ENABLED",
"MOMENTUM_DAILY_PROFIT_TARGET_KRW",
"MOMENTUM_DAILY_PROFIT_TARGET_PCT",
"MOMENTUM_DAILY_PROFIT_HALT_NEW_BUYS",
"MOMENTUM_DAILY_PROFIT_MODE", "MOMENTUM_DAILY_PROFIT_TRAIL_TIERS",
"MOMENTUM_DAILY_PROFIT_TRAIL_DROP_PCT", "MOMENTUM_DAILY_PROFIT_TRAIL_ARM_KRW", "MOMENTUM_DAILY_PROFIT_TRAIL_ARM_PCT",
"BREAKOUT_DAILY_PROFIT_TARGET_ENABLED",
"BREAKOUT_DAILY_PROFIT_TARGET_KRW",
"BREAKOUT_DAILY_PROFIT_TARGET_PCT",
"BREAKOUT_DAILY_PROFIT_HALT_NEW_BUYS",
"BREAKOUT_DAILY_PROFIT_MODE", "BREAKOUT_DAILY_PROFIT_TRAIL_TIERS",
"BREAKOUT_DAILY_PROFIT_TRAIL_DROP_PCT", "BREAKOUT_DAILY_PROFIT_TRAIL_ARM_KRW", "BREAKOUT_DAILY_PROFIT_TRAIL_ARM_PCT",
"RANGE_BREAK_DAILY_PROFIT_TARGET_ENABLED",
"RANGE_BREAK_DAILY_PROFIT_TARGET_KRW",
"RANGE_BREAK_DAILY_PROFIT_TARGET_PCT",
"RANGE_BREAK_DAILY_PROFIT_HALT_NEW_BUYS",
"RANGE_BREAK_DAILY_PROFIT_MODE", "RANGE_BREAK_DAILY_PROFIT_TRAIL_TIERS",
"RANGE_BREAK_DAILY_PROFIT_TRAIL_DROP_PCT", "RANGE_BREAK_DAILY_PROFIT_TRAIL_ARM_KRW", "RANGE_BREAK_DAILY_PROFIT_TRAIL_ARM_PCT",
"UPDOW_DAILY_PROFIT_TARGET_ENABLED",
"UPDOW_DAILY_PROFIT_TARGET_KRW",
"UPDOW_DAILY_PROFIT_TARGET_PCT",
"UPDOW_DAILY_PROFIT_HALT_NEW_BUYS",
"UPDOW_DAILY_PROFIT_MODE", "UPDOW_DAILY_PROFIT_TRAIL_TIERS",
"UPDOW_DAILY_PROFIT_TRAIL_DROP_PCT", "UPDOW_DAILY_PROFIT_TRAIL_ARM_KRW", "UPDOW_DAILY_PROFIT_TRAIL_ARM_PCT",
"DBBAND_DAILY_PROFIT_TARGET_ENABLED",
"DBBAND_DAILY_PROFIT_TARGET_KRW",
"DBBAND_DAILY_PROFIT_TARGET_PCT",
"DBBAND_DAILY_PROFIT_HALT_NEW_BUYS",
"DBBAND_DAILY_PROFIT_MODE", "DBBAND_DAILY_PROFIT_TRAIL_TIERS",
"DBBAND_DAILY_PROFIT_TRAIL_DROP_PCT", "DBBAND_DAILY_PROFIT_TRAIL_ARM_KRW", "DBBAND_DAILY_PROFIT_TRAIL_ARM_PCT",
# ── 시세 WS 공급자 토글 (키움 시세 마이그레이션) ─────────────────
# 운영(매매 의사결정)에는 항상 KIS WS 만 사용. 키움 WS 는 검증 모드에서만
# 백그라운드 동시 구독 → ws_price_validation 테이블에 가격 비교 기록.
# kis_only : 현행 (기본). 키움 WS 미기동.
# kis_with_validation : KIS WS 운영 + 키움 WS 검증 동시 (매매 영향 없음)
# kiwoom_only : 시세를 키움으로 전환 (검증 통과 후에만 사용)
"WS_PROVIDER",
# true 시: KIS WS 는 PERMANENT_WS_CODES 보유 종목만 구독, 후보 종목은 키움 WS.
# 키움 WS 기동 필요(WS_PROVIDER=kis_with_validation 권장 또는 동시 true).
"WS_SUBSCRIBE_KIS_MINIMAL",
# 검증 비교 주기(초) — 너무 짧으면 부하, 너무 길면 표본 부족. 기본 5.
"WS_VALIDATION_INTERVAL_SEC",
# 차이 경고 임계(%). |diff| 가 이 값 이상이면 WARN 로그. 기본 0.10.
"WS_VALIDATION_DIFF_WARN_PCT",
# 키움 WS 시세는 KIS_MOCK 와 무관하게 항상 실키/실전 도메인 사용.
# 모의 도메인은 장외 데이터 제공 X / 실시간 표본도 빈약 → 시세 비교/마이그레이션 가치 없음.
# true (기본) = 실키 강제, false = KIS_MOCK 따라 자동 (디버그용).
"KIWOOM_WS_FORCE_REAL",
# 키움 WS: 그룹당 최대 구독 수·REG 전송 레이트(초기 기동 시 TRNM 건수 초과 완화)
"KIWOOM_WS_MAX_SUBSCRIPTIONS",
"KIWOOM_WS_REG_CHUNK_SIZE",
"KIWOOM_WS_REG_GAP_SEC",
"KIWOOM_WS_REG_DEBOUNCE_SEC",
# ── Updow(직전 분봉 음봉·몸통 하락 → 다음 봉 시가 매수) — 백테·웹·CLI 공통 ──
"UPDOW_BODY_DROP_MIN_PCT",
"UPDOW_TP_PCT",
"UPDOW_STOP_LOSS_PCT",
"UPDOW_ATR_USE_DYNAMIC",
"UPDOW_ATR_PERIOD",
"UPDOW_ATR_SL_MULT",
"UPDOW_ATR_TP_MULT",
"UPDOW_ATR_SL_MIN_PCT",
"UPDOW_ATR_SL_MAX_PCT",
"UPDOW_ATR_TP_MIN_PCT",
"UPDOW_ATR_TP_MAX_PCT",
"UPDOW_ATR_MULT_FLOOR",
"UPDOW_ATR_PCT_FLOOR",
"UPDOW_EXIT_PCT_FLOOR",
"UPDOW_SHOULDER_MIN_HIGH_PCT",
"UPDOW_SHOULDER_CUT_PCT",
"UPDOW_MAX_HOLD_BARS",
"UPDOW_EXIT_ON_GREEN",
"UPDOW_SLOT_MONEY",
"UPDOW_TF_MIN",
"UPDOW_GRID_BODY0",
"UPDOW_GRID_BODY1",
"UPDOW_GRID_BODY2",
"UPDOW_GRID_BODY3",
"UPDOW_GRID_TP0",
"UPDOW_GRID_TP1",
"UPDOW_GRID_TP2",
"UPDOW_GRID_TP3",
"UPDOW_GRID_TP4",
"UPDOW_GRID_SL0",
"UPDOW_GRID_SL1",
"UPDOW_GRID_SL2",
"UPDOW_GRID_SL3",
"UPDOW_GRID_HOLD0",
"UPDOW_GRID_HOLD1",
"UPDOW_GRID_HOLD2",
"UPDOW_GRID_HOLD3",
"UPDOW_GRID_HOLD4",
"UPDOW_GRID_SHOULDER_SMIN0",
"UPDOW_GRID_SHOULDER_SMIN1",
"UPDOW_GRID_SHOULDER_SMIN2",
"UPDOW_GRID_SHOULDER_CUT0",
"UPDOW_GRID_SHOULDER_CUT1",
"UPDOW_GRID_SHOULDER_CUT2",
"UPDOW_GRID_REGIME0",
"UPDOW_GRID_REGIME1",
"UPDOW_REGIME_MA_BARS",
"UPDOW_REGIME_MA_EASE_PCT",
"UPDOW_REGIME_MA_EASE_CAP",
"UPDOW_GRID_REGIME_EASE0",
"UPDOW_GRID_REGIME_EASE1",
"UPDOW_KOSPI_1MIN_PROXY_CODE",
"UPDOW_REGIME_PREFER_WS_CANDLES",
"UPDOW_REGIME_WS_CANDLE_MIN",
# ── 익절 호가 (OrderManager) — config_short 에 저장 ──
"SELL_USE_ORDERBOOK_ON_PROFIT",
"SELL_ORDERBOOK_BID_LEVELS",
"SELL_ORDERBOOK_DEPTH_MULT",
# ── 키움 WS 호가(0D) + TRIGGER 호가 필터 ──
"KIWOOM_WS_ORDERBOOK_ENABLED",
"KIWOOM_WS_PROGRAM_ENABLED",
"ORDERBOOK_FILTER_ENABLED",
"ORDERBOOK_MAX_SPREAD_PCT",
"ORDERBOOK_ENTRY_BID_LEVELS",
"ORDERBOOK_ENTRY_BID_DEPTH_MULT",
"ORDERBOOK_ENTRY_ASK_MAX_MULT",
"ORDERBOOK_MIN_BID_ASK_RATIO",
"ORDERBOOK_BREAKOUT_ASK_WALL_MAX_QTY",
"MOMENTUM_ORDERBOOK_FILTER_ENABLED",
"MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT",
"MOMENTUM_ORDERBOOK_ENTRY_BID_LEVELS",
"MOMENTUM_ORDERBOOK_ENTRY_BID_DEPTH_MULT",
"MOMENTUM_ORDERBOOK_ENTRY_ASK_MAX_MULT",
"MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO",
"BREAKOUT_ORDERBOOK_FILTER_ENABLED",
"BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT",
"BREAKOUT_ORDERBOOK_ENTRY_BID_LEVELS",
"BREAKOUT_ORDERBOOK_ENTRY_BID_DEPTH_MULT",
"BREAKOUT_ORDERBOOK_ENTRY_ASK_MAX_MULT",
"BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO",
"BREAKOUT_ORDERBOOK_ASK_WALL_MAX_QTY",
"TAIL_ORDERBOOK_FILTER_ENABLED",
"TAIL_ORDERBOOK_MAX_SPREAD_PCT",
"TAIL_ORDERBOOK_ENTRY_BID_LEVELS",
"TAIL_ORDERBOOK_ENTRY_BID_DEPTH_MULT",
"TAIL_ORDERBOOK_ENTRY_ASK_MAX_MULT",
"TAIL_ORDERBOOK_MIN_BID_ASK_RATIO",
# ── 키움 WS 프로그램매매(0w) + TRIGGER 프로그램 필터 ──
"PROGRAM_FILTER_ENABLED",
"PROGRAM_MIN_NET_BUY_QTY",
"PROGRAM_MIN_NET_BUY_AMT",
"PROGRAM_MAX_SELL_BUY_QTY_RATIO",
"PROGRAM_MIN_NET_DELTA_QTY",
"PROGRAM_SNAPSHOT_MAX_AGE_SEC",
"MOMENTUM_PROGRAM_FILTER_ENABLED",
"MOMENTUM_PROGRAM_MIN_NET_BUY_QTY",
"MOMENTUM_PROGRAM_MIN_NET_BUY_AMT",
"MOMENTUM_PROGRAM_MAX_SELL_BUY_QTY_RATIO",
"MOMENTUM_PROGRAM_MIN_NET_DELTA_QTY",
"MOMENTUM_PROGRAM_SNAPSHOT_MAX_AGE_SEC",
"BREAKOUT_PROGRAM_FILTER_ENABLED",
"BREAKOUT_PROGRAM_MIN_NET_BUY_QTY",
"BREAKOUT_PROGRAM_MIN_NET_BUY_AMT",
"BREAKOUT_PROGRAM_MAX_SELL_BUY_QTY_RATIO",
"BREAKOUT_PROGRAM_MIN_NET_DELTA_QTY",
"BREAKOUT_PROGRAM_SNAPSHOT_MAX_AGE_SEC",
"TAIL_PROGRAM_FILTER_ENABLED",
"TAIL_PROGRAM_MIN_NET_BUY_QTY",
"TAIL_PROGRAM_MIN_NET_BUY_AMT",
"TAIL_PROGRAM_MAX_SELL_BUY_QTY_RATIO",
"TAIL_PROGRAM_MIN_NET_DELTA_QTY",
"TAIL_PROGRAM_SNAPSHOT_MAX_AGE_SEC",
)
# ── 전략별 config_* 테이블 분류 (config_schema.py) ─────────────────────
from config_schema import ( # noqa: E402
CONFIG_TABLE_NAMES,
STRATEGY_ID_TO_TABLE,
_EXPLICIT_KEY_TABLE,
classify_config_key,
split_env_keys,
)
_CONFIG_SPLIT = split_env_keys(ENV_CONFIG_KEYS)
ENV_GLOBAL_KEYS = _CONFIG_SPLIT["env_config"]
CONFIG_SCALP_KEYS = _CONFIG_SPLIT["config_scalp"]
CONFIG_SHORT_KEYS = _CONFIG_SPLIT["config_short"]
CONFIG_MOMENTUM_KEYS = _CONFIG_SPLIT["config_momentum"]
CONFIG_BREAKOUT_KEYS = _CONFIG_SPLIT["config_breakout"]
CONFIG_RANGE_BREAK_KEYS = _CONFIG_SPLIT["config_range_break"]
CONFIG_UPDOW_KEYS = _CONFIG_SPLIT["config_updow"]
CONFIG_DBBAND_KEYS = _CONFIG_SPLIT["config_dbband"]
CONFIG_TABLE_KEYS: Dict[str, Tuple[str, ...]] = {
"env_config": ENV_GLOBAL_KEYS,
"config_scalp": CONFIG_SCALP_KEYS,
"config_short": CONFIG_SHORT_KEYS,
"config_momentum": CONFIG_MOMENTUM_KEYS,
"config_breakout": CONFIG_BREAKOUT_KEYS,
"config_range_break": CONFIG_RANGE_BREAK_KEYS,
"config_updow": CONFIG_UPDOW_KEYS,
"config_dbband": CONFIG_DBBAND_KEYS,
}
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.RLock() # 마이그레이션 중 get_env→TradeDB 재진입 허용
def __init__(self, db_path="quant_bot.db"):
"""
Args:
db_path: 하위 호환용 (무시됨). MariaDB 접속 정보는 환경변수/모듈 상수 사용.
"""
self.db_path = db_path # 호환용 보존
self.conn = _MariaDBConn()
# 마이그레이션(ws_candles RSI/EMA 컬럼 등)이 get_env_from_db → TradeDB() 재호출 시
# 동일 인스턴스를 재사용 — 미등록이면 _tables_lock 데드락으로 기동 무음 hang.
try:
from kis_trader.utils.env import set_db
set_db(self)
except Exception:
pass
# 테이블 생성은 첫 인스턴스에서만 — 매 호출마다 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 테이블 생성 (없을 경우)"""
with self.conn:
# 1. 활성 트레이딩 테이블 (현재 보유 중이거나 매수 중인 종목)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS active_trades (
code VARCHAR(20) NOT NULL, -- 종목코드
name VARCHAR(100) NOT NULL, -- 종목명
strategy VARCHAR(50) NOT NULL DEFAULT 'MANUAL', -- 매매 전략 (SHORT_ANT_SHAKING / SCALP_RSI_REVERSAL 등)
PRIMARY KEY (code, strategy), -- 복합 PK: 같은 종목을 서로 다른 봇이 독립 보유 가능
-- [가격 정보]
avg_buy_price DOUBLE NOT NULL, -- 평단가
current_price DOUBLE, -- 현재가 (업데이트용)
stop_price DOUBLE, -- 손절가
target_price REAL, -- 목표가
max_price REAL, -- 최고가 (트레일링 스탑용)
atr_entry REAL, -- 진입 시 ATR 변동성
-- [수량 및 진행 상태 (분할매수용)]
target_qty INTEGER NOT NULL, -- 목표 매수 수량
current_qty INTEGER NOT NULL,-- 현재 체결 수량
total_invested REAL, -- 총 투입 금액 (수수료 제외)
-- [상태 관리]
status TEXT NOT NULL, -- BUYING(매수중), HOLDING(보유중), SELLING(매도중)
buy_date TEXT NOT NULL, -- 첫 매수 시작 시간
updated_at TEXT NOT NULL, -- 마지막 업데이트 시간
size_class TEXT -- 대/중/소형 (매수 시점)
)
""")
# 2. 매매 기록 테이블 (손익 분석 & 켈리 공식용)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS trade_history (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
strategy VARCHAR(50),
buy_price DOUBLE NOT NULL,
sell_price DOUBLE NOT NULL,
qty INT NOT NULL,
profit_rate DOUBLE NOT NULL,
realized_pnl DOUBLE NOT NULL,
hold_minutes INT,
buy_date VARCHAR(30),
sell_date VARCHAR(30) NOT NULL,
sell_reason VARCHAR(200),
env_snapshot TEXT,
size_class VARCHAR(20)
) CHARACTER SET utf8mb4
""")
# 3. 일일 손익 요약 테이블 (대시보드용)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS daily_summary (
date VARCHAR(10) NOT NULL PRIMARY KEY,
start_asset DOUBLE,
end_asset DOUBLE,
total_trades INT,
win_trades INT,
total_pnl DOUBLE,
win_rate DOUBLE
) CHARACTER SET utf8mb4
""")
# 4. 주문·체결 보강 테이블 (kt00007 / ka10076)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS order_execution_history (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
source VARCHAR(20) NOT NULL,
ord_no VARCHAR(30),
stk_cd VARCHAR(20),
stk_nm VARCHAR(100),
trde_tp VARCHAR(20),
ord_qty VARCHAR(20),
ord_uv VARCHAR(20),
cntr_qty VARCHAR(20),
cntr_uv VARCHAR(20),
ord_tm VARCHAR(20),
cnfm_tm VARCHAR(20),
sell_tp VARCHAR(20),
ord_dt VARCHAR(20),
raw_json TEXT,
fetched_at VARCHAR(30) NOT NULL
) CHARACTER SET utf8mb4
""")
# 5. 매수 체결 이력 (일일 한도용 - '산 시점' 날짜 기준 누적)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS buy_execution_log (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
strategy VARCHAR(50) NOT NULL,
buy_date VARCHAR(10) NOT NULL,
executed_at VARCHAR(30) NOT NULL,
amount DOUBLE NOT NULL,
qty INT NOT NULL
) CHARACTER SET utf8mb4
""")
# 6. 매수 후보군 테이블 (target_universe 대체)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS target_candidates (
code VARCHAR(20) NOT NULL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
score DOUBLE NOT NULL,
price DOUBLE NOT NULL,
scan_time VARCHAR(30) NOT NULL,
updated_at VARCHAR(30) NOT NULL
) 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, 예: '반도체')
# theme : 주요 테마 (예: 'AI반도체', '2차전지', '원자력')
# - 직접 UPDATE 또는 별도 스크립트로 채움
# theme_rank: 테마 내 대장주/추종주 순위 (1=핵심, 2=연관, 3=주변)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS stock_meta (
code VARCHAR(20) NOT NULL PRIMARY KEY,
name VARCHAR(100) NOT NULL DEFAULT '',
market CHAR(1) NOT NULL DEFAULT 'Q',
sector_code VARCHAR(20),
sector VARCHAR(100),
theme VARCHAR(100),
theme_rank TINYINT DEFAULT 3,
updated_at VARCHAR(30) NOT NULL
) CHARACTER SET utf8mb4
""")
# 8. env 공통 설정 (API·MM·인프라·전략 스위치) — 컬럼 수 축소
gcols = ", ".join([f"`{k}` TEXT" for k in ENV_GLOBAL_KEYS])
self.conn.execute(f"""
CREATE TABLE IF NOT EXISTS env_config (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at VARCHAR(30) NOT NULL,
{gcols}
) CHARACTER SET utf8mb4
""")
# 8a. 전략별 설정 테이블 (config_scalp / config_short / …)
for tbl, keys in CONFIG_TABLE_KEYS.items():
if tbl == "env_config":
continue
scols = ", ".join([f"`{k}` TEXT" for k in keys])
self.conn.execute(f"""
CREATE TABLE IF NOT EXISTS {tbl} (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at VARCHAR(30) NOT NULL,
{scols}
) CHARACTER SET utf8mb4
""")
# 8. 키-값 저장소 (매터모스트 원격 조종: 마지막 AI 추천문, last_seen 등)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS kv_store (
k VARCHAR(100) NOT NULL PRIMARY KEY,
v MEDIUMTEXT
) CHARACTER SET utf8mb4
""")
# 8b. env_config 컬럼 한도(Row size) 초과 키 — KIWOOM_WS_* · WHIPSAW_* 등
self.conn.execute("""
CREATE TABLE IF NOT EXISTS env_config_ext (
env_key VARCHAR(128) NOT NULL PRIMARY KEY,
env_value TEXT,
updated_at VARCHAR(30) NOT NULL
) CHARACTER SET utf8mb4
""")
# 9. AI 분석 기록 (Butler !클로드분석/!애미분석 시 프롬프트 요약·응답 저장 → 나중에 꺼내보기)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ai_analysis_log (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at VARCHAR(30) NOT NULL,
model VARCHAR(50) NOT NULL,
context_summary TEXT,
response MEDIUMTEXT
) CHARACTER SET utf8mb4
""")
# 10. WebSocket 실시간 봉 집계 (백테스트용 — CandleAggregator 배치 INSERT)
# - is_confirmed=1 인 확정 봉만 저장 (진행 중 봉은 RAM에만 존재)
# - source: 'ws'=WebSocket틱 집계, 'rest'=갭보정 REST 조회
# - UNIQUE(code, timeframe, candle_time) → ON DUPLICATE KEY UPDATE
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ws_candles (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) NOT NULL,
timeframe TINYINT NOT NULL,
candle_time VARCHAR(12) NOT NULL,
`open` DOUBLE NOT NULL,
high DOUBLE NOT NULL,
low DOUBLE NOT NULL,
close DOUBLE NOT NULL,
volume BIGINT NOT NULL DEFAULT 0,
rsi_2 DOUBLE,
rsi_3 DOUBLE,
rsi_5 DOUBLE,
is_confirmed TINYINT NOT NULL DEFAULT 1,
source VARCHAR(10) NOT NULL DEFAULT 'ws',
updated_at VARCHAR(30) NOT NULL,
UNIQUE KEY uq_candle (code, timeframe, candle_time)
) CHARACTER SET utf8mb4
""")
# 11. WebSocket 실시간 체결 틱 (후보 종목, 배치 INSERT — TickRecorder)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ws_ticks (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
market VARCHAR(8) NOT NULL DEFAULT 'KR',
exchange VARCHAR(16) DEFAULT NULL,
code VARCHAR(32) NOT NULL,
tick_time VARCHAR(14) NOT NULL,
price DOUBLE NOT NULL,
volume BIGINT NOT NULL DEFAULT 0,
tick_seq BIGINT DEFAULT NULL,
session VARCHAR(8) DEFAULT NULL,
currency VARCHAR(8) NOT NULL DEFAULT 'KRW',
source VARCHAR(16) NOT NULL DEFAULT 'kis',
recv_ts VARCHAR(30) NOT NULL,
KEY idx_ws_ticks_lookup (market, code, tick_time),
KEY idx_ws_ticks_recv (recv_ts)
) CHARACTER SET utf8mb4
""")
# 11b. TRIGGER 호가 스냅샷 (키움 0D — 백테·파람서치 재현)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ws_orderbook (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
market VARCHAR(8) NOT NULL DEFAULT 'KR',
code VARCHAR(32) NOT NULL,
snap_time VARCHAR(14) NOT NULL,
best_bid BIGINT NOT NULL DEFAULT 0,
best_ask BIGINT NOT NULL DEFAULT 0,
total_bid_qty BIGINT NOT NULL DEFAULT 0,
total_ask_qty BIGINT NOT NULL DEFAULT 0,
bid_qty_l3 BIGINT NOT NULL DEFAULT 0,
ask_qty_l3 BIGINT NOT NULL DEFAULT 0,
levels_json MEDIUMTEXT,
source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0d',
recv_ts VARCHAR(30) NOT NULL,
KEY idx_ws_orderbook_lookup (market, code, snap_time),
KEY idx_ws_orderbook_recv (recv_ts)
) CHARACTER SET utf8mb4
""")
# 11c. TRIGGER 프로그램매매 스냅샷 (키움 0w)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ws_program (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
market VARCHAR(8) NOT NULL DEFAULT 'KR',
code VARCHAR(32) NOT NULL,
snap_time VARCHAR(14) NOT NULL,
buy_qty BIGINT NOT NULL DEFAULT 0,
sell_qty BIGINT NOT NULL DEFAULT 0,
net_qty BIGINT NOT NULL DEFAULT 0,
buy_amt BIGINT NOT NULL DEFAULT 0,
sell_amt BIGINT NOT NULL DEFAULT 0,
net_amt BIGINT NOT NULL DEFAULT 0,
source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0w',
recv_ts VARCHAR(30) NOT NULL,
KEY idx_ws_program_lookup (market, code, snap_time),
KEY idx_ws_program_recv (recv_ts)
) CHARACTER SET utf8mb4
""")
# 12. 종목 유통/상장주식수 (키움 ka10001 — 백테 회전율·전략 공통)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS stock_share_meta (
code VARCHAR(20) NOT NULL PRIMARY KEY,
flo_stk BIGINT NOT NULL DEFAULT 0,
dstr_stk BIGINT NOT NULL DEFAULT 0,
dstr_rt DOUBLE NULL,
source VARCHAR(16) NOT NULL DEFAULT 'ka10001',
updated_at VARCHAR(30) NOT NULL,
KEY idx_share_updated (updated_at)
) CHARACTER SET utf8mb4
""")
self._migrate_add_columns()
self._migrate_env_config_to_columns()
logger.info("📊 DB 테이블 생성/확인 완료")
def _migrate_add_columns(self):
"""기존 DB에 누락된 컬럼 추가 (한 번만) — PRAGMA → information_schema 대체"""
try:
cols = self.conn.get_columns("trade_history")
if "env_snapshot" not in cols:
self.conn.execute("ALTER TABLE trade_history ADD COLUMN env_snapshot TEXT")
logger.info("📌 trade_history.env_snapshot 컬럼 추가")
if "size_class" not in cols:
self.conn.execute("ALTER TABLE trade_history ADD COLUMN size_class VARCHAR(20)")
logger.info("📌 trade_history.size_class 컬럼 추가")
except Exception as e:
logger.debug(f"migrate trade_history: {e}")
# ── active_trades PK 복합키 마이그레이션 (code → code+strategy) ──────────
# 두 봇(SHORT/SCALP)이 같은 종목을 독립 보유 가능하도록 PK 확장.
# 신규 설치는 DDL에서 처리됨. 기존 테이블은 여기서 한 번만 ALTER.
try:
cursor = self.conn.execute("""
SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'active_trades'
AND CONSTRAINT_NAME = 'PRIMARY'
ORDER BY ORDINAL_POSITION
""")
pk_cols = [row[0] if isinstance(row, (list, tuple)) else row['COLUMN_NAME']
for row in cursor.fetchall()]
if 'strategy' not in pk_cols:
logger.info("⚙️ active_trades PK 복합키 마이그레이션 시작 (code → code+strategy)")
# NULL strategy → 'MANUAL' 로 채움 (NOT NULL 변경 전 필수)
self.conn.execute("UPDATE active_trades SET strategy = 'MANUAL' WHERE strategy IS NULL OR strategy = ''")
# strategy 컬럼 NOT NULL DEFAULT 'MANUAL' 로 변경 후 PK 재구성
self.conn.execute("ALTER TABLE active_trades MODIFY COLUMN strategy VARCHAR(50) NOT NULL DEFAULT 'MANUAL'")
self.conn.execute("ALTER TABLE active_trades DROP PRIMARY KEY, ADD PRIMARY KEY (code, strategy)")
logger.info("✅ active_trades PK 복합키(code, strategy) 변환 완료")
except Exception as e:
logger.debug("active_trades PK 마이그레이션 스킵(이미 완료 또는 신규): %s", e)
try:
cols = self.conn.get_columns("active_trades")
if "size_class" not in cols:
self.conn.execute("ALTER TABLE active_trades ADD COLUMN size_class VARCHAR(20)")
logger.info("📌 active_trades.size_class 컬럼 추가")
for c in ML_ENTRY_FEATURE_COLUMNS:
if c not in cols:
self.conn.execute(f"ALTER TABLE active_trades ADD COLUMN `{c}` DOUBLE")
logger.info(f"📌 active_trades.{c} 컬럼 추가 (ML 진입 피처)")
except Exception as e:
logger.debug(f"migrate active_trades: {e}")
try:
self.migrate_trigger_eval_columns()
except Exception as e:
logger.debug("migrate_trigger_eval_columns: %s", e)
try:
cols = self.conn.get_columns("trade_history")
for c in ML_ENTRY_FEATURE_COLUMNS:
if c not in cols:
self.conn.execute(f"ALTER TABLE trade_history ADD COLUMN `{c}` DOUBLE")
logger.info(f"📌 trade_history.{c} 컬럼 추가 (ML 진입 피처)")
except Exception as e:
logger.debug(f"migrate trade_history ML columns: {e}")
try:
self._migrate_config_table_columns()
self._migrate_strategy_keys_from_env_config()
self._migrate_short_time_to_tail_time()
except Exception as e:
logger.debug(f"migrate config tables: {e}")
try:
cols = self.conn.get_columns("ws_candles")
if "holding_peak" not in cols:
self.conn.execute(
"ALTER TABLE ws_candles ADD COLUMN holding_peak DOUBLE NULL "
"COMMENT '보유 중 트레일 고점(WS 틱 max_price 스냅샷)'"
)
logger.info("📌 ws_candles.holding_peak 컬럼 추가")
# RSI(7,14,21)·EMA(5,9,12,15,21,34) materialized — indicator_cache 정의 기준
try:
from kis_trader.engine.indicator_cache import ensure_ws_candles_indicator_columns
n_added = ensure_ws_candles_indicator_columns(self)
if n_added:
logger.info("📌 ws_candles materialized 지표 컬럼 %d개 추가", n_added)
except Exception as mig_ex:
logger.debug("ws_candles indicator columns migrate: %s", mig_ex)
except Exception as e:
logger.debug(f"migrate ws_candles holding_peak: {e}")
# ── target_candidates 테마/섹터/시장구분 컬럼 추가 ──────────────────
try:
cols = self.conn.get_columns("target_candidates")
for col, ddl in [
("market", "CHAR(1) DEFAULT 'Q'"),
("sector", "VARCHAR(100)"),
("theme", "VARCHAR(100)"),
]:
if col not in cols:
self.conn.execute(
f"ALTER TABLE target_candidates ADD COLUMN `{col}` {ddl}"
)
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}")
# ── ws_price_validation (KIS↔키움 시세 검증, 마이그레이션 단계용) ────
# 5초마다 같은 종목의 KIS WS 가격과 키움 WS 가격을 비교해 한 행 INSERT.
# diff_pct = (kiwoom - kis) / kis × 100.
# 운영에는 영향 없음 (검증 모드 ON 일 때만 채워짐). 1~2주 누적 후
# 통계 분석 → 본격 마이그레이션 결정 근거.
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ws_price_validation (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
ts DATETIME(3) NOT NULL,
code VARCHAR(20) NOT NULL,
kis_price DOUBLE,
kiwoom_price DOUBLE,
diff_pct DOUBLE,
kis_age_ms INT,
kiwoom_age_ms INT,
INDEX idx_ts (ts),
INDEX idx_code (code),
INDEX idx_diff (diff_pct)
) CHARACTER SET utf8mb4
""")
logger.info("📌 ws_price_validation 테이블 확인/생성")
except Exception as e:
logger.warning(f"migrate ws_price_validation 실패: {e}")
# ── stock_share_meta (유통주식수, 백테·라이브 공통) ─────────────────
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS stock_share_meta (
code VARCHAR(20) NOT NULL PRIMARY KEY,
flo_stk BIGINT NOT NULL DEFAULT 0,
dstr_stk BIGINT NOT NULL DEFAULT 0,
dstr_rt DOUBLE NULL,
source VARCHAR(16) NOT NULL DEFAULT 'ka10001',
updated_at VARCHAR(30) NOT NULL,
KEY idx_share_updated (updated_at)
) CHARACTER SET utf8mb4
""")
logger.info("📌 stock_share_meta 테이블 확인/생성")
except Exception as e:
logger.warning(f"migrate stock_share_meta 실패: {e}")
def _migrate_env_config_to_columns(self):
"""env_config가 예전 JSON 컬럼(snapshot_json)이면 컬럼 스키마로 이전"""
try:
cols = self.conn.get_columns("env_config")
if "snapshot_json" not in cols:
return
rows = self.conn.execute(
"SELECT id, created_at, snapshot_json FROM env_config ORDER BY id"
).fetchall()
col_defs = ", ".join([f"`{k}` TEXT" for k in ENV_CONFIG_KEYS])
self.conn.execute(f"""
CREATE TABLE IF NOT EXISTS env_config_new (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at VARCHAR(30) NOT NULL,
{col_defs}
) CHARACTER SET utf8mb4
""")
key_list = ", ".join(f"`{k}`" for k in ENV_CONFIG_KEYS)
placeholders = ", ".join(["%s"] * (1 + len(ENV_CONFIG_KEYS)))
for row in rows:
snap = json.loads(row["snapshot_json"]) if row["snapshot_json"] else {}
vals = [row["created_at"]] + [snap.get(k) for k in ENV_CONFIG_KEYS]
self.conn.execute(
f"INSERT INTO env_config_new (created_at, {key_list}) VALUES ({placeholders})",
vals,
)
self.conn.execute("DROP TABLE env_config")
self.conn.execute("ALTER TABLE env_config_new RENAME TO env_config")
logger.info("📌 env_config: snapshot_json -> 컬럼 스키마 마이그레이션 완료")
except Exception as e:
logger.debug(f"migrate env_config: {e}")
# ============================================================
# [CRUD] Active Trades (활성 트레이딩 관리)
# ============================================================
def upsert_trade(self, trade_data: Dict):
"""
신규 매수하거나 정보 업데이트 (평단가, 수량 등)
Args:
trade_data: 트레이드 정보 딕셔너리
필수: code, name, avg_buy_price, target_qty, current_qty, status
선택: strategy, stop_price, target_price, max_price, atr_entry, total_invested
ML 학습용: entry_features (dict) 또는 rsi, volume_ratio 등 개별 키
"""
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 기본값 설정
code = trade_data.get('code')
if not code:
logger.error("종목코드 누락: upsert 실패")
return False
size_class = trade_data.get('size_class')
feats = trade_data.get('entry_features') or {}
feat_vals = []
for k in ML_ENTRY_FEATURE_COLUMNS:
v = feats.get(k) if k in feats else trade_data.get(k)
feat_vals.append(v if isinstance(v, (int, float)) else None)
cols = ", ".join(["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", "size_class"] + list(ML_ENTRY_FEATURE_COLUMNS))
placeholders = ", ".join(["%s"] * (16 + len(ML_ENTRY_FEATURE_COLUMNS)))
# MySQL: ON DUPLICATE KEY UPDATE (excluded. → VALUES())
updates = (
"avg_buy_price = VALUES(avg_buy_price), current_price = VALUES(current_price), "
"stop_price = COALESCE(VALUES(stop_price), stop_price), "
"target_price = COALESCE(VALUES(target_price), target_price), "
"atr_entry = COALESCE(VALUES(atr_entry), atr_entry), "
"current_qty = VALUES(current_qty), total_invested = VALUES(total_invested), "
"max_price = GREATEST(max_price, VALUES(max_price)), "
"status = VALUES(status), updated_at = VALUES(updated_at), "
"size_class = COALESCE(VALUES(size_class), size_class)"
)
for c in ML_ENTRY_FEATURE_COLUMNS:
updates += f", `{c}` = COALESCE(VALUES(`{c}`), `{c}`)"
sql = f"""
INSERT INTO active_trades (
{cols}
) VALUES ({placeholders})
ON DUPLICATE KEY UPDATE
{updates}
"""
try:
from kis_trader.utils.strategy_ids import canonical_strategy_id
_stored_strategy = canonical_strategy_id(trade_data.get("strategy", "MANUAL"))
except Exception:
_stored_strategy = trade_data.get("strategy", "MANUAL") or "MANUAL"
params = (
code,
trade_data.get('name', 'Unknown'),
_stored_strategy,
trade_data.get('avg_buy_price') or trade_data.get('buy_price', 0),
trade_data.get('current_price', 0),
trade_data.get('stop_price', 0),
trade_data.get('target_price', 0),
trade_data.get('max_price', trade_data.get('buy_price', 0)),
trade_data.get('atr_at_entry') or trade_data.get('atr_entry', 0),
trade_data.get('target_qty', trade_data.get('qty', 0)),
trade_data.get('current_qty') or trade_data.get('qty', 0),
trade_data.get('total_invested', 0),
trade_data.get('status', 'HOLDING'),
trade_data.get('buy_date', now),
now,
size_class,
) + tuple(feat_vals)
try:
with self.conn:
self.conn.execute(sql, params)
return True
except Exception as e:
logger.error(f"❌ upsert_trade 실패 ({code}): {e}")
return False
def get_active_trades(self, strategy_prefix: Optional[str] = None):
"""
활성 트레이딩 목록 조회 (봇 재시작 시 사용)
Args:
strategy_prefix: None이면 전부, 'LONG'이면 strategy LIKE 'LONG%'만, 'SHORT''SHORT%'
(늘림목/단타 섞임 방지)
Returns:
{종목코드: {trade_info}} 형태의 딕셔너리
"""
try:
if strategy_prefix:
cursor = self.conn.execute(
"SELECT * FROM active_trades WHERE strategy LIKE %s",
(strategy_prefix.strip().upper() + "%",)
)
else:
cursor = self.conn.execute("SELECT * FROM active_trades")
rows = cursor.fetchall()
# 기존 JSON 포맷과 호환되도록 딕셔너리 변환
result = {}
for row in rows:
code = row['code']
result[code] = {
'code': code,
'name': row['name'],
'strategy': row['strategy'],
'buy_price': row['avg_buy_price'], # JSON 호환
'avg_buy_price': row['avg_buy_price'],
'current_price': row['current_price'],
'stop_price': row['stop_price'],
'target_price': row['target_price'],
'max_price': row['max_price'],
'atr_at_entry': row['atr_entry'],
'qty': row['current_qty'], # JSON 호환
'target_qty': row['target_qty'],
'current_qty': row['current_qty'],
'total_invested': row['total_invested'],
'status': row['status'],
'buy_date': row['buy_date'],
'updated_at': row['updated_at'],
'size_class': row['size_class'] if 'size_class' in row.keys() else None,
}
logger.debug(f"📂 활성 트레이드 로드: {len(result)}")
return result
except Exception as e:
logger.error(f"❌ get_active_trades 실패: {e}")
return {}
def get_active_trade(self, code: str) -> Optional[Dict]:
"""
활성 트레이딩 단일 종목 조회.
잔고 동기화 시 DB에 저장된 평단가를 폴백용으로 사용할 때 쓴다.
"""
try:
cursor = self.conn.execute(
"SELECT * FROM active_trades WHERE code = ?",
(code,),
)
row = cursor.fetchone()
if not row:
return None
return {
"code": row["code"],
"name": row["name"],
"strategy": row["strategy"],
"avg_buy_price": row["avg_buy_price"],
"current_price": row["current_price"],
"stop_price": row["stop_price"],
"target_price": row["target_price"],
"max_price": row["max_price"],
"atr_entry": row["atr_entry"],
"target_qty": row["target_qty"],
"current_qty": row["current_qty"],
"total_invested": row["total_invested"],
"status": row["status"],
"buy_date": row["buy_date"],
"updated_at": row["updated_at"],
}
except Exception as e:
logger.error(f"❌ get_active_trade 실패 ({code}): {e}")
return None
def update_current_price(self, code: str, current_price: float):
"""현재가 업데이트 (매도 판단용)"""
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
with self.conn:
self.conn.execute(
"UPDATE active_trades SET current_price=?, updated_at=? WHERE code=?",
(current_price, now, code)
)
except Exception as e:
logger.error(f"❌ 현재가 업데이트 실패 ({code}): {e}")
def update_max_price(self, code: str, new_max_price: float):
"""최고가 갱신 (트레일링 스탑용)"""
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
with self.conn:
# 기존 max_price보다 클 때만 업데이트
self.conn.execute(
"""UPDATE active_trades
SET max_price = MAX(max_price, ?), updated_at = ?
WHERE code = ?""",
(new_max_price, now, code)
)
except Exception as e:
logger.error(f"❌ 최고가 갱신 실패 ({code}): {e}")
def close_trade(
self,
code: str,
sell_price: float,
sell_reason: str = "",
env_snapshot: str = None,
size_class: str = None,
strategy: str = None,
realized_pnl_override: float = None,
):
"""
매도 완료 처리: active_trades 삭제 -> trade_history 이동 (INSERT만, env 스냅샷 포함)
Args:
code: 종목코드
sell_price: 매도가
sell_reason: 매도 사유
env_snapshot: 매도 시점 env JSON (백테스트/대시보드용)
size_class: 대/중/소형 (매수 시점 저장값)
strategy: 봇 전략 ID (SHORT_ANT_SHAKING / SCALP_RSI_REVERSAL 등)
지정 시 해당 전략 row만 삭제 (다른 봇의 동일 종목 보호).
None이면 code 단독 조회 (단일 봇 운영 환경 호환).
"""
try:
# 1. 활성 트레이드 정보 조회 (strategy 지정 시 정확히 해당 row만 조회)
if strategy:
cursor = self.conn.execute(
"SELECT * FROM active_trades WHERE code=%s AND strategy=%s",
(code, strategy),
)
else:
cursor = self.conn.execute("SELECT * FROM active_trades WHERE code=%s", (code,))
trade = cursor.fetchone()
if not trade:
logger.warning(f"⚠️ close_trade: {code} 종목이 active_trades에 없음")
return False
# 2. 손익 계산
buy_price = trade['avg_buy_price']
qty = trade['current_qty']
# realized_pnl_override 가 있으면 수수료·세금 반영 순손익을 외부에서 주입
# 없으면 내부 계산 (수수료 미포함 gross)
if realized_pnl_override is not None:
realized_pnl = realized_pnl_override
else:
realized_pnl = (sell_price - buy_price) * qty
profit_rate = (realized_pnl / (buy_price * qty) * 100) if buy_price * qty > 0 else 0
# 3. 보유 시간 계산
buy_time = datetime.datetime.strptime(trade['buy_date'], '%Y-%m-%d %H:%M:%S')
sell_time = datetime.datetime.now()
hold_minutes = int((sell_time - buy_time).total_seconds() / 60)
# size_class는 active_trades에 있으면 그대로 사용
if size_class is None and 'size_class' in trade.keys() and trade['size_class']:
size_class = trade['size_class']
# 4. trade_history에 저장 — strategy 는 kis_trader canonical ID (실거래 탭·봇 ID 일치)
try:
from kis_trader.utils.strategy_ids import canonical_strategy_id
hist_strategy = canonical_strategy_id(trade.get("strategy"))
except Exception:
hist_strategy = trade.get("strategy")
feat_vals = [trade[c] if c in trade.keys() else None for c in ML_ENTRY_FEATURE_COLUMNS]
cols_th = "code, name, strategy, buy_price, sell_price, qty, profit_rate, realized_pnl, hold_minutes, buy_date, sell_date, sell_reason, env_snapshot, size_class"
if ML_ENTRY_FEATURE_COLUMNS:
cols_th += ", " + ", ".join(ML_ENTRY_FEATURE_COLUMNS)
placeholders = ", ".join(["?"] * (14 + len(ML_ENTRY_FEATURE_COLUMNS)))
with self.conn:
self.conn.execute(f"""
INSERT INTO trade_history (
{cols_th}
) VALUES ({placeholders})
""", (
trade['code'],
trade['name'],
hist_strategy,
buy_price,
sell_price,
qty,
profit_rate,
realized_pnl,
hold_minutes,
trade['buy_date'],
sell_time.strftime('%Y-%m-%d %H:%M:%S'),
sell_reason,
env_snapshot,
size_class,
) + tuple(feat_vals))
# 5. active_trades에서 삭제 (strategy 지정 시 해당 봇 row만 삭제)
if strategy:
self.conn.execute(
"DELETE FROM active_trades WHERE code=%s AND strategy=%s",
(code, strategy),
)
else:
self.conn.execute("DELETE FROM active_trades WHERE code=%s", (code,))
logger.info(f"✅ [{trade['name']}] 매매 종료: 수익률 {profit_rate:.2f}% ({realized_pnl:+,.0f}원)")
return True
except Exception as e:
logger.error(f"❌ close_trade 실패 ({code}): {e}")
return False
def delete_active_trade(self, code: str, strategy: str = None):
"""
활성 트레이드 삭제 (긴급 정리용).
strategy 지정 시 해당 봇 row만 삭제, None이면 해당 종목 전체 삭제.
"""
try:
with self.conn:
if strategy:
self.conn.execute(
"DELETE FROM active_trades WHERE code=%s AND strategy=%s",
(code, strategy),
)
else:
self.conn.execute("DELETE FROM active_trades WHERE code=%s", (code,))
logger.info(f"🗑️ active_trade 삭제: {code}" + (f" [{strategy}]" if strategy else ""))
return True
except Exception as e:
logger.error(f"❌ 삭제 실패 ({code}): {e}")
return False
def insert_buy_execution(
self,
code: str,
name: str,
strategy: str,
amount: float,
qty: int,
):
"""
매수 체결 이력 저장 (일일 한도용). '하루' = 산 날짜(buy_date) 기준.
"""
now = datetime.datetime.now()
buy_date = now.strftime("%Y-%m-%d")
executed_at = now.strftime("%Y-%m-%d %H:%M:%S")
try:
with self.conn:
self.conn.execute("""
INSERT INTO buy_execution_log (code, name, strategy, buy_date, executed_at, amount, qty)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (code, name, strategy, buy_date, executed_at, amount, qty))
return True
except Exception as e:
logger.error(f"❌ insert_buy_execution 실패 ({code}): {e}")
return False
def get_daily_buy_amount(self, date_str: str, strategy_prefix: str = "LONG") -> Tuple[float, int]:
"""
해당 날짜(산 시점 기준)에 strategy_prefix에 해당하는 매수 누적 금액·건수.
date_str: YYYY-MM-DD
Returns:
(누적 금액, 건수)
"""
try:
cursor = self.conn.execute("""
SELECT COALESCE(SUM(amount), 0) AS total_amount, COUNT(*) AS cnt
FROM buy_execution_log
WHERE buy_date = %s AND strategy LIKE %s
""", (date_str, strategy_prefix.strip().upper() + "%"))
row = cursor.fetchone()
return (float(row["total_amount"]), int(row["cnt"]))
except Exception as e:
logger.error(f"❌ get_daily_buy_amount 실패: {e}")
return (0.0, 0)
# ============================================================
# [보강] 주문·체결 이력 (kt00007 / ka10076)
# ============================================================
def insert_order_execution(
self, source: str, row: dict, ord_dt: str = None, sell_tp: str = None, raw_json: str = None
):
"""주문·체결 1건 INSERT (보강용, 이력만 쌓음)"""
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
self.conn.execute("""
INSERT INTO order_execution_history (
source, ord_no, stk_cd, stk_nm, trde_tp, ord_qty, ord_uv,
cntr_qty, cntr_uv, ord_tm, cnfm_tm, sell_tp, ord_dt, raw_json, fetched_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
source,
row.get('ord_no') or row.get('orig_ord_no'),
row.get('stk_cd', ''),
row.get('stk_nm', ''),
row.get('trde_tp', ''),
str(row.get('ord_qty', '') or row.get('cntr_qty', '')),
str(row.get('ord_uv', '') or row.get('ord_pric', '') or row.get('cntr_uv', '')),
str(row.get('cntr_qty', '') or row.get('cnfm_qty', '')),
str(row.get('cntr_uv', '') or row.get('cntr_pric', '')),
row.get('ord_tm', ''),
row.get('cnfm_tm', ''),
sell_tp or '',
ord_dt or '',
raw_json,
now,
))
self.conn.commit()
return True
except Exception as e:
logger.debug(f"insert_order_execution: {e}")
return False
# ============================================================
# [분석] 켈리 공식 및 통계 계산
# ============================================================
def calculate_half_kelly(self, recent_days: int = 30) -> float:
"""
하프 켈리 공식 계산 (과거 매매 기록 기반)
Args:
recent_days: 최근 N일 데이터만 사용
Returns:
하프 켈리 비율 (0.0 ~ 1.0)
예: 0.15 리턴 -> "예수금의 15%씩 배팅하는 게 최적"
"""
try:
# 최근 N일 데이터 조회
cutoff_date = (datetime.datetime.now() - datetime.timedelta(days=recent_days)).strftime('%Y-%m-%d')
cursor = self.conn.execute(
"SELECT profit_rate FROM trade_history WHERE sell_date >= ? ORDER BY sell_date DESC",
(cutoff_date,)
)
rows = cursor.fetchall()
if len(rows) < 20: # 최소 20건 이상 필요
logger.warning(f"⚠️ 켈리 공식: 데이터 부족 ({len(rows)}건) -> 기본값 10% 리턴")
return 0.10
# 승률 계산
wins = [r['profit_rate'] for r in rows if r['profit_rate'] > 0]
losses = [r['profit_rate'] for r in rows if r['profit_rate'] <= 0]
total_count = len(rows)
win_count = len(wins)
win_rate = win_count / total_count
loss_rate = 1.0 - win_rate
# 손익비 계산 (평균 수익 / 평균 손실)
if not wins or not losses:
logger.warning("⚠️ 켈리 공식: 승 또는 패만 있음 -> 기본값 10%")
return 0.10
avg_win = sum(wins) / len(wins)
avg_loss = abs(sum(losses) / len(losses))
if avg_loss == 0:
return 0.50 # 손실이 0이면 최대치
odds = avg_win / avg_loss
# 켈리 공식: f = (p * b - q) / b
# p=승률, b=손익비, q=패율
kelly_fraction = ((win_rate * odds) - loss_rate) / odds
# 하프 켈리 (안전성 확보)
half_kelly = kelly_fraction * 0.5
# 음수면 0 리턴 (통계적으로 지는 구조)
final_kelly = max(0.0, min(half_kelly, 0.5)) # 최대 50%로 제한
logger.info(
f"📊 [켈리 분석] 승률:{win_rate*100:.1f}% | 손익비:{odds:.2f} | "
f"켈리:{kelly_fraction*100:.1f}% | 하프켈리:{final_kelly*100:.1f}%"
)
return final_kelly
except Exception as e:
logger.error(f"❌ 켈리 계산 실패: {e}")
return 0.10
def get_recent_performance(self, days: int = 7) -> Tuple[float, int, int]:
"""
최근 N일 성과 조회
Returns:
(총손익, 익절횟수, 손절횟수)
"""
try:
cutoff = (datetime.datetime.now() - datetime.timedelta(days=days)).strftime('%Y-%m-%d')
cursor = self.conn.execute(
"SELECT realized_pnl FROM trade_history WHERE sell_date >= ?",
(cutoff,)
)
rows = cursor.fetchall()
total_pnl = sum([r['realized_pnl'] for r in rows])
wins = len([r for r in rows if r['realized_pnl'] > 0])
losses = len([r for r in rows if r['realized_pnl'] <= 0])
return total_pnl, wins, losses
except Exception as e:
logger.error(f"❌ 성과 조회 실패: {e}")
return 0.0, 0, 0
def get_trade_stats(self) -> Dict:
"""전체 매매 통계"""
try:
cursor = self.conn.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN profit_rate > 0 THEN 1 ELSE 0 END) as wins,
AVG(profit_rate) as avg_profit_rate,
SUM(realized_pnl) as total_pnl
FROM trade_history
""")
row = cursor.fetchone()
return {
'total_trades': row['total'] or 0,
'win_trades': row['wins'] or 0,
'win_rate': (row['wins'] / row['total'] * 100) if row['total'] > 0 else 0,
'avg_profit_rate': row['avg_profit_rate'] or 0,
'total_pnl': row['total_pnl'] or 0
}
except Exception as e:
logger.error(f"❌ 통계 조회 실패: {e}")
return {}
# ============================================================
# [유틸] JSON 마이그레이션
# ============================================================
def migrate_from_json(self, json_data: Dict):
"""
기존 JSON 포트폴리오를 DB로 마이그레이션
Args:
json_data: portfolio.json 내용 (딕셔너리)
"""
count = 0
for code, info in json_data.items():
trade_data = info.copy()
trade_data['code'] = code
# 필드 매핑 (JSON -> DB)
if 'target_qty' not in trade_data:
trade_data['target_qty'] = info.get('qty', 0)
if 'current_qty' not in trade_data:
trade_data['current_qty'] = info.get('qty', 0)
if 'total_invested' not in trade_data:
trade_data['total_invested'] = info.get('buy_price', 0) * info.get('qty', 0)
if 'status' not in trade_data:
trade_data['status'] = 'HOLDING'
if self.upsert_trade(trade_data):
count += 1
logger.info(f"✅ JSON -> DB 마이그레이션 완료: {count}개 종목")
return count
# ============================================================
# [CRUD] Target Candidates (매수 후보군 관리)
# ============================================================
def update_target_candidates(self, candidates: List[Dict]):
"""
매수 후보군 업데이트 (5분마다 호출)
Args:
candidates: [{
'code': '005930', 'name': '삼성전자',
'score': 5.2, 'price': 75000,
'market': 'K', # 선택 (없으면 stock_meta에서 조회)
'sector': '반도체', # 선택
'theme': 'AI반도체' # 선택
}, ...]
"""
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] = {}
# 기존 데이터 전체 삭제 (5분마다 새로 갱신)
with self.conn:
self.conn.execute("DELETE FROM target_candidates")
for item in candidates:
code = item['code']
# market/sector/theme 없으면 stock_meta에서 조회
market = item.get('market') or ""
sector = item.get('sector') or ""
theme = item.get('theme') or ""
if not (market and sector and theme):
if code not in meta_cache:
try:
r = self.conn.execute(
"SELECT market, sector, theme FROM stock_meta WHERE code=%s",
(code,)
).fetchone()
meta_cache[code] = dict(r) if r else {}
except Exception:
meta_cache[code] = {}
m = meta_cache[code]
market = market or m.get("market") or "Q"
sector = sector or m.get("sector") or ""
theme = theme or m.get("theme") or ""
self.conn.execute("""
INSERT INTO target_candidates
(code, name, score, price, scan_time, updated_at, market, sector, theme)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
code,
item.get('name', ''),
item.get('score', 0),
item.get('price', 0),
scan_time,
scan_time,
market,
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)}개 (slot_key={slot_key}, 이력 적재 안함)")
return True
except Exception as e:
logger.error(f"❌ 후보군 저장 실패: {e}")
return False
def add_target_candidate(self, candidate: Dict):
"""
매수 후보군 개별 추가 (통과 즉시 저장용, UPSERT 방식)
- 500개 스캔 시 시간이 오래 걸려서 통과하는 즉시 DB에 저장
Args:
candidate: {
'code': '005930', 'name': '삼성전자',
'score': 5.2, 'price': 75000,
'market': 'K', 'sector': '반도체', 'theme': 'AI반도체' # 선택
}
"""
try:
scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
code = candidate['code']
market = candidate.get('market') or ""
sector = candidate.get('sector') or ""
theme = candidate.get('theme') or ""
# 없으면 stock_meta 에서 보완
if not (market and sector and theme):
try:
r = self.conn.execute(
"SELECT market, sector, theme FROM stock_meta WHERE code=%s",
(code,)
).fetchone()
if r:
m = dict(r)
market = market or m.get("market") or "Q"
sector = sector or m.get("sector") or ""
theme = theme or m.get("theme") or ""
except Exception:
pass
with self.conn:
self.conn.execute("""
INSERT INTO target_candidates
(code, name, score, price, scan_time, updated_at, market, sector, theme)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
score = VALUES(score),
price = VALUES(price),
scan_time = VALUES(scan_time),
updated_at = VALUES(updated_at),
market = VALUES(market),
sector = VALUES(sector),
theme = VALUES(theme)
""", (
code,
candidate.get('name', ''),
candidate.get('score', 0),
candidate.get('price', 0),
scan_time, scan_time,
market, sector, theme,
))
return True
except Exception as e:
logger.debug(f"후보 개별 저장 실패({candidate.get('code', '')}): {e}")
return False
def get_target_candidates(self) -> List[Dict]:
"""
매수 후보군 조회 (점수 순).
stock_meta JOIN으로 테마/섹터 보완 반환.
Returns:
[{'code': '005930', 'name': '삼성전자', 'score': 5.2, 'price': 75000,
'market': 'K', 'sector': '반도체', 'theme': 'AI반도체'}, ...]
"""
try:
cursor = self.conn.execute("""
SELECT
t.code, t.name, t.score, t.price, t.scan_time,
COALESCE(t.market, m.market, 'Q') AS market,
COALESCE(t.sector, m.sector, '') AS sector,
COALESCE(t.theme, m.theme, '') AS theme
FROM target_candidates t
LEFT JOIN stock_meta m ON m.code = t.code
ORDER BY t.score DESC, t.price ASC
""")
rows = cursor.fetchall()
result = []
for row in rows:
result.append({
'code': row['code'],
'name': row['name'],
'score': row['score'],
'price': row['price'],
'scan_time': row['scan_time'],
'market': row.get('market', 'Q'),
'sector': row.get('sector', ''),
'theme': row.get('theme', ''),
})
return result
except Exception as e:
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 테마/섹터 메타데이터 헬퍼
# ------------------------------------------------------------------
def upsert_stock_meta(
self,
code: str,
name: str = "",
market: str = "Q",
sector_code: str = "",
sector: str = "",
theme: str = "",
theme_rank: int = 3,
) -> bool:
"""
종목 메타데이터 저장/갱신 (스캐너·수동 입력 모두 사용).
Args:
code : 종목코드 (6자리)
name : 종목명
market : 'K'=KOSPI, 'Q'=KOSDAQ, 'E'=ETF
sector_code: 업종코드 (KIS bstp_cls_code 등)
sector : 업종명 (예: '반도체')
theme : 테마명 (예: 'AI반도체', '2차전지')
theme_rank : 테마 내 순위 (1=핵심주, 2=연관, 3=주변)
"""
try:
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with self.conn:
self.conn.execute("""
INSERT INTO stock_meta
(code, name, market, sector_code, sector, theme, theme_rank, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
name = IF(VALUES(name) != '', VALUES(name), name),
market = IF(VALUES(market) != '', VALUES(market), market),
sector_code = IF(VALUES(sector_code) != '', VALUES(sector_code), sector_code),
sector = IF(VALUES(sector) != '', VALUES(sector), sector),
theme = IF(VALUES(theme) != '', VALUES(theme), theme),
theme_rank = IF(VALUES(theme) != '', VALUES(theme_rank), theme_rank),
updated_at = VALUES(updated_at)
""", (code, name, market, sector_code, sector, theme, theme_rank, now))
return True
except Exception as e:
logger.debug("upsert_stock_meta 실패(%s): %s", code, e)
return False
def get_stock_meta(self, code: str) -> Optional[Dict]:
"""종목 메타데이터 단건 조회. 없으면 None."""
try:
row = self.conn.execute(
"SELECT * FROM stock_meta WHERE code = %s", (code,)
).fetchone()
return dict(row) if row else None
except Exception as e:
logger.debug("get_stock_meta 실패(%s): %s", code, e)
return None
def get_theme_momentum(self, theme: str, tf: int = 60) -> Dict:
"""
테마 종목들의 최신 확정 봉(timeframe=tf) RSI 통계.
"이 테마가 지금 뜨겁냐 식었냐"를 숫자로 파악.
Args:
theme: 테마명 (stock_meta.theme)
tf : 봉 단위 분 (기본 60분봉)
Returns:
{
'theme': 'AI반도체',
'tf': 60,
'count': 5, # 데이터 있는 종목 수
'avg_rsi3': 62.1, # 평균 RSI(3)
'max_rsi3': 78.4, # 최고 RSI(3)
'min_rsi3': 44.2, # 최저 RSI(3)
'hot_count': 2, # RSI>70 (과열) 종목 수
'cold_count': 1, # RSI<30 (과매도) 종목 수
}
"""
try:
row = self.conn.execute("""
SELECT
COUNT(*) AS cnt,
AVG(w.rsi_3) AS avg_rsi,
MAX(w.rsi_3) AS max_rsi,
MIN(w.rsi_3) AS min_rsi,
SUM(w.rsi_3 > 70) AS hot_cnt,
SUM(w.rsi_3 < 30) AS cold_cnt
FROM ws_candles w
INNER JOIN stock_meta m ON m.code = w.code
WHERE m.theme = %s
AND w.timeframe = %s
AND w.is_confirmed = 1
AND w.candle_time = (
SELECT MAX(candle_time)
FROM ws_candles
WHERE code = w.code AND timeframe = w.timeframe
)
""", (theme, tf)).fetchone()
if not row or not row['cnt']:
return {'theme': theme, 'tf': tf, 'count': 0}
return {
'theme': theme,
'tf': tf,
'count': int(row['cnt'] or 0),
'avg_rsi3': round(float(row['avg_rsi'] or 0), 1),
'max_rsi3': round(float(row['max_rsi'] or 0), 1),
'min_rsi3': round(float(row['min_rsi'] or 0), 1),
'hot_count': int(row['hot_cnt'] or 0),
'cold_count':int(row['cold_cnt'] or 0),
}
except Exception as e:
logger.debug("get_theme_momentum 실패(%s, %dM): %s", theme, tf, e)
return {'theme': theme, 'tf': tf, 'count': 0}
def get_all_theme_momentum(self, tf: int = 60) -> List[Dict]:
"""
모든 테마의 최신 모멘텀 요약 (테마 히트맵).
가장 뜨거운 테마부터 정렬 반환.
"""
try:
rows = self.conn.execute("""
SELECT
m.theme,
COUNT(DISTINCT w.code) AS cnt,
AVG(w.rsi_3) AS avg_rsi,
SUM(w.rsi_3 > 70) AS hot_cnt,
SUM(w.rsi_3 < 30) AS cold_cnt
FROM ws_candles w
INNER JOIN stock_meta m ON m.code = w.code
WHERE m.theme IS NOT NULL AND m.theme != ''
AND w.timeframe = %s
AND w.is_confirmed = 1
AND w.candle_time = (
SELECT MAX(candle_time)
FROM ws_candles
WHERE code = w.code AND timeframe = w.timeframe
)
GROUP BY m.theme
ORDER BY avg_rsi DESC
""", (tf,)).fetchall()
return [{
'theme': r['theme'],
'count': int(r['cnt'] or 0),
'avg_rsi3': round(float(r['avg_rsi'] or 0), 1),
'hot_count': int(r['hot_cnt'] or 0),
'cold_count':int(r['cold_cnt'] or 0),
} for r in rows]
except Exception as e:
logger.debug("get_all_theme_momentum 실패: %s", e)
return []
def get_market_regime(self, codes: list = None, tf: int = 60) -> Dict:
"""
영구 구독 ETF (KODEX200 등)의 최신 60분봉 RSI로 시장 방향 판단.
스캘핑/꼬리잡기 진입 전 "지금 상승장인가?" 필터로 활용.
Args:
codes: ETF 코드 리스트 (None=env PERMANENT_WS_CODES)
tf : 봉 단위 분 (기본 60)
Returns:
{
'is_bull': True, # avg_rsi > 50 → 상승 국면
'avg_rsi': 58.4,
'details': [{'code': '069500', 'rsi': 61.2}, ...]
}
"""
try:
if not codes:
raw = self.conn.execute(
"SELECT PERMANENT_WS_CODES FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
perm = str(raw['PERMANENT_WS_CODES'] if raw else "") if raw else ""
codes = [c.strip() for c in perm.split(",") if c.strip()] or ["069500", "229200"]
rows = self.conn.execute("""
SELECT code, rsi_3
FROM ws_candles
WHERE code IN ({})
AND timeframe = %s
AND is_confirmed = 1
AND candle_time = (
SELECT MAX(candle_time) FROM ws_candles
WHERE code = ws_candles.code AND timeframe = ws_candles.timeframe
)
""".format(",".join(["%s"] * len(codes))), (*codes, tf)).fetchall()
details = [{'code': r['code'], 'rsi': round(float(r['rsi_3'] or 50), 1)} for r in rows]
avg_rsi = sum(d['rsi'] for d in details) / len(details) if details else 50.0
return {
'is_bull': avg_rsi > 50,
'avg_rsi': round(avg_rsi, 1),
'details': details,
}
except Exception as e:
logger.debug("get_market_regime 실패: %s", e)
return {'is_bull': True, 'avg_rsi': 50.0, 'details': []}
def get_trades_by_date(self, date_str: str) -> List[Dict]:
"""
특정 날짜의 매매 기록 조회
Args:
date_str: 날짜 (YYYYMMDD 또는 YYYY-MM-DD 모두 허용)
Returns:
매매 기록 리스트
"""
try:
# YYYYMMDD(8자) → YYYY-MM-DD 변환, 이미 YYYY-MM-DD(10자)면 그대로 사용
if len(date_str) == 8 and "-" not in date_str:
date_formatted = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:]}"
else:
date_formatted = date_str[:10] # 'YYYY-MM-DD'만 사용 (시간 부분 제거)
cursor = self.conn.execute("""
SELECT * FROM trade_history
WHERE DATE(sell_date) = %s
ORDER BY sell_date DESC
""", (date_formatted,))
rows = cursor.fetchall()
result = []
for row in rows:
result.append({
'id': row['id'],
'code': row['code'],
'name': row['name'],
'strategy': row['strategy'],
'buy_price': row['buy_price'],
'sell_price': row['sell_price'],
'qty': row['qty'],
'profit_rate': row['profit_rate'],
'realized_pnl': row['realized_pnl'],
'hold_minutes': row['hold_minutes'],
'buy_date': row['buy_date'],
'sell_date': row['sell_date'],
'sell_reason': row['sell_reason']
})
return result
except Exception as e:
logger.error(f"❌ 날짜별 조회 실패: {e}")
return []
# ============================================================
# [ws_price_validation] KIS↔키움 시세 비교 검증
# ============================================================
def insert_ws_price_validation(
self,
*,
code: str,
kis_price: Optional[float],
kiwoom_price: Optional[float],
kis_age_ms: Optional[int] = None,
kiwoom_age_ms: Optional[int] = None,
) -> bool:
"""단일 비교 결과 1행 INSERT.
둘 다 None 이면 저장 안 함. 한쪽만 있어도 저장(소스별 가용성 분석용).
diff_pct 는 둘 다 있을 때만 계산.
"""
if kis_price is None and kiwoom_price is None:
return False
diff_pct: Optional[float] = None
if kis_price not in (None, 0) and kiwoom_price is not None:
try:
diff_pct = (float(kiwoom_price) - float(kis_price)) / float(kis_price) * 100.0
except (ValueError, ZeroDivisionError):
diff_pct = None
try:
now = datetime.datetime.now()
self.conn.execute(
"INSERT INTO ws_price_validation "
"(ts, code, kis_price, kiwoom_price, diff_pct, kis_age_ms, kiwoom_age_ms) "
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
(now, code, kis_price, kiwoom_price, diff_pct, kis_age_ms, kiwoom_age_ms),
)
return True
except Exception as e:
logger.debug("ws_price_validation INSERT 실패: %s", e)
return False
def get_ws_validation_stats(
self, *, hours: int = 24, code: Optional[str] = None,
) -> Dict[str, Any]:
"""최근 N시간 검증 통계 (운영자용 분석).
Returns:
{
"samples": 1234,
"both_present": 1100, # KIS·키움 둘 다 가격 있던 비율
"avg_diff_pct": 0.012,
"max_abs_diff_pct": 0.45,
"stddev_diff_pct": 0.08,
"kis_only": 80, # KIS 만 가격 있던 횟수 (키움 미수신)
"kiwoom_only": 30, # 키움 만 가격 있던 횟수
}
"""
try:
where = ["ts >= NOW() - INTERVAL %s HOUR"]
args: List[Any] = [hours]
if code:
where.append("code = %s")
args.append(code)
wsql = " AND ".join(where)
row = self.conn.execute(f"""
SELECT
COUNT(*) AS samples,
SUM(kis_price IS NOT NULL AND kiwoom_price IS NOT NULL) AS both_present,
AVG(diff_pct) AS avg_diff_pct,
MAX(ABS(diff_pct)) AS max_abs_diff_pct,
STDDEV(diff_pct) AS stddev_diff_pct,
SUM(kis_price IS NOT NULL AND kiwoom_price IS NULL) AS kis_only,
SUM(kis_price IS NULL AND kiwoom_price IS NOT NULL) AS kiwoom_only
FROM ws_price_validation
WHERE {wsql}
""", args).fetchone()
return dict(row) if row else {}
except Exception as e:
logger.debug("ws_validation_stats 조회 실패: %s", e)
return {}
# ============================================================
# [env_config + config_*] 관리자용 env (INSERT만 / 최신 1건 = 현재 설정)
# ============================================================
def _migrate_config_table_columns(self) -> None:
"""config_* · env_config(글로벌) 테이블에 누락 컬럼 추가."""
for tbl, keys in CONFIG_TABLE_KEYS.items():
try:
cols = set(self.conn.get_columns(tbl))
except Exception:
continue
for key in keys:
if key not in cols:
try:
self.conn.execute(
f"ALTER TABLE {tbl} ADD COLUMN `{key}` TEXT"
)
logger.info("📌 %s.%s 컬럼 추가", tbl, key)
except Exception as e:
logger.debug("%s.%s 컬럼 추가 스킵: %s", tbl, key, e)
def _migrate_strategy_keys_from_env_config(self) -> None:
"""
env_config 레거시 컬럼에만 있는 전략 ON/OFF·MM alias → config_* 최신 행으로 이전.
get_merged_env_snapshot() 폴백과 별도로, 전략 테이블에 명시 저장.
"""
by_table: Dict[str, Tuple[str, ...]] = {}
for k, tbl in _EXPLICIT_KEY_TABLE.items():
if tbl not in by_table:
by_table[tbl] = ()
by_table[tbl] = by_table[tbl] + (k,)
try:
env_row = self.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
if not env_row:
return
rk = env_row.keys() if hasattr(env_row, "keys") else []
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for tbl, keys in by_table.items():
patch: Dict[str, str] = {}
all_keys = CONFIG_TABLE_KEYS.get(tbl, ())
latest = self._read_latest_config_table(tbl, all_keys)
for k in keys:
if latest.get(k):
continue
if k not in rk:
continue
v = env_row[k]
if v is None or str(v).strip() == "":
continue
patch[k] = str(v)
if not patch:
continue
merged = dict(latest)
merged.update(patch)
self._insert_config_table_row(tbl, merged, all_keys, now)
logger.info("📌 %s ← env_config 값 이전: %s", tbl, list(patch.keys()))
except Exception as e:
logger.debug("strategy keys env→config migrate: %s", e)
def _migrate_short_time_to_tail_time(self) -> None:
"""
레거시 ``SHORT_TIME_START`` / ``SHORT_TIME_END`` → ``TAIL_TIME_*`` 이전 후 단일화.
config_short 최신 행에 SHORT_TIME 값만 있고 TAIL_TIME 이 비어 있으면 복사 INSERT.
"""
try:
keys = CONFIG_TABLE_KEYS.get("config_short", ())
if not keys:
return
latest = self._read_latest_config_table("config_short", keys)
st_short = str(latest.get("SHORT_TIME_START") or "").strip()
en_short = str(latest.get("SHORT_TIME_END") or "").strip()
st_tail = str(latest.get("TAIL_TIME_START") or "").strip()
en_tail = str(latest.get("TAIL_TIME_END") or "").strip()
patch: Dict[str, str] = {}
if st_short and not st_tail:
patch["TAIL_TIME_START"] = st_short
if en_short and not en_tail:
patch["TAIL_TIME_END"] = en_short
if not patch:
return
merged = dict(latest)
merged.update(patch)
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
rid = self._insert_config_table_row("config_short", merged, keys, now)
if rid:
logger.info(
"📌 config_short SHORT_TIME_* → TAIL_TIME_* 이전: %s (row id=%s)",
list(patch.keys()), rid,
)
try:
from kis_trader.utils.env import invalidate_merged_env_cache
invalidate_merged_env_cache()
except Exception:
pass
except Exception as e:
logger.debug("SHORT_TIME→TAIL_TIME migrate: %s", e)
def _insert_config_table_row(
self,
table: str,
snapshot: Dict[str, Any],
keys: Tuple[str, ...],
created_at: str,
) -> Optional[int]:
"""단일 config 테이블에 스냅샷 INSERT."""
if not keys:
return None
try:
table_cols = set(self.conn.get_columns(table))
except Exception:
return None
prev = self._read_latest_config_table(table, keys)
row_data: Dict[str, Any] = {}
for k in keys:
if k not in table_cols:
continue
v = snapshot.get(k) if k in snapshot else None
if v not in (None, ""):
row_data[k] = v
elif prev.get(k):
row_data[k] = prev[k]
if not row_data:
return None
valid = list(dict.fromkeys(row_data.keys()))
key_list = ", ".join(f"`{k}`" for k in valid)
placeholders = ", ".join(["%s"] * (1 + len(valid)))
vals = [created_at] + [row_data[k] for k in valid]
cur = self.conn.execute(
f"INSERT INTO {table} (created_at, {key_list}) VALUES ({placeholders})",
vals,
)
return cur.lastrowid
def _read_latest_config_table(self, table: str, keys: Tuple[str, ...]) -> Dict[str, str]:
"""테이블 최신 행들에서 키별 마지막 비어 있지 않은 값 (부분 INSERT NULL 덮어쓰기 방지)."""
out: Dict[str, str] = {}
if not keys:
return out
try:
rows = self.conn.execute(
f"SELECT * FROM {table} ORDER BY id DESC LIMIT 30"
).fetchall()
except Exception:
return out
if not rows:
return out
rk = rows[0].keys() if hasattr(rows[0], "keys") else []
for k in keys:
if k not in rk:
continue
for row in rows:
v = row[k]
if v is not None and str(v).strip() != "":
out[k] = str(v)
break
if k not in out:
out[k] = ""
return out
def _env_config_column_set(self) -> set:
"""env_config 실제 DB 컬럼 (MariaDB row size 한도로 ENV_GLOBAL_KEYS 일부 미생성)."""
cached = getattr(self, "_env_config_cols_cache", None)
if cached is not None:
return cached
try:
cached = set(self.conn.get_columns("env_config"))
except Exception:
cached = set()
self._env_config_cols_cache = cached
return cached
def _persist_env_config_overflow(
self, snapshot: Dict[str, Any], created_at: str,
) -> int:
"""
env_config 테이블에 컬럼이 없는 글로벌 키 → env_config_ext UPSERT.
(Row size too large 로 ALTER 불가한 KIWOOM_WS_* · WHIPSAW_* · PROGRAM_* 등)
"""
cols = self._env_config_column_set()
saved = 0
for k, v in (snapshot or {}).items():
key = str(k).strip()
if not key or classify_config_key(key) != "env_config":
continue
if key in cols:
continue
if v is None:
continue
sv = str(v).strip()
if sv == "":
# 빈 문자열 = 명시적 클리어/OFF.
# ext 에 이전 값이 남아 있으면 머지 폴백에서 되살아나므로(예:
# UPDOWN_BOX_RATCHET_TIERS='' → 래칫 OFF 인데 직전 '2:1.5,5:1.0' 이 부활)
# 반드시 ext row 를 삭제해 "꺼짐"이 실제 반영되도록 한다.
try:
self.conn.execute(
"DELETE FROM env_config_ext WHERE env_key=%s", (key,)
)
except Exception as ex:
logger.warning("env_config_ext 클리어 실패 %s: %s", key, ex)
continue
try:
self.conn.execute(
"INSERT INTO env_config_ext (env_key, env_value, updated_at) "
"VALUES (%s, %s, %s) "
"ON DUPLICATE KEY UPDATE env_value=VALUES(env_value), "
"updated_at=VALUES(updated_at)",
(key, sv, created_at),
)
saved += 1
except Exception as ex:
logger.warning("env_config_ext 저장 실패 %s: %s", key, ex)
if saved:
logger.info("📌 env_config_ext overflow 저장 %d", saved)
return saved
def get_merged_env_snapshot(self) -> Dict[str, str]:
"""env_config + config_* 최신 행 병합 flat dict (get_env_from_db 호환)."""
merged: Dict[str, str] = {}
for tbl, keys in CONFIG_TABLE_KEYS.items():
merged.update(self._read_latest_config_table(tbl, keys))
# 레거시 env_config(381컬럼) 에만 있는 값 — 신규 테이블에 없을 때 폴백
try:
row = self.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
if row:
rk = row.keys() if hasattr(row, "keys") else []
for k in ENV_CONFIG_KEYS:
if k not in merged and k in rk:
v = row[k]
if v is not None and str(v).strip() != "":
merged[k] = str(v)
except Exception:
pass
# env_config_ext — 컬럼 한도 초과 키 + 레거시 overflow
try:
ext_rows = self.conn.execute(
"SELECT env_key, env_value FROM env_config_ext"
).fetchall()
env_cols = self._env_config_column_set()
for er in ext_rows or []:
ek = er["env_key"] if isinstance(er, dict) else er[0]
ev = er["env_value"] if isinstance(er, dict) else er[1]
if not ek:
continue
sk = str(ek)
# 전략 config_* 테이블 키는 ext 가 덮어쓰지 않는다 (전략 테이블이 우선).
# 레거시로 ext 에 남은 TAIL_*/MOMENTUM_* 등이 config_short/_momentum 값을
# 가리던 버그 방지 — ext 는 순수 글로벌(env_config) 키 폴백 전용.
if classify_config_key(sk) != "env_config":
continue
# ext 우선: env_config 컬럼 없음 · 또는 병합값 비어 있음
if sk not in env_cols or not str(merged.get(sk) or "").strip():
merged[sk] = "" if ev is None else str(ev)
except Exception:
pass
return merged
def insert_env_snapshot(self, snapshot) -> Optional[int]:
"""
env 설정 INSERT — 테이블별 분리 저장.
env_config(공통) + config_scalp/short/momentum/breakout/updow.
Returns: env_config 행 id (없으면 마지막 성공 config_* id)
"""
try:
if isinstance(snapshot, str):
snapshot = json.loads(snapshot) if snapshot else {}
if not isinstance(snapshot, dict):
return None
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
primary_id: Optional[int] = None
for tbl, keys in CONFIG_TABLE_KEYS.items():
rid = self._insert_config_table_row(tbl, snapshot, keys, now)
if rid and tbl == "env_config":
primary_id = rid
elif rid and primary_id is None:
primary_id = rid
if primary_id is None:
logger.error("config INSERT: 모든 테이블 저장 실패")
return None
ext_n = self._persist_env_config_overflow(snapshot, now)
logger.info(
"📌 env 스냅샷 저장 (env_config id=%s, 테이블 %d개, ext+%d)",
primary_id,
len(CONFIG_TABLE_KEYS),
ext_n,
)
try:
from kis_trader.utils.env import invalidate_merged_env_cache
invalidate_merged_env_cache()
except Exception:
pass
return primary_id
except Exception as e:
logger.error(f"❌ env/config INSERT 실패: {e}")
return None
def get_latest_env(self) -> Optional[Dict]:
"""
최신 env — env_config + config_* 병합 snapshot.
Returns:
{"id": int, "created_at": str, "snapshot": dict} 또는 None
"""
try:
row = self.conn.execute(
"SELECT id, created_at FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
merged = self.get_merged_env_snapshot()
if not merged and not row:
return None
return {
"id": int(row["id"]) if row else 0,
"created_at": row["created_at"] if row else "",
"snapshot": merged,
}
except Exception as e:
logger.error(f"❌ env 최신 조회 실패: {e}")
return None
def get_strategy_config_snapshot(self, strategy_id: str) -> Dict[str, str]:
"""전략 ID(SCALP/SHORT/…) 해당 config_* 테이블만 조회."""
tbl = STRATEGY_ID_TO_TABLE.get((strategy_id or "").upper())
if not tbl:
return {}
keys = CONFIG_TABLE_KEYS.get(tbl, ())
snap = self._read_latest_config_table(tbl, keys)
if snap:
return snap
return {
k: v
for k, v in self.get_merged_env_snapshot().items()
if classify_config_key(k) == tbl
}
# ============================================================
# [kv_store] 매터모스트 원격 조종용 키-값 (마지막 AI 추천, last_seen 등)
# ============================================================
def get_kv(self, key: str) -> Optional[str]:
"""kv_store에서 값 조회. 없으면 None."""
try:
row = self.conn.execute("SELECT v FROM kv_store WHERE k = ?", (key,)).fetchone()
return row["v"] if row else None
except Exception as e:
logger.debug(f"get_kv 실패: {e}")
return None
def set_kv(self, key: str, value: str) -> bool:
"""kv_store에 값 저장 (REPLACE INTO = INSERT OR REPLACE MySQL 버전)."""
try:
self.conn.execute("REPLACE INTO kv_store (k, v) VALUES (%s, %s)", (key, value))
return True
except Exception as e:
logger.error(f"❌ set_kv 실패: {e}")
return False
def set_last_ai_recommendations(self, text: str) -> bool:
"""마지막 AI 수치 추천문 저장 (!적용 시 사용)."""
return self.set_kv("last_ai_recommendations", text)
def get_last_ai_recommendations(self) -> Optional[str]:
"""마지막 AI 수치 추천문 조회. 없으면 None."""
return self.get_kv("last_ai_recommendations")
# ------------------------------------------------------------------
# [ai_analysis_log] Butler 분석 기록 (프롬프트 요약·응답 저장/조회)
# ------------------------------------------------------------------
def insert_ai_analysis_log(self, model: str, context_summary: str, response: str) -> Optional[int]:
"""AI 분석 한 건 저장. model=claude|gemini, context_summary=현재상태 요약, response=AI 전체 응답."""
try:
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cur = self.conn.execute(
"INSERT INTO ai_analysis_log (created_at, model, context_summary, response) VALUES (%s, %s, %s, %s)",
(now, model, (context_summary or "")[:2000], response or ""),
)
return cur.lastrowid
except Exception as e:
logger.error("insert_ai_analysis_log 실패: %s", e)
return None
def get_ai_analysis_log_list(self, limit: int = 10) -> list:
"""최근 N건 목록. 각 항목: id, created_at, model, context_summary, response(앞 400자)."""
try:
rows = self.conn.execute(
"""
SELECT id, created_at, model, context_summary, response
FROM ai_analysis_log
ORDER BY id DESC
LIMIT ?
""",
(limit,),
).fetchall()
out = []
for r in rows:
resp = (r["response"] or "")[:400]
if len(r["response"] or "") > 400:
resp += "..."
out.append({"id": r["id"], "created_at": r["created_at"], "model": r["model"],
"context_summary": r["context_summary"], "response_preview": resp})
return out
except Exception as e:
logger.error("get_ai_analysis_log_list 실패: %s", e)
return []
def get_ai_analysis_log_by_id(self, log_id: int) -> Optional[dict]:
"""id로 전체 한 건 조회. 없으면 None."""
try:
row = self.conn.execute(
"SELECT id, created_at, model, context_summary, response FROM ai_analysis_log WHERE id = ?",
(log_id,),
).fetchone()
if not row:
return None
return {"id": row["id"], "created_at": row["created_at"], "model": row["model"],
"context_summary": row["context_summary"], "response": row["response"]}
except Exception as e:
logger.error("get_ai_analysis_log_by_id 실패: %s", e)
return None
# ==================================================================
# ws_candles 헬퍼 (kis_scalping_ver1 전용)
# ==================================================================
def upsert_ws_candle(self, code: str, timeframe: int, candle_time: str,
open_: float, high: float, low: float, close: float,
volume: int, is_confirmed: int = 0, source: str = "ws",
rsi_2: float = None, rsi_3: float = None, rsi_5: float = None,
holding_peak: float = None):
"""
봉 1개를 INSERT OR REPLACE 로 저장.
- 진행 중인 봉(is_confirmed=0): 틱마다 close/high/low/volume 갱신
- 확정 봉(is_confirmed=1): RSI 계산 후 최종 저장
- holding_peak: SHORT 보유 중 WS 트레일 고점 (백테 실매 정합용, 선택)
"""
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
with self.conn:
self.conn.execute("""
INSERT INTO ws_candles
(code, timeframe, candle_time, open, high, low, close, volume,
rsi_2, rsi_3, rsi_5, is_confirmed, source, holding_peak, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(code, timeframe, candle_time) DO UPDATE SET
high = MAX(high, excluded.high),
low = MIN(low, excluded.low),
close = excluded.close,
volume = excluded.volume,
rsi_2 = COALESCE(excluded.rsi_2, rsi_2),
rsi_3 = COALESCE(excluded.rsi_3, rsi_3),
rsi_5 = COALESCE(excluded.rsi_5, rsi_5),
is_confirmed = excluded.is_confirmed,
source = excluded.source,
holding_peak = MAX(COALESCE(holding_peak, 0), COALESCE(excluded.holding_peak, 0)),
updated_at = excluded.updated_at
""", (code, timeframe, candle_time, open_, high, low, close, volume,
rsi_2, rsi_3, rsi_5, is_confirmed, source, holding_peak, now_str))
except Exception as e:
logger.error("upsert_ws_candle 실패(%s): %s", code, e)
def get_ws_candles(self, code: str, timeframe: int, limit: int = 100,
confirmed_only: bool = False) -> list:
"""
최근 N개 봉 리스트 반환 (오래된 순 → 최신 순).
confirmed_only=True 면 확정 봉만 반환 (RSI 계산용).
"""
try:
cond = "AND is_confirmed = 1" if confirmed_only else ""
rows = self.conn.execute(f"""
SELECT candle_time, open, high, low, close, volume,
rsi_2, rsi_3, rsi_5, is_confirmed, source
FROM ws_candles
WHERE code = ? AND timeframe = ? {cond}
ORDER BY candle_time DESC
LIMIT ?
""", (code, timeframe, limit)).fetchall()
# 최신→오래된 순으로 왔으므로 역정렬해서 반환 (오래된→최신)
result = [dict(r) for r in rows]
result.reverse()
return result
except Exception as e:
logger.error("get_ws_candles 실패(%s): %s", code, e)
return []
def get_latest_ws_candle(self, code: str, timeframe: int) -> Optional[dict]:
"""가장 최신 봉 1개 반환 (진행 중 봉 포함)."""
try:
row = self.conn.execute("""
SELECT candle_time, open, high, low, close, volume,
rsi_2, rsi_3, rsi_5, is_confirmed
FROM ws_candles
WHERE code = ? AND timeframe = ?
ORDER BY candle_time DESC LIMIT 1
""", (code, timeframe)).fetchone()
return dict(row) if row else None
except Exception as e:
logger.error("get_latest_ws_candle 실패(%s): %s", code, e)
return None
def get_latest_confirmed_ws_candle(self, code: str, timeframe: int) -> Optional[dict]:
"""가장 최신 확정 봉 1개 반환 (RSI 포함)."""
try:
row = self.conn.execute("""
SELECT candle_time, open, high, low, close, volume,
rsi_2, rsi_3, rsi_5
FROM ws_candles
WHERE code = ? AND timeframe = ? AND is_confirmed = 1
ORDER BY candle_time DESC LIMIT 1
""", (code, timeframe)).fetchone()
return dict(row) if row else None
except Exception as e:
logger.error("get_latest_confirmed_ws_candle 실패(%s): %s", code, e)
return None
def cleanup_old_ws_candles(self, keep_days: int = 3):
"""오래된 ws_candles 정리 (기본 3일 이상 지난 봉 삭제)."""
cutoff = (datetime.datetime.now() - datetime.timedelta(days=keep_days)).strftime("%Y%m%d%H%M")
try:
with self.conn:
self.conn.execute(
"DELETE FROM ws_candles WHERE candle_time < ?", (cutoff,)
)
except Exception as e:
logger.error("cleanup_old_ws_candles 실패: %s", e)
# ==================================================================
# ws_ticks — 실시간 체결 틱 (TickRecorder 배치 INSERT)
# ==================================================================
def ensure_ws_ticks_table(self) -> None:
"""런타임 테이블 보장 (구 DB 마이그레이션)."""
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ws_ticks (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
market VARCHAR(8) NOT NULL DEFAULT 'KR',
exchange VARCHAR(16) DEFAULT NULL,
code VARCHAR(32) NOT NULL,
tick_time VARCHAR(14) NOT NULL,
price DOUBLE NOT NULL,
volume BIGINT NOT NULL DEFAULT 0,
tick_seq BIGINT DEFAULT NULL,
session VARCHAR(8) DEFAULT NULL,
currency VARCHAR(8) NOT NULL DEFAULT 'KRW',
source VARCHAR(16) NOT NULL DEFAULT 'kis',
recv_ts VARCHAR(30) NOT NULL,
KEY idx_ws_ticks_lookup (market, code, tick_time),
KEY idx_ws_ticks_recv (recv_ts)
) CHARACTER SET utf8mb4
""")
except Exception as e:
logger.debug("ensure_ws_ticks_table: %s", e)
def insert_ws_ticks_batch(self, batch: List[Dict]) -> int:
"""체결 틱 배치 append INSERT (중복 키 없음 — 이벤트 로그)."""
if not batch:
return 0
rows = []
for it in batch:
rows.append((
(it.get("market") or "KR")[:8],
(it.get("exchange") or None),
(it.get("code") or "")[:32],
(it.get("tick_time") or "")[:14],
float(it.get("price") or 0),
int(it.get("volume") or 0),
it.get("tick_seq"),
(it.get("session") or None),
(it.get("currency") or "KRW")[:8],
(it.get("source") or "kis")[:16],
it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
))
try:
ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
placeholders = ", ".join([ph] * len(rows))
flat: List[Any] = []
for row in rows:
flat.extend(row)
self.conn.execute(
f"""
INSERT INTO ws_ticks
(market, exchange, code, tick_time, price, volume,
tick_seq, session, currency, source, recv_ts)
VALUES {placeholders}
""",
tuple(flat),
)
return len(rows)
except Exception as e:
logger.error("insert_ws_ticks_batch 실패: %s", e)
return 0
def get_ws_ticks(
self,
code: str,
*,
market: str = "KR",
start_tick_time: str = "",
end_tick_time: str = "",
limit: int = 5000,
) -> List[Dict]:
"""백테·분석용 틱 조회 (오래된→최신)."""
code = (code or "").strip()
if not code:
return []
limit = max(1, min(int(limit), 500000))
try:
cond = "market=%s AND code=%s"
params: List[Any] = [(market or "KR")[:8], code[:32]]
if start_tick_time:
cond += " AND tick_time >= %s"
params.append(start_tick_time[:14])
if end_tick_time:
cond += " AND tick_time <= %s"
params.append(end_tick_time[:14])
rows = self.conn.execute(
f"""
SELECT market, exchange, code, tick_time, price, volume,
tick_seq, session, currency, source, recv_ts
FROM ws_ticks
WHERE {cond}
ORDER BY tick_time ASC
LIMIT %s
""",
(*params, limit),
).fetchall()
return [dict(r) for r in rows]
except Exception as e:
logger.error("get_ws_ticks 실패(%s): %s", code, e)
return []
def cleanup_old_ws_ticks(self, keep_days: int = 7) -> None:
"""recv_ts 기준 오래된 틱 삭제."""
if keep_days <= 0:
return
cutoff = (
datetime.datetime.now() - datetime.timedelta(days=keep_days)
).strftime("%Y-%m-%d %H:%M:%S")
try:
self.conn.execute(
"DELETE FROM ws_ticks WHERE recv_ts < %s", (cutoff,)
)
logger.info("🧹 ws_ticks 정리 완료 (recv_ts < %s)", cutoff)
except Exception as e:
logger.error("cleanup_old_ws_ticks 실패: %s", e)
# ==================================================================
# ws_orderbook / ws_program — TRIGGER 스냅샷 (TriggerSnapshotRecorder)
# ==================================================================
def ensure_ws_orderbook_table(self) -> None:
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ws_orderbook (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
market VARCHAR(8) NOT NULL DEFAULT 'KR',
code VARCHAR(32) NOT NULL,
snap_time VARCHAR(14) NOT NULL,
best_bid BIGINT NOT NULL DEFAULT 0,
best_ask BIGINT NOT NULL DEFAULT 0,
total_bid_qty BIGINT NOT NULL DEFAULT 0,
total_ask_qty BIGINT NOT NULL DEFAULT 0,
bid_qty_l3 BIGINT NOT NULL DEFAULT 0,
ask_qty_l3 BIGINT NOT NULL DEFAULT 0,
levels_json MEDIUMTEXT,
source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0d',
recv_ts VARCHAR(30) NOT NULL,
KEY idx_ws_orderbook_lookup (market, code, snap_time),
KEY idx_ws_orderbook_recv (recv_ts)
) CHARACTER SET utf8mb4
""")
except Exception as e:
logger.debug("ensure_ws_orderbook_table: %s", e)
def ensure_ws_program_table(self) -> None:
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ws_program (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
market VARCHAR(8) NOT NULL DEFAULT 'KR',
code VARCHAR(32) NOT NULL,
snap_time VARCHAR(14) NOT NULL,
buy_qty BIGINT NOT NULL DEFAULT 0,
sell_qty BIGINT NOT NULL DEFAULT 0,
net_qty BIGINT NOT NULL DEFAULT 0,
buy_amt BIGINT NOT NULL DEFAULT 0,
sell_amt BIGINT NOT NULL DEFAULT 0,
net_amt BIGINT NOT NULL DEFAULT 0,
source VARCHAR(16) NOT NULL DEFAULT 'kiwoom_0w',
recv_ts VARCHAR(30) NOT NULL,
KEY idx_ws_program_lookup (market, code, snap_time),
KEY idx_ws_program_recv (recv_ts)
) CHARACTER SET utf8mb4
""")
except Exception as e:
logger.debug("ensure_ws_program_table: %s", e)
def migrate_trigger_eval_columns(self) -> None:
"""ws_orderbook / ws_program — 판정 스냅샷 메타 컬럼 (filter_eval)."""
for table in ("ws_orderbook", "ws_program"):
try:
cols = self.conn.get_columns(table)
except Exception:
continue
if "strategy" not in cols:
self.conn.execute(
f"ALTER TABLE {table} ADD COLUMN strategy VARCHAR(16) DEFAULT NULL"
)
logger.info("📌 %s.strategy 컬럼 추가", table)
if "reject_code" not in cols:
self.conn.execute(
f"ALTER TABLE {table} ADD COLUMN reject_code VARCHAR(64) DEFAULT NULL"
)
logger.info("📌 %s.reject_code 컬럼 추가", table)
if "reject_msg" not in cols:
self.conn.execute(
f"ALTER TABLE {table} ADD COLUMN reject_msg VARCHAR(255) DEFAULT NULL"
)
logger.info("📌 %s.reject_msg 컬럼 추가", table)
if "eval_price" not in cols:
self.conn.execute(
f"ALTER TABLE {table} ADD COLUMN eval_price BIGINT NOT NULL DEFAULT 0"
)
logger.info("📌 %s.eval_price 컬럼 추가", table)
def insert_ws_orderbook_eval_batch(self, batch: List[Dict]) -> int:
"""판정 시점 호가 스냅샷 INSERT (filter_eval)."""
if not batch:
return 0
rows = []
for it in batch:
rows.append((
(it.get("market") or "KR")[:8],
(it.get("code") or "")[:32],
(it.get("snap_time") or "")[:14],
int(it.get("best_bid") or 0),
int(it.get("best_ask") or 0),
int(it.get("total_bid_qty") or 0),
int(it.get("total_ask_qty") or 0),
int(it.get("bid_qty_l3") or 0),
int(it.get("ask_qty_l3") or 0),
it.get("levels_json") or "{}",
(it.get("source") or "filter_eval")[:16],
it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
(it.get("strategy") or "")[:16] or None,
(it.get("reject_code") or "")[:64] or None,
(it.get("reject_msg") or "")[:255] or None,
int(it.get("eval_price") or 0),
))
try:
ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
placeholders = ", ".join([ph] * len(rows))
flat: List[Any] = []
for row in rows:
flat.extend(row)
self.conn.execute(
f"""
INSERT INTO ws_orderbook
(market, code, snap_time, best_bid, best_ask,
total_bid_qty, total_ask_qty, bid_qty_l3, ask_qty_l3,
levels_json, source, recv_ts, strategy, reject_code, reject_msg, eval_price)
VALUES {placeholders}
""",
tuple(flat),
)
return len(rows)
except Exception as e:
logger.error("insert_ws_orderbook_eval_batch 실패: %s", e)
return self.insert_ws_orderbook_batch(batch)
def insert_ws_program_eval_batch(self, batch: List[Dict]) -> int:
"""판정 시점 프로그램 스냅샷 INSERT (filter_eval)."""
if not batch:
return 0
rows = []
for it in batch:
rows.append((
(it.get("market") or "KR")[:8],
(it.get("code") or "")[:32],
(it.get("snap_time") or "")[:14],
int(it.get("buy_qty") or 0),
int(it.get("sell_qty") or 0),
int(it.get("net_qty") or 0),
int(it.get("buy_amt") or 0),
int(it.get("sell_amt") or 0),
int(it.get("net_amt") or 0),
(it.get("source") or "filter_eval")[:16],
it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
(it.get("strategy") or "")[:16] or None,
(it.get("reject_code") or "")[:64] or None,
(it.get("reject_msg") or "")[:255] or None,
int(it.get("eval_price") or 0),
))
try:
ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
placeholders = ", ".join([ph] * len(rows))
flat: List[Any] = []
for row in rows:
flat.extend(row)
self.conn.execute(
f"""
INSERT INTO ws_program
(market, code, snap_time, buy_qty, sell_qty, net_qty,
buy_amt, sell_amt, net_amt, source, recv_ts,
strategy, reject_code, reject_msg, eval_price)
VALUES {placeholders}
""",
tuple(flat),
)
return len(rows)
except Exception as e:
logger.error("insert_ws_program_eval_batch 실패: %s", e)
return self.insert_ws_program_batch(batch)
def cleanup_old_ws_orderbook_eval(self, keep_days: int = 7) -> None:
"""filter_eval 호가 스냅만 정리."""
if keep_days <= 0:
return
cutoff = (
datetime.datetime.now() - datetime.timedelta(days=keep_days)
).strftime("%Y-%m-%d %H:%M:%S")
try:
self.conn.execute(
"DELETE FROM ws_orderbook WHERE source = 'filter_eval' AND recv_ts < %s",
(cutoff,),
)
logger.info("🧹 ws_orderbook filter_eval 정리 완료 (recv_ts < %s)", cutoff)
except Exception as e:
logger.error("cleanup_old_ws_orderbook_eval 실패: %s", e)
def cleanup_old_ws_program_eval(self, keep_days: int = 7) -> None:
"""filter_eval 프로그램 스냅만 정리."""
if keep_days <= 0:
return
cutoff = (
datetime.datetime.now() - datetime.timedelta(days=keep_days)
).strftime("%Y-%m-%d %H:%M:%S")
try:
self.conn.execute(
"DELETE FROM ws_program WHERE source = 'filter_eval' AND recv_ts < %s",
(cutoff,),
)
logger.info("🧹 ws_program filter_eval 정리 완료 (recv_ts < %s)", cutoff)
except Exception as e:
logger.error("cleanup_old_ws_program_eval 실패: %s", e)
def insert_ws_orderbook_batch(self, batch: List[Dict]) -> int:
if not batch:
return 0
rows = []
for it in batch:
rows.append((
(it.get("market") or "KR")[:8],
(it.get("code") or "")[:32],
(it.get("snap_time") or "")[:14],
int(it.get("best_bid") or 0),
int(it.get("best_ask") or 0),
int(it.get("total_bid_qty") or 0),
int(it.get("total_ask_qty") or 0),
int(it.get("bid_qty_l3") or 0),
int(it.get("ask_qty_l3") or 0),
it.get("levels_json") or "{}",
(it.get("source") or "kiwoom_0d")[:16],
it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
))
try:
ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
placeholders = ", ".join([ph] * len(rows))
flat: List[Any] = []
for row in rows:
flat.extend(row)
self.conn.execute(
f"""
INSERT INTO ws_orderbook
(market, code, snap_time, best_bid, best_ask,
total_bid_qty, total_ask_qty, bid_qty_l3, ask_qty_l3,
levels_json, source, recv_ts)
VALUES {placeholders}
""",
tuple(flat),
)
return len(rows)
except Exception as e:
logger.error("insert_ws_orderbook_batch 실패: %s", e)
return 0
def insert_ws_program_batch(self, batch: List[Dict]) -> int:
if not batch:
return 0
rows = []
for it in batch:
rows.append((
(it.get("market") or "KR")[:8],
(it.get("code") or "")[:32],
(it.get("snap_time") or "")[:14],
int(it.get("buy_qty") or 0),
int(it.get("sell_qty") or 0),
int(it.get("net_qty") or 0),
int(it.get("buy_amt") or 0),
int(it.get("sell_amt") or 0),
int(it.get("net_amt") or 0),
(it.get("source") or "kiwoom_0w")[:16],
it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
))
try:
ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
placeholders = ", ".join([ph] * len(rows))
flat: List[Any] = []
for row in rows:
flat.extend(row)
self.conn.execute(
f"""
INSERT INTO ws_program
(market, code, snap_time, buy_qty, sell_qty, net_qty,
buy_amt, sell_amt, net_amt, source, recv_ts)
VALUES {placeholders}
""",
tuple(flat),
)
return len(rows)
except Exception as e:
logger.error("insert_ws_program_batch 실패: %s", e)
return 0
def cleanup_old_ws_orderbook(self, keep_days: int = 7) -> None:
if keep_days <= 0:
return
cutoff = (
datetime.datetime.now() - datetime.timedelta(days=keep_days)
).strftime("%Y-%m-%d %H:%M:%S")
try:
self.conn.execute(
"DELETE FROM ws_orderbook WHERE recv_ts < %s", (cutoff,)
)
logger.info("🧹 ws_orderbook 정리 완료 (recv_ts < %s)", cutoff)
except Exception as e:
logger.error("cleanup_old_ws_orderbook 실패: %s", e)
def cleanup_old_ws_program(self, keep_days: int = 7) -> None:
if keep_days <= 0:
return
cutoff = (
datetime.datetime.now() - datetime.timedelta(days=keep_days)
).strftime("%Y-%m-%d %H:%M:%S")
try:
self.conn.execute(
"DELETE FROM ws_program WHERE recv_ts < %s", (cutoff,)
)
logger.info("🧹 ws_program 정리 완료 (recv_ts < %s)", cutoff)
except Exception as e:
logger.error("cleanup_old_ws_program 실패: %s", e)
def close(self):
"""DB 연결 종료 (호출자가 매번 close 하므로 로그는 DEBUG 로 강등)."""
if self.conn:
self.conn.close()
logger.debug("🔒 DB 연결 종료")