Files
kis_trader/scalping_engine.py
Hwang f61c471aac 브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
2026-05-05 21:04:17 +09:00

717 lines
31 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.
#!/usr/bin/env python3
"""
scalping_engine.py — 스캘핑 백테스트·파라미터서치·실매매 공통 엔진
====================================================================
계산식만 같으면 백테스트와 실매매 결과가 같아지도록, 모든 로직을 이 모듈로 통합했습니다.
backtest_web / param_search / kis_scalping_ver2 가 모두 이 엔진만 호출합니다.
■ 엔진에 통합된 공통 로직 (백테스트·파라미터서치·실매매 100% 동일)
- 매수: 시간대, 쿨다운, 일일 진입 횟수, RSI 과매도/과매수, 되돌림(음봉->양봉), 낙폭, 거래량.
- 매수 방어: 고점추격 방지(high_chase_thr), 급등주(max_daily_chg), 최소 가격(min_price).
- 매도: 손절가(sl_pct), 익절가(tp_pct), 금액손실컷(max_loss_krw), 본절사수(breakeven), 트레일링스탑, 장마감청산.
캔들 형식: list of dict with keys candle_time(YYYYMMDDHHMI), open, high, low, close, volume
"""
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple
# 문자열/숫자/불리언 입력을 안전하게 bool로 변환
def _to_bool(v: Any, default: bool = True) -> bool:
if v is None:
return default
if isinstance(v, bool):
return v
s = str(v).strip().lower()
if s in ("1", "true", "t", "y", "yes", "on"):
return True
if s in ("0", "false", "f", "n", "no", "off", ""):
return False
return default
# DB 기본값 로드 (백테스트/param_search가 동일한 값 사용하도록 단일 소스)
def get_scalping_defaults_from_db() -> Dict[str, Any]:
"""
env_config 최신 행에서 스캘핑 관련 기본값 로드.
백테스트 API·param_search가 이 함수만 쓰면 실매매(DB)와 동일한 값으로 동작.
"""
try:
from database import TradeDB
db = TradeDB()
row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
db.close()
if row:
r = dict(row)
# SCALP_COOLDOWN_SEC(초) → cooldown_min(분). 실매매와 동일 키 사용
sec = r.get("SCALP_COOLDOWN_SEC") or r.get("REENTRY_COOLDOWN_SEC") or "600"
cooldown_min = max(0, int(float(sec)) // 60)
fee_pct = float(r.get("FEE_RATE_PCT") or 0.015)
tax_pct = float(r.get("SELL_TAX_RATE_PCT") or 0.18)
slot = float(r.get("SLOT_MONEY_DEFAULT") or 300_000)
# ──────── 방어 로직 파라미터 (스캘핑 전용 키 우선, 없으면 꼬리잡기 공용 키 fallback) ────────
high_chase_thr = float(r.get("SCALP_HIGH_PRICE_CHASE_THRESHOLD") or r.get("HIGH_PRICE_CHASE_THRESHOLD") or 0.96)
max_daily_chg = float(r.get("SCALP_MAX_DAILY_CHANGE_PCT") or r.get("MAX_DAILY_CHANGE_PCT") or 20.0)
min_price = float(r.get("SCALP_MIN_PRICE") or r.get("MIN_PRICE_TAIL") or 1000.0)
max_loss_krw = int(r.get("SCALP_MAX_LOSS_PER_TRADE_KRW") or r.get("MAX_LOSS_PER_TRADE_KRW") or 200000)
_min_drop_loss = r.get("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT")
min_drop_pct_for_loss_cut = 0.015
if _min_drop_loss not in (None, ""):
v = float(_min_drop_loss)
min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v
min_margin = float(r.get("SCALP_MIN_PROFIT_PCT") or 0.2)
rsi_overbought = float(r.get("SCALP_RSI_OVERBOUGHT") or 75.0)
use_defense_filters = _to_bool(r.get("SCALP_USE_DEFENSE_FILTERS"), True)
else:
cooldown_min, fee_pct, tax_pct, slot = 10, 0.015, 0.18, 300_000.0
high_chase_thr, max_daily_chg, min_price = 0.96, 20.0, 1000.0
max_loss_krw, min_margin, rsi_overbought = 200000, 0.2, 75.0
min_drop_pct_for_loss_cut = 0.015
use_defense_filters = True
except Exception:
cooldown_min, fee_pct, tax_pct, slot = 10, 0.015, 0.18, 300_000.0
high_chase_thr, max_daily_chg, min_price = 0.96, 20.0, 1000.0
max_loss_krw, min_margin, rsi_overbought = 200000, 0.2, 75.0
min_drop_pct_for_loss_cut = 0.015
use_defense_filters = True
return {
"cooldown_min": cooldown_min,
"time_start_hm": 900,
"time_end_hm": 1530,
"time_start": 900,
"time_end": 1530,
"fee_rate": fee_pct / 100,
"sell_tax": tax_pct / 100,
"slot_money": slot,
"rsi_period": 3,
"vol_mult": 0,
"trail_trigger": 0.007,
"trail_stop": 0.004,
"max_daily": 3,
"high_chase_thr": high_chase_thr,
"max_daily_chg": max_daily_chg,
"min_price": min_price,
"max_loss_krw": max_loss_krw,
"min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut,
"min_margin": min_margin / 100,
"rsi_overbought": rsi_overbought,
"use_defense_filters": use_defense_filters,
}
def compute_rsi_series(closes: list, period: int = 3) -> list:
"""RSI 시리즈 계산 (Wilder 스무딩). backtest_web과 동일."""
rsi_list = [None] * len(closes)
if len(closes) < period + 1:
return rsi_list
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
gains = [max(d, 0) for d in deltas]
losses = [max(-d, 0) for d in deltas]
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
for i in range(period, len(closes)):
idx = i - 1
if i > period:
avg_gain = (avg_gain * (period - 1) + gains[idx]) / period
avg_loss = (avg_loss * (period - 1) + losses[idx]) / period
rs = avg_gain / avg_loss if avg_loss > 0 else float("inf")
rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0
rsi_list[i] = rsi_val
return rsi_list
def _t2dt(t: str) -> datetime:
"""candle_time 문자열 → datetime."""
return datetime.strptime(t, "%Y%m%d%H%M")
def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str:
"""
봉 시각을 N분 단위 슬롯 키로 변환.
신봇 기준:
* 실매매는 10초 주기 REST 폴링 + 변동 tick 마다 초단위 event_time 저장.
* 백테스트 caller(``param_search``/``tail_param_search``)가
``TradeDBExt.get_universe_by_candle_time()`` 으로 **1분 캔들 시각 키**
(YYYYMMDDHHMM) 를 가진 dict 를 만들어 엔진에 주입한다.
* 엔진은 그 dict 를 분단위(scan_interval_min=1) 키로 바로 조회.
과거 호환:
* ``--fallback-universe`` 시뮬레이션 경로는 여전히 5분 버킷팅을 쓸 수
있도록 ``scan_interval_min`` 파라미터는 남겨 둔다.
"""
date = candle_time[:8]
hm = int(candle_time[8:12])
total_min = (hm // 100) * 60 + (hm % 100)
slot_min = (total_min // scan_interval_min) * scan_interval_min
slot_hm = (slot_min // 60) * 100 + (slot_min % 60)
return date + str(slot_hm).zfill(4)
def build_universe_simulation(
codes_candles: Dict[str, List[Dict]],
top_n: int = 20,
min_score: float = 4.0,
scan_interval_min: int = 5,
) -> Dict[str, List[str]]:
"""
과거 1분봉만으로 '5분마다 강도 순 상위 N종목' 유니버스를 흉내 냄.
kiwoom_universe_scanner의 개미털기 점수(drop_rate·회복률)를 1분봉 기준으로 근사.
실매매의 target_candidates 5분 갱신과 동일한 타이밍으로 백테스트 제한용.
Returns:
slot_key(YYYYMMDDHHMM) → 해당 슬롯에 매수 검사 허용할 종목 코드 리스트
"""
slot_codes_scores: Dict[str, List[Tuple[str, float, float]]] = {} # slot -> [(code, score, vol)]
for code, rows in codes_candles.items():
if len(rows) < 2:
continue
candles = [dict(r) for r in rows]
# 날짜별로 묶어서 당일 시가/고/저/종가 누적
by_day: Dict[str, List[Dict]] = {}
for c in candles:
day = c["candle_time"][:8]
if day not in by_day:
by_day[day] = []
by_day[day].append(c)
for day, day_candles in by_day.items():
day_candles.sort(key=lambda x: x["candle_time"])
day_open = float(day_candles[0]["open"])
running_high = max(float(c["high"]) for c in day_candles)
running_low = min(float(c["low"]) for c in day_candles)
vol_sum = sum(float(c.get("volume", 0)) for c in day_candles)
# 5분 단위 슬롯: 해당 슬롯 시작 시점까지의 데이터로 점수 계산 (슬롯 시작 직전 봉까지)
market_open_min = 9 * 60 # 09:00 = 540분
seen_slots = set()
for j, c in enumerate(day_candles):
ct = c["candle_time"]
hm = int(ct[8:12])
total_min = (hm // 100) * 60 + (hm % 100)
slot_min = (total_min // scan_interval_min) * scan_interval_min
slot_hm = (slot_min // 60) * 100 + (slot_min % 60)
slot_key = day + str(slot_hm).zfill(4)
if slot_key in seen_slots:
continue
seen_slots.add(slot_key)
# as_of: 이 슬롯에 쓰일 마지막 봉 (슬롯 시작 직전 봉, 장시작 09:00 이상)
slot_min_val = (slot_hm // 100) * 60 + (slot_hm % 100)
as_of_min = max(market_open_min, slot_min_val - 1)
as_of_hm = (as_of_min // 60) * 100 + (as_of_min % 60)
as_of_str = day + str(as_of_hm).zfill(4)
up_to = [x for x in day_candles if x["candle_time"] <= as_of_str]
if not up_to:
continue
o = float(up_to[0]["open"])
hi = max(float(x["high"]) for x in up_to)
lo = min(float(x["low"]) for x in up_to)
cl = float(up_to[-1]["close"])
drop_rate = (o - lo) / o if o > 0 else 0
rng = hi - lo
recovery_pos = (cl - lo) / rng if rng > 0 else 0
score = (drop_rate * 100) if (drop_rate >= 0.03 and recovery_pos >= 0.5) else 0.0
vol_part = sum(float(x.get("volume", 0)) for x in up_to)
if slot_key not in slot_codes_scores:
slot_codes_scores[slot_key] = []
slot_codes_scores[slot_key].append((code, score, vol_part))
# 슬롯별 상위 top_n, min_score 이상만
universe_by_slot: Dict[str, List[str]] = {}
for slot_key, lst in slot_codes_scores.items():
lst = [(c, s, v) for c, s, v in lst if s >= min_score]
lst.sort(key=lambda x: (-x[1], -x[2]))
universe_by_slot[slot_key] = [x[0] for x in lst[:top_n]]
return universe_by_slot
def run_scalping_backtest(
codes_candles: Dict[str, List[Dict]],
params: Dict[str, Any],
universe_by_slot: Optional[Dict[str, List[str]]] = None,
) -> List[Dict]:
"""
종목별 캔들에 대해 스캘핑 백테스트 실행. 실매매와 동일한 규칙(방어로직 포함) 적용.
universe_by_slot이 주어지면, 5분마다 해당 슬롯의 후보 종목에서만 매수 신호를 검사
(실매매의 target_candidates 5분 갱신과 동일한 유니버스 시뮬레이션).
"""
rsi_period = int(params.get("rsi_period", 3))
rsi_oversold = float(params.get("rsi_oversold", 25))
rsi_overbought = float(params.get("rsi_overbought", 75.0))
sl_pct = float(params.get("sl_pct", 0.015))
tp_pct = float(params.get("tp_pct", 0.015))
drop_rate = float(params.get("drop_rate", 0.015))
slot_money = float(params.get("slot_money", 300_000))
fee_rate = float(params.get("fee_rate", 0.00015))
sell_tax = float(params.get("sell_tax", 0.0018))
cooldown_min = float(params.get("cooldown_min", 10))
trail_trigger = float(params.get("trail_trigger", 0))
trail_stop = float(params.get("trail_stop", 0.004))
time_start_hm = int(params.get("time_start_hm", 900))
time_end_hm = int(params.get("time_end_hm", 1530))
max_daily = int(params.get("max_daily", 3))
vol_mult = float(params.get("vol_mult", 0))
# 방어 로직 파라미터
high_chase_thr = float(params.get("high_chase_thr", 0.96))
max_daily_chg = float(params.get("max_daily_chg", 20.0))
min_price = float(params.get("min_price", 1000.0))
max_loss_krw = float(params.get("max_loss_krw", 200000.0))
min_margin = float(params.get("min_margin", 0.002))
use_defense_filters = _to_bool(params.get("use_defense_filters", True), True)
all_trades: List[Dict] = []
for code, rows in codes_candles.items():
if len(rows) < rsi_period + 5:
continue
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
volumes = [float(c.get("volume", 0)) for c in candles]
rsis = compute_rsi_series(closes, rsi_period)
position: Optional[Dict] = None
last_exit_dt: Dict[str, datetime] = {}
daily_cnt: Dict[str, int] = {}
cur_day = None
running_open = 0.0
running_high = 0.0
running_low = 0.0
for i in range(rsi_period + 1, len(candles)):
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
cl = float(c["close"])
lo = float(c["low"])
hi = float(c["high"])
vol = volumes[i] if i < len(volumes) else 0
# 당일 고가/저가 누적 (피뢰침 방지용)
if day != cur_day:
cur_day = day
running_open = float(c["open"])
running_high = hi
running_low = lo
else:
running_high = max(running_high, hi)
running_low = min(running_low, lo)
is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
# ── 포지션 보유 중: 청산 체크 ──
if position is not None:
# check_sell_signal_live 재사용하여 로직 100% 일치
reason, exit_price = check_sell_signal_live(position, c, params, is_eod) or (None, cl)
if reason:
qty = position["qty"]
buy_amt = position["entry_price"] * qty
sell_amt = exit_price * qty
pnl = (
sell_amt
- buy_amt
- buy_amt * fee_rate
- sell_amt * fee_rate
- sell_amt * sell_tax
)
hold_min = int(
(_t2dt(c["candle_time"]) - _t2dt(position["entry_time"])).total_seconds() / 60
)
all_trades.append({
"code": code,
"buy_time": position["entry_time"],
"sell_time": c["candle_time"],
"buy_price": position["entry_price"],
"sell_price": round(exit_price, 2),
"qty": qty,
"pnl": round(pnl),
"profit_rate": round(
(exit_price - position["entry_price"]) / position["entry_price"] * 100, 2
),
"hold_min": hold_min,
"sell_reason": reason,
"rsi_entry": round(position["rsi"], 1),
})
last_exit_dt[day] = _t2dt(c["candle_time"])
position = None
continue
# ── 포지션 없음: 매수 신호 (유니버스 시뮬레이션 시 해당 슬롯 후보만 검사) ──
if universe_by_slot is not None:
# 신봇 기본: 1분봉 == 슬롯 키. simulation fallback 은 caller 가
# ``scan_interval_min`` 을 params 에 명시해 5분 슬롯으로 바꿀 수 있음.
slot_key = _slot_key(c["candle_time"], params.get("scan_interval_min", 1))
if code not in universe_by_slot.get(slot_key, []):
continue
if hm < time_start_hm or hm >= time_end_hm:
continue
if day in last_exit_dt:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
if elapsed < cooldown_min:
continue
if daily_cnt.get(day, 0) >= max_daily:
continue
rsi = rsis[i]
if rsi is None or rsi > rsi_oversold:
continue
prev_c = candles[i - 1]
prev_bear = float(prev_c["close"]) < float(prev_c["open"])
curr_bull = cl > float(c["open"])
if not (prev_bear and curr_bull):
continue
if running_open <= 0:
continue
# 진입 방어 ON/OFF: 스캘핑에서 진입 빈도가 너무 낮을 때 필터를 일괄 비활성화 가능
# (손절/익절/장마감청산 등 청산 리스크 관리는 그대로 유지)
if use_defense_filters:
if cl < min_price:
continue
dr = (running_open - running_low) / running_open
if dr < drop_rate:
continue
if cl >= running_high * high_chase_thr:
continue
if running_low > 0 and ((running_high - running_low) / running_low * 100) > max_daily_chg:
continue
if vol_mult > 0:
win = max(1, min(20, i))
vol_avg = sum(volumes[i - win : i]) / win
if vol_avg > 0 and vol < vol_avg * vol_mult:
continue
# 다음 봉 시가에 진입
if i + 1 >= len(candles):
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day:
continue
entry_price = float(next_c["open"])
if entry_price <= 0:
continue
# 포지션 크기 계산 (최대 금액 손실 컷 기반)
invest_amount = slot_money
if max_loss_krw > 0 and sl_pct > 0:
invest_limit = max_loss_krw / sl_pct
invest_amount = min(invest_limit, slot_money)
qty = max(1, int(invest_amount / entry_price))
stop = entry_price * (1 - sl_pct)
target = entry_price * (1 + tp_pct)
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"qty": qty,
"stop": stop,
"target": target,
"max_price": entry_price,
"rsi": rsi,
}
daily_cnt[day] = daily_cnt.get(day, 0) + 1
all_trades.sort(key=lambda x: x["sell_time"])
return all_trades
# ── 실시간 봇용: 단일 시점 매수/매도 판단 (백테스트와 100% 동일 규칙) ──────────────
def check_buy_signal_live(
candles: List[Dict],
params: Dict[str, Any],
state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""
실시간 봇: 현재 캔들 리스트의 마지막 봉이 매수 신호인지 판단.
방어 로직 포함.
"""
if len(candles) < 4:
return ("탈락-봉부족", "확정봉 4개 미만", None)
rsi_period = int(params.get("rsi_period", 3))
rsi_oversold = float(params.get("rsi_oversold", 25))
rsi_overbought = float(params.get("rsi_overbought", 75.0))
drop_rate = float(params.get("drop_rate", 0.015))
time_start_hm = int(params.get("time_start_hm", 900))
time_end_hm = int(params.get("time_end_hm", 1530))
cooldown_min = float(params.get("cooldown_min", 10))
max_daily = int(params.get("max_daily", 3))
vol_mult = float(params.get("vol_mult", 0))
high_chase_thr = float(params.get("high_chase_thr", 0.96))
max_daily_chg = float(params.get("max_daily_chg", 20.0))
min_price = float(params.get("min_price", 1000.0))
use_defense_filters = _to_bool(params.get("use_defense_filters", True), True)
# 되돌림 봉 조건: True면 직전 음봉+현재 양봉 필수, False면 RSI 과매도만으로도 진입 허용 (실매 테스트용)
require_reversal_candle = bool(params.get("require_reversal_candle", True))
i = len(candles) - 1
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
cl = float(c["close"])
lo = float(c["low"])
prev_c = candles[i - 1]
if hm < time_start_hm or hm >= time_end_hm:
return (None, None, None)
last_exit_dt = state.get("last_exit_dt")
if last_exit_dt is not None:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60
if elapsed < cooldown_min:
return (None, None, None)
if state.get("daily_cnt", 0) >= max_daily:
return (None, None, None)
if use_defense_filters and cl < min_price:
return ("탈락-최소가격", "%.0f < %.0f" % (cl, min_price), None)
closes = [float(x["close"]) for x in candles]
rsis = compute_rsi_series(closes, rsi_period)
rsi = rsis[i] if i < len(rsis) else None
if rsi is None:
return ("탈락-RSI없음", "RSI 미계산 (봉 축적 중)", None)
if rsi <= 0.0:
return ("탈락-RSI무효", "RSI3=0.0 (봉 부족, 계산 불가)", None)
if rsi > rsi_overbought:
return ("탈락-RSI과열", "RSI3=%.1f > %.0f" % (rsi, rsi_overbought), None)
if rsi > rsi_oversold:
return ("탈락-RSI조건", "RSI3=%.1f (과매도<%.0f 아님)" % (rsi, rsi_oversold), None)
if require_reversal_candle:
prev_bear = float(prev_c["close"]) < float(prev_c["open"])
curr_bull = cl > float(c["open"])
if not (prev_bear and curr_bull):
return ("탈락-되돌림없음", "prev_bear=%s curr_bull=%s" % (prev_bear, curr_bull), None)
running_open = float(c["open"])
running_low = lo
running_high = float(c["high"])
for j in range(i - 1, -1, -1):
if candles[j]["candle_time"][:8] != day:
break
running_open = float(candles[j]["open"])
running_low = min(running_low, float(candles[j]["low"]))
running_high = max(running_high, float(candles[j]["high"]))
if running_open <= 0:
return (None, None, None)
if use_defense_filters:
dr = (running_open - running_low) / running_open
if dr < drop_rate:
return ("탈락-낙폭", "%.2f%% < %.1f%%(SCALP_MIN_DROP_RATE)" % (dr * 100, drop_rate * 100), None)
if cl >= running_high * high_chase_thr:
return ("탈락-고점추격", "현재가 %.0f >= 고가 %.0f * %.2f" % (cl, running_high, high_chase_thr), None)
if running_low > 0:
daily_chg_pct = (running_high - running_low) / running_low * 100
if daily_chg_pct > max_daily_chg:
return ("탈락-급등주", "일일변동 %.1f%% > %.0f%%" % (daily_chg_pct, max_daily_chg), None)
if vol_mult > 0:
volumes = [float(x.get("volume", 0)) for x in candles]
vol = volumes[i] if i < len(volumes) else 0
win = max(1, min(20, i))
vol_avg = sum(volumes[i - win : i]) / win
if vol_avg > 0 and vol < vol_avg * vol_mult:
return ("탈락-거래량", "%.0f < 평균%.0f × %.1f" % (vol, vol_avg, vol_mult), None)
return (None, None, {"signal": True, "rsi": rsi})
# ── 모멘텀 추격형 진입 (SCALP_MODE=momentum) ────────────────────────────────
# 키움 'scalp' 조건검색(갭상승 + 신고가 돌파 + 등락률 +1~10%)이 이미 모멘텀
# 종목군을 거른다는 전제 → 봇은 "모멘텀이 살아있고 끝물이 아닌지" + "현재봉이
# 양봉 마무리인지" + "거래량이 직전 평균보다 많은지" 만 검증해서 따라붙는다.
#
# 기존 ``check_buy_signal_live`` (RSI 과매도 V자 반전형) 와 정반대 방향이라
# 별도 함수로 둠. ``SCALP_MODE`` 토글로 어느 쪽이든 즉시 전환 가능.
def check_buy_signal_momentum_live(
candles: List[Dict],
params: Dict[str, Any],
state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""모멘텀 추격 1분봉 진입 시그널.
탈락 사유:
- 봉 부족 / 시간대 / 쿨다운 / 일일 횟수 (기존과 동일)
- 최소 가격
- RSI3 < ``mom_rsi_min`` → 모멘텀 약함
- RSI3 > ``mom_rsi_max`` → 끝물(과열)
- 음봉/도지 (현재봉 close ≤ open)
- 직전봉 종가 미만 (모멘텀 꺾임)
- 거래량 < 직전 ``mom_vol_win``봉 평균 × ``mom_vol_mult``
- 일중 변동률 > ``max_daily_chg`` (피뢰침 컷)
"""
if len(candles) < 6:
return ("탈락-봉부족", "확정봉 6개 미만", None)
rsi_period = int(params.get("rsi_period", 3))
rsi_min = float(params.get("mom_rsi_min", 50.0))
rsi_max = float(params.get("mom_rsi_max", 80.0))
vol_mult = float(params.get("mom_vol_mult", 1.5))
vol_win = int(params.get("mom_vol_win", 5))
time_start_hm = int(params.get("time_start_hm", 900))
time_end_hm = int(params.get("mom_time_end_hm", params.get("time_end_hm", 1430)))
cooldown_min = float(params.get("cooldown_min", 10))
max_daily = int(params.get("max_daily", 5))
max_daily_chg = float(params.get("max_daily_chg", 20.0))
min_price = float(params.get("min_price", 1000.0))
i = len(candles) - 1
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
cl = float(c["close"])
op = float(c["open"])
vol = float(c.get("volume", 0))
prev_c = candles[i - 1]
if hm < time_start_hm or hm >= time_end_hm:
return (None, None, None)
if cl < min_price:
return ("탈락-최소가격", "%.0f < %.0f" % (cl, min_price), None)
last_exit_dt = state.get("last_exit_dt")
if last_exit_dt is not None:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60
if elapsed < cooldown_min:
return (None, None, None)
if state.get("daily_cnt", 0) >= max_daily:
return (None, None, None)
closes = [float(x["close"]) for x in candles]
rsis = compute_rsi_series(closes, rsi_period)
rsi = rsis[i] if i < len(rsis) else None
if rsi is None:
return ("탈락-RSI없음", "RSI 미계산 (봉 축적 중)", None)
if rsi <= 0.0:
return ("탈락-RSI무효", "RSI3=0.0 (봉 부족)", None)
if rsi < rsi_min:
return ("탈락-모멘텀약함", "RSI3=%.1f < %.0f" % (rsi, rsi_min), None)
if rsi > rsi_max:
return ("탈락-과열끝물", "RSI3=%.1f > %.0f" % (rsi, rsi_max), None)
if cl <= op:
return ("탈락-음봉", "close=%.0f ≤ open=%.0f" % (cl, op), None)
if cl <= float(prev_c["close"]):
return ("탈락-전봉미만", "close=%.0f ≤ prev=%.0f" % (cl, float(prev_c["close"])), None)
# 거래량 (직전 vol_win봉 평균 × 배수 이상)
if vol_mult > 0:
volumes = [float(x.get("volume", 0)) for x in candles]
win = max(1, min(vol_win, i))
vol_avg = sum(volumes[i - win : i]) / win
if vol_avg > 0 and vol < vol_avg * vol_mult:
ratio = vol / vol_avg if vol_avg > 0 else 0
return ("탈락-거래량부족", "%.2fx < %.1fx (avg %.0f)" % (ratio, vol_mult, vol_avg), None)
# 일중 변동률 컷 (피뢰침/이상급등 회피)
running_low = float(c["low"])
running_high = float(c["high"])
for j in range(i - 1, -1, -1):
if candles[j]["candle_time"][:8] != day:
break
running_low = min(running_low, float(candles[j]["low"]))
running_high = max(running_high, float(candles[j]["high"]))
if running_low > 0:
daily_chg_pct = (running_high - running_low) / running_low * 100
if daily_chg_pct > max_daily_chg:
return ("탈락-급등주", "일일변동 %.1f%% > %.0f%%" % (daily_chg_pct, max_daily_chg), None)
return (None, None, {"signal": True, "rsi": rsi, "mode": "momentum"})
def check_sell_signal_live(
position: Dict[str, Any],
current_candle: Dict[str, Any],
params: Dict[str, Any],
is_eod: bool = False,
) -> Optional[tuple]:
"""
실시간 봇 및 백테스트 공용: 보유 포지션에 대해 방어 로직이 포함된 청산 조건 판단.
position: { "entry_price", "entry_time", "qty", "stop", "target", "max_price", "rsi" }
current_candle: { "high", "low", "close" }
반환: (reason_str, exit_price) 또는 None
"""
trail_trigger = float(params.get("trail_trigger", 0))
trail_stop = float(params.get("trail_stop", 0.004))
fee_rate = float(params.get("fee_rate", 0.00015))
sell_tax = float(params.get("sell_tax", 0.0018))
min_margin = float(params.get("min_margin", 0.002))
max_loss_krw = float(params.get("max_loss_krw", 200000.0))
hi = float(current_candle.get("high", current_candle["close"]))
lo = float(current_candle.get("low", current_candle["close"]))
cl = float(current_candle["close"])
max_price = max(position["max_price"], hi)
position["max_price"] = max_price # 참조형 변형으로 백테스트루프에 상태 업데이트
buy_price = position["entry_price"]
qty = position["qty"]
# 본절가 계산: 매수가 * (1 + 수수료 * 2 + 세금 + 최소마진)
breakeven_pct = fee_rate * 2 + sell_tax + min_margin
breakeven_price = buy_price * (1 + breakeven_pct)
reason = None
exit_price = cl
# 현재가 기준 (수수료 미적용) 손실액
profit_val = (cl - buy_price) * qty
drop_pct = (buy_price - lo) / buy_price if buy_price > 0 else 0
min_drop_pct = float(params.get("min_drop_pct_for_loss_cut", 0.015))
# 1. 원화 기준 최대 손실컷 — 손실 금액 한도 초과 **이면서** 하락률이 최소값 이상일 때만 발동 (흔들림 방지)
if profit_val <= -max_loss_krw and drop_pct >= min_drop_pct:
reason = "금액손실컷"
exit_price = cl
# 2. 일반 손절 (비율 기준)
elif lo <= position["stop"]:
reason = "손절"
exit_price = position["stop"]
# 3. 일반 익절 (비율 기준)
elif hi >= position["target"]:
reason = "익절"
exit_price = position["target"]
# 4. 본절사수 (Breakeven) - 고점이 본절가를 넘은 적이 있는데 현재가가 본절가 이하로 떨어질 때
elif max_price >= breakeven_price and cl <= breakeven_price:
reason = "본절사수"
exit_price = cl
# 5. 트레일링 스탑
elif trail_trigger > 0 and max_price >= buy_price * (1 + trail_trigger):
ts = max_price * (1 - trail_stop)
if cl <= ts:
reason = "트레일링스탑"
exit_price = cl
# 6. 장마감 청산
if reason is None and is_eod:
reason = "장마감청산"
exit_price = cl
if reason:
return (reason, exit_price)
return None