커서가 망쳐놓은 듯
This commit is contained in:
762
backtest_web.py
762
backtest_web.py
@@ -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 기준(예: 930–1500)을 쓰므로, 비교 시 웹에서도 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) + '%';
|
||||
|
||||
Reference in New Issue
Block a user