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

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

View File

@@ -7,6 +7,7 @@ backtest_web.py — 매매 성과 분석 & 백테스트 웹 대시보드
탭1. 실거래 분석 → trade_history 기반 실제 매매 결과
탭2. 스캘핑 백테스트 → ws_candles 1분봉 가격 재현(Price-Replay) 백테스트
탭3. 꼬리잡기 백테스트 → ws_candles 3분봉 기반, tail_engine 연동 (V3 방어 파라미터 지원)
"""
import sys, os, math, json, logging, threading, uuid
@@ -81,6 +82,30 @@ def _compute_rsi_series(closes: list, period: int = 3) -> list:
return rsi_list
# ────────────────────────────────────────────────────────────────────────────
# 가상거래에 종목명 부여 (stock_meta 조회, 실거래와 동일하게 표시)
# ────────────────────────────────────────────────────────────────────────────
def _enrich_trades_with_names(db, trades: list) -> None:
"""trades 리스트 내 각 거래에 'name' 필드 추가. code만 있으면 stock_meta에서 조회."""
if not trades:
return
codes = list({t.get("code") for t in trades if t.get("code")})
if not codes:
return
try:
placeholders = ", ".join(["%s"] * len(codes))
rows = db.conn.execute(
"SELECT code, name FROM stock_meta WHERE code IN (" + placeholders + ")",
codes,
).fetchall()
code_to_name = {r["code"]: (r.get("name") or r["code"]) for r in rows}
except Exception:
code_to_name = {}
for t in trades:
c = t.get("code")
t["name"] = code_to_name.get(c, c) if c else ""
# ────────────────────────────────────────────────────────────────────────────
# API: 실거래 분석
# ────────────────────────────────────────────────────────────────────────────
@@ -93,8 +118,22 @@ def api_actual():
db = _db()
try:
params = [strategy]
sql = "SELECT * FROM trade_history WHERE strategy = %s"
# ── 전략 ID prefix 매칭 ─────────────────────────────────────────
# 과거 봇(kis_scalping_ver2.py 등): strategy='SCALP_RSI_REVERSAL'
# 신봇(kis_trader/main.py): strategy='SCALP'
# 두 포맷을 모두 한 화면에 잡기 위해 prefix LIKE 로 조회한다.
# (스캘핑 = 'SCALP%', 꼬리잡기 = 'SHORT%')
s_upper = (strategy or "").upper().strip()
if s_upper.startswith("SCALP"):
prefix = "SCALP"
elif s_upper.startswith("SHORT") or s_upper.startswith("TAIL"):
prefix = "SHORT"
else:
prefix = s_upper or "SCALP"
like_pattern = prefix + "%"
params = [like_pattern]
sql = "SELECT * FROM trade_history WHERE strategy LIKE %s"
if start:
sql += " AND sell_date >= %s"
params.append(start + " 00:00:00")
@@ -105,6 +144,23 @@ def api_actual():
rows = db.conn.execute(sql, params).fetchall()
trades = [dict(r) for r in rows]
# 잔고 동기화 이슈로 생성된 0원 강제정리 레코드는 실거래 성과를 왜곡하므로 분석 집계에서 제외
# (실제 체결 손익이 아니라 로컬-브로커 불일치 정리용 레코드)
filtered_out = 0
cleaned: List[Dict] = []
for t in trades:
reason = str(t.get("sell_reason") or "")
try:
sell_price = float(t.get("sell_price") or 0)
except Exception:
sell_price = 0.0
is_forced_ghost = ("잔고없음(강제정리)" in reason) or ("잔고동기화(외부매도)" in reason)
if is_forced_ghost and sell_price <= 0:
filtered_out += 1
continue
cleaned.append(t)
trades = cleaned
# 날짜 직렬화
for t in trades:
for k in ("buy_date", "sell_date"):
@@ -179,6 +235,7 @@ def api_actual():
"profit_factor": round(profit_factor, 2),
"max_drawdown": round(mdd),
},
"meta": {"filtered_forced_rows": filtered_out},
"equity": equity,
"daily": daily_list,
"reasons": reasons,
@@ -206,17 +263,18 @@ def api_backtest_scalping():
end = request.args.get("end", "")
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", _def["slot_money"])) # 1회 투자금
rsi_overbought = float(request.args.get("rsi_overbought", 75))
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", _def["slot_money"]))
_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=비활성)
vol_mult = float(request.args.get("vol_mult", _def["vol_mult"]))
_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")
@@ -225,11 +283,15 @@ def api_backtest_scalping():
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"])) # 종목당 일일 최대 거래횟수
max_daily = int(request.args.get("max_daily", _def["max_daily"]))
_use_defense = request.args.get("use_defense_filters")
if _use_defense in (None, ""):
use_defense_filters = bool(_def.get("use_defense_filters", True))
else:
use_defense_filters = str(_use_defense).strip().lower() in ("1", "true", "y", "yes", "on")
db = _db()
try:
# 날짜 → candle_time 형식 (YYYYMMDDHHMI) 변환
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
@@ -255,9 +317,22 @@ def api_backtest_scalping():
continue
codes_candles[code] = [dict(r) for r in rows]
# 방어로직: 쿼리 인자로 넘어오면 우선 사용 (웹 입력란), 없으면 DB 기본값
_high_chase = request.args.get("high_chase_thr")
_max_daily_ch = request.args.get("max_daily_chg")
_min_pr = request.args.get("min_price")
_max_loss = request.args.get("max_loss_krw")
_min_marg = request.args.get("min_margin")
high_chase_thr = float(_high_chase) if _high_chase not in (None, "") else _def.get("high_chase_thr", 0.96)
max_daily_chg = float(_max_daily_ch) if _max_daily_ch not in (None, "") else _def.get("max_daily_chg", 20.0)
min_price = float(_min_pr) if _min_pr not in (None, "") else _def.get("min_price", 1000.0)
max_loss_krw = int(float(_max_loss)) if _max_loss not in (None, "") else int(_def.get("max_loss_krw", 200000))
# min_margin: 웹에서 % 단위(0.2 등)로 오면 0.002로 변환
min_margin = float(_min_marg) / 100 if _min_marg not in (None, "") else _def.get("min_margin", 0.002)
params = {
"rsi_period": rsi_period,
"rsi_oversold": rsi_oversold,
"rsi_overbought": rsi_overbought,
"sl_pct": sl_pct,
"tp_pct": tp_pct,
"drop_rate": drop_rate,
@@ -271,8 +346,35 @@ def api_backtest_scalping():
"time_end_hm": time_end_hm,
"max_daily": max_daily,
"vol_mult": vol_mult,
"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": _def.get("min_drop_pct_for_loss_cut", 0.015),
"min_margin": min_margin,
"use_defense_filters": use_defense_filters,
"scan_interval_min": 5,
}
all_virtual_trades = se.run_scalping_backtest(codes_candles, params)
# 유니버스: 저장된 이력(target_candidates_history) 있으면 그 슬롯만 사용(시간 맞춤). 없으면 1분봉 시뮬레이션
universe_source = "fallback"
universe_history_slots = 0
universe_by_slot = None
try:
history = db.get_universe_history_for_backtest(start_key[:8], end_key[:8])
if history:
universe_by_slot = history # 이력 있는 슬롯만 사용 → 이력 없는 시간대는 매수 후보 없음(거래 없음)
universe_source = "history"
universe_history_slots = len(history)
except Exception:
pass
if universe_by_slot is None:
_universe_top_n = int(request.args.get("universe_top_n") or os.environ.get("UPDATE_UNIVERSE_TOP_N", "20"))
_universe_min_score = float(request.args.get("universe_min_score") or os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0"))
universe_by_slot = se.build_universe_simulation(
codes_candles, top_n=_universe_top_n, min_score=_universe_min_score, scan_interval_min=5
)
all_virtual_trades = se.run_scalping_backtest(codes_candles, params, universe_by_slot=universe_by_slot)
# ... 이하 로직 생략 (기존과 동일) ...
# ─────────────────────────────────────────────────────────────────
# 결과 집계
@@ -311,10 +413,15 @@ def api_backtest_scalping():
daily_list = [{"date": d[:4]+"-"+d[4:6]+"-"+d[6:], "pnl": round(v)}
for d, v in sorted(daily.items())]
# 가상거래에도 종목명 표시 (실거래와 동일)
trades_out = all_virtual_trades[-200:]
_enrich_trades_with_names(db, trades_out)
return jsonify({
"params": {
"rsi_period": rsi_period,
"rsi_oversold": rsi_oversold,
"rsi_overbought": rsi_overbought,
"sl_pct": sl_pct * 100,
"tp_pct": tp_pct * 100,
"drop_rate": drop_rate * 100,
@@ -326,6 +433,8 @@ def api_backtest_scalping():
"time_window": f"{time_start_hm:04d}-{time_end_hm:04d}",
"max_daily": max_daily,
"codes_analyzed": len(codes),
"universe_source": universe_source,
"universe_history_slots": universe_history_slots,
},
"summary": {
"total_trades": total,
@@ -340,7 +449,7 @@ def api_backtest_scalping():
"equity": equity,
"daily": daily_list,
"reasons": reasons,
"trades": all_virtual_trades[-200:],
"trades": trades_out,
})
finally:
db.close()
@@ -370,6 +479,7 @@ def api_backtest_tail():
꼬리잡기 전략 가격 재현 백테스트.
entry 조건: 당일 낙폭(drop_rate) + 회복률(recovery_ratio) + 망치봉 꼬리 + RSI
exit 조건: 손절 / 익절 / 어깨 컷(trailing) / 장 마감 강제 청산
[V3 통합]: 추가 방어 파라미터(MA20, ATR 배수, 피뢰침 등) 적용
기본값 = DB(env_config) → tail_engine.get_tail_defaults_from_db(), 요청으로 덮어쓰기.
"""
_def = _get_tail_defaults_for_backtest()
@@ -399,6 +509,23 @@ def api_backtest_tail():
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)))
# V3 방어 파라미터 연동
min_price = float(request.args.get("min_price", _def.get("min_price", 1000.0)))
max_daily_change = float(request.args.get("max_daily_change", _def.get("max_daily_change", 20.0)))
ma20_max_above = float(request.args.get("ma20_max_above", _def.get("ma20_max_above", 3.0)))
stop_atr_mult = float(request.args.get("stop_atr_mult", _def.get("stop_atr_mult", 2.5)))
target_atr_mult = float(request.args.get("target_atr_mult", _def.get("target_atr_mult", 8.0)))
max_loss_krw = int(request.args.get("max_loss_krw", _def.get("max_loss_krw", 200000)))
_min_drop_loss_arg = request.args.get("min_drop_pct_for_loss_cut")
min_drop_pct_for_loss_cut = _def.get("min_drop_pct_for_loss_cut", 0.015)
if _min_drop_loss_arg not in (None, ""):
v = float(_min_drop_loss_arg)
min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v
risk_pct = float(request.args.get("risk_pct", _def.get("risk_pct", 0.01) * 100)) / 100
kelly_mult = float(request.args.get("kelly_mult", _def.get("kelly_mult", 0.25)))
min_hold_sec = float(request.args.get("min_hold_sec", _def.get("min_hold_sec", 30.0)))
capital = float(request.args.get("capital", _def.get("capital", 100000000.0)))
db = _db()
try:
start_key = (start.replace("-", "") + "0000") if start else "20260101"
@@ -413,6 +540,8 @@ def api_backtest_tail():
use_engine = _TAIL_ENGINE_AVAILABLE and request.args.get("use_engine", "1") == "1"
all_trades: List[Dict] = []
universe_source = "fallback"
universe_history_slots = 0
if use_engine:
# tail_engine 공통 로직 사용 (실매매 ver3과 동일 계산식)
@@ -428,6 +557,7 @@ def api_backtest_tail():
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,
@@ -436,10 +566,28 @@ def api_backtest_tail():
"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,
"min_price": min_price, "max_daily_change": max_daily_change, "ma20_max_above": ma20_max_above,
"stop_atr_mult": stop_atr_mult, "target_atr_mult": target_atr_mult, "max_loss_krw": max_loss_krw,
"min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut,
"risk_pct": risk_pct, "kelly_mult": kelly_mult, "min_hold_sec": min_hold_sec, "capital": capital,
"scan_interval_min": 5,
}
all_trades = te.run_tail_backtest(candles_by_code, params)
# 유니버스: 저장된 이력(target_candidates_history) 우선, 없으면 전체 종목
universe_by_slot = None
try:
history = db.get_universe_history_for_backtest(start_key[:8], end_key[:8])
if history:
universe_by_slot = history
universe_source = "history"
universe_history_slots = len(history)
except Exception:
pass
all_trades = te.run_tail_backtest(candles_by_code, params, universe_by_slot=universe_by_slot)
for t in all_trades:
qty = max(1, int(slot_money / t["entry"]))
# 엔진이 동적 수량(qty)을 내려주면 사용하고, 없으면 기존 고정 투자금 적용 (하위 호환성)
qty = t.get("qty")
if qty is None:
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)
@@ -671,6 +819,9 @@ def api_backtest_tail():
for t in all_trades:
reasons[t["reason"]] = reasons.get(t["reason"], 0) + 1
tail_trades_out = all_trades[-200:]
_enrich_trades_with_names(db, tail_trades_out)
return jsonify({
"params": {
"start": start, "end": end,
@@ -690,7 +841,19 @@ def api_backtest_tail():
"time_end_hm": time_end_hm,
"time_window": f"{time_start_hm:04d}-{time_end_hm:04d}",
"max_daily": max_daily,
"min_price": min_price,
"max_daily_change": max_daily_change,
"ma20_max_above": ma20_max_above,
"stop_atr_mult": stop_atr_mult,
"target_atr_mult": target_atr_mult,
"max_loss_krw": max_loss_krw,
"risk_pct": risk_pct * 100,
"kelly_mult": kelly_mult,
"min_hold_sec": min_hold_sec,
"capital": capital,
"codes_analyzed": len(codes),
"universe_source": universe_source,
"universe_history_slots": universe_history_slots,
},
"summary": {
"total_trades": total,
@@ -705,7 +868,7 @@ def api_backtest_tail():
"equity": equity,
"daily": daily_list,
"reasons": reasons,
"trades": all_trades[-200:],
"trades": tail_trades_out,
})
finally:
db.close()
@@ -1205,10 +1368,11 @@ def api_scalping_param_search():
top_n = int(request.args.get("top", 10))
min_tr = int(request.args.get("min_trades", 3))
# 그리드 외 파라미터: JS가 현재 UI 값을 전달하면 사용, 없으면 Python 기본값
# 그리드 외 파라미터: JS가 현재 UI 값을 전달하면 사용, 없으면 Python 기본값 (방어로직 포함)
_SCALP_NON_GRID_KEYS = [
"rsi_period", "slot_money", "cooldown_min", "vol_mult",
"rsi_period", "rsi_overbought", "slot_money", "cooldown_min", "vol_mult",
"trail_trigger", "trail_stop", "time_start", "time_end", "max_daily",
"high_chase_thr", "max_daily_chg", "min_price", "max_loss_krw", "min_margin", "use_defense_filters",
]
base_params = {"start": start, "end": end}
for k in _SCALP_NON_GRID_KEYS:
@@ -1255,6 +1419,8 @@ def api_scalping_save_config():
# UI % 단위 → 봇이 사용하는 소수 단위로 변환
if "rsi_oversold" in body:
snap["SCALP_RSI_OVERSOLD"] = str(float(body["rsi_oversold"]))
if "rsi_overbought" in body:
snap["SCALP_RSI_OVERBOUGHT"] = str(float(body["rsi_overbought"]))
if "sl_pct" in body:
snap["SCALP_STOP_LOSS_PCT"] = str(float(body["sl_pct"]) / 100)
if "tp_pct" in body:
@@ -1265,10 +1431,28 @@ def api_scalping_save_config():
snap["SCALP_ATR_UP_MULT"] = str(float(body["trail_trigger"]))
if "cooldown_min" in body:
snap["SCALP_COOLDOWN_SEC"] = str(int(float(body["cooldown_min"])) * 60)
# 스캘핑 전용 방어로직 (꼬리잡기와 값 분리)
if "high_chase_thr" in body:
snap["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = str(float(body["high_chase_thr"]))
if "max_daily_chg" in body:
snap["SCALP_MAX_DAILY_CHANGE_PCT"] = str(float(body["max_daily_chg"]))
if "min_price" in body:
snap["SCALP_MIN_PRICE"] = str(int(float(body["min_price"])))
if "max_loss_krw" in body:
snap["SCALP_MAX_LOSS_PER_TRADE_KRW"] = str(int(float(body["max_loss_krw"])))
if "min_margin" in body:
snap["SCALP_MIN_PROFIT_PCT"] = str(float(body["min_margin"])) # % 단위 그대로 (0.2 등)
if "min_drop_pct_for_loss_cut" in body:
v = float(body["min_drop_pct_for_loss_cut"])
snap["SCALP_MIN_DROP_PCT_FOR_LOSS_CUT"] = str(v) if v >= 1 else str(v * 100) # % 단위 (1.5)
if "use_defense_filters" in body:
snap["SCALP_USE_DEFENSE_FILTERS"] = "true" if bool(body["use_defense_filters"]) else "false"
saved_keys = [
"SCALP_RSI_OVERSOLD", "SCALP_RSI_OVERBOUGHT", "SCALP_STOP_LOSS_PCT", "SCALP_TAKE_PROFIT_PCT", "SCALP_MIN_DROP_RATE",
"SCALP_HIGH_PRICE_CHASE_THRESHOLD", "SCALP_MAX_DAILY_CHANGE_PCT", "SCALP_MIN_PRICE", "SCALP_MAX_LOSS_PER_TRADE_KRW", "SCALP_MIN_DROP_PCT_FOR_LOSS_CUT", "SCALP_MIN_PROFIT_PCT", "SCALP_USE_DEFENSE_FILTERS",
]
env_id = db.insert_env_snapshot(snap)
return jsonify({"ok": True, "env_id": env_id, "saved_keys": [
"SCALP_RSI_OVERSOLD","SCALP_STOP_LOSS_PCT","SCALP_TAKE_PROFIT_PCT","SCALP_MIN_DROP_RATE"
]})
return jsonify({"ok": True, "env_id": env_id, "saved_keys": saved_keys})
except Exception as e:
logger.error(f"스캘핑 설정저장 오류: {e}")
return jsonify({"error": str(e)}), 500
@@ -1288,6 +1472,9 @@ def api_tail_param_search():
_TAIL_NON_GRID_KEYS = [
"slot_money", "tail_ratio_min", "shoulder_min_high", "shoulder_cut_pct",
"rsi_threshold", "cooldown_min", "time_start", "time_end", "max_daily",
"tail_pct_min", "max_rec_3m", "high_chase_thr",
"min_price", "max_daily_change", "ma20_max_above",
"stop_atr_mult", "target_atr_mult", "max_loss_krw",
]
base_params = {"start": start, "end": end}
for k in _TAIL_NON_GRID_KEYS:
@@ -1364,12 +1551,29 @@ def api_tail_save_config():
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)
# ── 방어 로직 (V3) 추가 저장 (tail_engine이 읽는 DB 키 사용) ──
if "min_price" in body:
snap["MIN_PRICE_TAIL"] = str(float(body["min_price"]))
if "max_daily_change" in body:
snap["MAX_DAILY_CHANGE_PCT"] = str(float(body["max_daily_change"]))
if "ma20_max_above" in body:
snap["MA20_MAX_ABOVE_PCT"] = str(float(body["ma20_max_above"]))
if "stop_atr_mult" in body:
snap["STOP_ATR_MULTIPLIER_TAIL"] = str(float(body["stop_atr_mult"]))
if "target_atr_mult" in body:
snap["TARGET_ATR_MULTIPLIER_TAIL"] = str(float(body["target_atr_mult"]))
if "max_loss_krw" in body:
snap["MAX_LOSS_PER_TRADE_KRW"] = str(int(body["max_loss_krw"]))
if "min_drop_pct_for_loss_cut" in body:
v = float(body["min_drop_pct_for_loss_cut"])
snap["MIN_DROP_PCT_FOR_LOSS_CUT"] = str(v) if v >= 1 else str(v * 100) # % 단위로 저장 (1.5 또는 0.015 → 1.5)
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","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"
"TIME_START","TIME_END","MAX_STOCKS","MAX_RECOVERY_RATIO_3M","HIGH_PRICE_CHASE_THRESHOLD",
"MIN_PRICE_TAIL","MAX_DAILY_CHANGE_PCT","MA20_MAX_ABOVE_PCT","STOP_ATR_MULTIPLIER_TAIL","TARGET_ATR_MULTIPLIER_TAIL","MAX_LOSS_PER_TRADE_KRW","MIN_DROP_PCT_FOR_LOSS_CUT"
]})
except Exception as e:
logger.error(f"꼬리잡기 설정저장 오류: {e}")
@@ -1436,15 +1640,31 @@ def api_env_params():
pass
return default
# 스캘핑 방어로직: SCALP_* 우선, 없으면 꼬리잡기 공용 키 fallback (전략별 값 분리)
scalp_high_chase = fv("SCALP_HIGH_PRICE_CHASE_THRESHOLD") or fv("HIGH_PRICE_CHASE_THRESHOLD")
scalp_max_daily = fv("SCALP_MAX_DAILY_CHANGE_PCT") or fv("MAX_DAILY_CHANGE_PCT")
scalp_min_price = fv("SCALP_MIN_PRICE") or fv("MIN_PRICE_TAIL")
scalp_max_loss = fv("SCALP_MAX_LOSS_PER_TRADE_KRW") or fv("MAX_LOSS_PER_TRADE_KRW")
scalp_min_margin = fv("SCALP_MIN_PROFIT_PCT")
scalp_use_def = fv("SCALP_USE_DEFENSE_FILTERS")
return jsonify({
"scalp": {
"rsi_oversold": fv("SCALP_RSI_OVERSOLD"), # 과매도 임계값 (숫자 그대로)
"rsi_overbought": fv("SCALP_RSI_OVERBOUGHT"), # 과열 임계값 (이 값 초과 시 진입 금지)
"sl_pct": smart_pct("SCALP_STOP_LOSS_PCT"), # 소수/퍼센트 자동감지
"tp_pct": smart_pct("SCALP_TAKE_PROFIT_PCT"),
"drop_rate": smart_pct("SCALP_MIN_DROP_RATE"),
"trail_trigger": fv("SCALP_ATR_UP_MULT"), # 배수 그대로
"cooldown_min": sec_to_min("SCALP_COOLDOWN_SEC"), # 스캘핑 전용 (꼬리잡기 REENTRY_COOLDOWN_SEC 와 분리)
"slot_money": fv("SLOT_MONEY_DEFAULT"),
# 방어로직 (스캘핑 전용 env — 웹 입력란·저장·백테스트 반영)
"high_chase_thr": float(scalp_high_chase) if scalp_high_chase not in (None, "") else 0.96,
"max_daily_chg": float(scalp_max_daily) if scalp_max_daily not in (None, "") else 20.0,
"min_price": float(scalp_min_price) if scalp_min_price not in (None, "") else 1000.0,
"max_loss_krw": int(float(scalp_max_loss)) if scalp_max_loss not in (None, "") else 200000,
"min_drop_pct_for_loss_cut": (lambda v: (v * 100 if v is not None and v < 1 else v) if v is not None else 1.5)(fv("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT")),
"min_margin": float(scalp_min_margin) if scalp_min_margin not in (None, "") else 0.2,
"use_defense_filters": str(scalp_use_def).strip().lower() in ("1", "true", "y", "yes", "on") if scalp_use_def not in (None, "") else True,
},
"tail": {
"drop": smart_pct("MIN_DROP_RATE"),
@@ -1464,6 +1684,14 @@ def api_env_params():
# 비율(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),
# 방어 로직 (V3) — tail_engine과 동일 DB 키
"min_price": fv("MIN_PRICE_TAIL"),
"max_daily_change": fv("MAX_DAILY_CHANGE_PCT"),
"ma20_max_above": fv("MA20_MAX_ABOVE_PCT"),
"stop_atr_mult": fv("STOP_ATR_MULTIPLIER_TAIL"),
"target_atr_mult": fv("TARGET_ATR_MULTIPLIER_TAIL"),
"max_loss_krw": fv("MAX_LOSS_PER_TRADE_KRW"),
"min_drop_pct_for_loss_cut": (lambda v: (v * 100 if v is not None and v < 1 else v) if v is not None else 1.5)(fv("MIN_DROP_PCT_FOR_LOSS_CUT")), # 금액손실컷 발동 최소 하락률(%)
},
})
finally:
@@ -1639,6 +1867,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<div class="card-title">평균 보유(분)</div><div class="stat-val" id="a_hold">-</div>
</div></div>
</div>
<div id="act_filter_note" class="mb-2" style="display:none;font-size:12px;color:var(--muted)"></div>
<!-- 승/패 바 -->
<div class="card p-3 mb-3" id="act_winbar_card" style="display:none">
<div class="d-flex justify-content-between mb-1">
@@ -1715,6 +1944,10 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<label class="form-label param-row">RSI 과매도</label>
<input type="number" class="form-control" id="bt_rsi_oversold" value="18" min="10" max="45" step="1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="이 값 초과 시 고점추격으로 진입 금지">RSI 과열</label>
<input type="number" class="form-control" id="bt_rsi_overbought" value="75" min="50" max="100" step="1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">손절(%)</label>
<input type="number" class="form-control" id="bt_sl" value="1.0" min="0.3" max="5" step="0.1">
@@ -1752,16 +1985,46 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="매수 가능 시작 시각(HHMM)">매수시작</label>
<input type="number" class="form-control" id="bt_time_start" value="900" min="900" max="1500" step="100">
<input type="number" class="form-control" id="bt_time_start" value="830" min="900" max="1500" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="매수 가능 종료 시각(HHMM)">매수종료</label>
<input type="number" class="form-control" id="bt_time_end" value="1400" min="900" max="1530" step="100">
<input type="number" class="form-control" id="bt_time_end" value="1530" min="900" max="1530" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="종목당 하루 최대 거래횟수">일일최대매수</label>
<input type="number" class="form-control" id="bt_max_daily" value="3" min="1" max="10">
</div>
</div>
<!-- 방어로직 (스캘핑 전용 env — param_search/엔진과 동일) -->
<div class="row g-2 align-items-end mt-1">
<div class="col-12 col-md-2">
<label class="form-label param-row" title="OFF면 진입 방어(낙폭/고점추격/급등/최소가격)를 비활성화하고, 손절/익절/장마감청산은 유지">방어로직</label>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" id="bt_use_defense" checked>
<label class="form-check-label" for="bt_use_defense">진입 방어 ON</label>
</div>
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="당일고가 대비 이 비율 이하면 진입 허용">고점추격방지</label>
<input type="number" class="form-control" id="bt_high_chase" value="0.96" min="0.5" max="1" step="0.01">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="당일 등락률 이 값 초과 시 진입 금지(%)">급등주필터(%)</label>
<input type="number" class="form-control" id="bt_max_daily_chg" value="20" min="0" max="100" step="1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="이 가격 미만 종목 진입 금지(원)">최소가격(원)</label>
<input type="number" class="form-control" id="bt_min_price" value="1000" min="0" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="종목당 1회 최대 손실 한도(원)">1회최대손실(원)</label>
<input type="number" class="form-control" id="bt_max_loss_krw" value="200000" min="0" step="10000">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="본절사수 시 최소 순이익 마진(%)">본절마진(%)</label>
<input type="number" class="form-control" id="bt_min_margin" value="0.2" min="0" max="2" step="0.1">
</div>
<div class="col-12 col-md-auto mt-2 d-flex gap-2 flex-wrap">
<button class="btn btn-primary px-4" onclick="runBacktest()">🚀 백테스트 실행</button>
<button class="btn btn-warning px-3" onclick="runScalpParamSearch()">🔍 파라미터탐색</button>
@@ -1773,7 +2036,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<div class="card-title" style="font-size:13px;color:var(--accent)">🔍 파라미터탐색 결과 (수익 기준 정렬)</div>
<div class="table-responsive">
<table class="table table-sm table-hover" style="font-size:12px">
<thead><tr><th>#</th><th>RSI과매도</th><th>손절%</th><th>익절%</th><th>낙폭%</th>
<thead><tr><th>#</th><th>RSI과매도</th><th>RSI과열</th><th>손절%</th><th>익절%</th><th>낙폭%</th><th>방어</th>
<th>손익(원)</th><th>승률%</th><th>거래수</th><th>PF</th><th>MDD</th><th></th></tr></thead>
<tbody id="scalp_search_tbody"></tbody>
</table>
@@ -1787,6 +2050,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<table style="width:100%;border-collapse:collapse">
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI 기간</td><td>RSI 계산 캔들 수 → 봇: <b>3</b> (SCALP_RSI_PERIOD 고정)</td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI 과매도</td><td>이 값 <b>이하</b>일 때 매수 신호 → 봇 DB: <b>SCALP_RSI_OVERSOLD=25</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">RSI 과열</td><td>이 값 <b>초과</b>면 진입 금지 (고점 추격 방지) → 봇 DB: <b>SCALP_RSI_OVERBOUGHT=75</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">손절(%)</td><td>매수가 대비 하락 시 손절 → 봇 DB: <b>SCALP_STOP_LOSS_PCT=1.5%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">익절(%)</td><td>매수가 대비 상승 시 익절 → 봇 DB: <b>SCALP_TAKE_PROFIT_PCT=1.5%</b></td></tr>
<tr><td style="color:#58a6ff;padding:1px 6px;white-space:nowrap">낙폭필터(%)</td><td>당일 시가→저가 낙폭이 이 값 이상이어야 진입 → 봇 DB: <b>SCALP_MIN_DROP_RATE=1.5%</b></td></tr>
@@ -1820,7 +2084,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<div class="card-title">승률</div><div class="stat-val" id="b_winrate">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">순손익(수수료 제외)</div><div class="stat-val" id="b_pnl">-</div>
<div class="card-title">순손익(수수료·세금 포함)</div><div class="stat-val" id="b_pnl">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">Profit Factor</div><div class="stat-val" id="b_pf">-</div>
@@ -1867,6 +2131,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<!-- 백테스트 거래 목록 -->
<div class="section-title">가상 거래 내역 (최근 200건)</div>
<div id="bt_trade_context" class="mb-2 p-2 rounded" style="font-size:12px;background:var(--bg-secondary, #1c2128);color:var(--muted);display:none"></div>
<div class="card p-3">
<div class="table-responsive">
<table class="table table-hover" id="bt_table">
@@ -1942,6 +2207,37 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<input type="number" class="form-control" id="tl_high_chase" value="96" min="90" max="100" step="1">
</div>
</div>
<!-- V3 방어 파라미터 -->
<div class="row g-2 align-items-end mt-1">
<div class="col-4 col-md-1">
<label class="form-label param-row" title="최소 주가">최소가격(원)</label>
<input type="number" class="form-control" id="tl_min_price" value="1000" min="0" step="500">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="당일 최고/최저 상승폭 제한">최대급등(%)</label>
<input type="number" class="form-control" id="tl_max_daily_change" value="20" min="5" max="30" step="1">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="MA20 대비 가격 이격도 허용 범위">MA20이격(%)</label>
<input type="number" class="form-control" id="tl_ma20_above" value="3.0" min="1.0" max="10.0" step="0.5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="1회 매매 최대 손실 원화 (금액 컷)">최대손실컷(원)</label>
<input type="number" class="form-control" id="tl_max_loss_krw" value="200000" min="10000" step="10000">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="금액손실컷 발동 최소 하락률(%) — 이 미만은 흔들림으로 보고 미발동">손실컷최소하락(%)</label>
<input type="number" class="form-control" id="tl_min_drop_loss_cut" value="1.5" min="0.5" max="5" step="0.5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="ATR 기준 손절 배수 (변동성 대응)">ATR손절(배)</label>
<input type="number" class="form-control" id="tl_stop_atr" value="2.5" min="0.5" max="10" step="0.5">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="ATR 기준 익절 배수 (변동성 대응)">ATR익절(배)</label>
<input type="number" class="form-control" id="tl_target_atr" value="8.0" min="1.0" max="20" step="0.5">
</div>
</div>
<!-- 고급 파라미터 (2행) -->
<div class="row g-2 align-items-end mt-1">
<div class="col-4 col-md-1">
@@ -1958,11 +2254,11 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">매수시작(HHMM)</label>
<input type="number" class="form-control" id="tl_ts" value="930" min="900" max="1500" step="100">
<input type="number" class="form-control" id="tl_ts" value="830" min="900" max="1500" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row">매수종료(HHMM)</label>
<input type="number" class="form-control" id="tl_te" value="1500" min="900" max="1530" step="100">
<input type="number" class="form-control" id="tl_te" value="1530" min="900" max="1530" step="100">
</div>
<div class="col-4 col-md-1">
<label class="form-label param-row" title="종목당 하루 최대 거래횟수 (실매 MAX_STOCKS)">일일최대매수</label>
@@ -2027,7 +2323,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<div class="card-title">승률</div><div class="stat-val" id="tl_winrate">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">순손익(수수료 제외)</div><div class="stat-val" id="tl_pnl">-</div>
<div class="card-title">순손익(수수료·세금 포함)</div><div class="stat-val" id="tl_pnl">-</div>
</div></div>
<div class="col-6 col-md-3 col-lg-2"><div class="card p-3">
<div class="card-title">Profit Factor</div><div class="stat-val" id="tl_pf">-</div>
@@ -2073,6 +2369,7 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
<!-- 거래 내역 테이블 -->
<div class="section-title">가상 거래 내역 (최근 200건)</div>
<div id="tl_trade_context" class="mb-2 p-2 rounded" style="font-size:12px;background:var(--bg-secondary, #1c2128);color:var(--muted);display:none"></div>
<div class="card p-3">
<div class="table-responsive">
<table class="table table-hover" id="tl_table">
@@ -2337,12 +2634,22 @@ document.querySelectorAll('[data-tab]').forEach(el => {
// 스캘핑 탭
const s = d.scalp || {};
set('bt_rsi_oversold', s.rsi_oversold);
set('bt_rsi_overbought', s.rsi_overbought);
set('bt_sl', s.sl_pct);
set('bt_tp', s.tp_pct);
set('bt_drop', s.drop_rate);
set('bt_trail_trig', s.trail_trigger);
set('bt_cooldown', s.cooldown_min);
if (s.slot_money) set('bt_slot', s.slot_money);
if (s.high_chase_thr != null) set('bt_high_chase', s.high_chase_thr);
if (s.max_daily_chg != null) set('bt_max_daily_chg', s.max_daily_chg);
if (s.min_price != null) set('bt_min_price', s.min_price);
if (s.max_loss_krw != null) set('bt_max_loss_krw', s.max_loss_krw);
if (s.min_margin != null) set('bt_min_margin', s.min_margin);
if (s.use_defense_filters !== undefined && $('bt_use_defense')) {
const v = String(s.use_defense_filters).trim().toLowerCase();
$('bt_use_defense').checked = (v === '1' || v === 'true' || v === 'y' || v === 'yes' || v === 'on');
}
// 꼬리잡기 탭 — DB(env_config)와 동일 값으로 채움 (실매·파라서치와 동일)
const t = d.tail || {};
@@ -2362,8 +2669,21 @@ document.querySelectorAll('[data-tab]').forEach(el => {
set('tl_tail_pct', t.tail_pct_min);
set('tl_max_rec_3m', t.max_rec_3m);
set('tl_high_chase', t.high_chase);
// V3 방어 파라미터
set('tl_min_price', t.min_price);
set('tl_max_daily_change', t.max_daily_change);
set('tl_ma20_above', t.ma20_max_above);
set('tl_max_loss_krw', t.max_loss_krw);
if (t.min_drop_pct_for_loss_cut != null) set('tl_min_drop_loss_cut', t.min_drop_pct_for_loss_cut);
set('tl_stop_atr', t.stop_atr_mult);
set('tl_target_atr', t.target_atr_mult);
})
.catch(() => { /* DB 연결 실패 시 HTML 기본값 유지 */ });
const defChk = $('bt_use_defense');
if (defChk) {
defChk.addEventListener('change', saveScalpDefenseRealtime);
}
})();
// ────────────────────────────────────────────
@@ -2385,6 +2705,7 @@ function loadActual() {
function renderActual(d) {
const s = d.summary;
const filtered = Number((d.meta || {}).filtered_forced_rows || 0);
$('a_total').textContent = fmt(s.total_trades) + '';
$('a_winrate').textContent = s.win_rate + '%';
@@ -2398,6 +2719,19 @@ function renderActual(d) {
$('a_mdd').textContent = '-' + fmt(s.max_drawdown) + '';
$('a_hold').textContent = s.avg_hold_min + '';
const note = $('act_filter_note');
if (note) {
if (filtered > 0) {
note.style.display = '';
note.innerHTML = `⚠️ 집계 제외: 0원 강제정리 <b>${filtered}</b>건 (잔고 동기화 레코드)`;
} else {
note.style.display = 'none';
note.innerHTML = '';
}
}
if (filtered > 0) {
console.log(`[실거래 분석] 0원 강제정리 ${filtered}건 제외 후 집계`);
}
// 승/패 바
if (s.total_trades > 0) {
@@ -2463,6 +2797,7 @@ function runScalpParamSearch() {
const base = new URLSearchParams({
start, end, top: 10, min_trades: 3,
rsi_period: $('bt_rsi_period').value,
rsi_overbought: $('bt_rsi_overbought')?.value || 75,
slot_money: $('bt_slot').value,
cooldown_min: $('bt_cooldown').value,
vol_mult: $('bt_vol_mult').value,
@@ -2471,6 +2806,12 @@ function runScalpParamSearch() {
time_start: $('bt_time_start').value,
time_end: $('bt_time_end').value,
max_daily: $('bt_max_daily').value,
high_chase_thr: $('bt_high_chase').value,
max_daily_chg: $('bt_max_daily_chg').value,
min_price: $('bt_min_price').value,
max_loss_krw: $('bt_max_loss_krw').value,
min_margin: $('bt_min_margin').value,
use_defense_filters: $('bt_use_defense')?.checked ? 1 : 0,
}).toString();
showSpinner(true);
fetch('/api/backtest/scalping/param_search?' + base)
@@ -2486,12 +2827,13 @@ function runScalpParamSearch() {
const p = r.params;
tbody.insertAdjacentHTML('beforeend', `<tr>
<td>${i+1}</td>
<td>${p.rsi_oversold}</td><td>${p.sl_pct}</td><td>${p.tp_pct}</td><td>${p.drop_rate}</td>
<td>${p.rsi_oversold}</td><td>${p.rsi_overbought ?? 75}</td><td>${p.sl_pct}</td><td>${p.tp_pct}</td><td>${p.drop_rate}</td>
<td><span class="badge ${String(p.use_defense_filters) === '0' ? 'badge-secondary' : 'badge-info'}">${String(p.use_defense_filters) === '0' ? 'OFF' : 'ON'}</span></td>
<td style="color:${r.total_pnl>=0?'var(--green)':'var(--red)'}">${r.total_pnl?.toLocaleString()}</td>
<td>${r.win_rate?.toFixed(1)}%</td><td>${r.total_trades}</td>
<td>${r.pf?.toFixed(2)}</td><td>${Math.round(r.mdd)?.toLocaleString()}</td>
<td><button class="btn btn-xs btn-outline-info" style="font-size:10px;padding:1px 6px"
onclick="applyScalpParams(${p.rsi_oversold},${p.sl_pct},${p.tp_pct},${p.drop_rate})">적용</button></td>
onclick="applyScalpParams(${p.rsi_oversold},${p.rsi_overbought ?? 75},${p.sl_pct},${p.tp_pct},${p.drop_rate})">적용</button></td>
</tr>`);
});
$('scalp_search_result').style.display = '';
@@ -2500,25 +2842,34 @@ function runScalpParamSearch() {
.catch(e => { showSpinner(false); alert('오류: ' + e); });
}
function applyScalpParams(rsi_oversold, sl_pct, tp_pct, drop_rate) {
function applyScalpParams(rsi_oversold, rsi_overbought, sl_pct, tp_pct, drop_rate) {
if (arguments.length === 4) { drop_rate = tp_pct; tp_pct = sl_pct; sl_pct = rsi_overbought; rsi_overbought = 75; }
$('bt_rsi_oversold').value = rsi_oversold;
if ($('bt_rsi_overbought')) $('bt_rsi_overbought').value = rsi_overbought;
$('bt_sl').value = sl_pct;
$('bt_tp').value = tp_pct;
$('bt_drop').value = drop_rate;
alert(`✅ 파라미터 적용 완료\nRSI과매도:${rsi_oversold} / 손절:${sl_pct}% / 익절:${tp_pct}% / 낙폭:${drop_rate}%\n\n백테스트를 다시 실행하세요.`);
alert(`✅ 파라미터 적용 완료\nRSI과매도:${rsi_oversold} / RSI과열:${rsi_overbought} / 손절:${sl_pct}% / 익절:${tp_pct}% / 낙폭:${drop_rate}%\n\n백테스트를 다시 실행하세요.`);
}
function saveScalpConfig() {
const body = {
rsi_oversold: parseFloat($('bt_rsi_oversold').value),
rsi_overbought: parseFloat($('bt_rsi_overbought')?.value || 75),
sl_pct: parseFloat($('bt_sl').value),
tp_pct: parseFloat($('bt_tp').value),
drop_rate: parseFloat($('bt_drop').value),
trail_trigger: parseFloat($('bt_trail_trig').value),
cooldown_min: parseFloat($('bt_cooldown').value),
high_chase_thr: parseFloat($('bt_high_chase').value),
max_daily_chg: parseFloat($('bt_max_daily_chg').value),
min_price: parseFloat($('bt_min_price').value),
max_loss_krw: parseFloat($('bt_max_loss_krw').value),
min_margin: parseFloat($('bt_min_margin').value),
use_defense_filters: !!($('bt_use_defense')?.checked),
};
if (!confirm(`💾 스캘핑 봇(kis_scalping_ver1.py)에 아래 파라미터를 저장합니까?\n\n` +
`RSI과매도: ${body.rsi_oversold}\n손절: ${body.sl_pct}%\n익절: ${body.tp_pct}%\n낙폭필터: ${body.drop_rate}%\n` +
if (!confirm(`💾 스캘핑 봇에 아래 파라미터를 저장합니까?\n\n` +
`RSI과매도: ${body.rsi_oversold} / RSI과열: ${body.rsi_overbought}\n손절: ${body.sl_pct}%\n익절: ${body.tp_pct}%\n낙폭필터: ${body.drop_rate}%\n` +
`\n⚠ 봇이 실행 중이면 다음 루프부터 즉시 반영됩니다.`)) return;
fetch('/api/backtest/scalping/save_config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
@@ -2529,6 +2880,27 @@ function saveScalpConfig() {
}).catch(e => alert('오류: ' + e));
}
// 방어 ON/OFF 토글은 클릭 즉시 DB 저장 (실시간 반영)
async function saveScalpDefenseRealtime() {
const enabled = !!($('bt_use_defense')?.checked);
try {
const r = await fetch('/api/backtest/scalping/save_config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ use_defense_filters: enabled }),
});
const d = await r.json();
if (d.error) {
alert('❌ 방어로직 저장 실패: ' + d.error);
return;
}
// 현재 값으로 즉시 백테스트 재실행해서 카드/요약도 동기화
runBacktest();
} catch (e) {
alert('오류: ' + e);
}
}
// ── 꼬리잡기 파라미터탐색 ────────────────────────────────────────────────────
function runTailParamSearch() {
const start = $('tl_start').value;
@@ -2546,6 +2918,16 @@ function runTailParamSearch() {
time_start: $('tl_ts').value,
time_end: $('tl_te').value,
max_daily: $('tl_maxd').value,
tail_pct_min: $('tl_tail_pct').value,
max_rec_3m: $('tl_max_rec_3m').value,
high_chase_thr: $('tl_high_chase').value,
min_price: $('tl_min_price').value,
max_daily_change: $('tl_max_daily_change').value,
ma20_max_above: $('tl_ma20_above').value,
max_loss_krw: $('tl_max_loss_krw').value,
min_drop_pct_for_loss_cut: parseFloat($('tl_min_drop_loss_cut').value) / 100 || 0.015,
stop_atr_mult: $('tl_stop_atr').value,
target_atr_mult: $('tl_target_atr').value,
}).toString();
showSpinner(true);
fetch('/api/backtest/tail/param_search?' + base)
@@ -2602,11 +2984,19 @@ function saveTailConfig() {
time_start: parseInt($('tl_ts').value, 10),
time_end: parseInt($('tl_te').value, 10),
max_daily: parseInt($('tl_maxd').value, 10),
min_price: parseFloat($('tl_min_price').value),
max_daily_change: parseFloat($('tl_max_daily_change').value),
ma20_max_above: parseFloat($('tl_ma20_above').value),
max_loss_krw: parseInt($('tl_max_loss_krw').value, 10),
min_drop_pct_for_loss_cut: parseFloat($('tl_min_drop_loss_cut').value) || 1.5, // % 단위로 전송 (1.5)
stop_atr_mult: parseFloat($('tl_stop_atr').value),
target_atr_mult: parseFloat($('tl_target_atr').value),
};
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` +
`최소가격: ${body.min_price} | 최대급등: ${body.max_daily_change}% | MA20이격: ${body.ma20_max_above}% | 최대손실: ${body.max_loss_krw}\n` +
`ATR손절: ${body.stop_atr_mult}배 | ATR익절: ${body.target_atr_mult}\n\n` +
`⚠️ STOP_LOSS_PCT는 음수로 저장됩니다. 봇 실행 중이면 다음 루프부터 반영됩니다.`)) return;
fetch('/api/backtest/tail/save_config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
@@ -2624,6 +3014,7 @@ function runBacktest() {
end: $('bt_end').value,
rsi_period: $('bt_rsi_period').value,
rsi_oversold: $('bt_rsi_oversold').value,
rsi_overbought: $('bt_rsi_overbought').value,
sl_pct: $('bt_sl').value,
tp_pct: $('bt_tp').value,
drop_rate: $('bt_drop').value,
@@ -2635,6 +3026,12 @@ function runBacktest() {
time_start: $('bt_time_start').value,
time_end: $('bt_time_end').value,
max_daily: $('bt_max_daily').value,
high_chase_thr: $('bt_high_chase').value,
max_daily_chg: $('bt_max_daily_chg').value,
min_price: $('bt_min_price').value,
max_loss_krw: $('bt_max_loss_krw').value,
min_margin: $('bt_min_margin').value,
use_defense_filters: $('bt_use_defense')?.checked ? 1 : 0,
};
const qs = new URLSearchParams(params).toString();
showSpinner(true);
@@ -2649,13 +3046,25 @@ function renderBacktest(d) {
const p = d.params;
$('bt_params_bar').style.display = '';
$('bt_params_bar').innerHTML =
`RSI(${p.rsi_period}) &lt;${p.rsi_oversold} | ` +
`손절-${p.sl_pct}% 익절+${p.tp_pct}% | ` +
`낙폭≥${p.drop_rate}% | 쿨다운${p.cooldown_min}분 | ` +
`거래량${p.vol_mult}배 | 트레일(${p.trail_trigger}%발동/${p.trail_stop}%추적) | ` +
const universeLabel = (p.universe_source === 'history' && p.universe_history_slots > 0)
? `유니버스: 저장 이력 ${p.universe_history_slots}슬롯`
: '유니버스: 시뮬레이션(폴백)';
const tr = (v) => (typeof v === 'number' ? Number(v).toFixed(1) : v);
const btSummaryLine =
`<span class="badge ${p.universe_source === 'history' ? 'badge-info' : 'badge-secondary'}" title="target_candidates_history 사용 여부">${universeLabel}</span> ` +
`<span class="badge ${p.use_defense_filters ? 'badge-info' : 'badge-secondary'}">방어:${p.use_defense_filters ? 'ON' : 'OFF'}</span> ` +
`RSI(${p.rsi_period}) &lt;${p.rsi_oversold} / &gt;${p.rsi_overbought ?? 75} 과열차단 | ` +
`손절-${tr(p.sl_pct)}% 익절+${tr(p.tp_pct)}% | ` +
`낙폭≥${tr(p.drop_rate)}% | 쿨다운${p.cooldown_min}분 | ` +
`거래량${tr(p.vol_mult)}배 | 트레일(${tr(p.trail_trigger)}%발동/${tr(p.trail_stop)}%추적) | ` +
`${p.time_window} | 일${p.max_daily}회 | ` +
`종목수 ${p.codes_analyzed}개`;
$('bt_params_bar').innerHTML = btSummaryLine;
const ctx = $('bt_trade_context');
if (ctx) {
ctx.innerHTML = '📋 ' + btSummaryLine;
ctx.style.display = '';
}
$('b_total').textContent = fmt(s.total_trades) + '';
$('b_winrate').textContent = s.win_rate + '%';
@@ -2699,9 +3108,10 @@ function renderBacktest(d) {
const pnlCls = pnl > 0 ? 'text-pnl-pos' : (pnl < 0 ? 'text-pnl-neg' : '');
const bt = t.buy_time || '';
const st = t.sell_time || '';
const nameCell = (t.name || t.code) ? `${t.name || t.code}<br><span style="color:var(--muted);font-size:11px">${t.code}</span>` : t.code;
tbody.insertAdjacentHTML('beforeend', `
<tr>
<td>${t.code}</td>
<td>${nameCell}</td>
<td style="font-size:11px">${bt.slice(0,4)+'-'+bt.slice(4,6)+'-'+bt.slice(6,8)+' '+bt.slice(8,10)+':'+bt.slice(10,12)}</td>
<td style="font-size:11px">${st.slice(0,4)+'-'+st.slice(4,6)+'-'+st.slice(6,8)+' '+st.slice(8,10)+':'+st.slice(10,12)}</td>
<td>${fmt(t.buy_price)}</td>
@@ -2740,6 +3150,13 @@ function runTailBacktest() {
time_end: $('tl_te').value,
max_daily: $('tl_maxd').value,
slot_money: $('tl_slot').value,
min_price: $('tl_min_price').value,
max_daily_change: $('tl_max_daily_change').value,
ma20_max_above: $('tl_ma20_above').value,
max_loss_krw: $('tl_max_loss_krw').value,
min_drop_pct_for_loss_cut: (parseFloat($('tl_min_drop_loss_cut').value) / 100) || 0.015,
stop_atr_mult: $('tl_stop_atr').value,
target_atr_mult: $('tl_target_atr').value,
};
const qs = new URLSearchParams(params).toString();
showSpinner(true);
@@ -2754,10 +3171,21 @@ function renderTailBacktest(d) {
const p = d.params;
$('tl_params_bar').style.display = '';
$('tl_params_bar').innerHTML =
const tlUniverseLabel = (p.universe_source === 'history' && (p.universe_history_slots || 0) > 0)
? `유니버스: 저장 이력 ${p.universe_history_slots}슬롯`
: '유니버스: 전체종목(폴백)';
const tlSummaryLine =
`<span class="badge ${p.universe_source === 'history' ? 'badge-info' : 'badge-secondary'}" title="target_candidates_history 사용 여부">${tlUniverseLabel}</span> ` +
`낙폭≥${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}%하락) | ` +
(p.max_loss_krw != null ? `최대손실컷 ${Number(p.max_loss_krw).toLocaleString()}원 | ` : '') +
`RSI(${p.rsi_period})과열<${p.rsi_threshold} | 쿨다운${p.cooldown_min}분 | ${p.time_window || '930-1500'} | 일${p.max_daily || 3}회 | 종목수 ${p.codes_analyzed}개`;
$('tl_params_bar').innerHTML = tlSummaryLine;
const tlCtx = $('tl_trade_context');
if (tlCtx) {
tlCtx.innerHTML = '📋 ' + tlSummaryLine;
tlCtx.style.display = '';
}
$('tl_total').textContent = (s.total_trades||0) + '';
$('tl_winrate').textContent = (s.win_rate||0) + '%';
@@ -2814,9 +3242,10 @@ function renderTailBacktest(d) {
if (!ct || ct.length < 12) return ct || '';
return ct.slice(0,4)+'-'+ct.slice(4,6)+'-'+ct.slice(6,8)+' '+ct.slice(8,10)+':'+ct.slice(10,12);
}
const nameCell = (t.name || t.code) ? `${t.name || t.code}<br><span style="color:var(--muted);font-size:11px">${t.code}</span>` : t.code;
tbody.insertAdjacentHTML('beforeend', `
<tr>
<td>${t.code}</td>
<td>${nameCell}</td>
<td style="font-size:11px">${fmtCT(entry)}</td>
<td style="font-size:11px">${fmtCT(exit)}</td>
<td>${fmt(ep)}</td>