커서가 망쳐놓은 듯

This commit is contained in:
2026-03-17 12:33:30 +09:00
parent 6fc179f598
commit c2b2b711e0
91 changed files with 45391 additions and 2244 deletions

View File

@@ -196,26 +196,36 @@ def api_actual():
def _t2dt(t: str) -> datetime:
return datetime.strptime(t, "%Y%m%d%H%M")
import scalping_engine as se
@app.route("/api/backtest/scalping", methods=["GET"])
def api_backtest_scalping():
# 기본값 = DB(엔진 단일 소스) → 백테스트/param_search/실매매 동일 값
_def = se.get_scalping_defaults_from_db()
start = request.args.get("start", "")
end = request.args.get("end", "")
rsi_period = int(request.args.get("rsi_period", 3))
rsi_period = int(request.args.get("rsi_period", _def["rsi_period"]))
rsi_oversold = float(request.args.get("rsi_oversold", 25))
sl_pct = float(request.args.get("sl_pct", 1.5)) / 100 # 손절 %
tp_pct = float(request.args.get("tp_pct", 1.5)) / 100 # 익절 %
drop_rate = float(request.args.get("drop_rate", 1.5)) / 100 # 최소 낙폭(당일 시가→저가)
slot_money = float(request.args.get("slot_money", 1_000_000)) # 1회 투자금
_fee_d = _get_fee_defaults()
fee_rate = float(request.args.get("fee_rate", _fee_d["fee_rate"])) / 100
sell_tax = float(request.args.get("sell_tax", _fee_d["sell_tax"])) / 100
cooldown_min = int(request.args.get("cooldown_min", 10)) # 손익 후 재진입 금지(분)
vol_mult = float(request.args.get("vol_mult", 0)) # 거래량 필터(0=비활성)
trail_trigger = float(request.args.get("trail_trigger", 0.7)) / 100 # 트레일링 발동 수익률
trail_stop = float(request.args.get("trail_stop", 0.4)) / 100 # 트레일링 추적폭
time_start_hm = int(request.args.get("time_start", 900)) # 매수 가능 시작(HHMM)
time_end_hm = int(request.args.get("time_end", 1400)) # 매수 가능 종료(HHMM)
max_daily = int(request.args.get("max_daily", 3)) # 종목당 일일 최대 거래횟수
slot_money = float(request.args.get("slot_money", _def["slot_money"])) # 1회 투자금
_fee_rate = request.args.get("fee_rate")
fee_rate = float(_fee_rate) / 100 if _fee_rate not in (None, "") else _def["fee_rate"]
_sell_tax = request.args.get("sell_tax")
sell_tax = float(_sell_tax) / 100 if _sell_tax not in (None, "") else _def["sell_tax"]
_cooldown = request.args.get("cooldown_min")
cooldown_min = float(_cooldown) if _cooldown not in (None, "") else _def["cooldown_min"]
vol_mult = float(request.args.get("vol_mult", _def["vol_mult"])) # 거래량 필터(0=비활성)
_tr_trigger = request.args.get("trail_trigger")
trail_trigger = float(_tr_trigger) / 100 if _tr_trigger not in (None, "") else _def["trail_trigger"]
_tr_stop = request.args.get("trail_stop")
trail_stop = float(_tr_stop) / 100 if _tr_stop not in (None, "") else _def["trail_stop"]
_time_start = request.args.get("time_start")
time_start_hm = int(_time_start) if _time_start not in (None, "") else _def["time_start_hm"]
_time_end = request.args.get("time_end")
time_end_hm = int(_time_end) if _time_end not in (None, "") else _def["time_end_hm"]
max_daily = int(request.args.get("max_daily", _def["max_daily"])) # 종목당 일일 최대 거래횟수
db = _db()
try:
@@ -228,10 +238,9 @@ def api_backtest_scalping():
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key]
).fetchall()
codes = [r['code'] for r in codes_raw]
all_virtual_trades: List[Dict] = []
codes = [r["code"] for r in codes_raw]
codes_candles: Dict[str, List[Dict]] = {}
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
@@ -244,170 +253,30 @@ def api_backtest_scalping():
).fetchall()
if len(rows) < rsi_period + 5:
continue
codes_candles[code] = [dict(r) for r in rows]
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
volumes = [float(c["volume"]) for c in candles]
rsis = _compute_rsi_series(closes, rsi_period)
position = None
last_exit_dt: Dict[str, datetime] = {} # day → 마지막 청산 시각 (쿨다운)
daily_cnt: Dict[str, int] = {} # day → 당일 거래횟수
# 선행 편향 없는 당일 OHLC 누적값
cur_day = None
running_open = 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]) # HHMM 정수
cl = float(c["close"])
lo = float(c["low"])
hi = float(c["high"])
vol = volumes[i]
# ── 당일 누적값 갱신 (look-ahead 없이 실시간 계산) ────────
if day != cur_day:
cur_day = day
running_open = float(c["open"])
running_low = lo
else:
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"] # limit-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
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_virtual_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 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 과매도 조건
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
# 당일 낙폭 필터 (look-ahead 없는 running_low 사용)
if running_open <= 0:
continue
dr = (running_open - running_low) / running_open
if dr < drop_rate:
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
qty = max(1, int(slot_money / 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
params = {
"rsi_period": rsi_period,
"rsi_oversold": rsi_oversold,
"sl_pct": sl_pct,
"tp_pct": tp_pct,
"drop_rate": drop_rate,
"slot_money": slot_money,
"fee_rate": fee_rate,
"sell_tax": sell_tax,
"cooldown_min": cooldown_min,
"trail_trigger": trail_trigger,
"trail_stop": trail_stop,
"time_start_hm": time_start_hm,
"time_end_hm": time_end_hm,
"max_daily": max_daily,
"vol_mult": vol_mult,
}
all_virtual_trades = se.run_scalping_backtest(codes_candles, params)
# ─────────────────────────────────────────────────────────────────
# 결과 집계
# ─────────────────────────────────────────────────────────────────
all_virtual_trades.sort(key=lambda x: x["sell_time"])
equity = []
cum = 0.0
@@ -478,38 +347,57 @@ def api_backtest_scalping():
# ────────────────────────────────────────────────────────────────────────────
# API: 꼬리잡기 가격 재현 백테스트 (ws_candles 3분봉 기반)
# API: 꼬리잡기 가격 재현 백테스트 (ws_candles 3분봉 기반, tail_engine 공통 로직 사용)
# ────────────────────────────────────────────────────────────────────────────
try:
import tail_engine as te
_TAIL_ENGINE_AVAILABLE = True
except ImportError:
_TAIL_ENGINE_AVAILABLE = False
def _get_tail_defaults_for_backtest():
"""꼬리잡기 백테스트 기본값: DB 단일 소스. 엔진 없으면 빈 dict."""
if not _TAIL_ENGINE_AVAILABLE:
return {}
return te.get_tail_defaults_from_db()
@app.route("/api/backtest/tail", methods=["GET"])
def api_backtest_tail():
"""
꼬리잡기 전략 가격 재현 백테스트.
entry 조건: 당일 낙폭(drop_rate) + 회복률(recovery_ratio) + 망치봉 꼬리 + RSI
exit 조건: 손절 / 익절 / 어깨 컷(trailing) / 장 마감 강제 청산
기본값 = DB(env_config) → tail_engine.get_tail_defaults_from_db(), 요청으로 덮어쓰기.
"""
_def = _get_tail_defaults_for_backtest()
start = request.args.get("start", "")
end = request.args.get("end", "")
rsi_period = int( request.args.get("rsi_period", 14))
rsi_threshold = float(request.args.get("rsi_threshold", 78)) # RSI 과열 기준
min_drop_rate = float(request.args.get("min_drop_rate", 3.0)) / 100 # 최소 당일 낙폭
min_recovery_ratio = float(request.args.get("min_recovery_ratio",50)) / 100 # 최소 당일 회복률
max_rec_3m = float(request.args.get("max_rec_3m", 80)) / 100 # 3분봉 최대 회복위치
tail_ratio_min = float(request.args.get("tail_ratio_min", 1.5)) # 꼬리/몸통 비율
tail_pct_min = float(request.args.get("tail_pct_min", 0.3)) / 100 # 꼬리 최소 %
sl_pct = float(request.args.get("sl_pct", 3.0)) / 100 # 손절 %
tp_pct = float(request.args.get("tp_pct", 5.0)) / 100 # 익절 %
shoulder_min_high = float(request.args.get("shoulder_min_high", 1.5)) / 100 # 어깨 발동 최소 이익
shoulder_cut_pct = float(request.args.get("shoulder_cut_pct", 3.0)) / 100 # 고점 대비 하락 시 매도
high_chase_thr = float(request.args.get("high_chase_thr", 96.0)) / 100 # 고점 추격 방지
rsi_period = int( request.args.get("rsi_period", _def.get("rsi_period", 14)))
rsi_threshold = float(request.args.get("rsi_threshold", _def.get("rsi_threshold", 78)))
min_drop_rate = float(request.args.get("min_drop_rate", _def.get("min_drop_rate", 0.03) * 100)) / 100
min_recovery_ratio = float(request.args.get("min_recovery_ratio", _def.get("min_recovery_ratio", 0.5) * 100)) / 100
# max_rec_3m / high_chase_thr: 폼에서 80·96(퍼센트) 또는 0.8·0.96(비율) 전달 가능 → 엔진은 항상 비율(0~1)
_max_rec_raw = float(request.args.get("max_rec_3m", _def.get("max_rec_3m", 0.8)))
max_rec_3m = _max_rec_raw if 0 < _max_rec_raw <= 1 else _max_rec_raw / 100
tail_ratio_min = float(request.args.get("tail_ratio_min", _def.get("tail_ratio_min", 1.5)))
tail_pct_min = float(request.args.get("tail_pct_min", _def.get("tail_pct_min", 0.003) * 100)) / 100
sl_pct = float(request.args.get("sl_pct", _def.get("sl_pct", 0.03) * 100)) / 100
tp_pct = float(request.args.get("tp_pct", _def.get("tp_pct", 0.05) * 100)) / 100
shoulder_min_high = float(request.args.get("shoulder_min_high", _def.get("shoulder_min_high", 0.015) * 100)) / 100
shoulder_cut_pct = float(request.args.get("shoulder_cut_pct", _def.get("shoulder_cut_pct", 0.03) * 100)) / 100
_high_chase_raw = float(request.args.get("high_chase_thr", _def.get("high_chase_thr", 0.96)))
high_chase_thr = _high_chase_raw if 0 < _high_chase_raw <= 1 else _high_chase_raw / 100
slot_money = float(request.args.get("slot_money", 1_000_000))
_fee_d = _get_fee_defaults()
fee_rate = float(request.args.get("fee_rate", _fee_d["fee_rate"])) / 100
sell_tax = float(request.args.get("sell_tax", _fee_d["sell_tax"])) / 100
cooldown_min = int( request.args.get("cooldown_min", 30))
time_start_hm = int( request.args.get("time_start", 930))
time_end_hm = int( request.args.get("time_end", 1500))
max_daily = int( request.args.get("max_daily", 3))
cooldown_min = int( request.args.get("cooldown_min", _def.get("cooldown_min", 15)))
time_start_hm = int( request.args.get("time_start", _def.get("time_start_hm", 930)))
time_end_hm = int( request.args.get("time_end", _def.get("time_end_hm", 1500)))
max_daily = int( request.args.get("max_daily", _def.get("max_daily", 3)))
db = _db()
try:
@@ -523,201 +411,233 @@ def api_backtest_tail():
).fetchall()
codes = [r["code"] for r in codes_raw]
use_engine = _TAIL_ENGINE_AVAILABLE and request.args.get("use_engine", "1") == "1"
all_trades: List[Dict] = []
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=3 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
if use_engine:
# tail_engine 공통 로직 사용 (실매매 ver3과 동일 계산식)
candles_by_code = {}
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles WHERE timeframe=3 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
candles_by_code[code] = [dict(r) for r in rows]
params = {
"min_drop_rate": min_drop_rate, "min_recovery_ratio": min_recovery_ratio,
"max_rec_3m": max_rec_3m, "tail_ratio_min": tail_ratio_min, "tail_pct_min": tail_pct_min,
"sl_pct": sl_pct, "tp_pct": tp_pct,
"shoulder_min_high": shoulder_min_high, "shoulder_cut_pct": shoulder_cut_pct,
"rsi_period": rsi_period, "rsi_threshold": rsi_threshold, "high_chase_thr": high_chase_thr,
"time_start_hm": time_start_hm, "time_end_hm": time_end_hm,
"cooldown_min": cooldown_min, "max_daily": max_daily,
}
all_trades = te.run_tail_backtest(candles_by_code, params)
for t in all_trades:
qty = max(1, int(slot_money / t["entry"]))
fee = (t["entry"] + t["exit"]) * qty * fee_rate
tax = t["exit"] * qty * sell_tax
t["pnl"] = round((t["exit"] - t["entry"]) * qty - fee - tax)
t["hold_min"] = round((_t2dt(t["exit_time"]) - _t2dt(t["entry_time"])).total_seconds() / 60, 1)
else:
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=3 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
rsis = _compute_rsi_series(closes, rsi_period)
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
rsis = _compute_rsi_series(closes, rsi_period)
position = None
last_exit_dt: Dict[str, datetime] = {}
daily_cnt: Dict[str, int] = {}
position = None
last_exit_dt: Dict[str, datetime] = {}
daily_cnt: Dict[str, int] = {}
# look-ahead 없는 당일 누적 OHLC
cur_day = None
running_open = 0.0
running_high = 0.0
running_low = 0.0
# look-ahead 없는 당일 누적 OHLC
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])
op = float(c["open"])
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
for i in range(rsi_period + 1, len(candles)):
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
op = float(c["open"])
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
# ── 당일 누적 OHLC 갱신 (선행 편향 없음) ─────────────────
if day != cur_day:
cur_day = day
running_open = op
running_high = hi
running_low = lo if lo > 0 else hi
else:
running_high = max(running_high, hi)
if lo > 0:
running_low = min(running_low, lo)
# ── 당일 누적 OHLC 갱신 (선행 편향 없음) ─────────────────
if day != cur_day:
cur_day = day
running_open = op
running_high = hi
running_low = lo if lo > 0 else hi
else:
running_high = max(running_high, hi)
if lo > 0:
running_low = min(running_low, lo)
# ── 마지막 봉 여부 ────────────────────────────────────────
is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
# ── 마지막 봉 여부 ────────────────────────────────────────
is_eod = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
# ─────────────────────────────────────────────────────────
# 포지션 보유 중: 청산 체크
# ─────────────────────────────────────────────────────────
if position is not None:
max_p = max(position["max_price"], hi)
position["max_price"] = max_p
# ─────────────────────────────────────────────────────────
# 포지션 보유 중: 청산 체크
# ─────────────────────────────────────────────────────────
if position is not None:
max_p = max(position["max_price"], hi)
position["max_price"] = max_p
reason = None
exit_price = cl
# 손절: 저가가 손절선 이하
if lo > 0 and lo <= position["stop"]:
reason = "손절"
exit_price = position["stop"]
# 익절: 고가가 익절선 이상
elif hi >= position["target"]:
reason = "익절"
exit_price = position["target"]
# 어깨 컷(trailing): 충분히 오른 뒤 고점에서 일정 이상 하락
elif (max_p >= position["entry_price"] * (1 + shoulder_min_high)
and cl <= max_p * (1 - shoulder_cut_pct)):
reason = "어깨컷"
exit_price = cl
# 장 마감 강제 청산
elif is_eod:
reason = "장마감"
reason = None
exit_price = cl
if reason:
ep = position["entry_price"]
qty = max(1, int(slot_money / ep))
fee = (ep + exit_price) * qty * fee_rate
tax = exit_price * qty * sell_tax
pnl = (exit_price - ep) * qty - fee - tax
hold = round((
_t2dt(c["candle_time"]) -
_t2dt(position["entry_time"])
).total_seconds() / 60, 1)
# 손절: 저가가 손절선 이하
if lo > 0 and lo <= position["stop"]:
reason = "손절"
exit_price = position["stop"]
# 익절: 고가가 익절선 이상
elif hi >= position["target"]:
reason = "익절"
exit_price = position["target"]
# 어깨 컷(trailing): 충분히 오른 뒤 고점에서 일정 이상 하락
elif (max_p >= position["entry_price"] * (1 + shoulder_min_high)
and cl <= max_p * (1 - shoulder_cut_pct)):
reason = "어깨컷"
exit_price = cl
# 장 마감 강제 청산
elif is_eod:
reason = "장마감"
exit_price = cl
all_trades.append({
"code": code,
"entry_time": position["entry_time"],
"exit_time": c["candle_time"],
"entry": round(ep),
"exit": round(exit_price),
"pnl": round(pnl),
"reason": reason,
"hold_min": hold,
})
last_exit_dt[day] = _t2dt(c["candle_time"])
daily_cnt[day] = daily_cnt.get(day, 0) + 1
position = None
continue # 다음 봉으로
if reason:
ep = position["entry_price"]
qty = max(1, int(slot_money / ep))
fee = (ep + exit_price) * qty * fee_rate
tax = exit_price * qty * sell_tax
pnl = (exit_price - ep) * qty - fee - tax
hold = round((
_t2dt(c["candle_time"]) -
_t2dt(position["entry_time"])
).total_seconds() / 60, 1)
# ─────────────────────────────────────────────────────────
# 포지션 없음: 매수 조건 체크
# ─────────────────────────────────────────────────────────
if cl <= 0 or running_open <= 0:
continue
if hm < time_start_hm or hm > time_end_hm:
continue
if daily_cnt.get(day, 0) >= max_daily:
continue
all_trades.append({
"code": code,
"entry_time": position["entry_time"],
"exit_time": c["candle_time"],
"entry": round(ep),
"exit": round(exit_price),
"pnl": round(pnl),
"reason": reason,
"hold_min": hold,
})
last_exit_dt[day] = _t2dt(c["candle_time"])
daily_cnt[day] = daily_cnt.get(day, 0) + 1
position = None
continue # 다음 봉으로
# 쿨다운: 마지막 청산 후 N분 이내 재진입 금지
if day in last_exit_dt:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
if elapsed < cooldown_min:
# ─────────────────────────────────────────────────────────
# 포지션 없음: 매수 조건 체크
# ─────────────────────────────────────────────────────────
if cl <= 0 or running_open <= 0:
continue
if hm < time_start_hm or hm > time_end_hm:
continue
if daily_cnt.get(day, 0) >= max_daily:
continue
# ── 1. 당일 낙폭 ─────────────────────────────────────────
drop = (running_open - running_low) / running_open
if drop < min_drop_rate:
continue
# ── 2. 당일 회복률 ─────────────────────────────────────
day_range = running_high - running_low
rec_day = (cl - running_low) / day_range if day_range > 0 else 0
if rec_day < min_recovery_ratio:
continue
# ── 3. 망치봉 꼬리 비율 계산 ────────────────────────────
body_top = max(op, cl)
body_bot = min(op, cl)
body_len = body_top - body_bot if body_top > body_bot else 1.0
tail_len = body_bot - lo if lo > 0 else 0.0
# 꼬리 없는 봉이면 이전 봉에서 재탐색 (최대 3봉 전)
if tail_len <= 0:
for j in range(i - 1, max(i - 4, rsi_period), -1):
prev = candles[j]
o2, h2, l2, c2 = float(prev["open"]), float(prev["high"]), float(prev["low"]), float(prev["close"])
if l2 <= 0:
# 쿨다운: 마지막 청산 후 N분 이내 재진입 금지
if day in last_exit_dt:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
if elapsed < cooldown_min:
continue
bt2, bb2 = max(o2, c2), min(o2, c2)
bl2 = bt2 - bb2 if bt2 > bb2 else 1.0
tl2 = bb2 - l2
if tl2 > 0:
tail_len = tl2
body_len = bl2
lo = l2
break
tail_ratio = tail_len / body_len
tail_pct = tail_len / lo if lo > 0 and tail_len > 0 else 0.0
# ── 1. 당일 낙폭 ─────────────────────────────────────────
drop = (running_open - running_low) / running_open
if drop < min_drop_rate:
continue
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
continue
# ── 2. 당일 회복률 ─────────────────────────────────────
day_range = running_high - running_low
rec_day = (cl - running_low) / day_range if day_range > 0 else 0
if rec_day < min_recovery_ratio:
continue
# ── 4. 3분봉 내 회복 위치 (무릎~어깨) ──────────────────
c_range = float(c["high"]) - float(c["low"])
rec_3m = (cl - float(c["low"])) / c_range if c_range > 0 else 0
if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
continue
# ── 3. 망치봉 꼬리 비율 계산 ────────────────────────────
body_top = max(op, cl)
body_bot = min(op, cl)
body_len = body_top - body_bot if body_top > body_bot else 1.0
tail_len = body_bot - lo if lo > 0 else 0.0
# 꼬리 없는 봉이면 이전 봉에서 재탐색 (최대 3봉 전)
if tail_len <= 0:
for j in range(i - 1, max(i - 4, rsi_period), -1):
prev = candles[j]
o2, h2, l2, c2 = float(prev["open"]), float(prev["high"]), float(prev["low"]), float(prev["close"])
if l2 <= 0:
continue
bt2, bb2 = max(o2, c2), min(o2, c2)
bl2 = bt2 - bb2 if bt2 > bb2 else 1.0
tl2 = bb2 - l2
if tl2 > 0:
tail_len = tl2
body_len = bl2
lo = l2
break
# ── 5. RSI 과열 방지 ────────────────────────────────────
rsi_val = rsis[i]
if rsi_val is None or rsi_val >= rsi_threshold:
continue
tail_ratio = tail_len / body_len
tail_pct = tail_len / lo if lo > 0 and tail_len > 0 else 0.0
# ── 6. 피뢰침 방지: 고점 근접 추격 금지 ────────────────
if cl >= running_high * high_chase_thr:
continue
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
continue
# ── 매수 실행: 다음 봉 시가 진입 ───────────────────────
if i + 1 >= len(candles):
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day:
continue # 장 마감 직전 봉이면 다음날 시가 = 갭위험 → skip
# ── 4. 3분봉 내 회복 위치 (무릎~어깨) ──────────────────
c_range = float(c["high"]) - float(c["low"])
rec_3m = (cl - float(c["low"])) / c_range if c_range > 0 else 0
if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
continue
entry_price = float(next_c["open"])
if entry_price <= 0:
entry_price = cl
# ── 5. RSI 과열 방지 ────────────────────────────────────
rsi_val = rsis[i]
if rsi_val is None or rsi_val >= rsi_threshold:
continue
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"max_price": entry_price,
}
# 진입 봉을 이미 처리했으므로 다음 인덱스로 이동
i += 1
# ── 6. 피뢰침 방지: 고점 근접 추격 금지 ────────────────
if cl >= running_high * high_chase_thr:
continue
# ── 매수 실행: 다음 봉 시가 진입 ───────────────────────
if i + 1 >= len(candles):
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day:
continue # 장 마감 직전 봉이면 다음날 시가 = 갭위험 → skip
entry_price = float(next_c["open"])
if entry_price <= 0:
entry_price = cl
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"max_price": entry_price,
}
# 진입 봉을 이미 처리했으므로 다음 인덱스로 이동
i += 1
# ── 통계 집계 ──────────────────────────────────────────────────
total = len(all_trades)
@@ -766,6 +686,10 @@ def api_backtest_tail():
"shoulder_cut_pct": shoulder_cut_pct * 100,
"slot_money": slot_money,
"cooldown_min": cooldown_min,
"time_start_hm": time_start_hm,
"time_end_hm": time_end_hm,
"time_window": f"{time_start_hm:04d}-{time_end_hm:04d}",
"max_daily": max_daily,
"codes_analyzed": len(codes),
},
"summary": {
@@ -1424,10 +1348,28 @@ def api_tail_save_config():
snap["SHOULDER_MIN_HIGH_PCT"] = str(float(body["shoulder_min_high"]) / 100)
if "cooldown_min" in body:
snap["REENTRY_COOLDOWN_SEC"] = str(int(float(body["cooldown_min"])) * 60)
if "rsi_threshold" in body:
snap["RSI_OVERHEAT_THRESHOLD"] = str(float(body["rsi_threshold"]))
if "rsi_period" in body:
snap["RSI_PERIOD"] = str(int(float(body["rsi_period"])))
if "time_start" in body:
snap["TIME_START"] = str(int(float(body["time_start"])))
if "time_end" in body:
snap["TIME_END"] = str(int(float(body["time_end"])))
if "max_daily" in body:
snap["MAX_STOCKS"] = str(int(float(body["max_daily"])))
if "tail_pct_min" in body:
snap["TAIL_PCT_MIN"] = str(float(body["tail_pct_min"]) / 100)
if "max_rec_3m" in body:
snap["MAX_RECOVERY_RATIO_3M"] = str(float(body["max_rec_3m"]) / 100)
if "high_chase_thr" in body:
snap["HIGH_PRICE_CHASE_THRESHOLD"] = str(float(body["high_chase_thr"]) / 100)
env_id = db.insert_env_snapshot(snap)
return jsonify({"ok": True, "env_id": env_id, "saved_keys": [
"MIN_DROP_RATE","MIN_RECOVERY_RATIO_SHORT","STOP_LOSS_PCT",
"TAKE_PROFIT_PCT","TAIL_RATIO_MIN","SHOULDER_CUT_PCT"
"TAKE_PROFIT_PCT","TAIL_RATIO_MIN","TAIL_PCT_MIN","SHOULDER_CUT_PCT",
"REENTRY_COOLDOWN_SEC","RSI_OVERHEAT_THRESHOLD","RSI_PERIOD",
"TIME_START","TIME_END","MAX_STOCKS","MAX_RECOVERY_RATIO_3M","HIGH_PRICE_CHASE_THRESHOLD"
]})
except Exception as e:
logger.error(f"꼬리잡기 설정저장 오류: {e}")
@@ -1480,6 +1422,20 @@ def api_env_params():
v = fv(key)
return round(v / 60) if v is not None else None
def _ratio_to_pct(val, default):
"""비율(0~1)을 폼 퍼센트 표시용(80, 96 등)으로. DB 0.8 → 80 반환."""
if val is None:
return default
try:
v = float(val)
if 0 < v <= 1:
return round(v * 100, 2)
if v > 1:
return round(v, 2)
except (ValueError, TypeError):
pass
return default
return jsonify({
"scalp": {
"rsi_oversold": fv("SCALP_RSI_OVERSOLD"), # 과매도 임계값 (숫자 그대로)
@@ -1491,15 +1447,23 @@ def api_env_params():
"slot_money": fv("SLOT_MONEY_DEFAULT"),
},
"tail": {
"drop": smart_pct("MIN_DROP_RATE"),
"rec": smart_pct("MIN_RECOVERY_RATIO_SHORT"),
"tail_ratio": fv("TAIL_RATIO_MIN"),
"sl_pct": smart_pct("STOP_LOSS_PCT"), # 음수 → 양수도 자동처리
"tp_pct": smart_pct("TAKE_PROFIT_PCT"),
"smin": smart_pct("SHOULDER_MIN_HIGH_PCT"),
"scut": smart_pct("SHOULDER_CUT_PCT"),
"cool": sec_to_min("REENTRY_COOLDOWN_SEC"),
"rsi": fv("RSI_OVERHEAT_THRESHOLD"),
"drop": smart_pct("MIN_DROP_RATE"),
"rec": smart_pct("MIN_RECOVERY_RATIO_SHORT"),
"tail_ratio": fv("TAIL_RATIO_MIN"),
"tail_pct_min": smart_pct("TAIL_PCT_MIN"),
"sl_pct": smart_pct("STOP_LOSS_PCT"), # 음수 → 양수도 자동처리
"tp_pct": smart_pct("TAKE_PROFIT_PCT"),
"smin": smart_pct("SHOULDER_MIN_HIGH_PCT"),
"scut": smart_pct("SHOULDER_CUT_PCT"),
"cool": sec_to_min("REENTRY_COOLDOWN_SEC"),
"rsi": fv("RSI_OVERHEAT_THRESHOLD"),
"rsi_period": int(fv("RSI_PERIOD") or 14), # RSI 기간 (실매·파라서치와 동일)
"time_start": int(fv("TIME_START") or 930), # 매수시작 HHMM
"time_end": int(fv("TIME_END") or 1500), # 매수종료 HHMM
"max_daily": int(fv("MAX_STOCKS") or 3), # 일일최대매수 (실매 MAX_STOCKS)
# 비율(0~1) 저장값을 폼 퍼센트 표시용으로 80·96 형태로 반환 (백테 API는 80→0.8로 변환)
"max_rec_3m": _ratio_to_pct(fv("MAX_RECOVERY_RATIO_3M"), 80),
"high_chase": _ratio_to_pct(fv("HIGH_PRICE_CHASE_THRESHOLD"), 96),
},
})
finally:
@@ -1965,9 +1929,25 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<label class="form-label param-row" title="고점 대비 이 % 하락 시 어깨 컷 매도">어깨컷(%)</label>
<input type="number" class="form-control" id="tl_scut" value="3.0" min="0.5" max="8" step="0.5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="꼬리 길이 최소 비율(%) → TAIL_PCT_MIN">꼬리최소(%)</label>
<input type="number" class="form-control" id="tl_tail_pct" value="0.3" min="0.01" max="2" step="0.05">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="3분봉 회복위치 상한(%) → MAX_RECOVERY_RATIO_3M">3분최대회복(%)</label>
<input type="number" class="form-control" id="tl_max_rec_3m" value="80" min="50" max="100" step="5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="고점 대비 이 비율 이상이면 진입 거부(%) → HIGH_PRICE_CHASE_THRESHOLD">고점추격방지(%)</label>
<input type="number" class="form-control" id="tl_high_chase" value="96" min="90" max="100" step="1">
</div>
</div>
<!-- 고급 파라미터 (2행) -->
<div class="row g-2 align-items-end mt-1">
<div class="col-4 col-md-1">
<label class="form-label param-row" title="RSI 계산 캔들 수 (실매·파라서치와 동일)">RSI기간</label>
<input type="number" class="form-control" id="tl_rsi_period" value="14" min="3" max="21" step="1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="RSI 이 값 이상이면 과열로 진입 거부">RSI과열기준</label>
<input type="number" class="form-control" id="tl_rsi" value="78" min="50" max="95" step="1">
@@ -1985,7 +1965,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<input type="number" class="form-control" id="tl_te" value="1500" min="900" max="1530" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="종목당 하루 최대 거래횟수">일일최대매수</label>
<label class="form-label param-row" title="종목당 하루 최대 거래횟수 (실매 MAX_STOCKS)">일일최대매수</label>
<input type="number" class="form-control" id="tl_maxd" value="3" min="1" max="10">
</div>
<div class="col-12 col-md-auto mt-2 d-flex gap-2 flex-wrap">
@@ -2007,7 +1987,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
</div>
<!-- 파라미터 설명 -->
<div class="mt-3 p-2 rounded" style="background:#0d1117;border:1px solid #21262d;font-size:11px;color:var(--muted)">
<b style="color:var(--accent)">📖 파라미터 설명 (실제 봇 <code>kis_short_ver2.py</code> 기준)</b>
<b style="color:var(--accent)">📖 파라미터 설명 (실매·파라서치와 동일 DB env_config — 화면 값 = 백테에 사용되는 값)</b>
<div class="row g-1 mt-1">
<div class="col-12 col-md-6">
<table style="width:100%;border-collapse:collapse">
@@ -2022,12 +2002,16 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<table style="width:100%;border-collapse:collapse">
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">어깨발동(%)</td><td>수익이 이 값 이상일 때 어깨 컷 활성 → 봇 DB: <b>SHOULDER_MIN_HIGH_PCT=1.5%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">어깨컷(%)</td><td>고점 대비 이 값 하락 시 매도 → 봇 DB: <b>SHOULDER_CUT_PCT=3%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">꼬리최소(%)</td><td>꼬리 길이 최소 비율 → 봇 DB: <b>TAIL_PCT_MIN</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">3분최대회복(%)</td><td>3분봉 회복위치 상한 → 봇 DB: <b>MAX_RECOVERY_RATIO_3M</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">고점추격방지(%)</td><td>고점 대비 이 비율 이상이면 진입 거부 → 봇 DB: <b>HIGH_PRICE_CHASE_THRESHOLD</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI과열기준</td><td>RSI 이상이면 진입 거부 → 봇 DB: <b>RSI_OVERHEAT_THRESHOLD=78</b></td></tr>
<tr><td style="color:#d29922;padding:1px 6px;white-space:nowrap">쿨다운(분)</td><td>청산 후 재진입 금지 → <span style="opacity:.7">봇에는 없음(백테스트 전용)</span></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">쿨다운(분)</td><td>청산 후 재진입 금지 → 봇 DB: <b>REENTRY_COOLDOWN_SEC</b> (초→분)</td></tr>
</table>
</div>
</div>
<div class="mt-1">⚡ 진입가 = <b>신호봉(3분봉) 다음 봉 시가</b> | 수수료 0.015%×2 + 거래세 0.18% 자동 차감 | 데이터: ws_candles 3분봉</div>
<div class="mt-1" style="color:var(--muted)">📌 웹 백테와 파라미터서치 결과를 비교하려면 <b>시작일/종료일</b>과 <b>매수시작/매수종료</b>를 동일하게 두세요. 파라서치는 DB 기준(예: 9301500)을 쓰므로, 비교 시 웹에서도 0930·1500으로 맞추면 거래 수·손익이 비슷해집니다.</div>
</div>
</div>
@@ -2360,7 +2344,7 @@ document.querySelectorAll('[data-tab]').forEach(el => {
set('bt_cooldown', s.cooldown_min);
if (s.slot_money) set('bt_slot', s.slot_money);
// 꼬리잡기 탭
// 꼬리잡기 탭 — DB(env_config)와 동일 값으로 채움 (실매·파라서치와 동일)
const t = d.tail || {};
set('tl_drop', t.drop);
set('tl_rec', t.rec);
@@ -2371,6 +2355,13 @@ document.querySelectorAll('[data-tab]').forEach(el => {
set('tl_scut', t.scut);
set('tl_cool', t.cool);
set('tl_rsi', t.rsi);
set('tl_ts', t.time_start);
set('tl_te', t.time_end);
set('tl_maxd', t.max_daily);
set('tl_rsi_period', t.rsi_period);
set('tl_tail_pct', t.tail_pct_min);
set('tl_max_rec_3m', t.max_rec_3m);
set('tl_high_chase', t.high_chase);
})
.catch(() => { /* DB 연결 실패 시 HTML 기본값 유지 */ });
})();
@@ -2598,16 +2589,25 @@ function saveTailConfig() {
min_drop_rate: parseFloat($('tl_drop').value),
min_recovery_ratio: parseFloat($('tl_rec').value),
tail_ratio_min: parseFloat($('tl_tail').value),
tail_pct_min: parseFloat($('tl_tail_pct').value),
max_rec_3m: parseFloat($('tl_max_rec_3m').value),
sl_pct: parseFloat($('tl_sl').value),
tp_pct: parseFloat($('tl_tp').value),
shoulder_cut_pct: parseFloat($('tl_scut').value),
shoulder_min_high: parseFloat($('tl_smin').value),
high_chase_thr: parseFloat($('tl_high_chase').value),
cooldown_min: parseFloat($('tl_cool').value),
rsi_threshold: parseFloat($('tl_rsi').value),
rsi_period: parseInt($('tl_rsi_period').value, 10),
time_start: parseInt($('tl_ts').value, 10),
time_end: parseInt($('tl_te').value, 10),
max_daily: parseInt($('tl_maxd').value, 10),
};
if (!confirm(`💾 꼬리잡기 봇(kis_short_ver2.py)에 아래 파라미터를 저장합니까?\n\n` +
`낙폭: ${body.min_drop_rate}%\n회복: ${body.min_recovery_ratio}%\n손절: ${body.sl_pct}%\n익절: ${body.tp_pct}%\n` +
`꼬리/몸통: ${body.tail_ratio_min}\n어깨컷: ${body.shoulder_cut_pct}%\n` +
`\n⚠ STOP_LOSS_PCT는 음수로 저장됩니다 (봇 규칙). 봇이 실행 중이면 다음 루프부터 반영됩니다.`)) return;
if (!confirm(`💾 꼬리잡기 봇(실매·백테와 동일 DB)에 아래 파라미터를 저장합니까?\n\n` +
`낙폭: ${body.min_drop_rate}% | 회복: ${body.min_recovery_ratio}% | 꼬리/몸통: ${body.tail_ratio_min} | 꼬리최소: ${body.tail_pct_min}%\n` +
`손절: ${body.sl_pct}% | 익절: ${body.tp_pct}% | 어깨컷: ${body.shoulder_cut_pct}% | 3분최대회복: ${body.max_rec_3m}% | 고점추격방지: ${body.high_chase_thr}%\n` +
`RSI기간: ${body.rsi_period} | RSI과열: ${body.rsi_threshold} | 쿨다운: ${body.cooldown_min}분 | 매수시간: ${body.time_start}-${body.time_end} | 일일최대: ${body.max_daily}\n\n` +
`⚠️ STOP_LOSS_PCT는 음수로 저장됩니다. 봇 실행 중이면 다음 루프부터 반영됩니다.`)) return;
fetch('/api/backtest/tail/save_config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
@@ -2726,10 +2726,14 @@ function runTailBacktest() {
min_drop_rate: $('tl_drop').value,
min_recovery_ratio: $('tl_rec').value,
tail_ratio_min: $('tl_tail').value,
tail_pct_min: $('tl_tail_pct').value,
max_rec_3m: $('tl_max_rec_3m').value,
sl_pct: $('tl_sl').value,
tp_pct: $('tl_tp').value,
shoulder_min_high: $('tl_smin').value,
shoulder_cut_pct: $('tl_scut').value,
high_chase_thr: $('tl_high_chase').value,
rsi_period: $('tl_rsi_period').value,
rsi_threshold: $('tl_rsi').value,
cooldown_min: $('tl_cool').value,
time_start: $('tl_ts').value,
@@ -2753,7 +2757,7 @@ function renderTailBacktest(d) {
$('tl_params_bar').innerHTML =
`낙폭≥${p.min_drop_rate}% | 회복률≥${p.min_recovery_ratio}% | 꼬리/몸통≥${p.tail_ratio_min} | ` +
`손절-${p.sl_pct}% 익절+${p.tp_pct}% | 어깨컷(${p.shoulder_min_high}%발동/${p.shoulder_cut_pct}%하락) | ` +
`RSI과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | 종목수 ${p.codes_analyzed}개`;
`RSI(${p.rsi_period})과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | ${p.time_window || '930-1500'} | 일${p.max_daily || 3}회 | 종목수 ${p.codes_analyzed}개`;
$('tl_total').textContent = (s.total_trades||0) + '';
$('tl_winrate').textContent = (s.win_rate||0) + '%';