브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
@@ -2,17 +2,13 @@
|
||||
"""
|
||||
scalping_engine.py — 스캘핑 백테스트·파라미터서치·실매매 공통 엔진
|
||||
====================================================================
|
||||
계산식만 같으면 백테스트와 실매매 결과가 같아지도록, 공통 로직만 이 모듈에 두고
|
||||
계산식만 같으면 백테스트와 실매매 결과가 같아지도록, 모든 로직을 이 모듈로 통합했습니다.
|
||||
backtest_web / param_search / kis_scalping_ver2 가 모두 이 엔진만 호출합니다.
|
||||
|
||||
■ 엔진에 들어간 공통 로직 (백테스트·파라미터서치·실매매 동일)
|
||||
- 매수: 시간대(time_start_hm~time_end_hm), 쿨다운(cooldown_min), 일일 진입 횟수(max_daily),
|
||||
RSI(3) 과매도(rsi_oversold), 이전봉 음봉+현재봉 양봉, 당일 낙폭(drop_rate), 거래량 배수(vol_mult).
|
||||
- 매도: 손절가(sl_pct), 익절가(tp_pct), 트레일링스탑(trail_trigger/trail_stop), 장마감청산(is_eod).
|
||||
|
||||
■ 엔진 밖(실매매 전용) 로직 — 백테스트에는 없음
|
||||
- 매수 추가 필터: 고점추격 방지(high_chase_thr), 급등주(max_daily_chg), 시장/테마 필터, ML, min_price 등.
|
||||
- 매도 추가: 본절사수(breakeven), 금액손실컷(MAX_LOSS_PER_TRADE_KRW). (백테스트는 % 손절/익절/트레일만 사용)
|
||||
■ 엔진에 통합된 공통 로직 (백테스트·파라미터서치·실매매 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
|
||||
"""
|
||||
@@ -20,13 +16,24 @@ backtest_web / param_search / kis_scalping_ver2 가 모두 이 엔진만 호출
|
||||
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)와 동일한 값으로 동작.
|
||||
반환 키: cooldown_min, time_start_hm, time_end_hm, fee_rate, sell_tax,
|
||||
slot_money, rsi_period, vol_mult, trail_trigger, trail_stop, max_daily
|
||||
"""
|
||||
try:
|
||||
from database import TradeDB
|
||||
@@ -41,10 +48,34 @@ def get_scalping_defaults_from_db() -> Dict[str, Any]:
|
||||
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,
|
||||
@@ -54,11 +85,19 @@ def get_scalping_defaults_from_db() -> Dict[str, Any]:
|
||||
"fee_rate": fee_pct / 100,
|
||||
"sell_tax": tax_pct / 100,
|
||||
"slot_money": slot,
|
||||
"rsi_period": 3,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -88,24 +127,122 @@ def _t2dt(t: str) -> 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]:
|
||||
"""
|
||||
종목별 캔들에 대해 스캘핑 백테스트 실행. 실매매와 동일한 규칙 적용.
|
||||
종목별 캔들에 대해 스캘핑 백테스트 실행. 실매매와 동일한 규칙(방어로직 포함) 적용.
|
||||
|
||||
params:
|
||||
rsi_period, rsi_oversold, sl_pct, tp_pct, drop_rate,
|
||||
slot_money, fee_rate, sell_tax,
|
||||
cooldown_min, trail_trigger, trail_stop,
|
||||
time_start_hm, time_end_hm, max_daily, vol_mult
|
||||
sl_pct/tp_pct/drop_rate/trail_trigger/trail_stop: 비율 (0.015 = 1.5%)
|
||||
fee_rate, sell_tax: 비율 (0.00015, 0.0018)
|
||||
time_start_hm, time_end_hm: HHMM 정수 (900, 1530)
|
||||
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))
|
||||
@@ -119,6 +256,14 @@ def run_scalping_backtest(
|
||||
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] = []
|
||||
|
||||
@@ -135,6 +280,7 @@ def run_scalping_backtest(
|
||||
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)):
|
||||
@@ -146,37 +292,23 @@ def run_scalping_backtest(
|
||||
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:
|
||||
max_price = max(position["max_price"], hi)
|
||||
position["max_price"] = max_price
|
||||
reason = None
|
||||
exit_price = cl
|
||||
|
||||
if lo <= position["stop"]:
|
||||
reason = "손절"
|
||||
exit_price = position["stop"]
|
||||
elif hi >= position["target"]:
|
||||
reason = "익절"
|
||||
exit_price = position["target"]
|
||||
elif trail_trigger > 0 and max_price >= position["entry_price"] * (1 + trail_trigger):
|
||||
ts = max_price * (1 - trail_stop)
|
||||
if cl <= ts:
|
||||
reason = "트레일링스탑"
|
||||
exit_price = cl
|
||||
if reason is None and is_eod:
|
||||
reason = "장마감청산"
|
||||
exit_price = cl
|
||||
|
||||
# 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
|
||||
@@ -210,7 +342,13 @@ def run_scalping_backtest(
|
||||
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:
|
||||
@@ -223,22 +361,36 @@ def run_scalping_backtest(
|
||||
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
|
||||
dr = (running_open - running_low) / running_open
|
||||
if dr < drop_rate:
|
||||
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]
|
||||
@@ -248,9 +400,16 @@ def run_scalping_backtest(
|
||||
if entry_price <= 0:
|
||||
continue
|
||||
|
||||
qty = max(1, int(slot_money / entry_price))
|
||||
# 포지션 크기 계산 (최대 금액 손실 컷 기반)
|
||||
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"],
|
||||
@@ -266,7 +425,7 @@ def run_scalping_backtest(
|
||||
return all_trades
|
||||
|
||||
|
||||
# ── 실시간 봇용: 단일 시점 매수/매도 판단 (백테스트와 동일 규칙) ─────────────────
|
||||
# ── 실시간 봇용: 단일 시점 매수/매도 판단 (백테스트와 100% 동일 규칙) ──────────────
|
||||
|
||||
def check_buy_signal_live(
|
||||
candles: List[Dict],
|
||||
@@ -274,25 +433,28 @@ def check_buy_signal_live(
|
||||
state: Dict[str, Any],
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
실시간 봇: 현재 캔들 리스트의 마지막 봉이 백테스트와 동일한 매수 신호인지 판단.
|
||||
candles: 확정 봉 리스트 (candle_time, open, high, low, close, volume)
|
||||
state: { "last_exit_dt": datetime or None (당일 마지막 청산 시각), "daily_cnt": int (당일 이미 진입한 횟수) }
|
||||
반환: (reject_reason, reject_msg, sig) — 통과 시 (None, None, {"signal": True, "rsi": float}),
|
||||
탈락 시 ("탈락-XXX", "메시지", None). 실매 ver2에서 ver1과 동일한 로그 출력용.
|
||||
실시간 봇: 현재 캔들 리스트의 마지막 봉이 매수 신호인지 판단.
|
||||
방어 로직 포함.
|
||||
"""
|
||||
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))
|
||||
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))
|
||||
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]
|
||||
@@ -304,11 +466,134 @@ def check_buy_signal_live(
|
||||
|
||||
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)
|
||||
|
||||
@@ -318,36 +603,40 @@ def check_buy_signal_live(
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
|
||||
running_open = float(c["open"])
|
||||
running_low = lo
|
||||
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_open = float(candles[j]["open"])
|
||||
running_low = min(running_low, float(candles[j]["low"]))
|
||||
if running_open <= 0:
|
||||
return (None, None, None)
|
||||
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 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})
|
||||
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(
|
||||
@@ -357,36 +646,72 @@ def check_sell_signal_live(
|
||||
is_eod: bool = False,
|
||||
) -> Optional[tuple]:
|
||||
"""
|
||||
실시간 봇: 보유 포지션에 대해 백테스트와 동일한 청산 조건 판단.
|
||||
실시간 봇 및 백테스트 공용: 보유 포지션에 대해 방어 로직이 포함된 청산 조건 판단.
|
||||
position: { "entry_price", "entry_time", "qty", "stop", "target", "max_price", "rsi" }
|
||||
current_candle: { "high", "low", "close" } (현재가 기준이면 high=low=close 또는 고점 갱신값)
|
||||
is_eod: 장 마감 구간이면 True 시 장마감청산 반환
|
||||
current_candle: { "high", "low", "close" }
|
||||
반환: (reason_str, exit_price) 또는 None
|
||||
"""
|
||||
sl_pct = float(params.get("sl_pct", 0.015))
|
||||
tp_pct = float(params.get("tp_pct", 0.015))
|
||||
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
|
||||
if lo <= position["stop"]:
|
||||
|
||||
# 현재가 기준 (수수료 미적용) 손실액
|
||||
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"]
|
||||
elif trail_trigger > 0 and max_price >= position["entry_price"] * (1 + trail_trigger):
|
||||
|
||||
# 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
|
||||
return None
|
||||
Reference in New Issue
Block a user