#!/usr/bin/env python3 """ backtest_web.py — 매매 성과 분석 & 백테스트 웹 대시보드 ========================================================== 실행: python3 backtest_web.py 접속: http://localhost:5050 탭1. 실거래 분석 → trade_history 기반 (SCALP / SHORT / MOMENTUM / UPDOW / BREAKOUT / HOLDING 등) 탭2. 오늘 운영 → 당일 전략별·합계 거래대금·뽀찌(운용한도) 사용량·총수익률 (봇 재시작 불필요) 탭2b. 운영 설정 → 일일익절·전략ON/OFF·운용한도·TRIGGER 필터 등 env 실시간 조회·저장 탭3. 보유·매도 → active_trades + 실계좌 대조, OrderManager 시장가 매도 (HTS 수동매도 DB 꼬임 방지) 탭3. 스캘핑 백테스트 → ws_candles 1분봉 가격 재현(Price-Replay) 백테스트 탭4. 꼬리잡기 백테스트 → ws_candles 3분봉 기반, tail_engine 연동 (V3 방어 파라미터 지원) """ import sys, os, math, json, logging, threading, uuid from datetime import datetime, timedelta from typing import Any, List, Dict, Optional, Tuple sys.path.insert(0, os.path.dirname(__file__)) from database import TradeDB import holding_bot as hb import kis_holding_ver1 as hv1 # V1: RSI 3단계 분할매수 (횡보장 전략) from kis_trader.strategies import updow_holding_cfg as uhc # _updow_db(분봉 BT 테이블 보장)·영구구독 메타 resolve from kis_trader.utils.strategy_ids import ( EXCLUDED_STRATEGY_IDS, KIS_TRADER_STRATEGY_IDS, PORTFOLIO_EXCLUDED_STRATEGY_IDS, canonical_strategy_id, is_bot_strategy, strategy_like_pattern, strategy_prefix_for_filter, ) _LEGACY_STRATEGY_SQL = ", ".join(["%s"] * len(EXCLUDED_STRATEGY_IDS)) _PORTFOLIO_EXCLUDED_SQL = ", ".join(["%s"] * len(PORTFOLIO_EXCLUDED_STRATEGY_IDS)) from flask import Flask, jsonify, request, render_template logging.basicConfig(level=logging.INFO) logger = logging.getLogger("backtest_web") # TradeDB 초기화/종료 반복 로그 억제 (백테스트 루프에서 수백 번 찍히는 것 방지) logging.getLogger("TradeDB").setLevel(logging.WARNING) app = Flask(__name__) app.config["TEMPLATES_AUTO_RELOAD"] = True # 60분봉 수집 백그라운드 job 상태 저장소 _min_fetch_jobs: Dict[str, Dict] = {} # 보유·매도 탭: OrderManager / 시세 client (프로세스 내 1회 초기화) _portfolio_order_mgr = None _portfolio_market_client = None _portfolio_infra_lock = threading.Lock() def _portfolio_build_market_client(trade_client): """kis_trader.main 과 동일 정책: 시세는 실키, 매매는 KIS_MOCK.""" from kis_trader.execution.kis_client import KISClient from kis_trader.utils.env import get_env_from_db if not trade_client.mock: return trade_client real_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip() real_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip() if not real_key or not real_secret: logger.warning( "KIS_APP_KEY_REAL 미설정 → 보유탭 시세도 모의 client 사용 (현재가 조회 제한 가능)" ) return trade_client return KISClient(mock=False, app_key=real_key, app_secret=real_secret) def _portfolio_infra(): """웹 수동 매도용 OrderManager (봇 main 과 동일 매도·DB 경로).""" global _portfolio_order_mgr, _portfolio_market_client with _portfolio_infra_lock: if _portfolio_order_mgr is not None: return _portfolio_order_mgr, _portfolio_market_client from kis_trader.database.db_manager import get_db from kis_trader.execution.account_cash import AccountCashLedger from kis_trader.execution.kis_client import KISClient from kis_trader.execution.order_manager import OrderManager db = get_db() trade_client = KISClient() _portfolio_order_mgr = OrderManager( client=trade_client, db=db, cash_ledger=AccountCashLedger(db), ) _portfolio_market_client = _portfolio_build_market_client(trade_client) return _portfolio_order_mgr, _portfolio_market_client def _portfolio_origin_sets() -> tuple: """미등록 보유분 origin 판별 — orphan_reconcile 모듈과 동일 로직.""" from kis_trader.execution.orphan_reconcile import get_portfolio_origin_sets return get_portfolio_origin_sets(_db()) def _list_active_trades_rows( strategy_filter: Optional[str] = None, *, for_portfolio: bool = False, ) -> List[Dict[str, Any]]: """active_trades 전 행 (code+strategy 복합키 — get_active_trades dict 와 달리 중복 없음).""" db = _db() sql = "SELECT * FROM active_trades" params: List[Any] = [] clauses: List[str] = [] like_pat = strategy_like_pattern(strategy_filter) if like_pat: clauses.append("strategy LIKE %s") params.append(like_pat) excluded = PORTFOLIO_EXCLUDED_STRATEGY_IDS if for_portfolio else EXCLUDED_STRATEGY_IDS ex_sql = _PORTFOLIO_EXCLUDED_SQL if for_portfolio else _LEGACY_STRATEGY_SQL clauses.append(f"strategy NOT IN ({ex_sql})") params.extend(list(excluded)) if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY strategy, code" try: cur = db.conn.execute(sql, tuple(params)) return list(cur.fetchall() or []) except Exception as e: logger.error("active_trades 조회 실패: %s", e) return [] def _portfolio_price_fast( row: Dict[str, Any], broker_row: Optional[Dict[str, Any]] = None, ) -> float: """ 보유 목록용 현재가 — REST 시세 N회 호출 없음 (느림 방지). 실잔고 평가금/수량 → DB current_price → 매수가 순. """ br = broker_row or {} try: qty = int(br.get("qty") or 0) evlu = float(br.get("evlu_amt") or 0) if qty > 0 and evlu > 0: return evlu / qty prpr = abs(float(str(br.get("current_price") or br.get("prpr") or 0).replace(",", ""))) if prpr > 0: return prpr except (TypeError, ValueError): pass try: cp = float(row.get("current_price") or 0) if cp > 0: return cp except (TypeError, ValueError): pass return float(row.get("avg_buy_price") or 0) def _portfolio_live_price(code: str, row: Dict[str, Any], market_client) -> float: """매도 직전 참고가 — 1종목만 시세 API (필요 시).""" try: if market_client is not None: pd_ = market_client.inquire_price(code) if pd_: px = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", ""))) if px > 0: return px except Exception as e: logger.debug("inquire_price %s: %s", code, e) return _portfolio_price_fast(row) # ──────────────────────────────────────────────────────────────────────────── # 헬퍼 함수 # ──────────────────────────────────────────────────────────────────────────── def _db() -> TradeDB: return TradeDB() def _get_fee_defaults() -> dict: """ DB 병합 스냅샷(env_config 공통)에서 수수료/세금 기본값 로드. """ try: from kis_trader.utils.env import get_merged_env_dict r = get_merged_env_dict() if r: return { "fee_rate": float(r.get("FEE_RATE_PCT") or 0.015), "sell_tax": float(r.get("SELL_TAX_RATE_PCT") or 0.18), } except Exception: pass return {"fee_rate": 0.015, "sell_tax": 0.18} def _strategy_env(strategy_id: str) -> Dict[str, Any]: """config_{strategy} + env_config 병합 — 웹·백테 단일 소스.""" from kis_trader.utils.env import get_strategy_env_dict return get_strategy_env_dict(strategy_id) def _compute_rsi_series(closes: list, period: int = 3) -> list: """RSI 시리즈 계산 (Wilder 스무딩)""" rsi_list = [None] * len(closes) if len(closes) < period + 1: return rsi_list deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))] gains = [max(d, 0) for d in deltas] losses = [max(-d, 0) for d in deltas] avg_gain = sum(gains[:period]) / period avg_loss = sum(losses[:period]) / period for i in range(period, len(closes)): idx = i - 1 # delta 배열 기준 if i > period: avg_gain = (avg_gain * (period - 1) + gains[idx]) / period avg_loss = (avg_loss * (period - 1) + losses[idx]) / period rs = avg_gain / avg_loss if avg_loss > 0 else float('inf') rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0 rsi_list[i] = rsi_val return rsi_list # ──────────────────────────────────────────────────────────────────────────── # 가상거래에 종목명 부여 (stock_meta 조회, 실거래와 동일하게 표시) # ──────────────────────────────────────────────────────────────────────────── def _enrich_trades_with_names(db, trades: list) -> None: """trades 리스트 내 각 거래에 'name' 필드 추가. 1) ``stock_meta`` (키움 테마 스크립트 등으로 채운 마스터) 2) 없으면 ``target_candidates_history`` 최근 스냅샷 name (조건검색 적재) 3) 둘 다 없으면 code 그대로 """ if not trades: return codes = list({str(t.get("code")).strip() for t in trades if t.get("code")}) if not codes: return code_to_name: Dict[str, str] = {} try: placeholders = ", ".join(["%s"] * len(codes)) rows = db.conn.execute( "SELECT code, name FROM stock_meta WHERE code IN (" + placeholders + ")", codes, ).fetchall() for r in rows: c = str(r["code"]).strip() n = (r.get("name") or "").strip() if n and n != c: code_to_name[c] = n except Exception as exc: logger.debug("stock_meta 이름 조회 실패: %s", exc) missing = [c for c in codes if c not in code_to_name] if missing: try: ph = ", ".join(["%s"] * len(missing)) rows = db.conn.execute( "SELECT code, name FROM target_candidates_history " "WHERE code IN (" + ph + ") AND name IS NOT NULL AND name != '' " "ORDER BY COALESCE(event_time, scan_time) DESC, id DESC", missing, ).fetchall() for r in rows: c = str(r["code"]).strip() if c in code_to_name: continue n = (r.get("name") or "").strip() if n and n != c: code_to_name[c] = n except Exception as exc: logger.debug("target_candidates_history 이름 조회 실패: %s", exc) for t in trades: c = str(t.get("code") or "").strip() t["name"] = code_to_name.get(c, c) def _norm_ts(t: Dict[str, Any], keys) -> str: """거래 dict 에서 키 후보 중 첫 유효값을 14자리 타임스탬프 문자열로 정규화.""" for k in keys: v = t.get(k) if v is not None and str(v).strip(): s = str(v).replace("-", "").replace(":", "").replace(" ", "").replace("T", "") return s[:14].ljust(14, "0") return "" def _trade_exit_sort_key(t: Dict[str, Any]): """가상/실거래 행 — 매도(청산) 시각 정렬용 (엄격한 전순서). 매도시각만으로는 같은 분(09:02:00)에 청산된 거래가 동점이 되어, 오름차순(누적손익 계산)과 내림차순(화면 표시)의 동점 처리가 어긋나면서 누적손익 컬럼이 표시순과 따로 노는 버그가 있었다. 매수시각·종목·가격을 보조키로 추가해 동점을 제거 → 두 정렬이 정확히 거울상이 되도록 한다. """ exit_ts = _norm_ts(t, ("exit_time", "sell_time", "sell_date", "buy_time", "entry_time", "buy_date")) entry_ts = _norm_ts(t, ("entry_time", "buy_time", "buy_date")) return ( exit_ts, entry_ts, str(t.get("code") or ""), str(t.get("buy_price") or t.get("entry_price") or ""), str(t.get("qty") or t.get("quantity") or ""), ) def _int_display_price(v: Any) -> int: """거래내역 표시용 — 주식 가격 정수(원).""" try: return int(round(float(v or 0))) except (TypeError, ValueError): return 0 def _trade_with_int_prices(trade: Dict) -> Dict: """웹 거래표 매수가·매도가 — 소수점 제거.""" out = dict(trade) for k in ( "buy_price", "sell_price", "entry_price", "exit_price", "entry", "exit", "avg_price", "pnl", "realized_pnl", "unrealized_pnl", "cum_pnl", ): if k in out and out[k] is not None and out[k] != "": out[k] = _int_display_price(out[k]) return out def _trades_recent_first(trades: List[Dict], limit: int = 200) -> List[Dict]: """매도 시각 기준 최신순 상위 limit 건 (가상 거래 내역 표시용).""" if not trades: return [] ordered = sorted(trades, key=_trade_exit_sort_key, reverse=True) cap = max(0, int(limit)) sliced = ordered[:cap] if cap else ordered return [_trade_with_int_prices(t) for t in sliced] def _momentum_source_label(kind: str, src: str) -> str: s = str(src or "").strip().lower() if kind == "entry": if s == "ws_ticks": return "틱진입" if s == "ohlc_open": return "시가" return s or "-" if s == "ws_ticks": return "틱청산" if s == "ohlc_bar": return "OHLC" return s or "-" def _enrich_momentum_trades_debug( trades: List[Dict], *, total_budget_krw: float, ) -> None: """매도 시각 순 누적손익·틱/OHLC 디버그 라벨 (모멘텀 웹 거래표).""" if not trades: return ordered = sorted(trades, key=_trade_exit_sort_key) cum = 0.0 tb = float(total_budget_krw or 0) for t in ordered: pnl = float(t.get("pnl") or 0) cum += pnl t["cum_pnl"] = int(round(cum)) t["cum_return_pct"] = round(cum / tb * 100.0, 2) if tb > 0 else 0.0 el = _momentum_source_label("entry", str(t.get("entry_source") or "")) xl = _momentum_source_label("exit", str(t.get("exit_source") or "")) t["entry_source_label"] = el t["exit_source_label"] = xl t["debug_tick"] = f"{el}→{xl}" def _resolve_backtest_universe( db: TradeDB, start_key: str, end_key: str, use_saved_history: bool, codes_candles: Optional[Dict[str, List[Dict]]] = None, sim_kind: Optional[str] = None, scan_interval_min: int = 5, strategy_id: Optional[str] = None, ) -> Tuple[Optional[Dict[str, List[str]]], str, int, int]: """ 백테스트 유니버스 소스 통합 (param_search·신봇 실매와 동일 조회). - ``use_saved_history=True`` + ``strategy_id``: ``TradeDBExt.get_universe_by_candle_time`` — 전략별 event_time → 1분봉 키. - ``sim_kind`` reversal/momentum: 이력 없거나 미사용 시 ``scalping_engine`` 시뮬 (5분 슬롯). - 둘 다 아니면 ``None`` → ws_candles 전 종목 (꼬리·돌파). Returns: (universe_by_slot, source_label, history_bin_count, engine_scan_interval_min) """ start_ymd = start_key[:8] end_ymd = end_key[:8] sim_interval = scan_interval_min if use_saved_history and strategy_id: try: from kis_trader.database.db_manager import get_db as _get_ext_db strict = False strict_lag = 1 if strategy_id == "MOMENTUM": from kis_trader.backtest.momentum_backtest_common import ( momentum_backtest_universe_strict_enabled, momentum_backtest_universe_strict_lag_min, momentum_universe_exit_debounce_sec, ) strict = momentum_backtest_universe_strict_enabled() strict_lag = momentum_backtest_universe_strict_lag_min() debounce_sec = momentum_universe_exit_debounce_sec() else: debounce_sec = 0 history = _get_ext_db().get_universe_by_candle_time( strategy_id=strategy_id, start_ymd=start_ymd, end_ymd=end_ymd, strict=strict, strict_lag_minutes=strict_lag, exit_debounce_sec=debounce_sec, ) if history: src = "history_strict" if strict else "history" return history, src, len(history), 1 except Exception as exc: logger.debug( "유니버스 이력 조회 실패(strategy_id=%s): %s", strategy_id, exc, ) if sim_kind and codes_candles: top_n = int(os.environ.get("UPDATE_UNIVERSE_TOP_N", "20")) min_score = float(os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0")) if sim_kind == "momentum": from kis_trader.engine import momentum_engine as _me_uni slot_map = _me_uni.build_universe_simulation_momentum( codes_candles, top_n=top_n, min_score=min_score, scan_interval_min=sim_interval, ) else: slot_map = se.build_universe_simulation( codes_candles, top_n=top_n, min_score=min_score, scan_interval_min=sim_interval, ) return slot_map, "sim", 0, sim_interval return None, "all", 0, 1 def _parse_backtest_universe_arg( request, *, default: str = "history", sim_kind: Optional[str] = None, ) -> Tuple[bool, str, Optional[str]]: """ 백테스트 유니버스 쿼리 통일 — 모두 ``target_candidates_history`` 동일 테이블. - ``universe=history`` : 저장 후보 이력(슬롯·코드) - ``universe=sim`` : 시뮬 유니버스 (scalping_engine, sim_kind 필수) - ``universe=all`` : ws_candles 전 종목 (필터 없음) 레거시 별칭: scalp_universe, mom_universe, tail_universe, bo_universe 등 """ raw = ( request.args.get("universe") or request.args.get("scalp_universe") or request.args.get("mom_universe") or request.args.get("tail_universe") or request.args.get("tl_universe") or request.args.get("bo_universe") or request.args.get("breakout_universe") or request.args.get("bt_universe") or default ).strip().lower() if raw == "history": return True, "history", sim_kind if raw == "sim": return False, "sim", sim_kind return False, "all", None # ──────────────────────────────────────────────────────────────────────────── # API: 실거래 분석 # ──────────────────────────────────────────────────────────────────────────── @app.route("/api/actual", methods=["GET"]) def api_actual(): strategy = request.args.get("strategy", "SHORT") start = request.args.get("start", "") end = request.args.get("end", "") db = _db() try: # ── 전략 ID: kis_trader.utils.strategy_ids 와 동일 (SCALP/SHORT/UPDOW … + 구식명 접두어) like_pattern = strategy_like_pattern(strategy) or "SHORT%" params = [like_pattern, *EXCLUDED_STRATEGY_IDS] sql = ( "SELECT * FROM trade_history WHERE strategy LIKE %s " f"AND strategy NOT IN ({_LEGACY_STRATEGY_SQL})" ) if start: sql += " AND sell_date >= %s" params.append(start + " 00:00:00") if end: sql += " AND sell_date <= %s" params.append(end + " 23:59:59") sql += " ORDER BY sell_date ASC" 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) closed_trades = cleaned # ── 보유 중(active_trades) = 매수만 된 포지션 — 목록에 표시, 손익 집계는 제외 ── open_rows = _list_active_trades_rows( None if (strategy or "").upper() == "ALL" else strategy, for_portfolio=False, ) open_trades: List[Dict] = [] now_dt = datetime.now() for row in open_rows: bd_raw = row.get("buy_date") if not bd_raw: continue bd_str = str(bd_raw) if start and bd_str < start + " 00:00:00": continue if end and bd_str > end + " 23:59:59": continue buy_px = float(row.get("avg_buy_price") or 0) cur_px = float(row.get("current_price") or buy_px) qty = int(row.get("current_qty") or 0) if qty <= 0: continue try: buy_time = datetime.strptime(bd_str[:19], "%Y-%m-%d %H:%M:%S") hold_min = int((now_dt - buy_time).total_seconds() / 60) except Exception: hold_min = 0 unrealized = (cur_px - buy_px) * qty if buy_px > 0 else 0.0 profit_rate = ((cur_px - buy_px) / buy_px * 100.0) if buy_px > 0 else 0.0 open_trades.append({ "code": row.get("code"), "name": row.get("name"), "strategy": row.get("strategy"), "buy_price": _int_display_price(buy_px), "sell_price": _int_display_price(cur_px), "qty": qty, "realized_pnl": None, "unrealized_pnl": round(unrealized), "profit_rate": round(profit_rate, 2), "hold_minutes": hold_min, "buy_date": bd_str, "sell_date": None, "sell_reason": "보유중", "is_open": True, }) # 표시용: 보유(최신 매수일) + 청산 완료(매도일) trades = open_trades + closed_trades trades.sort( key=lambda t: str(t.get("sell_date") or t.get("buy_date") or ""), reverse=False, ) # 날짜 직렬화 for t in closed_trades: for k in ("buy_date", "sell_date"): if t.get(k): t[k] = str(t[k]) for t in open_trades: if t.get("buy_date"): t["buy_date"] = str(t["buy_date"]) # 누적 손익·승률 등은 청산 완료 건만 집계 equity = [] cum_pnl = 0.0 for t in closed_trades: cum_pnl += float(t.get("realized_pnl") or 0) equity.append({ "date": t["sell_date"][:10] if t.get("sell_date") else "", "cum_pnl": round(cum_pnl), "pnl": round(float(t.get("realized_pnl") or 0)), }) # 요약 통계 (청산 완료만) total = len(closed_trades) wins = [t for t in closed_trades if float(t.get("realized_pnl") or 0) > 0] losses = [t for t in closed_trades if float(t.get("realized_pnl") or 0) < 0] total_pnl = sum(float(t.get("realized_pnl") or 0) for t in closed_trades) avg_hold = ( sum(float(t.get("hold_minutes") or 0) for t in closed_trades) / total ) if total else 0 win_pnl = sum(float(t.get("realized_pnl") or 0) for t in wins) loss_pnl = sum(float(t.get("realized_pnl") or 0) for t in losses) profit_factor = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0 # 최대 낙폭(MDD) peak, mdd = 0.0, 0.0 cum = 0.0 for t in closed_trades: cum += float(t.get("realized_pnl") or 0) if cum > peak: peak = cum dd = peak - cum if dd > mdd: mdd = dd # 매도 이유별 집계 (청산 완료만) reasons: Dict[str, int] = {} for t in closed_trades: r = t.get("sell_reason") or "기타" reasons[r] = reasons.get(r, 0) + 1 # 일별 P&L daily: Dict[str, float] = {} for t in closed_trades: day = (t.get("sell_date") or "")[:10] if day: daily[day] = daily.get(day, 0) + float(t.get("realized_pnl") or 0) daily_list = [{"date": d, "pnl": round(v)} for d, v in sorted(daily.items())] # 종목별 상위 손익 code_pnl: Dict[str, float] = {} code_name: Dict[str, str] = {} for t in closed_trades: c = t["code"] code_pnl[c] = code_pnl.get(c, 0) + float(t.get("realized_pnl") or 0) code_name[c] = t.get("name") or c top_codes = sorted(code_pnl.items(), key=lambda x: x[1], reverse=True)[:10] top_list = [{"code": c, "name": code_name[c], "pnl": round(v)} for c, v in top_codes] # 누적손익·누적% (청산 완료, 매도시각 순 — 백테 거래표와 동일) latest_env = db.get_latest_env() env_row = dict(latest_env["snapshot"]) if latest_env else {} total_budget_krw = 0.0 try: from kis_trader.backtest.momentum_backtest_common import ( resolve_momentum_portfolio_params, ) from kis_trader.backtest.scalping_backtest_common import ( resolve_scalp_portfolio_params, ) strat_u = (strategy or "SCALP").upper() if strat_u == "MOMENTUM": port = resolve_momentum_portfolio_params(env_row, {}) else: port = resolve_scalp_portfolio_params( env_row, None, strategy=strat_u if strat_u != "ALL" else "SCALP", ) total_budget_krw = float(port.get("total_budget_krw") or 0) except Exception: total_budget_krw = 0.0 peak_cum = 0.0 peak_cum_at = "" cum_trace = 0.0 for t in sorted(closed_trades, key=lambda x: str(x.get("sell_date") or "")): cum_trace += float(t.get("realized_pnl") or 0) t["cum_pnl"] = int(round(cum_trace)) t["cum_return_pct"] = ( round(cum_trace / total_budget_krw * 100.0, 2) if total_budget_krw > 0 else 0.0 ) t["debug_tick"] = "실매체결" if cum_trace > peak_cum: peak_cum = cum_trace peak_cum_at = str(t.get("sell_date") or "") return jsonify({ "summary": { "total_trades": total, "win_trades": len(wins), "loss_trades": len(losses), "win_rate": round(len(wins) / total * 100, 1) if total else 0, "total_pnl": round(total_pnl), "avg_hold_min": round(avg_hold, 1), "profit_factor": round(profit_factor, 2), "max_drawdown": round(mdd), "peak_cum_pnl": round(peak_cum), "peak_cum_at": peak_cum_at[:19] if peak_cum_at else "", }, "params": { "strategy": strategy, "total_budget_krw": round(total_budget_krw), }, "meta": { "filtered_forced_rows": filtered_out, "closed_count": len(closed_trades), "open_count": len(open_trades), }, "equity": equity, "daily": daily_list, "reasons": reasons, "top_codes": top_list, "trades": _trades_recent_first(trades, 200), }) finally: db.close() # ──────────────────────────────────────────────────────────────────────────── # API: 실거래 당일 운영 대시보드 (전략별 + 합계) # ──────────────────────────────────────────────────────────────────────────── # ※ SCALP·DBBAND·RANGE_BREAK 는 숨김 전략(strategy_ids.HIDDEN_STRATEGY_IDS) → 대시보드 집계 제외. _ACTUAL_DASHBOARD_STRATEGIES: Tuple[str, ...] = ( "SHORT", "MOMENTUM", "UPDOW", "BREAKOUT", ) _ACTUAL_DASHBOARD_LABELS: Dict[str, str] = { "SCALP": "스캘핑", "SHORT": "꼬리잡기", "MOMENTUM": "모멘텀", "UPDOW": "60분 하락매수", "BREAKOUT": "돌파", "DBBAND": "더블BB", } _STRATEGY_ENABLED_DEFAULTS: Dict[str, bool] = { "SCALP": True, "SHORT": True, "MOMENTUM": False, "UPDOW": False, "BREAKOUT": False, "DBBAND": False, } def _portfolio_strategy_key(strategy_id: str) -> str: """backtest_portfolio_common STRATEGY_PORTFOLIO_KEYS (SHORT → TAIL).""" s = (strategy_id or "").upper() if s == "SHORT": return "TAIL" return s def _is_forced_ghost_trade(row: Dict[str, Any]) -> bool: reason = str(row.get("sell_reason") or "") try: sell_price = float(row.get("sell_price") or 0) except Exception: sell_price = 0.0 is_forced = ("잔고없음(강제정리)" in reason) or ("잔고동기화(외부매도)" in reason) return is_forced and sell_price <= 0 def _day_bounds_kst(day_iso: str) -> Tuple[str, str]: d = (day_iso or "").strip()[:10] if len(d) != 10: d = datetime.now().strftime("%Y-%m-%d") return f"{d} 00:00:00", f"{d} 23:59:59" def _strategy_enabled_from_snapshot(snap: Dict[str, str], strategy_id: str) -> bool: from kis_trader.utils.env import get_env_bool key = f"STRATEGY_{strategy_id}_ENABLED" default = _STRATEGY_ENABLED_DEFAULTS.get(strategy_id, False) # 스냅샷이 있으면 DB 값 우선 (get_env_bool은 os.environ 폴백 포함) if snap and key in snap: raw = str(snap.get(key) or "").strip().lower() if raw in ("1", "true", "yes", "on"): return True if raw in ("0", "false", "no", "off"): return False return get_env_bool(key, default) def _strategy_budget_limit_krw(snap: Dict[str, str], strategy_id: str) -> float: from kis_trader.backtest.backtest_portfolio_common import resolve_portfolio_params pf_key = _portfolio_strategy_key(strategy_id) pf = resolve_portfolio_params(snap, strategy=pf_key) return float(pf.get("total_budget_krw") or 0) def _empty_dashboard_row(strategy_id: str, snap: Dict[str, str]) -> Dict[str, Any]: limit = _strategy_budget_limit_krw(snap, strategy_id) return { "strategy_id": strategy_id, "label": _ACTUAL_DASHBOARD_LABELS.get(strategy_id, strategy_id), "enabled": _strategy_enabled_from_snapshot(snap, strategy_id), "buy_turnover_krw": 0, "sell_turnover_krw": 0, "turnover_krw": 0, "closed_trades": 0, "open_positions": 0, "realized_pnl_krw": 0, "budget_limit_krw": round(limit), "budget_used_krw": 0, "budget_now_krw": 0, "budget_usage_pct": 0.0, "return_pct": 0.0, } def _closed_buy_invested_krw(row: Dict[str, Any]) -> float: """청산 건 매수 투입금(평단×수량) — 당일 뽀찌 피크 계산용.""" try: qty = int(row.get("qty") or 0) buy_px = float(row.get("buy_price") or 0) except Exception: return 0.0 if qty > 0 and buy_px > 0: return float(buy_px * qty) return 0.0 def _open_invested_krw(row: Dict[str, Any]) -> float: """보유 중 투입금 — total_invested 우선.""" try: invested = float(row.get("total_invested") or 0) except Exception: invested = 0.0 if invested > 0: return invested try: qty = int(row.get("current_qty") or 0) avg_px = float(row.get("avg_buy_price") or 0) if qty > 0 and avg_px > 0: return float(avg_px * qty) except Exception: pass return 0.0 def _daily_peak_budget_krw( strategy_id: str, day_start: str, day_end: str, closed_overlap: List[Dict[str, Any]], open_rows: List[Dict[str, Any]], ) -> int: """ 당일(KST) 전략별 최대 동시 투입(뽀찌 피크). - 장 시작 시 이미 보유(전일 매수) → initial - 당일 매수 이벤트 +, 당일 매도 이벤트 − """ events: List[Tuple[str, float]] = [] initial = 0.0 for t in closed_overlap: if canonical_strategy_id(t.get("strategy")) != strategy_id: continue if _is_forced_ghost_trade(t): continue amt = _closed_buy_invested_krw(t) if amt <= 0: continue bd = str(t.get("buy_date") or "") sd = str(t.get("sell_date") or "") if bd < day_start and sd >= day_start: initial += amt if day_start <= bd <= day_end: events.append((bd, amt)) if day_start <= sd <= day_end: events.append((sd, -amt)) for o in open_rows: if canonical_strategy_id(o.get("strategy")) != strategy_id: continue amt = _open_invested_krw(o) if amt <= 0: continue bd = str(o.get("buy_date") or "") if bd < day_start: initial += amt elif day_start <= bd <= day_end: events.append((bd, amt)) events.sort(key=lambda x: x[0]) running = initial peak = initial for _, delta in events: running += delta if running > peak: peak = running return int(round(max(peak, 0))) def _build_actual_dashboard(db: TradeDB, day_iso: str) -> Dict[str, Any]: """당일(KST) 전략별 거래대금·뽀찌 사용·실현수익률 — 백테 bot_pct 분모와 동일(운용한도).""" snap = db.get_merged_env_snapshot() or {} day_start, day_end = _day_bounds_kst(day_iso) day = day_start[:10] rows_by_sid: Dict[str, Dict[str, Any]] = { sid: _empty_dashboard_row(sid, snap) for sid in _ACTUAL_DASHBOARD_STRATEGIES } ex_params = list(EXCLUDED_STRATEGY_IDS) sql_closed = ( "SELECT * FROM trade_history WHERE strategy NOT IN (" + _LEGACY_STRATEGY_SQL + ") AND ((sell_date >= %s AND sell_date <= %s) " "OR (buy_date >= %s AND buy_date <= %s))" ) params = ex_params + [day_start, day_end, day_start, day_end] closed_raw = db.conn.execute(sql_closed, params).fetchall() sql_overlap = ( "SELECT * FROM trade_history WHERE strategy NOT IN (" + _LEGACY_STRATEGY_SQL + ") AND buy_date <= %s AND sell_date >= %s" ) closed_overlap = [ dict(r) for r in db.conn.execute(sql_overlap, ex_params + [day_end, day_start]).fetchall() ] for raw in closed_raw: t = dict(raw) if _is_forced_ghost_trade(t): continue sid = canonical_strategy_id(t.get("strategy")) if sid not in rows_by_sid: continue row = rows_by_sid[sid] try: qty = int(t.get("qty") or 0) except Exception: qty = 0 try: buy_px = float(t.get("buy_price") or 0) sell_px = float(t.get("sell_price") or 0) except Exception: buy_px, sell_px = 0.0, 0.0 buy_notional = buy_px * qty if qty > 0 and buy_px > 0 else 0.0 sell_notional = sell_px * qty if qty > 0 and sell_px > 0 else 0.0 bd = str(t.get("buy_date") or "") sd = str(t.get("sell_date") or "") if day_start <= bd <= day_end: row["buy_turnover_krw"] = int(row["buy_turnover_krw"]) + int(round(buy_notional)) if day_start <= sd <= day_end: row["sell_turnover_krw"] = int(row["sell_turnover_krw"]) + int(round(sell_notional)) row["closed_trades"] = int(row["closed_trades"]) + 1 row["realized_pnl_krw"] = int(row["realized_pnl_krw"]) + int( round(float(t.get("realized_pnl") or 0)) ) open_rows = _list_active_trades_rows(None, for_portfolio=False) for orow in open_rows: sid = canonical_strategy_id(orow.get("strategy")) if sid not in rows_by_sid: continue row = rows_by_sid[sid] try: invested = float(orow.get("total_invested") or 0) except Exception: invested = 0.0 if invested <= 0: try: qty_o = int(orow.get("current_qty") or 0) avg_px = float(orow.get("avg_buy_price") or 0) invested = avg_px * qty_o except Exception: invested = 0.0 if invested > 0: row["budget_now_krw"] = int(row["budget_now_krw"]) + int(round(invested)) row["open_positions"] = int(row["open_positions"]) + 1 bd = str(orow.get("buy_date") or "") if day_start <= bd <= day_end and invested > 0: row["buy_turnover_krw"] = int(row["buy_turnover_krw"]) + int(round(invested)) strategy_list: List[Dict[str, Any]] = [] for sid in _ACTUAL_DASHBOARD_STRATEGIES: row = rows_by_sid[sid] row["turnover_krw"] = int(row["buy_turnover_krw"]) + int(row["sell_turnover_krw"]) peak = _daily_peak_budget_krw(sid, day_start, day_end, closed_overlap, open_rows) row["budget_used_krw"] = peak limit = float(row["budget_limit_krw"] or 0) pnl = float(row["realized_pnl_krw"] or 0) row["budget_usage_pct"] = round(peak / limit * 100, 2) if limit > 0 else 0.0 row["return_pct"] = round(pnl / limit * 100, 3) if limit > 0 else 0.0 strategy_list.append(row) total_limit = sum( float(r["budget_limit_krw"] or 0) for r in strategy_list if r.get("enabled") ) total_used = sum(float(r["budget_used_krw"] or 0) for r in strategy_list) total_now = sum(float(r["budget_now_krw"] or 0) for r in strategy_list) total_pnl = sum(float(r["realized_pnl_krw"] or 0) for r in strategy_list) total_buy = sum(int(r["buy_turnover_krw"] or 0) for r in strategy_list) total_sell = sum(int(r["sell_turnover_krw"] or 0) for r in strategy_list) total_turnover = total_buy + total_sell totals = { "buy_turnover_krw": total_buy, "sell_turnover_krw": total_sell, "turnover_krw": total_turnover, "closed_trades": sum(int(r["closed_trades"] or 0) for r in strategy_list), "open_positions": sum(int(r["open_positions"] or 0) for r in strategy_list), "realized_pnl_krw": int(round(total_pnl)), "budget_limit_krw": int(round(total_limit)), "budget_used_krw": int(round(total_used)), "budget_now_krw": int(round(total_now)), "budget_usage_pct": round(total_used / total_limit * 100, 2) if total_limit > 0 else 0.0, "return_pct": round(total_pnl / total_limit * 100, 3) if total_limit > 0 else 0.0, } return { "date": day, "as_of": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "strategies": strategy_list, "totals": totals, "notes": { "turnover": "당일 매수·매도 체결금액 합(매수일·매도일 각각 집계, 동일일 왕복 시 양쪽 합산)", "budget_used": "당일 최대 동시 투입(뽀찌 피크) — 매수·매도 시각 순으로 재구성, 청산 후에도 당일 사용량 유지", "budget_now": "현재 보유 중 투입금(active_trades) — 실시간 스냅샷", "return_pct": "당일 실현손익 ÷ ON 전략 운용한도 합 (백테 bot_pct와 동일 분모)", }, } @app.route("/api/actual/dashboard", methods=["GET"]) def api_actual_dashboard(): day = (request.args.get("date") or "").strip()[:10] if not day: day = datetime.now().strftime("%Y-%m-%d") db = _db() try: payload = _build_actual_dashboard(db, day) return jsonify({"ok": True, **payload}) except Exception as e: logger.exception("actual dashboard 실패") return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() # ──────────────────────────────────────────────────────────────────────────── # API: 스캘핑 가격 재현 백테스트 (ws_candles 기반) # ──────────────────────────────────────────────────────────────────────────── def _t2dt(t: str) -> datetime: return datetime.strptime(t, "%Y%m%d%H%M") def _backtest_period_days(start: str, end: str, fallback: int = 1) -> int: """백테스트 기간 일수(시작·종료일 포함).""" try: s = (start or "").replace("-", "")[:8] e = (end or "").replace("-", "")[:8] if len(s) == 8 and len(e) == 8: d0 = datetime.strptime(s, "%Y%m%d") d1 = datetime.strptime(e, "%Y%m%d") return max(1, (d1 - d0).days + 1) except (ValueError, TypeError): pass return max(1, fallback) from kis_trader.engine import scalping_engine as se from kis_trader.engine import momentum_engine as me from kis_trader.backtest import tail_backtest_common as tbc from kis_trader.backtest import momentum_backtest_common as mbc from kis_trader.backtest import breakout_backtest_common as bbc from kis_trader.backtest import range_break_backtest_common as rbc from kis_trader.backtest import scalping_backtest_common as sbc # 돌파매매 — strategies 안의 모듈 함수를 그대로 재사용 (실매매-백테 100% 일치) from kis_trader.strategies.range_break import range_break_ui_to_engine_params from kis_trader.strategies.breakout import ( breakout_entry_mode, breakout_invest_amount_krw, breakout_ui_to_engine_params, check_buy_signal_breakout_live as _bo_check_buy, check_sell_signal_breakout_live as _bo_check_sell, normalize_breakout_max_loss_krw, run_breakout_backtest as _bo_run_backtest, ) from kis_trader.strategies.base import is_strategy_eod_bar def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]: """config_momentum + env_config 병합에서 모멘텀 웹·백테 초기값. - MOMENTUM_* 우선 → SCALP_MOM_* / SCALP_* / ``get_scalping_defaults_from_db`` 폴백. - ``insert_env_snapshot`` / param_search_apply → config_momentum 저장. """ env: Dict[str, Any] = _strategy_env("MOMENTUM") def pickf(keys: Tuple[str, ...], default: float) -> float: for k in keys: v = env.get(k) if v not in (None, "", "None"): try: return float(v) except (ValueError, TypeError): continue return float(default) def picki(keys: Tuple[str, ...], default: int) -> int: for k in keys: v = env.get(k) if v not in (None, "", "None"): try: return int(float(v)) except (ValueError, TypeError): continue return int(default) def pick_sl_tp_pct(keys: Tuple[str, ...], default_pct: float) -> float: """DB 소수(0.015) 또는 퍼센트(1.5) → 화면·쿼리스트링용 퍼센트 숫자.""" for k in keys: v = env.get(k) if v not in (None, "", "None"): try: x = abs(float(v)) if x == 0: return 0.0 return x if x >= 0.5 else round(x * 100, 3) except (ValueError, TypeError): continue return float(default_pct) def pick_trail_ui(col: str, default_ratio: float) -> float: """DB 소수 비율(0.02=2%) 또는 퍼센트 숫자(2) → UI 퍼센트 (pick_sl_tp_pct 와 동일 임계).""" v = env.get(col) if v not in (None, "", "None"): try: x = abs(float(v)) if x == 0: return 0.0 return x if x >= 0.5 else round(x * 100.0, 6) except (ValueError, TypeError): pass return round(float(default_ratio) * 100, 3) sl_pct_disp = pick_sl_tp_pct( ("MOMENTUM_STOP_LOSS_PCT", "SCALP_STOP_LOSS_PCT"), 1.5, ) tp_pct_disp = pick_sl_tp_pct( ("MOMENTUM_TAKE_PROFIT_PCT", "SCALP_TAKE_PROFIT_PCT"), 2.5, ) tp_max_disp = pick_sl_tp_pct( ("MOMENTUM_TP_MAX_PCT", "SCALP_TP_MAX_PCT"), 2.0, ) high_chase = pickf( ("HIGH_CHASE_THR", "SCALP_HIGH_PRICE_CHASE_THRESHOLD", "HIGH_PRICE_CHASE_THRESHOLD"), float(_d.get("high_chase_thr", 0.96)), ) max_daily_chg = pickf( ("MAX_DAILY_CHG", "SCALP_MAX_DAILY_CHANGE_PCT", "MAX_DAILY_CHANGE_PCT"), float(_d.get("max_daily_chg", 20.0)), ) min_price = pickf( ("MOMENTUM_MIN_PRICE", "MIN_STOCK_PRICE", "SCALP_MIN_PRICE", "MIN_PRICE_TAIL"), float(_d.get("min_price", 1000.0)), ) max_loss_krw = picki( ("MOMENTUM_MAX_LOSS_PER_TRADE_KRW", "SCALP_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"), int(_d.get("max_loss_krw", 200000)), ) # DB SCALP_MIN_PROFIT_PCT 는 보통 0.2(=0.2% 표시) / 엔진 min_margin 은 비율 min_margin_disp = round(float(_d.get("min_margin", 0.002)) * 100, 3) _mmp = env.get("SCALP_MIN_PROFIT_PCT") if _mmp not in (None, "", "None"): try: min_margin_disp = float(_mmp) except (ValueError, TypeError): pass _mommp = env.get("MOMENTUM_MIN_PROFIT_PCT") if _mommp not in (None, "", "None"): try: min_margin_disp = float(_mommp) except (ValueError, TypeError): pass _udf = env.get("SCALP_USE_DEFENSE_FILTERS") if _udf not in (None, "", "None"): use_def = str(_udf).strip().lower() in ("1", "true", "y", "yes", "on") else: use_def = bool(_d.get("use_defense_filters", True)) slot_money = pickf( ("MOMENTUM_SLOT_MONEY", "SLOT_MONEY_DEFAULT"), float(_d.get("slot_money", 200_000)), ) mom_slots = picki(("MOMENTUM_MAX_STOCKS", "SCALP_MAX_STOCKS"), 20) total_budget_krw = int( float( pickf( ("MOMENTUM_TOTAL_BUDGET_KRW", "SCALP_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"), 2_000_000.0, ) or 0 ) ) # 백테·파라serch 와 동일 포트폴리오 해석 (웹 입력 = CLI --slot-money/--max-stocks/--total-budget) try: portfolio = sbc.resolve_scalp_portfolio_params( env, _d, strategy="MOMENTUM", slot_money=slot_money, max_stocks=mom_slots, total_budget_krw=total_budget_krw or None, ) slot_money = float(portfolio["slot_money"]) mom_slots = int(portfolio["max_stocks"]) total_budget_krw = int(float(portfolio["total_budget_krw"])) except Exception: portfolio = {} portfolio_ui_warning = None # max_loss/손절% 로 자동 계산된 1천만 등이 DB에 저장된 경우 — 1회투자 > 총한도 → 백테 0건 if total_budget_krw > 0 and slot_money > total_budget_krw: from kis_trader.utils.env import get_env_int portfolio_ui_warning = ( f"DB 1회투자({slot_money:,.0f}원) > 총운용한도({total_budget_krw:,.0f}원). " "max_loss÷손절% 자동계산값이 잘못 들어갔을 수 있어 " "파라serch CLI 기본(1회20만·동시20·한도200만)으로 표시를 교정했습니다." ) slot_money = float(get_env_int("MOMENTUM_SEARCH_SLOT_MONEY_KRW", 200_000)) mom_slots = int(get_env_int("MOMENTUM_SEARCH_MAX_STOCKS", 20)) total_budget_krw = int(get_env_int("MOMENTUM_SEARCH_TOTAL_BUDGET_KRW", 2_000_000)) # 시가 대비 과열/과매도 컷 — ``param_search_momentum`` coarse 그리드와 동일 개념. # DB 미설정 시 max=30 (%): coarse 탐색이 끝물 방지에 쓰는 값과 웹 백테를 맞춤 (999=OFF). mom_max_open = pickf(("MOMENTUM_MAX_FROM_OPEN_PCT",), 30.0) mom_min_open = pickf(("MOMENTUM_MIN_FROM_OPEN_PCT",), -999.0) def pick_bool(keys: Tuple[str, ...], default: bool) -> bool: for k in keys: v = env.get(k) if v not in (None, "", "None"): return str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on") return bool(default) use_ema_filter = pick_bool(("MOMENTUM_USE_EMA_FILTER",), True) use_rsi_max_filter = pick_bool(("MOMENTUM_USE_RSI_MAX_FILTER",), False) pattern_breakout = pick_bool(("MOMENTUM_PATTERN_BREAKOUT",), True) pattern_pullback = pick_bool(("MOMENTUM_PATTERN_PULLBACK",), True) ema_fast_period = picki(("MOMENTUM_EMA_FAST_PERIOD",), 9) ema_slow_period = picki(("MOMENTUM_EMA_SLOW_PERIOD",), 21) chase_lookback_min = picki(("MOMENTUM_CHASE_LOOKBACK_MIN",), 10) pullback_lookback_min = picki(("MOMENTUM_PULLBACK_LOOKBACK_MIN",), 15) pullback_min_pct = pickf(("MOMENTUM_PULLBACK_MIN_PCT",), 0.3) pullback_max_pct = pickf(("MOMENTUM_PULLBACK_MAX_PCT",), 3.0) setup_vol_max_mult = pickf(("MOMENTUM_SETUP_VOL_MAX_MULT",), 0.8) setup_bear_bars_min = picki(("MOMENTUM_SETUP_BEAR_BARS_MIN",), 1) def pick_shoulder_frac(keys: Tuple[str, ...], default_ratio: float) -> float: for k in keys: v = env.get(k) if v not in (None, "", "None"): try: x = abs(float(v)) if x == 0: return 0.0 return round(x * 100, 3) if x < 0.5 else round(x, 3) except (ValueError, TypeError): continue dr = abs(float(default_ratio)) return round(dr * 100, 3) if dr < 0.5 else round(dr, 3) # ── 호가/프로그램 필터 — 글로벌 vs 모멘텀 전용 분해 (웹 안내 표시용) ── # 우선순위: 모멘텀 전용키에 값이 있으면 그 값, 비어있으면 글로벌키를 상속. # (실매·백테 모두 orderbook_filter.orderbook_filter_enabled 가 같은 규칙) def _filt_truthy(raw: Any) -> bool: return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") _ob_strat_raw = env.get("MOMENTUM_ORDERBOOK_FILTER_ENABLED") _pg_strat_raw = env.get("MOMENTUM_PROGRAM_FILTER_ENABLED") _ob_strat_set = _ob_strat_raw not in (None, "", "None") _pg_strat_set = _pg_strat_raw not in (None, "", "None") return { "mom_rsi_min": pickf(("MOMENTUM_RSI_MIN", "SCALP_MOM_RSI_MIN"), 50.0), "mom_rsi_max": pickf(("MOMENTUM_RSI_MAX", "SCALP_MOM_RSI_MAX"), 80.0), "mom_vol_mult": pickf(("MOMENTUM_VOL_MULT", "SCALP_MOM_VOL_MULT"), 1.5), "mom_vol_win": picki(("MOMENTUM_VOL_WIN", "SCALP_MOM_VOL_WIN"), 5), "mom_time_end_hm": picki(("MOMENTUM_TIME_END_HM", "SCALP_MOM_TIME_END_HM"), 1430), "mom_time_start_hm": picki( ("MOMENTUM_TIME_START", "SCALP_TIME_START", "TIME_START"), 900, ), "sl_pct": sl_pct_disp, "tp_pct": tp_pct_disp, "tp_max_pct": tp_max_disp, "shoulder_min_high": pick_shoulder_frac( ("MOMENTUM_SHOULDER_MIN_HIGH_PCT", "SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"), float(_d.get("shoulder_min_high", 0.005)), ), "shoulder_cut_pct": pick_shoulder_frac( ("MOMENTUM_SHOULDER_CUT_PCT", "SCALP_SHOULDER_CUT_PCT", "SHOULDER_CUT_PCT"), float(_d.get("shoulder_cut_pct", 0.003)), ), "trail_trigger": pick_trail_ui("SCALP_ATR_UP_MULT", float(_d.get("trail_trigger", 0.007))), "trail_stop": pick_trail_ui("SCALP_ATR_DOWN_MULT", float(_d.get("trail_stop", 0.004))), "cooldown_min": ( max(0, int(float(env.get("SCALP_COOLDOWN_SEC"))) // 60) if env.get("SCALP_COOLDOWN_SEC") not in (None, "", "None") else float(_d.get("cooldown_min", 10)) ), "max_daily": picki(("MOMENTUM_MAX_DAILY", "SCALP_MAX_DAILY"), 5), "slot_money": slot_money, "mom_slots": mom_slots, "total_budget_krw": total_budget_krw, "portfolio_ui_warning": portfolio_ui_warning, "high_chase_thr": high_chase, "max_daily_chg": max_daily_chg, "min_price": min_price, "max_loss_krw": max_loss_krw, "min_margin": min_margin_disp, "use_defense_filters": use_def, "mom_max_from_open_pct": mom_max_open, "mom_min_from_open_pct": mom_min_open, "use_ema_filter": use_ema_filter, "use_rsi_max_filter": use_rsi_max_filter, "pattern_breakout": pattern_breakout, "pattern_pullback": pattern_pullback, "chase_lookback_min": chase_lookback_min, "pullback_lookback_min": pullback_lookback_min, "pullback_min_pct": pullback_min_pct, "pullback_max_pct": pullback_max_pct, "setup_vol_max_mult": setup_vol_max_mult, "setup_bear_bars_min": setup_bear_bars_min, "ema_fast_period": ema_fast_period, "ema_slow_period": ema_slow_period, "ob_filter_enabled": _strategy_trigger_filter_enabled( env, prefix="MOMENTUM", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED", ), "pg_filter_enabled": _strategy_trigger_filter_enabled( env, prefix="MOMENTUM", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED", ), # 글로벌값 / 전용 명시여부 — 웹 안내문("글로벌 X · 실매 Y")용 (체크값엔 영향 없음) "ob_global_enabled": _filt_truthy(env.get("ORDERBOOK_FILTER_ENABLED")), "ob_strategy_explicit": _ob_strat_set, "pg_global_enabled": _filt_truthy(env.get("PROGRAM_FILTER_ENABLED")), "pg_strategy_explicit": _pg_strat_set, # 호가 스프레드 상한(%) — 0.45 = 0.45%. kiwoom_0d 본체 재계산용 (6/25~ 유효) "max_spread_pct": pickf( ("MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT", "ORDERBOOK_MAX_SPREAD_PCT"), 0.45, ), "eod_enabled": pick_bool(("MOMENTUM_EOD_ENABLED",), True), "eod_hm": ( str(env.get("MOMENTUM_EOD_HM") or "15:25").strip() if env.get("MOMENTUM_EOD_HM") not in (None, "", "None") else "15:25" ), **_momentum_exit_ui_from_engine(), } def _momentum_ratio_to_ui_pct(val: Any, default: float = 0.0) -> float: """엔진 비율(0.015) 또는 UI 퍼센트(1.5) → 폼 표시 %.""" if val is None or val == "": return float(default) try: x = abs(float(val)) if x == 0: return 0.0 return round(x * 100, 3) if x < 0.5 else round(x, 3) except (ValueError, TypeError): return float(default) def _momentum_exit_ui_from_engine() -> Dict[str, Any]: """전용 청산 필드 — momentum_engine 단일 소스.""" try: d = me.get_momentum_defaults_from_db() except Exception: d = {} return { "trail_pct": _momentum_ratio_to_ui_pct(d.get("trail_pct"), 0.0), "trail_arm_pct": _momentum_ratio_to_ui_pct(d.get("trail_arm_pct"), 0.0), "max_hold_bars": int(d.get("max_hold_bars") or 0), "ratchet_tiers": str(d.get("ratchet_tiers") or ""), "use_high_chase_filter": bool(d.get("use_high_chase_filter", False)), "use_daily_range_filter": bool(d.get("use_daily_range_filter", False)), "use_ema_filter": bool(d.get("use_ema_filter", True)), "use_rsi_max_filter": bool(d.get("use_rsi_max_filter", False)), "pattern_breakout": bool(d.get("pattern_breakout", True)), "pattern_pullback": bool(d.get("pattern_pullback", True)), "chase_lookback_min": int(d.get("chase_lookback_min") or 10), "pullback_lookback_min": int(d.get("pullback_lookback_min") or 15), "pullback_min_pct": float(d.get("pullback_min_pct") or 0.3), "pullback_max_pct": float(d.get("pullback_max_pct") or 3.0), "ema_fast_period": int(d.get("ema_fast_period") or 9), "ema_slow_period": int(d.get("ema_slow_period") or 21), } def _momentum_engine_dict_to_ui( d: Dict[str, Any], snap: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """momentum_engine defaults 또는 파라서치 merged → 웹 입력란 값.""" snap = snap or {} if not d: return {} def _ui(k: str, default: Any = None) -> Any: v = d.get(k) return default if v in (None, "") else v slot_raw = snap.get("MOMENTUM_SLOT_MONEY") or d.get("slot_money") slots_raw = snap.get("MOMENTUM_MAX_STOCKS") or d.get("max_stocks") budget_raw = snap.get("MOMENTUM_TOTAL_BUDGET_KRW") or d.get("total_budget_krw") out: Dict[str, Any] = { "mom_rsi_min": _ui("mom_rsi_min"), "mom_rsi_max": _ui("mom_rsi_max"), "mom_vol_mult": _ui("mom_vol_mult"), "mom_vol_win": _ui("mom_vol_win"), "mom_time_end_hm": _ui("mom_time_end_hm") or _ui("mom_time_end"), "mom_time_start_hm": _ui("time_start_hm") or _ui("time_start"), "sl_pct": _momentum_ratio_to_ui_pct(d.get("sl_pct")), "tp_pct": _momentum_ratio_to_ui_pct(d.get("tp_pct")), "tp_max_pct": _momentum_ratio_to_ui_pct(d.get("tp_max_pct")), "shoulder_min_high": _momentum_ratio_to_ui_pct(d.get("shoulder_min_high")), "shoulder_cut_pct": _momentum_ratio_to_ui_pct(d.get("shoulder_cut_pct")), "trail_pct": _momentum_ratio_to_ui_pct( d.get("trail_pct") if d.get("trail_pct") is not None else d.get("trail_trigger"), ), "trail_arm_pct": _momentum_ratio_to_ui_pct( d.get("trail_arm_pct") if d.get("trail_arm_pct") is not None else d.get("trail_stop"), ), "max_hold_bars": int(float(d.get("max_hold_bars") or 0)), "ratchet_tiers": str(d.get("ratchet_tiers") or ""), "cooldown_min": d.get("cooldown_min"), "max_daily": d.get("max_daily"), "high_chase_thr": d.get("high_chase_thr"), "max_daily_chg": d.get("max_daily_chg"), "min_price": d.get("min_price"), "max_loss_krw": d.get("max_loss_krw"), "min_margin": d.get("min_margin"), "mom_max_from_open_pct": d.get("mom_max_from_open_pct"), "mom_min_from_open_pct": d.get("mom_min_from_open_pct"), "use_defense_filters": d.get("use_defense_filters"), "use_high_chase_filter": d.get("use_high_chase_filter"), "use_daily_range_filter": d.get("use_daily_range_filter"), "use_ema_filter": d.get("use_ema_filter"), "use_rsi_max_filter": d.get("use_rsi_max_filter"), "pattern_breakout": d.get("pattern_breakout"), "pattern_pullback": d.get("pattern_pullback"), "chase_lookback_min": d.get("chase_lookback_min"), "pullback_lookback_min": d.get("pullback_lookback_min"), "pullback_min_pct": d.get("pullback_min_pct"), "pullback_max_pct": d.get("pullback_max_pct"), "ema_fast_period": d.get("ema_fast_period"), "ema_slow_period": d.get("ema_slow_period"), } if slot_raw not in (None, ""): out["slot_money"] = int(float(slot_raw)) if slots_raw not in (None, ""): out["mom_slots"] = int(float(slots_raw)) if budget_raw not in (None, ""): out["total_budget_krw"] = int(float(budget_raw)) return out def _load_momentum_search_json(path: Optional[str] = None) -> Tuple[Optional[str], Optional[Dict[str, Any]]]: """최신 search_momentum_*.json 또는 지정 경로 로드.""" if path and os.path.isfile(path): try: with open(path, "r", encoding="utf-8") as f: return path, json.load(f) except (OSError, json.JSONDecodeError): return path, None try: from kis_trader.backtest.param_search_momentum import _latest_json p = _latest_json("search_momentum_") except ImportError: p = None if not p or not os.path.isfile(p): return None, None try: with open(p, "r", encoding="utf-8") as f: return p, json.load(f) except (OSError, json.JSONDecodeError): return p, None def _scalp_ui_defaults_from_db() -> Dict[str, Any]: """config_scalp + env_config 병합에서 스캘핑 reversal 웹·백테 초기값.""" _d = se.get_scalping_defaults_from_db() env = _strategy_env("SCALP") def pct_ui(db_key: str, ratio_default: float) -> float: v = env.get(db_key) if v not in (None, "", "None"): try: x = abs(float(v)) if x == 0: return 0.0 return x if x >= 0.5 else round(x * 100, 3) except (ValueError, TypeError): pass r = abs(float(ratio_default)) return round(r * 100, 3) if r < 0.5 else round(r, 3) def shoulder_ui(ratio_default: float) -> float: for k in ("SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"): v = env.get(k) if v not in (None, "", "None"): try: x = abs(float(v)) return round(x * 100, 3) if x < 0.5 else round(x, 3) except (ValueError, TypeError): continue r = abs(float(ratio_default)) return round(r * 100, 3) if r < 0.5 else round(r, 3) def shoulder_cut_ui(ratio_default: float) -> float: for k in ("SCALP_SHOULDER_CUT_PCT", "SHOULDER_CUT_PCT"): v = env.get(k) if v not in (None, "", "None"): try: x = abs(float(v)) return round(x * 100, 3) if x < 0.5 else round(x, 3) except (ValueError, TypeError): continue r = abs(float(ratio_default)) return round(r * 100, 3) if r < 0.5 else round(r, 3) _udf = env.get("SCALP_USE_DEFENSE_FILTERS") if _udf not in (None, "", "None"): use_def = str(_udf).strip().lower() in ("1", "true", "y", "yes", "on") else: use_def = bool(_d.get("use_defense_filters", True)) _um = env.get("SCALP_USE_MACD_CROSS") if _um not in (None, "", "None"): use_macd = str(_um).strip().lower() in ("1", "true", "y", "yes", "on") else: use_macd = bool(_d.get("use_macd_cross", False)) _mdl = env.get("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT") if _mdl not in (None, "", "None"): try: v = float(_mdl) min_drop_loss_ui = v * 100 if v < 1 else v except (ValueError, TypeError): min_drop_loss_ui = float(_d.get("min_drop_pct_for_loss_cut", 0.015)) * 100 else: min_drop_loss_ui = float(_d.get("min_drop_pct_for_loss_cut", 0.015)) * 100 sec = env.get("SCALP_COOLDOWN_SEC") if sec not in (None, "", "None"): cooldown_min = max(0, int(float(sec)) // 60) else: cooldown_min = int(_d.get("cooldown_min", 10)) return { "rsi_oversold": int(float(env.get("SCALP_RSI_OVERSOLD") or _d.get("rsi_oversold", 25))), "rsi_overbought": int(float(env.get("SCALP_RSI_OVERBOUGHT") or _d.get("rsi_overbought", 75))), "sl_pct": pct_ui("SCALP_STOP_LOSS_PCT", _d.get("sl_pct", 0.015)), "tp_pct": pct_ui("SCALP_TAKE_PROFIT_PCT", _d.get("tp_pct", 0.015)), "tp_max_pct": pct_ui("SCALP_TP_MAX_PCT", _d.get("tp_max_pct", 0.02)), "drop_rate": pct_ui("SCALP_MIN_DROP_RATE", _d.get("drop_rate", 0.015)), "shoulder_min_high": shoulder_ui(_d.get("shoulder_min_high", 0.005)), "shoulder_cut_pct": shoulder_cut_ui(_d.get("shoulder_cut_pct", 0.003)), "trail_trigger": pct_ui("SCALP_ATR_UP_MULT", _d.get("trail_trigger", 0.007)), "trail_stop": pct_ui("SCALP_ATR_DOWN_MULT", _d.get("trail_stop", 0.004)), "cooldown_min": cooldown_min, "slot_money": float(env.get("SLOT_MONEY_DEFAULT") or _d.get("slot_money", 3_000_000)), "high_chase_thr": float( env.get("SCALP_HIGH_PRICE_CHASE_THRESHOLD") or env.get("HIGH_CHASE_THR") or _d.get("high_chase_thr", 0.96) ), "max_daily_chg": float( env.get("SCALP_MAX_DAILY_CHANGE_PCT") or env.get("MAX_DAILY_CHG") or _d.get("max_daily_chg", 20.0) ), "min_price": float(env.get("SCALP_MIN_PRICE") or _d.get("min_price", 1000.0)), "max_loss_krw": int(float( env.get("SCALP_MAX_LOSS_PER_TRADE_KRW") or env.get("MAX_LOSS_PER_TRADE_KRW") or _d.get("max_loss_krw", 200_000) )), "min_drop_pct_for_loss_cut": min_drop_loss_ui, "min_margin": float(env.get("SCALP_MIN_PROFIT_PCT") or _d.get("min_margin", 0.2)), "use_defense_filters": use_def, "use_macd_cross": use_macd, } def _bo_golden_end_to_hm(s: str) -> int: """'10:30' 또는 HHMM → 1030 (실패 시 1030).""" try: raw = str(s or "").strip() if ":" in raw: hh, mm = raw.split(":", 1) return int(hh) * 100 + int(mm) if raw.isdigit(): return int(raw[:4]) if len(raw) >= 4 else int(raw) except Exception: pass return 1030 def _bo_defaults_from_db() -> Dict[str, Any]: """돌파 백테·웹 폼 — config_breakout + env_config 병합.""" fee = _get_fee_defaults() env = _strategy_env("BREAKOUT") def pick(keys: Tuple[str, ...], default: Any, cast=float): for k in keys: v = env.get(k) if v not in (None, "", "None"): try: return cast(v) except (ValueError, TypeError): continue return default sl_r = pick(("BREAKOUT_STOP_LOSS_PCT",), -0.02, float) tp_r = pick(("BREAKOUT_TAKE_PROFIT_PCT",), 0.05, float) tr_r = pick(("BREAKOUT_TRAIL_PCT",), 0.015, float) tra_r = pick(("BREAKOUT_TRAIL_ARM_PCT",), 0.0, float) smh_r = pick(("BREAKOUT_SHOULDER_MIN_HIGH_PCT",), 0.02, float) sc_r = pick(("BREAKOUT_SHOULDER_CUT_PCT",), 0.01, float) def pct_ui(ratio: float) -> float: av = abs(float(ratio)) if av == 0: return 0.0 return round(av * 100, 3) if av < 0.5 else round(av, 3) time_end_raw = env.get("BREAKOUT_TIME_END") if time_end_raw not in (None, "", "None"): try: time_end_hm = int(float(time_end_raw)) except (ValueError, TypeError): time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30"))) else: time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30"))) cd_sec = pick(("BREAKOUT_COOLDOWN_SEC",), 0.0, float) if cd_sec and cd_sec > 0: cooldown_min = int(cd_sec / 60) if cd_sec > 120 else int(cd_sec) else: re_sec = pick(("REENTRY_COOLDOWN_SEC",), 1800.0, float) cooldown_min = int(re_sec / 60) if re_sec > 120 else int(re_sec) sl_pct_ui = pct_ui(sl_r) max_loss_raw = pick( ("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"), 200_000, lambda v: int(float(v)), ) max_loss_krw = normalize_breakout_max_loss_krw(max_loss_raw) slot_cap = pick( ("BREAKOUT_SLOT_MONEY", "SLOT_MONEY_DEFAULT"), 2_000_000, lambda v: int(float(v)), ) slot_money = int(breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_cap)) portfolio = bbc.resolve_breakout_portfolio_params( env, None, slot_money=float(slot_money), ) max_stocks_v = int(portfolio["max_stocks"]) total_budget_v = int(float(portfolio["total_budget_krw"])) eod_raw = str(env.get("BREAKOUT_EOD_HM") or "15:15").strip() if eod_raw in ("", "None"): eod_hm = "15:15" elif ":" in eod_raw: eod_hm = eod_raw elif len(eod_raw) == 4 and eod_raw.isdigit(): eod_hm = f"{eod_raw[:2]}:{eod_raw[2:]}" else: eod_hm = eod_raw eod_enabled_raw = env.get("BREAKOUT_EOD_ENABLED") if eod_enabled_raw in (None, "", "None"): eod_enabled = True else: eod_enabled = str(eod_enabled_raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") return { "lookback_min": pick(("BREAKOUT_LOOKBACK_MIN",), 1, lambda v: int(float(v))), "vol_window": pick(("BREAKOUT_VOL_WIN",), 1, lambda v: int(float(v))), "vol_mult": pick(("BREAKOUT_VOL_MULT",), 0.0, float), "min_turnover_1m_pct": pick(("BREAKOUT_MIN_TURNOVER_1M_PCT",), 0.05, float), "prev_chg_min": pick(("BREAKOUT_PREV_CHG_MIN",), 1.0, float), "prev_chg_max": pick(("BREAKOUT_PREV_CHG_MAX",), 10.0, float), "sl_pct": sl_pct_ui, "tp_pct": pct_ui(tp_r), "trail_pct": pct_ui(tr_r), "trail_arm_pct": pct_ui(tra_r), "shoulder_min_high_pct": pct_ui(smh_r), "shoulder_cut_pct": pct_ui(sc_r), # ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정%) "sl_mode": str( env.get("BREAKOUT_SL_MODE") or "fixed" ).strip().lower() or "fixed", "atr_period": pick(("BREAKOUT_ATR_PERIOD",), 14, lambda v: int(float(v))), "atr_sl_mult": pick(("BREAKOUT_ATR_SL_MULT",), 2.0, float), "atr_sl_min_pct": pick(("BREAKOUT_ATR_SL_MIN_PCT",), 0.8, float), "atr_sl_max_pct": pick(("BREAKOUT_ATR_SL_MAX_PCT",), 6.0, float), "max_hold_bars": pick(("BREAKOUT_MAX_HOLD_BARS",), 0, lambda v: int(float(v))), "ratchet_tiers": str(env.get("BREAKOUT_RATCHET_TIERS") or ""), "time_start_hm": pick(("BREAKOUT_TIME_START",), 900, lambda v: int(float(v))), "time_end_hm": time_end_hm, "eod_enabled": eod_enabled, "eod_hm": eod_hm, "max_daily": pick(("BREAKOUT_MAX_DAILY",), 1, lambda v: int(float(v))), "cooldown_min": cooldown_min, "max_daily_chg": pick(("BREAKOUT_MAX_DAILY_CHG",), 15.0, float), "min_price": pick(("BREAKOUT_MIN_PRICE", "MIN_STOCK_PRICE"), 1000.0, float), # 가짜돌파(휩쏘) 필터 — 0=OFF "confirm_margin_pct": pick(("BREAKOUT_CONFIRM_MARGIN_PCT",), 0.0, float), "body_min_pct": pick(("BREAKOUT_BODY_MIN_PCT",), 0.0, float), "max_loss_krw": max_loss_krw, "slot_money": slot_money, "max_stocks": max_stocks_v, "total_budget_krw": total_budget_v, "fee_rate_pct": fee.get("fee_rate", 0.015), "sell_tax_pct": fee.get("sell_tax", 0.18), "entry_mode": str( env.get("BREAKOUT_ENTRY_MODE") or "intrabar" ).strip().lower(), "intrabar_slippage_pct": float( pick(("BREAKOUT_INTRABAR_SLIPPAGE_PCT",), 0.0, float) ), "use_ema_filter": ( str(env.get("BREAKOUT_USE_EMA_FILTER")).strip().lower() in ("1", "true", "t", "y", "yes", "on") if env.get("BREAKOUT_USE_EMA_FILTER") not in (None, "", "None") else False ), "ema_fast_period": pick(("BREAKOUT_EMA_FAST_PERIOD",), 9, lambda v: int(float(v))), "ema_slow_period": pick(("BREAKOUT_EMA_SLOW_PERIOD",), 21, lambda v: int(float(v))), "ob_filter_enabled": _strategy_trigger_filter_enabled( env, prefix="BREAKOUT", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED", ), "pg_filter_enabled": _strategy_trigger_filter_enabled( env, prefix="BREAKOUT", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED", ), "max_spread_pct": pick( ("BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT", "ORDERBOOK_MAX_SPREAD_PCT"), 0.45, float, ), } def _breakout_optimal_from_search() -> Dict[str, Any]: """최신 search_breakout_*.json 1위 merged_params → 웹 폼 키.""" try: from kis_trader.backtest.param_search_breakout import _latest_json except ImportError: return {} path = _latest_json("search_breakout_") if not path or not os.path.isfile(path): return {} try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) except (OSError, json.JSONDecodeError): return {} top = data.get("top") or [] if not top: return {} item = top[0] merged = dict(item.get("merged_params") or {}) grid = item.get("params") or {} if isinstance(grid, dict): merged.update(grid) # CLI 포트폴리오·매수시작만 meta 반영 (time_end는 그리드 1위 우선 — session 1530 덮어쓰기 방지) for k in ("slot_money", "max_stocks", "total_budget_krw", "time_start_hm"): if data.get(k) is not None: merged[k] = data[k] if grid.get("time_end_hm") is not None: merged["time_end_hm"] = grid["time_end_hm"] out: Dict[str, Any] = {} key_map = ( "lookback_min", "vol_window", "vol_mult", "prev_chg_min", "prev_chg_max", "sl_pct", "tp_pct", "trail_pct", "shoulder_min_high_pct", "shoulder_cut_pct", "sl_mode", "atr_period", "atr_sl_mult", "atr_sl_min_pct", "atr_sl_max_pct", "time_start_hm", "time_end_hm", "max_daily", "cooldown_min", "max_daily_chg", "min_price", "max_loss_krw", "slot_money", "max_stocks", "total_budget_krw", ) for k in key_map: v = merged.get(k) if v is not None and v != "": out[k] = v return out def _breakout_ui_defaults(*, prefer_search_json: bool = False) -> Dict[str, Any]: """돌파 탭 입력란용 — 기본은 config_breakout + env_config (봇·실매와 동일). ``prefer_search_json=True`` 일 때만 최신 search_breakout_*.json 1위로 덮어씀 (백테 탐색용). 💾 봇에 설정저장 / ``/api/env/params`` 는 DB만 사용해야 저장값이 보인다. """ base = _bo_defaults_from_db() if not prefer_search_json: return base opt = _breakout_optimal_from_search() for k, v in opt.items(): if v is not None: base[k] = v return base def _bo_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]: """웹 폼(%) → 엔진 (``breakout_ui_to_engine_params`` — param_search 와 동일).""" fee = _get_fee_defaults() merged = dict(ui) merged.setdefault("fee_rate_pct", fee.get("fee_rate", 0.015)) merged.setdefault("sell_tax_pct", fee.get("sell_tax", 0.18)) return breakout_ui_to_engine_params(merged) def _rb_defaults_from_db() -> Dict[str, Any]: """박스권 돌파 백테·웹 폼 — config_range_break + env_config 병합.""" fee = _get_fee_defaults() env = _strategy_env("RANGE_BREAK") def pick(keys: Tuple[str, ...], default: Any, cast=float): for k in keys: v = env.get(k) if v not in (None, "", "None"): try: return cast(v) except (ValueError, TypeError): continue return default def pct_ui(ratio: float) -> float: av = abs(float(ratio)) if av == 0: return 0.0 return round(av * 100, 3) if av < 0.5 else round(av, 3) sl_r = pick(("RANGE_BREAK_STOP_LOSS_PCT",), -0.03, float) tp_r = pick(("RANGE_BREAK_TAKE_PROFIT_PCT",), 0.10, float) tr_r = pick(("RANGE_BREAK_TRAIL_PCT",), 0.015, float) tra_r = pick(("RANGE_BREAK_TRAIL_ARM_PCT",), 0.015, float) smh_r = pick(("RANGE_BREAK_SHOULDER_MIN_HIGH_PCT",), 0.03, float) sc_r = pick(("RANGE_BREAK_SHOULDER_CUT_PCT",), 0.005, float) cd_sec = pick(("RANGE_BREAK_COOLDOWN_SEC",), 1800.0, float) cooldown_min = int(cd_sec / 60) if cd_sec > 120 else int(cd_sec) sl_pct_ui = pct_ui(sl_r) max_loss_raw = pick( ("RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"), 200_000, lambda v: int(float(v)), ) max_loss_krw = normalize_breakout_max_loss_krw(max_loss_raw) slot_cap = pick( ("RANGE_BREAK_SLOT_MONEY", "SLOT_MONEY_DEFAULT"), 200_000, lambda v: int(float(v)), ) slot_money = int(breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_cap)) portfolio = rbc.resolve_range_break_portfolio_params( env, None, slot_money=float(slot_money), ) use_hc = env.get("RANGE_BREAK_USE_HIGH_CHASE_FILTER") if use_hc not in (None, "", "None"): use_high_chase = str(use_hc).strip().lower() in ("1", "true", "t", "y", "yes", "on") else: use_high_chase = True return { "box_lookback_min": pick(("RANGE_BREAK_BOX_LOOKBACK_MIN",), 30, lambda v: int(float(v))), "box_max_width_pct": pick(("RANGE_BREAK_BOX_MAX_WIDTH_PCT",), 2.5, float), "box_min_width_pct": pick(("RANGE_BREAK_BOX_MIN_WIDTH_PCT",), 0.3, float), "setup_vol_max_mult": pick(("RANGE_BREAK_SETUP_VOL_MAX_MULT",), 0.8, float), "setup_bear_bars_min": pick(("RANGE_BREAK_SETUP_BEAR_BARS_MIN",), 1, lambda v: int(float(v))), "vol_mult": pick(("RANGE_BREAK_VOL_MULT",), 2.0, float), "vol_window": pick(("RANGE_BREAK_VOL_WIN",), 7, lambda v: int(float(v))), "vol_baseline_win": pick(("RANGE_BREAK_VOL_BASELINE_WIN",), 30, lambda v: int(float(v))), "break_margin_pct": pick(("RANGE_BREAK_BREAK_MARGIN_PCT",), 0.0, float), "body_min_pct": pick(("RANGE_BREAK_BODY_MIN_PCT",), 0.0, float), "sl_pct": sl_pct_ui, "tp_pct": pct_ui(tp_r), "trail_pct": pct_ui(tr_r), "trail_arm_pct": pct_ui(tra_r), "shoulder_min_high_pct": pct_ui(smh_r), "shoulder_cut_pct": pct_ui(sc_r), "max_hold_bars": pick(("RANGE_BREAK_MAX_HOLD_BARS",), 0, lambda v: int(float(v))), "time_start_hm": pick(("RANGE_BREAK_TIME_START",), 1030, lambda v: int(float(v))), "time_end_hm": pick(("RANGE_BREAK_TIME_END_HM",), 1520, lambda v: int(float(v))), "max_daily": pick(("RANGE_BREAK_MAX_DAILY",), 1, lambda v: int(float(v))), "cooldown_min": cooldown_min, "max_daily_chg": pick(("RANGE_BREAK_MAX_DAILY_CHG",), 25.0, float), "min_price": pick(("RANGE_BREAK_MIN_PRICE", "MIN_STOCK_PRICE"), 1000.0, float), "high_chase_thr": pick(("RANGE_BREAK_HIGH_CHASE_THR",), 0.96, float), "use_high_chase_filter": use_high_chase, "max_loss_krw": max_loss_krw, "slot_money": slot_money, "max_stocks": int(portfolio["max_stocks"]), "total_budget_krw": int(float(portfolio["total_budget_krw"])), "fee_rate_pct": fee.get("fee_rate", 0.015), "sell_tax_pct": fee.get("sell_tax", 0.18), } def _rb_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]: fee = _get_fee_defaults() merged = dict(ui) merged.setdefault("fee_rate_pct", fee.get("fee_rate", 0.015)) merged.setdefault("sell_tax_pct", fee.get("sell_tax", 0.18)) return range_break_ui_to_engine_params(merged) def _backtest_filter_toggle(raw: Any) -> Optional[bool]: """백테 폼 필터 토글 쿼리값 → True/False, 비었으면 None(=DB/실매값 사용). 켜고/끄고 돌리는 비교는 이 1회 백테에만 적용된다. DB(실매)는 안 건드린다. """ if raw is None or str(raw).strip() == "": return None return str(raw).strip().lower() in ("1", "true", "y", "yes", "on") def _eod_params_from_request( req: Any, defaults: Dict[str, Any], *, default_enabled: bool = True, default_hm: str = "15:25", ) -> Dict[str, Any]: """웹 백테 쿼리 → 실매와 동일 ``eod_enabled`` / ``eod_hm`` (비우면 DB 기본값).""" raw_en = req.args.get("eod_enabled") if raw_en in (None, ""): eod_enabled = bool(defaults.get("eod_enabled", default_enabled)) else: eod_enabled = str(raw_en).strip().lower() in ("1", "true", "t", "y", "yes", "on") raw_hm = req.args.get("eod_hm") if raw_hm not in (None, ""): eod_hm = str(raw_hm).strip() or default_hm else: eod_hm = str(defaults.get("eod_hm") or default_hm).strip() or default_hm return {"eod_enabled": eod_enabled, "eod_hm": eod_hm} def _daily_trail_params_from_request(req: Any) -> Dict[str, Any]: """백테 탭 '당일 누적손익 트레일 익절' 입력 → 시뮬 파라미터(전용 daily_trail_* 키). drop<=0 이면 빈 dict 반환 → 게이트 OFF(기존 백테 동작 불변). arm_krw<=0 이면 실매 _trail_reached 가 비활성이라 트레일이 발동하지 않는다 (작은 수익에 조기 종료 방지 — 실매와 동일 안전장치). UI 힌트로 안내. 꼬리 개별포지션 'trail_arm_pct' 와 키가 겹치지 않도록 daily_trail_* 전용 키 사용. """ mode = str(req.args.get("daily_profit_mode") or "trailing").strip().lower() or "trailing" # 다단 tier 우선 — 값이 있으면 단일 drop 무시. 'off'/빈값이면 단일 drop 경로. tiers = str(req.args.get("daily_trail_tiers") or "").strip() if tiers and tiers.lower() != "off": return { "_backtest_daily_profit_trail": True, "daily_profit_mode": mode, "daily_trail_tiers": tiers, } try: drop = float(req.args.get("daily_trail_drop_pct") or 0) except (TypeError, ValueError): drop = 0.0 if drop <= 0: return {} try: arm_krw = float(req.args.get("daily_trail_arm_krw") or 0) except (TypeError, ValueError): arm_krw = 0.0 return { "_backtest_daily_profit_trail": True, "daily_profit_mode": mode, "daily_trail_drop_pct": drop, "daily_trail_arm_krw": arm_krw, } def _strategy_trigger_filter_enabled( env: Dict[str, Any], *, prefix: str, kind: str, global_key: str, ) -> bool: """전략별 TRIGGER 필터 ON/OFF — 전략키 우선, 없으면 공통키, 둘 다 없으면 True.""" sk = f"{prefix}_{kind}_FILTER_ENABLED" raw = env.get(sk) if raw not in (None, "", "None"): return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") raw_g = env.get(global_key) if raw_g not in (None, "", "None"): return str(raw_g).strip().lower() in ("1", "true", "t", "y", "yes", "on") return True @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", _def["rsi_period"])) rsi_oversold = float(request.args.get("rsi_oversold", 25)) rsi_overbought = float(request.args.get("rsi_overbought", 75)) mode = (request.args.get("mode") or "reversal").strip().lower() if mode not in ("reversal", "momentum"): mode = "reversal" _mom_def: Optional[Dict[str, Any]] = ( _momentum_ui_defaults_from_db(_def) if mode == "momentum" else None ) _sl_req = request.args.get("sl_pct") if _sl_req not in (None, ""): sl_pct = float(_sl_req) / 100 elif _mom_def is not None: sl_pct = float(_mom_def["sl_pct"]) / 100 else: sl_pct = float(request.args.get("sl_pct", 1.5)) / 100 _tp_req = request.args.get("tp_pct") if _tp_req not in (None, ""): tp_pct = float(_tp_req) / 100 elif _mom_def is not None: tp_pct = float(_mom_def["tp_pct"]) / 100 else: tp_pct = float(request.args.get("tp_pct", 1.5)) / 100 # UI·DB에서 손절을 음수 퍼센트로 줄 때(예: -1.2) 엔진 sl_pct 가 음수로 들어가 # stop 가격이 진입가 위로 뒤집히는 문제 방지 (scalping_engine 도 abs 처리함). sl_pct = abs(sl_pct) tp_pct = abs(tp_pct) drop_rate = float(request.args.get("drop_rate", 1.5)) / 100 _slot_req = request.args.get("slot_money") if _slot_req not in (None, ""): slot_money = float(_slot_req) elif _mom_def is not None: slot_money = float(_mom_def["slot_money"]) else: slot_money = float(_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"])) _smin_req = request.args.get("shoulder_min_high") if _smin_req not in (None, ""): shoulder_min_high = float(_smin_req) / 100 elif _mom_def is not None: shoulder_min_high = float(_mom_def["shoulder_min_high"]) / 100.0 else: shoulder_min_high = float(_def.get("shoulder_min_high", 0.005)) _scut_req = request.args.get("shoulder_cut_pct") if _scut_req not in (None, ""): shoulder_cut_pct = float(_scut_req) / 100 elif _mom_def is not None: shoulder_cut_pct = float(_mom_def["shoulder_cut_pct"]) / 100.0 else: shoulder_cut_pct = float(_def.get("shoulder_cut_pct", 0.003)) _tpmax_req = request.args.get("tp_max_pct") if _tpmax_req not in (None, ""): tp_max_pct = float(_tpmax_req) / 100 elif _mom_def is not None: tp_max_pct = float(_mom_def["tp_max_pct"]) / 100.0 else: tp_max_pct = float(_def.get("tp_max_pct", 0.02)) min_hold_sec = float(_def.get("min_hold_sec", 30.0)) _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"])) _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") _use_macd = request.args.get("use_macd_cross") if _use_macd in (None, ""): use_macd_cross = bool(_def.get("use_macd_cross", False)) else: use_macd_cross = str(_use_macd).strip().lower() in ("1", "true", "y", "yes", "on") _force_eod_raw = request.args.get("force_eod_exit") if _force_eod_raw in (None, ""): _mom_eod_src = _mom_def if _mom_def is not None else _def eod_patch = _eod_params_from_request(request, _mom_eod_src, default_hm="15:25") else: # 레거시 force_eod_exit 쿼리 (하위호환) eod_patch = { "eod_enabled": str(_force_eod_raw).strip().lower() in ("1", "true", "y", "yes", "on"), "eod_hm": str( (_mom_def or _def).get("eod_hm") or "15:25" ).strip() or "15:25", } # ── 모드 분기: reversal(기존, V자 반전) vs momentum(추격형, SCALP_MODE=momentum 백테스트) ── # 실매매 봇이 ``check_buy_signal_momentum_live`` 를 사용 중이면 백테스트도 # ``mode=momentum`` 으로 호출해야 동일한 규칙으로 비교할 수 있다. # (mode / _mom_def 는 상단에서 이미 확정) # 모멘텀 진입 전용 파라미터 (mode=reversal 일 때는 무시) if _mom_def is not None: mom_rsi_min = float(request.args.get("mom_rsi_min", _mom_def["mom_rsi_min"])) mom_rsi_max = float(request.args.get("mom_rsi_max", _mom_def["mom_rsi_max"])) mom_vol_mult = float(request.args.get("mom_vol_mult", _mom_def["mom_vol_mult"])) mom_vol_win = int(float(request.args.get("mom_vol_win", _mom_def["mom_vol_win"]))) _mom_time_end = request.args.get("mom_time_end") mom_time_end_hm = int(_mom_time_end) if _mom_time_end not in (None, "") else int( _mom_def["mom_time_end_hm"], ) else: mom_rsi_min = float(request.args.get("mom_rsi_min", 50.0)) mom_rsi_max = float(request.args.get("mom_rsi_max", 80.0)) mom_vol_mult = float(request.args.get("mom_vol_mult", 1.5)) mom_vol_win = int(float(request.args.get("mom_vol_win", 5))) _mom_time_end = request.args.get("mom_time_end") mom_time_end_hm = int(_mom_time_end) if _mom_time_end not in (None, "") else 1430 # mode=momentum 일 때만: 위에서 이미 _def 기반으로 채운 값들을 DB 모멘텀 기본으로 덮어씀 if _mom_def is not None: if request.args.get("cooldown_min") in (None, ""): cooldown_min = float(_mom_def["cooldown_min"]) if request.args.get("shoulder_min_high") in (None, ""): shoulder_min_high = float(_mom_def["shoulder_min_high"]) / 100.0 if request.args.get("shoulder_cut_pct") in (None, ""): shoulder_cut_pct = float(_mom_def["shoulder_cut_pct"]) / 100.0 if request.args.get("tp_max_pct") in (None, ""): tp_max_pct = float(_mom_def["tp_max_pct"]) / 100.0 if request.args.get("time_start") in (None, ""): time_start_hm = int(_mom_def["mom_time_start_hm"]) if request.args.get("max_daily") in (None, ""): max_daily = int(_mom_def["max_daily"]) if request.args.get("use_defense_filters") in (None, ""): use_defense_filters = bool(_mom_def["use_defense_filters"]) db = _db() try: start_key = (start.replace("-", "") + "0000") if start else "20260101" end_key = (end.replace("-", "") + "2359") if end else "99991231" codes_raw = db.conn.execute( "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 " "AND candle_time >= %s AND candle_time <= %s ORDER BY code", [start_key, end_key] ).fetchall() 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 " "FROM ws_candles " "WHERE timeframe=1 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 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") if _mom_def is not None: high_chase_thr = float(_high_chase) if _high_chase not in (None, "") else float( _mom_def["high_chase_thr"], ) max_daily_chg = float(_max_daily_ch) if _max_daily_ch not in (None, "") else float( _mom_def["max_daily_chg"], ) min_price = float(_min_pr) if _min_pr not in (None, "") else float(_mom_def["min_price"]) max_loss_krw = int(float(_max_loss)) if _max_loss not in (None, "") else int( _mom_def["max_loss_krw"], ) min_margin = ( float(_min_marg) / 100 if _min_marg not in (None, "") else float(_mom_def["min_margin"]) / 100 ) else: 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, "tp_max_pct": tp_max_pct, "drop_rate": drop_rate, "slot_money": slot_money, "fee_rate": fee_rate, "sell_tax": sell_tax, "cooldown_min": cooldown_min, "shoulder_min_high": shoulder_min_high, "shoulder_cut_pct": shoulder_cut_pct, "min_hold_sec": min_hold_sec, "time_start_hm": time_start_hm, "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, "use_macd_cross": use_macd_cross, **eod_patch, "macd_fast": int(_def.get("macd_fast", 12)), "macd_slow": int(_def.get("macd_slow", 26)), "macd_signal": int(_def.get("macd_signal", 5)), "stoch_k_period": int(_def.get("stoch_k_period", 5)), "stoch_d_period": int(_def.get("stoch_d_period", 3)), "stoch_slow": int(_def.get("stoch_slow", 3)), # scan_interval_min — 유니버스 해석 후 덮어씀 (이력=1분, 시뮬=5분) "scan_interval_min": 5, # 모멘텀 진입 전용 (mode=momentum 에서만 사용) "mom_rsi_min": mom_rsi_min, "mom_rsi_max": mom_rsi_max, "mom_vol_mult": mom_vol_mult, "mom_vol_win": mom_vol_win, "mom_time_end_hm": mom_time_end_hm, } # 백테 전용 필터 토글 (폼 체크박스 → 이 1회 백테에만 적용. 비우면 DB=실매값 사용) _ob_tg = _backtest_filter_toggle(request.args.get("ob_filter")) if _ob_tg is not None: params["_orderbook_filter_enabled"] = _ob_tg _pg_tg = _backtest_filter_toggle(request.args.get("pg_filter")) if _pg_tg is not None: params["_program_filter_enabled"] = _pg_tg # 호가 스프레드 상한(%) — kiwoom_0d 본체 재계산 (6/25~ 유효, 그 외 log_backfill 폴백) _spread_req = request.args.get("max_spread_pct") if _spread_req not in (None, ""): params["_ob_max_spread_pct"] = float(_spread_req) params["backtest_use_kiwoom_body_snapshot"] = True params["_backtest_use_kiwoom_body"] = True if mode == "momentum": _mmax = request.args.get("mom_max_from_open_pct") _mmin = request.args.get("mom_min_from_open_pct") params["mom_max_from_open_pct"] = float(_mmax) if _mmax not in (None, "") else float( _mom_def.get("mom_max_from_open_pct", 999.0), ) params["mom_min_from_open_pct"] = float(_mmin) if _mmin not in (None, "") else float( _mom_def.get("mom_min_from_open_pct", -999.0), ) _mom_eng = me.get_momentum_defaults_from_db() _tr_req = request.args.get("trail_pct") params["trail_pct"] = ( abs(float(_tr_req)) / 100.0 if _tr_req not in (None, "") else float(_mom_eng.get("trail_pct") or 0.0) ) _ta_req = request.args.get("trail_arm_pct") params["trail_arm_pct"] = ( abs(float(_ta_req)) / 100.0 if _ta_req not in (None, "") else float(_mom_eng.get("trail_arm_pct") or 0.0) ) _mh_req = request.args.get("max_hold_bars") params["max_hold_bars"] = ( int(float(_mh_req)) if _mh_req not in (None, "") else int(_mom_eng.get("max_hold_bars") or 0) ) _rat_req = request.args.get("ratchet_tiers") params["ratchet_tiers"] = ( str(_rat_req).strip() if _rat_req is not None else str(_mom_eng.get("ratchet_tiers") or "") ) _uhcf = request.args.get("use_high_chase_filter") params["use_high_chase_filter"] = ( str(_uhcf).strip().lower() in ("1", "true", "y", "yes", "on") if _uhcf not in (None, "") else bool(_mom_eng.get("use_high_chase_filter", False)) ) _udrf = request.args.get("use_daily_range_filter") params["use_daily_range_filter"] = ( str(_udrf).strip().lower() in ("1", "true", "y", "yes", "on") if _udrf not in (None, "") else bool(_mom_eng.get("use_daily_range_filter", False)) ) _uef = request.args.get("use_ema_filter") params["use_ema_filter"] = ( str(_uef).strip().lower() in ("1", "true", "y", "yes", "on") if _uef not in (None, "") else bool(_mom_eng.get("use_ema_filter", True)) ) _urmf = request.args.get("use_rsi_max_filter") params["use_rsi_max_filter"] = ( str(_urmf).strip().lower() in ("1", "true", "y", "yes", "on") if _urmf not in (None, "") else bool(_mom_eng.get("use_rsi_max_filter", False)) ) _pbo = request.args.get("pattern_breakout") params["pattern_breakout"] = ( str(_pbo).strip().lower() in ("1", "true", "y", "yes", "on") if _pbo not in (None, "") else bool(_mom_eng.get("pattern_breakout", True)) ) _pbp = request.args.get("pattern_pullback") params["pattern_pullback"] = ( str(_pbp).strip().lower() in ("1", "true", "y", "yes", "on") if _pbp not in (None, "") else bool(_mom_eng.get("pattern_pullback", True)) ) _cl = request.args.get("chase_lookback_min") params["chase_lookback_min"] = ( int(float(_cl)) if _cl not in (None, "") else int(_mom_eng.get("chase_lookback_min", 10)) ) _pl = request.args.get("pullback_lookback_min") params["pullback_lookback_min"] = ( int(float(_pl)) if _pl not in (None, "") else int(_mom_eng.get("pullback_lookback_min", 15)) ) _pmin = request.args.get("pullback_min_pct") params["pullback_min_pct"] = ( float(_pmin) if _pmin not in (None, "") else float(_mom_eng.get("pullback_min_pct", 0.3)) ) _pmax = request.args.get("pullback_max_pct") params["pullback_max_pct"] = ( float(_pmax) if _pmax not in (None, "") else float(_mom_eng.get("pullback_max_pct", 3.0)) ) _sv = request.args.get("setup_vol_max_mult") params["setup_vol_max_mult"] = ( float(_sv) if _sv not in (None, "") else float(_mom_eng.get("setup_vol_max_mult", 0.8)) ) _sb = request.args.get("setup_bear_bars_min") params["setup_bear_bars_min"] = ( int(float(_sb)) if _sb not in (None, "") else int(_mom_eng.get("setup_bear_bars_min", 1)) ) _efp = request.args.get("ema_fast_period") params["ema_fast_period"] = ( int(float(_efp)) if _efp not in (None, "") else int(_mom_eng.get("ema_fast_period", 9)) ) _esp = request.args.get("ema_slow_period") params["ema_slow_period"] = ( int(float(_esp)) if _esp not in (None, "") else int(_mom_eng.get("ema_slow_period", 21)) ) # 유니버스 — 통일 쿼리 universe=history|sim|all (동일 테이블 target_candidates_history) _univ_default = "history" use_saved_history, universe_mode, sim_kind_univ = _parse_backtest_universe_arg( request, default=_univ_default, sim_kind=("momentum" if mode == "momentum" else "reversal"), ) _hist_strategy_id = "MOMENTUM" if mode == "momentum" else "SCALP" universe_by_slot, universe_source, universe_history_slots, _scan_iv = ( _resolve_backtest_universe( db, start_key, end_key, use_saved_history, codes_candles, sim_kind=sim_kind_univ, scan_interval_min=5, strategy_id=_hist_strategy_id, ) ) params["scan_interval_min"] = _scan_iv latest_env = db.get_latest_env() env_row = dict(latest_env["snapshot"]) if latest_env else {} strat_id = "MOMENTUM" if mode == "momentum" else "SCALP" fee_rate_v, sell_tax_v, slot_from_env = sbc.fee_and_slot_from_env( env_row, strategy=strat_id, ) slot_money_v = float(slot_money or slot_from_env) _mx = request.args.get("max_stocks") or request.args.get("slots") max_stocks_req = ( int(float(_mx)) if _mx not in (None, "") else None ) _tb = request.args.get("total_budget_krw") total_budget_req = ( float(_tb) if _tb not in (None, "") else None ) portfolio = sbc.resolve_scalp_portfolio_params( env_row, None, strategy=strat_id, slot_money=slot_money_v, max_stocks=max_stocks_req, total_budget_krw=total_budget_req, ) max_stocks_v = int(portfolio["max_stocks"]) total_budget_v = float(portfolio["total_budget_krw"]) slot_money_v = float(portfolio["slot_money"]) bt_meta: Dict[str, Any] = { "db": db, "start_key": start_key, "end_key": end_key, } all_virtual_trades = sbc.run_scalping_backtest_web_aligned( codes_candles, params, universe_by_slot, slot_money=slot_money_v, fee_rate=fee_rate_v, sell_tax=sell_tax_v, max_stocks=max_stocks_v, total_budget_krw=total_budget_v, mode=mode, meta_out=bt_meta, ) # 당일 누적손익 트레일 익절 시뮬 (백테 탭 입력 → drop>0 일 때만 ON). # trades 에 pnl·buy_time·sell_time 부착 완료 후 신규진입 차단. (모멘텀 포함) _trail_p = _daily_trail_params_from_request(request) if _trail_p: from kis_trader.backtest.backtest_portfolio_common import apply_daily_profit_halt_sim all_virtual_trades = apply_daily_profit_halt_sim( all_virtual_trades, _trail_p, budget_krw=float(total_budget_v or 0), ) # ───────────────────────────────────────────────────────────────── # 결과 집계 # ───────────────────────────────────────────────────────────────── period_days = _backtest_period_days(start, end, fallback=1) stats = sbc.summarize_scalp_trades( all_virtual_trades, total_budget_krw=total_budget_v, period_days=period_days, ) total = int(stats["total_trades"]) total_pnl = int(stats["total_pnl"]) wins_n = int(stats["wins"]) losses_n = int(stats["losses"]) avg_hold = float(stats["avg_hold_min"]) pf = float(stats["pf"]) bot_pct = float(stats["bot_pct"]) daily_avg_pct = float(stats["daily_avg_pct"]) equity = [] cum = 0.0 peak_cum = 0.0 peak_cum_at = "" for t in sorted(all_virtual_trades, key=lambda x: x.get("sell_time", "")): cum += t.get("pnl", 0) if cum > peak_cum: peak_cum = cum peak_cum_at = str(t.get("sell_time") or "") st = str(t.get("sell_time", "")) day = st[:8] if len(day) == 8: day_fmt = f"{day[:4]}-{day[4:6]}-{day[6:]}" else: day_fmt = day equity.append({"date": day_fmt, "cum_pnl": round(cum), "pnl": t.get("pnl", 0)}) peak, mdd, cum = 0.0, 0.0, 0.0 for t in all_virtual_trades: cum += t.get("pnl", 0) if cum > peak: peak = cum dd = peak - cum if dd > mdd: mdd = dd reasons: Dict[str, int] = {} for t in all_virtual_trades: rk = str(t.get("sell_reason") or "unknown") reasons[rk] = reasons.get(rk, 0) + 1 daily: Dict[str, float] = {} for t in all_virtual_trades: d8 = str(t.get("sell_time", ""))[:8] daily[d8] = daily.get(d8, 0) + t.get("pnl", 0) daily_list = [{"date": d[:4]+"-"+d[4:6]+"-"+d[6:], "pnl": round(v)} for d, v in sorted(daily.items())] # 가상거래에도 종목명 표시 (실거래와 동일) if mode == "momentum": _enrich_momentum_trades_debug(all_virtual_trades, total_budget_krw=total_budget_v) trades_out = _trades_recent_first(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, "tp_max_pct": tp_max_pct * 100, "effective_tp_pct": se.resolve_effective_tp_pct(tp_pct, tp_max_pct) * 100, "drop_rate": drop_rate * 100, "slot_money": slot_money_v, "max_stocks": max_stocks_v, "total_budget_krw": total_budget_v, "cooldown_min": cooldown_min, "vol_mult": vol_mult, "shoulder_min_high": shoulder_min_high * 100, "shoulder_cut_pct": shoulder_cut_pct * 100, "min_hold_sec": min_hold_sec, "time_window": f"{time_start_hm:04d}-{time_end_hm:04d}", **eod_patch, "max_daily": max_daily, "codes_analyzed": len(codes), "universe_source": universe_source, "universe_history_slots": universe_history_slots, "universe": universe_mode, "universe_timing": ( "strict" if universe_source == "history_strict" else ("minute" if universe_source == "history" else None) ) if mode == "momentum" else None, "strategy_id": _hist_strategy_id, # 모드 정보 (프론트 요약 배지/디버깅용) "mode": mode, "mom_rsi_min": mom_rsi_min, "mom_rsi_max": mom_rsi_max, "mom_vol_mult": mom_vol_mult, "mom_vol_win": mom_vol_win, "mom_time_end": mom_time_end_hm, "mom_max_from_open_pct": params.get("mom_max_from_open_pct") if mode == "momentum" else None, "mom_min_from_open_pct": params.get("mom_min_from_open_pct") if mode == "momentum" else None, "use_ema_filter": params.get("use_ema_filter") if mode == "momentum" else None, "use_rsi_max_filter": params.get("use_rsi_max_filter") if mode == "momentum" else None, "pattern_breakout": params.get("pattern_breakout") if mode == "momentum" else None, "pattern_pullback": params.get("pattern_pullback") if mode == "momentum" else None, "chase_lookback_min": params.get("chase_lookback_min") if mode == "momentum" else None, "pullback_lookback_min": params.get("pullback_lookback_min") if mode == "momentum" else None, "pullback_min_pct": params.get("pullback_min_pct") if mode == "momentum" else None, "pullback_max_pct": params.get("pullback_max_pct") if mode == "momentum" else None, "ema_fast_period": params.get("ema_fast_period") if mode == "momentum" else None, "ema_slow_period": params.get("ema_slow_period") if mode == "momentum" else None, "trail_pct": (params.get("trail_pct", 0) * 100) if mode == "momentum" else None, "trail_arm_pct": (params.get("trail_arm_pct", 0) * 100) if mode == "momentum" else None, "max_hold_bars": params.get("max_hold_bars") if mode == "momentum" else None, "ratchet_tiers": params.get("ratchet_tiers") if mode == "momentum" else None, "exit_priority": ( "ratchet/shoulder→trail→sl→time→loss_cap→tp_max→eod" if mode == "momentum" else None ), "start": start, "end": end, }, "summary": { "total_trades": total, "win_trades": wins_n, "loss_trades": losses_n, "win_rate": float(stats["win_rate"]), "total_pnl": total_pnl, "avg_hold_min": round(avg_hold, 1), "profit_factor": round(pf, 2), "max_drawdown": round(mdd), "bot_pct": bot_pct, "daily_avg_pct": daily_avg_pct, "backtest_days": period_days, "budget_warning": portfolio.get("budget_warning"), "peak_cum_pnl": round(peak_cum) if mode == "momentum" else None, "peak_cum_at": peak_cum_at if mode == "momentum" else None, "tick_backtest": bt_meta.get("tick_backtest") if mode == "momentum" else None, "skip_stats": bt_meta.get("skip_stats") if mode == "momentum" else None, }, "equity": equity, "daily": daily_list, "reasons": reasons, "trades": trades_out, }) except Exception as e: logger.exception("scalping backtest failed mode=%s", mode) return jsonify({"error": str(e)}), 500 finally: db.close() # ──────────────────────────────────────────────────────────────────────────── # API: 꼬리잡기 가격 재현 백테스트 (ws_candles 3분봉 기반, tail_engine 공통 로직 사용) # ──────────────────────────────────────────────────────────────────────────── try: from kis_trader.engine import tail_engine as te _TAIL_ENGINE_AVAILABLE = True except ImportError: _TAIL_ENGINE_AVAILABLE = False def _get_tail_defaults_for_backtest(): """꼬리잡기 백테스트 기본값: DB(config_short 병합) 단일 소스. 엔진 없으면 빈 dict.""" if not _TAIL_ENGINE_AVAILABLE: return {} return te.get_tail_defaults_from_db() def _tail_frac_to_ui_pct(v: Any) -> Optional[float]: """엔진 비율(0.003) 또는 퍼센트(3.0) → 웹 입력 퍼센트.""" if v is None or v == "": return None try: x = abs(float(v)) if x == 0: return 0.0 return round(x * 100, 3) if x < 0.5 else round(x, 3) except (ValueError, TypeError): return None def _tail_ratio_to_ui_pct(v: Any, default_pct: float) -> float: """0~1 비율 또는 퍼센트 → 폼 표시 % (max_rec_3m, high_chase).""" if v is None or v == "": return default_pct try: x = float(v) if 0 < x <= 1: return round(x * 100, 2) if x > 1: return round(x, 2) except (ValueError, TypeError): pass return default_pct def _tail_engine_dict_to_ui( d: Dict[str, Any], snap: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """tail_engine defaults 또는 파라서치 merged → 웹 입력란 값.""" snap = snap or {} if not d: return {} md_loss = d.get("min_drop_pct_for_loss_cut", 0.015) try: md_loss_f = float(md_loss) md_loss_ui = md_loss_f * 100 if md_loss_f < 1 else md_loss_f except (ValueError, TypeError): md_loss_ui = 1.5 slot_raw = snap.get("TAIL_SLOT_MONEY") or d.get("slot_money") or "3000000" _em = str(d.get("entry_mode") or "limit_atr").strip().lower() return { "entry_mode": _em, "limit_atr_mult": d.get("limit_atr_mult", 1.5), "limit_anchor": d.get("limit_anchor", "signal_low"), "limit_valid_bars": int(d.get("limit_valid_bars") or 1), "limit_fill_slip_pct": float(d.get("limit_fill_slip_pct") or 0.0), "drop": _tail_frac_to_ui_pct(d.get("min_drop_rate")), # 회복률은 0~1 비율(0.5=50%) — 낙폭%와 달리 _tail_ratio_to_ui_pct 사용 "rec": _tail_ratio_to_ui_pct(d.get("min_recovery_ratio"), 45.0), "tail_ratio": d.get("tail_ratio_min"), "tail_pct_min": _tail_frac_to_ui_pct(d.get("tail_pct_min")), "sl_pct": _tail_frac_to_ui_pct(d.get("sl_pct")), "tp_pct": _tail_frac_to_ui_pct(d.get("tp_pct")), "smin": _tail_frac_to_ui_pct(d.get("shoulder_min_high")), "scut": _tail_frac_to_ui_pct(d.get("shoulder_cut_pct")), "cool": d.get("cooldown_min"), "rsi": d.get("rsi_threshold"), "rsi_period": int(d.get("rsi_period") or 14), "time_start": int(d.get("time_start_hm") or 930), "time_end": int(d.get("time_end_hm") or 1500), "max_daily": int(d.get("max_daily") or 3), "max_rec_3m": _tail_ratio_to_ui_pct(d.get("max_rec_3m"), 90.0), "high_chase": _tail_ratio_to_ui_pct(d.get("high_chase_thr"), 96.0), "min_price": d.get("min_price"), "max_daily_change": d.get("max_daily_change"), "ma20_max_above": d.get("ma20_max_above"), "stop_atr_mult": d.get("stop_atr_mult"), "target_atr_mult": d.get("target_atr_mult"), "atr_sl_min_pct": d.get("atr_sl_min_pct"), "atr_sl_max_pct": d.get("atr_sl_max_pct"), "atr_tp_min_pct": d.get("atr_tp_min_pct"), "atr_tp_max_pct": d.get("atr_tp_max_pct"), "max_loss_krw": d.get("max_loss_krw"), "min_drop_pct_for_loss_cut": round(md_loss_ui, 2), "slot_money": int(float(slot_raw)), "max_stocks": int(snap.get("TAIL_MAX_STOCKS") or d.get("max_stocks") or 3), "total_budget_krw": int(float( snap.get("TAIL_TOTAL_BUDGET_KRW") or d.get("total_budget_krw") or 0 ) or int(float(slot_raw)) * int(snap.get("TAIL_MAX_STOCKS") or d.get("max_stocks") or 3)), "skip_hts_scan_dupes": d.get("skip_hts_scan_dupes", True), "use_intraday_drop": d.get("use_intraday_drop", False), "use_ma20_filter": d.get("use_ma20_filter", False), "use_rsi_filter": d.get("use_rsi_filter", True), "use_daily_range_filter": d.get("use_daily_range_filter", True), "use_high_chase_filter": d.get("use_high_chase_filter", True), "bar_chg_min_pct": d.get("bar_chg_min_pct", -10.0), "bar_chg_max_pct": d.get("bar_chg_max_pct", -1.5), "tail_vol_mult": d.get("tail_vol_mult", 0.0), "tail_vol_win": int(d.get("tail_vol_win") or 5), "ratchet_tiers": str(d.get("ratchet_tiers") or "").strip(), "max_hold_bars": int(d.get("max_hold_bars") or 0), "trail_pct": _tail_frac_to_ui_pct(d.get("trail_pct")) or 0.0, "trail_arm_pct": _tail_frac_to_ui_pct(d.get("trail_arm_pct")) or 0.0, "backtest_use_tick_db": d.get("backtest_use_tick_db", False), "backtest_tick_fallback_ohlc": d.get("backtest_tick_fallback_ohlc", True), "pattern_hammer": d.get("pattern_hammer", True), "pattern_pin": d.get("pattern_pin", False), "pattern_engulfing": d.get("pattern_engulfing", False), "pattern_piercing": d.get("pattern_piercing", False), "pattern_harami": d.get("pattern_harami", False), "pattern_doji": d.get("pattern_doji", False), "pattern_morning_star": d.get("pattern_morning_star", False), "ob_filter_enabled": _strategy_trigger_filter_enabled( snap or {}, prefix="TAIL", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED", ), "pg_filter_enabled": _strategy_trigger_filter_enabled( snap or {}, prefix="TAIL", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED", ), "max_spread_pct": float( snap.get("TAIL_ORDERBOOK_MAX_SPREAD_PCT") or snap.get("ORDERBOOK_MAX_SPREAD_PCT") or d.get("max_spread_pct") or 0.45 ), # 당일 누적손익 다단 트레일(SHORT 일일익절) 현재값 + 사용자 저장 프리셋 목록(세미콜론 구분) "daily_trail_tiers": str(snap.get("SHORT_DAILY_PROFIT_TRAIL_TIERS") or "").strip(), "daily_profit_mode": str(snap.get("SHORT_DAILY_PROFIT_MODE") or "trailing").strip().lower() or "trailing", "ratchet_presets": str(snap.get("BT_RATCHET_PRESETS") or "").strip(), "daily_trail_presets": str(snap.get("BT_DAILY_TRAIL_PRESETS") or "").strip(), "eod_enabled": ( str(snap.get("TAIL_EOD_ENABLED") or "1").strip().lower() in ("1", "true", "t", "y", "yes", "on") if snap.get("TAIL_EOD_ENABLED") not in (None, "", "None") else True ), "eod_hm": str(snap.get("TAIL_EOD_HM") or "15:25").strip() or "15:25", } def _tail_ui_defaults_from_db(snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ 꼬리잡기 웹 탭 초기값 — tail_engine.get_tail_defaults_from_db() 와 동일 (실매·파라서치·백테). 슬롯금액만 config_short / env 병합 snap 에서 읽음. """ d = _get_tail_defaults_for_backtest() return _tail_engine_dict_to_ui(d, snap) def _daily_trail_save_patch(body: Dict[str, Any], prefix: str) -> Dict[str, str]: """ 당일 누적손익 다단 트레일 익절(레칫식) 저장 패치 — 전 전략 공통. body 의 daily_trail_tiers/daily_profit_mode → {prefix}_DAILY_PROFIT_* (config_{strategy}). tier 값이 있으면 trailing 모드 + 일일익절 활성화를 함께 저장해야 실매가 작동한다 (없으면 _guard_active=False 라 무동작). 'off'/빈값이면 tier 만 비우고 모드는 안 건드림. prefix 예: 'SHORT'(꼬리)·'MOMENTUM'·'BREAKOUT'. """ out: Dict[str, str] = {} if "daily_trail_tiers" not in body: return out tiers = str(body.get("daily_trail_tiers") or "").strip() if tiers and tiers.lower() != "off": out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = tiers out[f"{prefix}_DAILY_PROFIT_MODE"] = ( str(body.get("daily_profit_mode") or "trailing").strip().lower() or "trailing" ) out[f"{prefix}_DAILY_PROFIT_TARGET_ENABLED"] = "true" else: out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = "" return out def _accumulate_preset( snap: Dict[str, Any], patch: Dict[str, str], key: str, value: Any, max_keep: int = 20 ) -> None: """ 사용자가 직접 입력한 값(래칫·다단트레일)을 세미콜론 구분 프리셋 목록 키에 누적. 값 내부에 콤마(0.5:0.3,1.0:0.25)가 있으므로 목록 구분자는 세미콜론을 쓴다. 중복·빈값·'off'는 제외, 최근 max_keep 개만 유지. DB(env)에 영구 저장돼 드롭다운 복원에 쓰인다. """ v = str(value or "").strip() if not v or v.lower() == "off": return existing = str(snap.get(key) or "").strip() items = [x.strip() for x in existing.split(";") if x.strip()] if existing else [] if v in items: return items.append(v) if len(items) > max_keep: items = items[-max_keep:] patch[key] = ";".join(items) def _tail_web_save_json_to_env_patch(body: Dict[str, Any]) -> Dict[str, str]: """ 꼬리잡기 탭 `saveTailConfig()` POST JSON → TAIL_* env 패치. insert_env_snapshot() 이 config_short 로 자동 분리 저장. """ from kis_trader.engine.tail_env_keys import web_body_to_tail_env_patch patch = web_body_to_tail_env_patch(body) if "ob_filter" in body: patch["TAIL_ORDERBOOK_FILTER_ENABLED"] = _env_bool_10(body.get("ob_filter")) if "pg_filter" in body: patch["TAIL_PROGRAM_FILTER_ENABLED"] = _env_bool_10(body.get("pg_filter")) # 당일 누적손익 다단 트레일 — TAIL_* 가 아니라 SHORT 일일익절(daily_profit_halt) 키 (공통 헬퍼) patch.update(_daily_trail_save_patch(body, "SHORT")) return patch @app.route("/api/backtest/tail/save_config", methods=["POST"]) def api_backtest_tail_save_config(): """꼬리잡기 웹 폼 → insert_env_snapshot (config_short + env_config 분리 저장).""" if not _TAIL_ENGINE_AVAILABLE: return jsonify({"error": "tail_engine 미설치 또는 임포트 실패"}), 503 body = request.get_json(force=True, silent=True) or {} try: patch = _tail_web_save_json_to_env_patch(body) if not patch: return jsonify({"error": "저장할 필드 없음(JSON 비어 있음)"}), 400 db = _db() try: snap = db.get_merged_env_snapshot() # 사용자가 직접 입력한 래칫·다단트레일 값을 프리셋 목록(env)에 영구 누적 _accumulate_preset(snap, patch, "BT_RATCHET_PRESETS", body.get("ratchet_tiers")) _accumulate_preset(snap, patch, "BT_DAILY_TRAIL_PRESETS", body.get("daily_trail_tiers")) for k, v in patch.items(): snap[k] = v env_id = db.insert_env_snapshot(snap) if env_id is None: return jsonify({"error": "env 저장 실패(insert_env_snapshot)"}), 500 from config_schema import classify_config_key saved_by_table: Dict[str, List[str]] = {} for k in patch: tbl = classify_config_key(k) saved_by_table.setdefault(tbl, []).append(k) return jsonify({ "ok": True, "env_id": env_id, "saved_keys": list(patch.keys()), "saved_by_table": saved_by_table, }) finally: db.close() except Exception as e: logger.error("꼬리잡기 설정저장 오류: %s", e) return jsonify({"error": str(e)}), 500 def _load_tail_search_json(path: Optional[str] = None) -> Tuple[Optional[str], Optional[Dict[str, Any]]]: """최신 search_tail_*.json 또는 지정 경로 로드.""" if path and os.path.isfile(path): try: with open(path, "r", encoding="utf-8") as f: return path, json.load(f) except (OSError, json.JSONDecodeError): return path, None try: from kis_trader.backtest.tail_param_search import _latest_tail_json_path p = _latest_tail_json_path() except ImportError: p = None if not p or not os.path.isfile(p): return None, None try: with open(p, "r", encoding="utf-8") as f: return p, json.load(f) except (OSError, json.JSONDecodeError): return p, None @app.route("/api/backtest/tail/search_results", methods=["GET"]) def api_backtest_tail_search_results(): """최신 tail_param_search JSON 상위 N — 웹 폼 프리필·Apply UX.""" top_n = max(1, min(100, int(request.args.get("top", 30)))) json_path = (request.args.get("json") or "").strip() or None path, data = _load_tail_search_json(json_path) if not data: return jsonify({"error": "search_tail_*.json 없음 또는 파싱 실패", "path": path}), 404 results = data.get("results") or [] rows = [] for idx, item in enumerate(results[:top_n]): merged = None try: from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source merged = merge_param_search_apply_source(item, data) except Exception: merged = dict(item.get("apply_cfg") or {}) rows.append({ "rank": idx + 1, "params": item.get("params") or {}, "apply_cfg": item.get("apply_cfg") or {}, "merged": merged, "total_trades": item.get("total_trades"), "win_rate": item.get("win_rate"), "total_pnl": item.get("total_pnl"), "pf": item.get("pf"), "avg_hold_min": item.get("avg_hold_min"), "sell_reasons": item.get("sell_reasons") or {}, }) meta = { "path": path, "mode": data.get("mode"), "start": data.get("start"), "end": data.get("end"), "timeframe": data.get("timeframe"), "grid_keys": data.get("grid_keys") or [], "grid_axis_hints": data.get("grid_axis_hints") or {}, "tested_combos": data.get("tested_combos"), "cartesian_product": data.get("cartesian_product"), "slot_money": data.get("slot_money"), "max_stocks": data.get("max_stocks"), "total_budget_krw": data.get("total_budget_krw"), } return jsonify({"ok": True, "meta": meta, "top": rows}) @app.route("/api/backtest/tail/apply_search", methods=["POST"]) def api_backtest_tail_apply_search(): """파라서치 N위 → 웹 폼 프리필 + 선택 시 DB 저장 (config_short TAIL_*).""" if not _TAIL_ENGINE_AVAILABLE: return jsonify({"error": "tail_engine 미설치 또는 임포트 실패"}), 503 body = request.get_json(force=True, silent=True) or {} rank = max(1, int(body.get("rank") or 1)) save_db = body.get("save_db", True) if isinstance(save_db, str): save_db = save_db.strip().lower() in ("1", "true", "yes", "on") json_path = (body.get("json") or "").strip() or None path, data = _load_tail_search_json(json_path) if not data: return jsonify({"error": "search_tail_*.json 없음", "path": path}), 404 results = data.get("results") or [] if rank > len(results): return jsonify({"error": f"rank 범위 초과 (1~{len(results)})"}), 400 item = results[rank - 1] pnl = int(item.get("total_pnl") or 0) if pnl <= 0 and not body.get("allow_non_positive_pnl"): return jsonify({ "error": f"total_pnl={pnl} ≤ 0 — DB 미적용. force 시 allow_non_positive_pnl=true", "rank": rank, }), 400 try: from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source merged = merge_param_search_apply_source(item, data) except Exception as e: return jsonify({"error": f"merge 실패: {e}"}), 500 ui = _tail_engine_dict_to_ui(merged) env_id = None if save_db: try: from kis_trader.backtest import tail_param_search as tps tps.apply_params_to_db(merged) db = _db() try: latest = db.get_latest_env() env_id = (latest or {}).get("id") finally: db.close() except Exception as e: logger.error("꼬리 파라서치 DB 적용 오류: %s", e) return jsonify({"error": str(e), "ui": ui}), 500 return jsonify({ "ok": True, "rank": rank, "path": path, "env_id": env_id, "saved": bool(save_db), "ui": ui, "merged": merged, "metrics": { "total_trades": item.get("total_trades"), "win_rate": item.get("win_rate"), "total_pnl": item.get("total_pnl"), "pf": item.get("pf"), "sell_reasons": item.get("sell_reasons") or {}, }, }) # ──────────────────────────────────────────────────────────────────────────── # API: 더블 볼린저 백테스트 (dbband_engine) # ──────────────────────────────────────────────────────────────────────────── try: from kis_trader.engine import dbband_engine as bbe from kis_trader.backtest import dbband_backtest_common as dbbc _DBBAND_ENGINE_AVAILABLE = True except ImportError: dbbc = None # type: ignore _DBBAND_ENGINE_AVAILABLE = False def _get_dbband_defaults_for_backtest(): if not _DBBAND_ENGINE_AVAILABLE: return {} return bbe.get_dbband_defaults_from_db() def _dbband_engine_dict_to_ui(d: Dict[str, Any], snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: snap = snap or {} if not d: return {} slot_raw = snap.get("DBBAND_SLOT_MONEY") or d.get("slot_money") or "3000000" return { "bb_period": int(d.get("bb_period") or 20), "bb_inner_std": float(d.get("bb_inner_std") or 2.0), "bb_outer_std": float(d.get("bb_outer_std") or 3.0), "trend_ma_period": int(d.get("trend_ma_period") or 200), "use_trend_filter": d.get("use_trend_filter", True), "side_mode": str(d.get("side_mode") or "long_only"), "entry_valid_bars": int(d.get("entry_valid_bars") or 3), "entry_mode": str(d.get("entry_mode") or "break_high"), "stop_mode": str(d.get("stop_mode") or "signal_low"), "stop_buffer_pct": float(d.get("stop_buffer_pct") or 0.0) * 100, "sl_pct": float(d.get("sl_pct") or 0.02) * 100, "tp_mode": str(d.get("tp_mode") or "opposite_band"), "tp_pct": float(d.get("tp_pct") or 0.03) * 100, "rr_ratio": float(d.get("rr_ratio") or 2.0), "shoulder_min_high": float(d.get("shoulder_min_high") or 0.003) * 100, "shoulder_cut_pct": float(d.get("shoulder_cut_pct") or 0.002) * 100, "trail_pct": float(d.get("trail_pct") or 0.0) * 100, "trail_arm_pct": float(d.get("trail_arm_pct") or 0.0) * 100, "cooldown_min": float(d.get("cooldown_min") or 15.0), "time_start": int(d.get("time_start_hm") or 930), "time_end": int(d.get("time_end_hm") or 1500), "max_daily": int(d.get("max_daily") or 3), "min_price": float(d.get("min_price") or 1000.0), "slot_money": int(float(slot_raw)), "max_stocks": int(snap.get("DBBAND_MAX_STOCKS") or d.get("max_stocks") or 3), "total_budget_krw": int(float( snap.get("DBBAND_TOTAL_BUDGET_KRW") or d.get("total_budget_krw") or 0 ) or int(float(slot_raw)) * int(snap.get("DBBAND_MAX_STOCKS") or d.get("max_stocks") or 3)), "max_hold_bars": int(d.get("max_hold_bars") or 0), "timeframe": int(d.get("timeframe") or 15), "exit_mode": str(d.get("exit_mode") or "classic"), "force_eod_exit": d.get("force_eod_exit", False), } def _dbband_ui_defaults_from_db(snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: d = _get_dbband_defaults_for_backtest() return _dbband_engine_dict_to_ui(d, snap) @app.route("/api/backtest/dbband/save_config", methods=["POST"]) def api_backtest_dbband_save_config(): if not _DBBAND_ENGINE_AVAILABLE: return jsonify({"error": "dbband_engine 미설치 또는 임포트 실패"}), 503 body = request.get_json(force=True, silent=True) or {} try: from kis_trader.engine.dbband_env_keys import web_body_to_dbband_env_patch patch = web_body_to_dbband_env_patch(body) if not patch: return jsonify({"error": "저장할 필드 없음"}), 400 db = _db() try: snap = db.get_merged_env_snapshot() for k, v in patch.items(): snap[k] = v env_id = db.insert_env_snapshot(snap) if env_id is None: return jsonify({"error": "env 저장 실패"}), 500 from config_schema import classify_config_key saved_by_table: Dict[str, List[str]] = {} for k in patch: tbl = classify_config_key(k) saved_by_table.setdefault(tbl, []).append(k) return jsonify({ "ok": True, "env_id": env_id, "saved_keys": list(patch.keys()), "saved_by_table": saved_by_table, }) finally: db.close() except Exception as e: logger.error("더블BB 설정저장 오류: %s", e) return jsonify({"error": str(e)}), 500 @app.route("/api/backtest/dbband", methods=["GET"]) def api_backtest_dbband_legacy(): """레거시 — 종목별 ``/api/dbband/backtest?code=`` 사용.""" return api_dbband_backtest() @app.route("/api/backtest/momentum", methods=["GET"]) def api_backtest_momentum(): """모멘텀 전용 백테스트 — momentum_engine + momentum_backtest_common (SCALP reversal 분리).""" args = request.args.to_dict(flat=True) args["mode"] = "momentum" with app.test_request_context( path="/api/backtest/scalping", query_string=args, method="GET", ): return api_backtest_scalping() @app.route("/api/backtest/momentum/save_config", methods=["POST"]) def api_backtest_momentum_save_config(): """모멘텀 탭 폼 → config_momentum + env_config INSERT.""" body = request.get_json(force=True, silent=True) or {} try: patch = _momentum_tab_save_patch(body) if not patch: return jsonify({"error": "저장할 필드 없음"}), 400 db = _db() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} # 사용자가 직접 입력한 다단 트레일 값을 프리셋 목록(env)에 영구 누적 (꼬리와 공유) _accumulate_preset(snap, patch, "BT_DAILY_TRAIL_PRESETS", body.get("daily_trail_tiers")) for k, v in patch.items(): snap[k] = v env_id = db.insert_env_snapshot(snap) if env_id is None: return jsonify({"error": "env 저장 실패(insert_env_snapshot)"}), 500 from config_schema import classify_config_key saved_by_table: Dict[str, List[str]] = {} for k in patch: tbl = classify_config_key(k) saved_by_table.setdefault(tbl, []).append(k) return jsonify({ "ok": True, "env_id": env_id, "saved_keys": list(patch.keys()), "saved_by_table": saved_by_table, }) finally: db.close() except Exception as e: logger.error("모멘텀 설정저장 오류: %s", e) return jsonify({"error": str(e)}), 500 @app.route("/api/backtest/momentum/search_results", methods=["GET"]) def api_backtest_momentum_search_results(): """최신 param_search_momentum JSON 상위 N — 웹 폼 프리필·Apply UX.""" top_n = max(1, min(100, int(request.args.get("top", 30)))) json_path = (request.args.get("json") or "").strip() or None path, data = _load_momentum_search_json(json_path) if not data: return jsonify({"error": "search_momentum_*.json 없음 또는 파싱 실패", "path": path}), 404 results = data.get("top") or data.get("results") or [] rows = [] for idx, item in enumerate(results[:top_n]): merged = None try: from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source merged = merge_param_search_apply_source(item, data) except Exception: merged = dict(item.get("merged_params") or item.get("params") or {}) rows.append({ "rank": item.get("rank") or (idx + 1), "params": item.get("params") or {}, "merged_params": item.get("merged_params") or {}, "merged": merged, "total_trades": item.get("total_trades"), "win_rate": item.get("win_rate"), "total_pnl": item.get("total_pnl"), "pf": item.get("pf"), "avg_hold_min": item.get("avg_hold") or item.get("avg_hold_min"), "sell_reasons": item.get("sell_reasons") or {}, }) meta = { "path": path, "strategy": data.get("strategy") or "MOMENTUM", "mode": data.get("mode"), "start": data.get("start"), "end": data.get("end"), "grid_keys": data.get("grid_keys") or [], "grid_axis_hints": data.get("grid_axis_hints") or {}, "tested_combos": data.get("tested_combos"), "cartesian_product": data.get("cartesian_product"), "slot_money": data.get("slot_money"), "max_stocks": data.get("max_stocks"), "total_budget_krw": data.get("total_budget_krw"), } return jsonify({"ok": True, "meta": meta, "top": rows}) @app.route("/api/backtest/momentum/apply_search", methods=["POST"]) def api_backtest_momentum_apply_search(): """파라서치 N위 → 웹 폼 프리필 + 선택 시 DB 저장 (MOMENTUM_*).""" body = request.get_json(force=True, silent=True) or {} rank = max(1, int(body.get("rank") or 1)) save_db = body.get("save_db", True) if isinstance(save_db, str): save_db = save_db.strip().lower() in ("1", "true", "yes", "on") json_path = (body.get("json") or "").strip() or None path, data = _load_momentum_search_json(json_path) if not data: return jsonify({"error": "search_momentum_*.json 없음", "path": path}), 404 results = data.get("top") or data.get("results") or [] if rank > len(results): return jsonify({"error": f"rank 범위 초과 (1~{len(results)})"}), 400 item = results[rank - 1] pnl = int(item.get("total_pnl") or 0) if pnl <= 0 and not body.get("allow_non_positive_pnl"): return jsonify({ "error": f"total_pnl={pnl} ≤ 0 — DB 미적용. force 시 allow_non_positive_pnl=true", "rank": rank, }), 400 try: from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source merged = merge_param_search_apply_source(item, data) except Exception as e: return jsonify({"error": f"merge 실패: {e}"}), 500 ui = _momentum_engine_dict_to_ui(merged) env_id = None if save_db: try: from kis_trader.backtest.param_search_momentum import apply_params_to_db env_id = apply_params_to_db(merged) if env_id is None: return jsonify({"error": "DB 적용 실패", "ui": ui}), 500 except Exception as e: logger.error("모멘텀 파라서치 DB 적용 오류: %s", e) return jsonify({"error": str(e), "ui": ui}), 500 return jsonify({ "ok": True, "rank": rank, "path": path, "env_id": env_id, "saved": bool(save_db), "ui": ui, "merged": merged, "metrics": { "total_trades": item.get("total_trades"), "win_rate": item.get("win_rate"), "total_pnl": item.get("total_pnl"), "pf": item.get("pf"), "avg_hold_min": item.get("avg_hold") or item.get("avg_hold_min"), "sell_reasons": item.get("sell_reasons") or {}, }, }) def _env_bool_10(v: Any) -> str: """env_config 불리언 컬럼용 문자열 (scalping_engine._to_bool 과 호환).""" if isinstance(v, bool): return "1" if v else "0" s = str(v).strip().lower() return "1" if s in ("1", "true", "t", "y", "yes", "on") else "0" def _tail_bool_arg(request, key: str, def_val: Any) -> bool: """꼬리 백테 쿼리 불리언 → bool (미전달 시 DB 기본값). 파라서치·실매와 동일.""" raw = request.args.get(key) if raw is None or raw == "": if isinstance(def_val, bool): return def_val s = str(def_val).strip().lower() return s in ("1", "true", "t", "y", "yes", "on") s = str(raw).strip().lower() return s in ("1", "true", "t", "y", "yes", "on") def _momentum_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: """모멘텀 백테 탭 → env_config (MomentumStrategy.reload_config · coarse 탐색과 동일 키 계열).""" if not isinstance(body, dict): return {} patch: Dict[str, str] = {} def gv(key: str) -> Any: v = body.get(key) if v is None or v == "": return None return v x = gv("mom_rsi_min") if x is not None: patch["MOMENTUM_RSI_MIN"] = str(float(x)) x = gv("mom_rsi_max") if x is not None: patch["MOMENTUM_RSI_MAX"] = str(float(x)) x = gv("mom_vol_mult") if x is not None: patch["MOMENTUM_VOL_MULT"] = str(float(x)) x = gv("mom_vol_win") if x is not None: patch["MOMENTUM_VOL_WIN"] = str(int(float(x))) x = gv("mom_time_end") if x is not None: patch["MOMENTUM_TIME_END_HM"] = str(int(float(x))) x = gv("time_start") if x is not None: patch["MOMENTUM_TIME_START"] = str(int(float(x))) x = gv("sl_pct") if x is not None: patch["MOMENTUM_STOP_LOSS_PCT"] = str(abs(float(x)) / 100.0) patch["SCALP_STOP_LOSS_PCT"] = str(abs(float(x)) / 100.0) x = gv("tp_pct") if x is not None: patch["MOMENTUM_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0) patch["SCALP_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0) x = gv("tp_max_pct") if x is not None: sr = str(abs(float(x)) / 100.0) patch["MOMENTUM_TP_MAX_PCT"] = sr patch["SCALP_TP_MAX_PCT"] = sr x = gv("shoulder_min_high") if x is not None: sr = str(abs(float(x)) / 100.0) patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = sr patch["SCALP_SHOULDER_MIN_HIGH_PCT"] = sr x = gv("shoulder_cut_pct") if x is not None: sr = str(abs(float(x)) / 100.0) patch["MOMENTUM_SHOULDER_CUT_PCT"] = sr patch["SCALP_SHOULDER_CUT_PCT"] = sr x = gv("trail_trigger") if x is not None: patch["SCALP_ATR_UP_MULT"] = str(abs(float(x)) / 100.0) x = gv("trail_stop") if x is not None: patch["SCALP_ATR_DOWN_MULT"] = str(abs(float(x)) / 100.0) x = gv("cooldown_min") if x is not None: cd = str(int(float(x) * 60)) patch["MOMENTUM_COOLDOWN_SEC"] = cd patch["SCALP_COOLDOWN_SEC"] = cd x = gv("max_daily") if x is not None: md = str(int(float(x))) patch["MOMENTUM_MAX_DAILY"] = md patch["SCALP_MAX_DAILY"] = md x = gv("slots") if x is not None: patch["MOMENTUM_MAX_STOCKS"] = str(int(float(x))) x = gv("slot_money") if x is not None: sms = str(int(float(x))) patch["MOMENTUM_SLOT_MONEY"] = sms patch["MOMENTUM_MAX_BUY_AMOUNT"] = sms x = gv("total_budget_krw") if x is not None: patch["MOMENTUM_TOTAL_BUDGET_KRW"] = str(int(float(x))) x = gv("high_chase_thr") if x is not None: hx = float(x) ratio = hx if 0 < hx <= 1 else hx / 100.0 sr = str(ratio) patch["HIGH_CHASE_THR"] = sr patch["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = sr patch["HIGH_PRICE_CHASE_THRESHOLD"] = sr x = gv("max_daily_chg") if x is not None: vchg = str(float(x)) patch["MAX_DAILY_CHG"] = vchg patch["SCALP_MAX_DAILY_CHANGE_PCT"] = vchg patch["MAX_DAILY_CHANGE_PCT"] = vchg x = gv("min_price") if x is not None: mp = str(float(x)) patch["MOMENTUM_MIN_PRICE"] = mp patch["SCALP_MIN_PRICE"] = mp x = gv("max_loss_krw") if x is not None: ml = str(int(float(x))) patch["MOMENTUM_MAX_LOSS_PER_TRADE_KRW"] = ml patch["SCALP_MAX_LOSS_PER_TRADE_KRW"] = ml patch["MAX_LOSS_PER_TRADE_KRW"] = ml x = gv("min_margin") if x is not None: patch["MOMENTUM_MIN_PROFIT_PCT"] = str(float(x)) patch["SCALP_MIN_PROFIT_PCT"] = str(float(x)) x = gv("mom_max_from_open_pct") if x is not None: patch["MOMENTUM_MAX_FROM_OPEN_PCT"] = str(float(x)) x = gv("mom_min_from_open_pct") if x is not None: patch["MOMENTUM_MIN_FROM_OPEN_PCT"] = str(float(x)) if "use_defense_filters" in body: patch["MOMENTUM_USE_DEFENSE_FILTERS"] = _env_bool_10(body.get("use_defense_filters")) patch["SCALP_USE_DEFENSE_FILTERS"] = _env_bool_10(body.get("use_defense_filters")) if "ob_filter" in body: patch["MOMENTUM_ORDERBOOK_FILTER_ENABLED"] = _env_bool_10(body.get("ob_filter")) if "pg_filter" in body: patch["MOMENTUM_PROGRAM_FILTER_ENABLED"] = _env_bool_10(body.get("pg_filter")) if "eod_enabled" in body: patch["MOMENTUM_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled")) x = gv("eod_hm") if x is not None: eod_s = str(x).strip() if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit(): eod_s = f"{eod_s[:2]}:{eod_s[2:]}" patch["MOMENTUM_EOD_HM"] = eod_s x = gv("trail_pct") if x is not None: patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(x)) / 100.0) x = gv("trail_arm_pct") if x is not None: patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0) x = gv("max_hold_bars") if x is not None: patch["MOMENTUM_MAX_HOLD_BARS"] = str(int(float(x))) x = gv("ratchet_tiers") if x is not None: patch["MOMENTUM_RATCHET_TIERS"] = str(x).strip() if "use_high_chase_filter" in body: patch["MOMENTUM_USE_HIGH_CHASE_FILTER"] = _env_bool_10(body.get("use_high_chase_filter")) if "use_daily_range_filter" in body: patch["MOMENTUM_USE_DAILY_RANGE_FILTER"] = _env_bool_10(body.get("use_daily_range_filter")) if "use_ema_filter" in body: patch["MOMENTUM_USE_EMA_FILTER"] = _env_bool_10(body.get("use_ema_filter")) if "use_rsi_max_filter" in body: patch["MOMENTUM_USE_RSI_MAX_FILTER"] = _env_bool_10(body.get("use_rsi_max_filter")) if "pattern_breakout" in body: patch["MOMENTUM_PATTERN_BREAKOUT"] = _env_bool_10(body.get("pattern_breakout")) if "pattern_pullback" in body: patch["MOMENTUM_PATTERN_PULLBACK"] = _env_bool_10(body.get("pattern_pullback")) x = gv("chase_lookback_min") if x is not None: patch["MOMENTUM_CHASE_LOOKBACK_MIN"] = str(int(float(x))) x = gv("pullback_lookback_min") if x is not None: patch["MOMENTUM_PULLBACK_LOOKBACK_MIN"] = str(int(float(x))) x = gv("pullback_min_pct") if x is not None: patch["MOMENTUM_PULLBACK_MIN_PCT"] = str(float(x)) x = gv("pullback_max_pct") if x is not None: patch["MOMENTUM_PULLBACK_MAX_PCT"] = str(float(x)) x = gv("setup_vol_max_mult") if x is not None: patch["MOMENTUM_SETUP_VOL_MAX_MULT"] = str(float(x)) x = gv("setup_bear_bars_min") if x is not None: patch["MOMENTUM_SETUP_BEAR_BARS_MIN"] = str(int(float(x))) x = gv("ema_fast_period") if x is not None: patch["MOMENTUM_EMA_FAST_PERIOD"] = str(int(float(x))) x = gv("ema_slow_period") if x is not None: patch["MOMENTUM_EMA_SLOW_PERIOD"] = str(int(float(x))) # 당일 누적손익 다단 트레일 익절 (레칫식) — 꼬리와 동일 (공통 헬퍼) patch.update(_daily_trail_save_patch(body, "MOMENTUM")) return patch def _scalp_reversal_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: """스캘핑 reversal 탭 saveScalpConfig JSON → SCALP_* env.""" if not isinstance(body, dict): return {} patch: Dict[str, str] = {} def gv(key: str) -> Any: v = body.get(key) if v is None or v == "": return None return v x = gv("rsi_oversold") if x is not None: patch["SCALP_RSI_OVERSOLD"] = str(float(x)) x = gv("rsi_overbought") if x is not None: patch["SCALP_RSI_OVERBOUGHT"] = str(float(x)) x = gv("sl_pct") if x is not None: r = abs(float(x)) / 100.0 patch["SCALP_STOP_LOSS_PCT"] = str(r) x = gv("tp_pct") if x is not None: patch["SCALP_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0) x = gv("tp_max_pct") if x is not None: patch["SCALP_TP_MAX_PCT"] = str(abs(float(x)) / 100.0) x = gv("drop_rate") if x is not None: patch["SCALP_MIN_DROP_RATE"] = str(abs(float(x)) / 100.0) x = gv("shoulder_min_high") if x is not None: sr = str(abs(float(x)) / 100.0) patch["SCALP_SHOULDER_MIN_HIGH_PCT"] = sr patch["SHOULDER_MIN_HIGH_PCT"] = sr x = gv("shoulder_cut_pct") if x is not None: sr = str(abs(float(x)) / 100.0) patch["SCALP_SHOULDER_CUT_PCT"] = sr patch["SHOULDER_CUT_PCT"] = sr x = gv("trail_trigger") if x is not None: patch["SCALP_ATR_UP_MULT"] = str(abs(float(x)) / 100.0) x = gv("trail_stop") if x is not None: patch["SCALP_ATR_DOWN_MULT"] = str(abs(float(x)) / 100.0) x = gv("cooldown_min") if x is not None: patch["SCALP_COOLDOWN_SEC"] = str(int(float(x) * 60)) x = gv("high_chase_thr") if x is not None: hx = float(x) ratio = hx if 0 < hx <= 1 else hx / 100.0 sr = str(ratio) patch["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = sr patch["HIGH_CHASE_THR"] = sr patch["HIGH_PRICE_CHASE_THRESHOLD"] = sr x = gv("max_daily_chg") if x is not None: vchg = str(float(x)) patch["SCALP_MAX_DAILY_CHANGE_PCT"] = vchg patch["MAX_DAILY_CHG"] = vchg patch["MAX_DAILY_CHANGE_PCT"] = vchg x = gv("min_price") if x is not None: patch["SCALP_MIN_PRICE"] = str(float(x)) x = gv("max_loss_krw") if x is not None: ml = str(int(float(x))) patch["SCALP_MAX_LOSS_PER_TRADE_KRW"] = ml patch["MAX_LOSS_PER_TRADE_KRW"] = ml x = gv("min_margin") if x is not None: mm = str(float(x)) patch["SCALP_MIN_PROFIT_PCT"] = mm patch["MOMENTUM_MIN_PROFIT_PCT"] = mm if "use_defense_filters" in body: patch["SCALP_USE_DEFENSE_FILTERS"] = _env_bool_10(body.get("use_defense_filters")) if "use_macd_cross" in body: patch["SCALP_USE_MACD_CROSS"] = _env_bool_10(body.get("use_macd_cross")) return patch def _breakout_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: """돌파 백테 탭 폼 → BREAKOUT_* env (BreakoutStrategy 가 읽는 음수 손절 비율 포함).""" if not isinstance(body, dict): return {} patch: Dict[str, str] = {} def gv(key: str) -> Any: v = body.get(key) if v is None or v == "": return None return v x = gv("lookback_min") if x is not None: patch["BREAKOUT_LOOKBACK_MIN"] = str(int(float(x))) x = gv("vol_window") if x is not None: patch["BREAKOUT_VOL_WIN"] = str(int(float(x))) x = gv("vol_mult") if x is not None: patch["BREAKOUT_VOL_MULT"] = str(float(x)) x = gv("min_turnover_1m_pct") if x is not None: patch["BREAKOUT_MIN_TURNOVER_1M_PCT"] = str(float(x)) x = gv("prev_chg_min") if x is not None: patch["BREAKOUT_PREV_CHG_MIN"] = str(float(x)) x = gv("prev_chg_max") if x is not None: patch["BREAKOUT_PREV_CHG_MAX"] = str(float(x)) x = gv("sl_pct") if x is not None: patch["BREAKOUT_STOP_LOSS_PCT"] = str(-abs(float(x)) / 100.0) x = gv("sl_mode") if x is not None: patch["BREAKOUT_SL_MODE"] = str(x).strip().lower() x = gv("atr_period") if x is not None: patch["BREAKOUT_ATR_PERIOD"] = str(int(float(x))) x = gv("atr_sl_mult") if x is not None: patch["BREAKOUT_ATR_SL_MULT"] = str(float(x)) x = gv("atr_sl_min_pct") if x is not None: patch["BREAKOUT_ATR_SL_MIN_PCT"] = str(float(x)) x = gv("atr_sl_max_pct") if x is not None: patch["BREAKOUT_ATR_SL_MAX_PCT"] = str(float(x)) x = gv("tp_pct") if x is not None: patch["BREAKOUT_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0) x = gv("trail_pct") if x is not None: patch["BREAKOUT_TRAIL_PCT"] = str(abs(float(x)) / 100.0) x = gv("trail_arm_pct") if x is not None: patch["BREAKOUT_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_min_high_pct") if x is not None: patch["BREAKOUT_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_cut_pct") if x is not None: patch["BREAKOUT_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0) if "eod_enabled" in body: patch["BREAKOUT_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled")) x = gv("eod_hm") if x is not None: eod_s = str(x).strip() if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit(): eod_s = f"{eod_s[:2]}:{eod_s[2:]}" patch["BREAKOUT_EOD_HM"] = eod_s x = gv("max_hold_bars") if x is not None: patch["BREAKOUT_MAX_HOLD_BARS"] = str(int(float(x))) x = gv("time_start_hm") if x is not None: patch["BREAKOUT_TIME_START"] = str(int(float(x))) x = gv("time_end_hm") if x is not None: te = str(int(float(x))) patch["BREAKOUT_TIME_END"] = te patch["BREAKOUT_GOLDEN_END_HM"] = te x = gv("max_daily") if x is not None: patch["BREAKOUT_MAX_DAILY"] = str(int(float(x))) x = gv("cooldown_min") if x is not None: patch["BREAKOUT_COOLDOWN_SEC"] = str(int(float(x) * 60)) x = gv("max_daily_chg") if x is not None: patch["BREAKOUT_MAX_DAILY_CHG"] = str(float(x)) x = gv("min_price") if x is not None: patch["BREAKOUT_MIN_PRICE"] = str(int(float(x))) # 가짜돌파(휩쏘) 필터 — 0=OFF x = gv("confirm_margin_pct") if x is not None: patch["BREAKOUT_CONFIRM_MARGIN_PCT"] = str(float(x)) x = gv("body_min_pct") if x is not None: patch["BREAKOUT_BODY_MIN_PCT"] = str(float(x)) x = gv("max_loss_krw") if x is not None: patch["BREAKOUT_MAX_LOSS_PER_TRADE_KRW"] = str(int(float(x))) x = gv("entry_mode") if x is not None: patch["BREAKOUT_ENTRY_MODE"] = str(x).strip().lower() x = gv("intrabar_slippage_pct") if x is not None: patch["BREAKOUT_INTRABAR_SLIPPAGE_PCT"] = str(float(x)) x = gv("slot_money") if x is not None: patch["BREAKOUT_SLOT_MONEY"] = str(int(float(x))) x = gv("max_stocks") if x is not None: patch["BREAKOUT_MAX_STOCKS"] = str(int(float(x))) x = gv("total_budget_krw") if x is not None: patch["BREAKOUT_TOTAL_BUDGET_KRW"] = str(int(float(x))) if "use_ema_filter" in body: patch["BREAKOUT_USE_EMA_FILTER"] = _env_bool_10(body.get("use_ema_filter")) if "ob_filter" in body: patch["BREAKOUT_ORDERBOOK_FILTER_ENABLED"] = _env_bool_10(body.get("ob_filter")) if "pg_filter" in body: patch["BREAKOUT_PROGRAM_FILTER_ENABLED"] = _env_bool_10(body.get("pg_filter")) x = gv("ema_fast_period") if x is not None: patch["BREAKOUT_EMA_FAST_PERIOD"] = str(int(float(x))) x = gv("ema_slow_period") if x is not None: patch["BREAKOUT_EMA_SLOW_PERIOD"] = str(int(float(x))) # 당일 누적손익 다단 트레일 익절 (레칫식) — 꼬리와 동일 (공통 헬퍼) patch.update(_daily_trail_save_patch(body, "BREAKOUT")) return patch def _range_break_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: """박스권 돌파 백테 탭 폼 → RANGE_BREAK_* env.""" if not isinstance(body, dict): return {} patch: Dict[str, str] = {} def gv(key: str) -> Any: v = body.get(key) if v is None or v == "": return None return v mapping = ( ("box_lookback_min", "RANGE_BREAK_BOX_LOOKBACK_MIN", lambda x: str(int(float(x)))), ("box_max_width_pct", "RANGE_BREAK_BOX_MAX_WIDTH_PCT", lambda x: str(float(x))), ("box_min_width_pct", "RANGE_BREAK_BOX_MIN_WIDTH_PCT", lambda x: str(float(x))), ("setup_vol_max_mult", "RANGE_BREAK_SETUP_VOL_MAX_MULT", lambda x: str(float(x))), ("setup_bear_bars_min", "RANGE_BREAK_SETUP_BEAR_BARS_MIN", lambda x: str(int(float(x)))), ("vol_mult", "RANGE_BREAK_VOL_MULT", lambda x: str(float(x))), ("vol_window", "RANGE_BREAK_VOL_WIN", lambda x: str(int(float(x)))), ("vol_baseline_win", "RANGE_BREAK_VOL_BASELINE_WIN", lambda x: str(int(float(x)))), ("break_margin_pct", "RANGE_BREAK_BREAK_MARGIN_PCT", lambda x: str(float(x))), ("body_min_pct", "RANGE_BREAK_BODY_MIN_PCT", lambda x: str(float(x))), ("time_start_hm", "RANGE_BREAK_TIME_START", lambda x: str(int(float(x)))), ("time_end_hm", "RANGE_BREAK_TIME_END_HM", lambda x: str(int(float(x)))), ("max_daily", "RANGE_BREAK_MAX_DAILY", lambda x: str(int(float(x)))), ("max_daily_chg", "RANGE_BREAK_MAX_DAILY_CHG", lambda x: str(float(x))), ("min_price", "RANGE_BREAK_MIN_PRICE", lambda x: str(int(float(x)))), ("high_chase_thr", "RANGE_BREAK_HIGH_CHASE_THR", lambda x: str(float(x))), ("max_loss_krw", "RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW", lambda x: str(int(float(x)))), ("slot_money", "RANGE_BREAK_SLOT_MONEY", lambda x: str(int(float(x)))), ("max_stocks", "RANGE_BREAK_MAX_STOCKS", lambda x: str(int(float(x)))), ("total_budget_krw", "RANGE_BREAK_TOTAL_BUDGET_KRW", lambda x: str(int(float(x)))), ("max_hold_bars", "RANGE_BREAK_MAX_HOLD_BARS", lambda x: str(int(float(x)))), ) for ui_key, env_key, fmt in mapping: x = gv(ui_key) if x is not None: patch[env_key] = fmt(x) x = gv("sl_pct") if x is not None: patch["RANGE_BREAK_STOP_LOSS_PCT"] = str(-abs(float(x)) / 100.0) x = gv("tp_pct") if x is not None: patch["RANGE_BREAK_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0) x = gv("trail_pct") if x is not None: patch["RANGE_BREAK_TRAIL_PCT"] = str(abs(float(x)) / 100.0) x = gv("trail_arm_pct") if x is not None: patch["RANGE_BREAK_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_min_high_pct") if x is not None: patch["RANGE_BREAK_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_cut_pct") if x is not None: patch["RANGE_BREAK_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0) x = gv("cooldown_min") if x is not None: patch["RANGE_BREAK_COOLDOWN_SEC"] = str(int(float(x) * 60)) if "use_high_chase_filter" in body: patch["RANGE_BREAK_USE_HIGH_CHASE_FILTER"] = _env_bool_10(body.get("use_high_chase_filter")) return patch @app.route("/api/backtest/scalping/save_config", methods=["POST"]) def api_backtest_scalping_save_config(): """스캘핑 reversal · 모멘텀 → insert_env_snapshot (config_scalp / config_momentum 분리 저장).""" body = request.get_json(force=True, silent=True) or {} try: sk = str(body.get("save_kind") or "").strip().lower() keys = set(body.keys()) if sk == "momentum": patch = _momentum_tab_save_patch(body) elif sk == "reversal" or "rsi_oversold" in keys: patch = _scalp_reversal_tab_save_patch(body) elif "use_defense_filters" in keys or "use_macd_cross" in keys: patch = {} if "use_defense_filters" in keys: patch["SCALP_USE_DEFENSE_FILTERS"] = _env_bool_10(body.get("use_defense_filters")) if "use_macd_cross" in keys: patch["SCALP_USE_MACD_CROSS"] = _env_bool_10(body.get("use_macd_cross")) else: return jsonify({"error": "알 수 없는 저장 요청(save_kind 또는 필드 없음)"}), 400 if not patch: return jsonify({"error": "저장할 필드 없음"}), 400 db = _db() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} for k, v in patch.items(): snap[k] = v env_id = db.insert_env_snapshot(snap) if env_id is None: return jsonify({"error": "env 저장 실패(insert_env_snapshot)"}), 500 from config_schema import classify_config_key saved_by_table: Dict[str, List[str]] = {} for k in patch: tbl = classify_config_key(k) saved_by_table.setdefault(tbl, []).append(k) return jsonify({ "ok": True, "env_id": env_id, "saved_keys": list(patch.keys()), "saved_by_table": saved_by_table, }) finally: db.close() except Exception as e: logger.error("스캘핑/모멘텀 설정저장 오류: %s", e) return jsonify({"error": str(e)}), 500 @app.route("/api/backtest/breakout/save_config", methods=["POST"]) def api_backtest_breakout_save_config(): """돌파 백테 탭 폼 → BREAKOUT_* env 스냅샷 INSERT.""" body = request.get_json(force=True, silent=True) or {} try: patch = _breakout_tab_save_patch(body) if not patch: return jsonify({"error": "저장할 필드 없음"}), 400 db = _db() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} # 사용자가 직접 입력한 다단 트레일 값을 프리셋 목록(env)에 영구 누적 (꼬리와 공유) _accumulate_preset(snap, patch, "BT_DAILY_TRAIL_PRESETS", body.get("daily_trail_tiers")) for k, v in patch.items(): snap[k] = v env_id = db.insert_env_snapshot(snap) if env_id is None: return jsonify({"error": "env_config 저장 실패"}), 500 from config_schema import classify_config_key saved_by_table: Dict[str, List[str]] = {} for k in patch: tbl = classify_config_key(k) saved_by_table.setdefault(tbl, []).append(k) return jsonify({ "ok": True, "env_id": env_id, "saved_keys": list(patch.keys()), "saved_by_table": saved_by_table, }) finally: db.close() except Exception as e: logger.error("돌파 설정저장 오류: %s", e) return jsonify({"error": str(e)}), 500 @app.route("/api/backtest/range_break/save_config", methods=["POST"]) def api_backtest_range_break_save_config(): """박스권 돌파 백테 탭 폼 → RANGE_BREAK_* env 스냅샷 INSERT.""" body = request.get_json(force=True, silent=True) or {} try: patch = _range_break_tab_save_patch(body) if not patch: return jsonify({"error": "저장할 필드 없음"}), 400 db = _db() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} for k, v in patch.items(): snap[k] = v env_id = db.insert_env_snapshot(snap) if env_id is None: return jsonify({"error": "env_config 저장 실패"}), 500 from config_schema import classify_config_key saved_by_table: Dict[str, List[str]] = {} for k in patch: tbl = classify_config_key(k) saved_by_table.setdefault(tbl, []).append(k) return jsonify({ "ok": True, "env_id": env_id, "saved_keys": list(patch.keys()), "saved_by_table": saved_by_table, }) finally: db.close() except Exception as e: logger.error("박스권 돌파 설정저장 오류: %s", e) return jsonify({"error": str(e)}), 500 @app.route("/api/backtest/tail", methods=["GET"]) 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() start = request.args.get("start", "") end = request.args.get("end", "") 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.003) * 100)) / 100 shoulder_cut_pct = float(request.args.get("shoulder_cut_pct", _def.get("shoulder_cut_pct", 0.002) * 100)) / 100 trail_pct = abs(float(request.args.get( "trail_pct", _tail_frac_to_ui_pct(_def.get("trail_pct", 0.0)) or 0, ))) / 100.0 trail_arm_pct = abs(float(request.args.get( "trail_arm_pct", _tail_frac_to_ui_pct(_def.get("trail_arm_pct", 0.0)) or 0, ))) / 100.0 _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", _def.get("slot_money", 3_000_000))) max_stocks = int( request.args.get("max_stocks", _def.get("max_stocks", 3))) total_budget_krw = float(request.args.get("total_budget_krw", _def.get("total_budget_krw", 0))) if total_budget_krw <= 0: total_budget_krw = float(max_stocks * slot_money) _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", _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))) eod_patch = _eod_params_from_request(request, _def, default_hm="15:25") _legacy_force_eod = request.args.get("force_eod_exit") if _legacy_force_eod not in (None, ""): eod_patch["eod_enabled"] = str(_legacy_force_eod).strip().lower() in ( "1", "true", "y", "yes", "on", ) # 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", 1.5))) target_atr_mult = float(request.args.get("target_atr_mult", _def.get("target_atr_mult", 2.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))) try: tail_tf = int(request.args.get("timeframe", request.args.get("tf", 3))) except (TypeError, ValueError): tail_tf = 3 if tail_tf not in (3, 5, 15, 60): return jsonify({ "error": f"timeframe(tf)는 ws_candles 저장 단위 3·5·15·60 중 하나여야 합니다 (요청: {tail_tf})", }), 400 db = _db() try: start_key = (start.replace("-", "") + "0000") if start else "20260101" end_key = (end.replace("-", "") + "2359") if end else "99991231" codes_raw = db.conn.execute( "SELECT DISTINCT code FROM ws_candles WHERE timeframe=%s " "AND candle_time >= %s AND candle_time <= %s ORDER BY code", [tail_tf, start_key, end_key] ).fetchall() codes = [r["code"] for r in codes_raw] use_saved_history_tail, tail_univ_mode, _ = _parse_backtest_universe_arg( request, default="history", sim_kind=None, ) use_engine = _TAIL_ENGINE_AVAILABLE and request.args.get("use_engine", "1") == "1" all_trades: List[Dict] = [] universe_source = "all" universe_history_slots = 0 candles_by_code: Dict[str, List[Dict]] = {} bt_meta: Dict[str, Any] = {} tail_trigger_flags: Dict[str, Any] = { "skip_hts_scan_dupes": _tail_bool_arg( request, "skip_hts_scan_dupes", _def.get("skip_hts_scan_dupes", True), ), "use_intraday_drop": _tail_bool_arg( request, "use_intraday_drop", _def.get("use_intraday_drop", False), ), "use_ma20_filter": _tail_bool_arg( request, "use_ma20_filter", _def.get("use_ma20_filter", False), ), "use_rsi_filter": _tail_bool_arg( request, "use_rsi_filter", _def.get("use_rsi_filter", True), ), "use_daily_range_filter": _tail_bool_arg( request, "use_daily_range_filter", _def.get("use_daily_range_filter", True), ), "use_high_chase_filter": _tail_bool_arg( request, "use_high_chase_filter", _def.get("use_high_chase_filter", True), ), "bar_chg_min_pct": float(request.args.get("bar_chg_min_pct", _def.get("bar_chg_min_pct", -10.0))), "bar_chg_max_pct": float(request.args.get("bar_chg_max_pct", _def.get("bar_chg_max_pct", -1.5))), "pattern_hammer": _tail_bool_arg(request, "pattern_hammer", _def.get("pattern_hammer", True)), "pattern_pin": _tail_bool_arg(request, "pattern_pin", _def.get("pattern_pin", False)), "pattern_engulfing": _tail_bool_arg(request, "pattern_engulfing", _def.get("pattern_engulfing", False)), "pattern_piercing": _tail_bool_arg(request, "pattern_piercing", _def.get("pattern_piercing", False)), "pattern_harami": _tail_bool_arg(request, "pattern_harami", _def.get("pattern_harami", False)), "pattern_doji": _tail_bool_arg(request, "pattern_doji", _def.get("pattern_doji", False)), "pattern_morning_star": _tail_bool_arg( request, "pattern_morning_star", _def.get("pattern_morning_star", False), ), } try: from kis_trader.engine.limit_entry_common import short_entry_mode, tail_limit_params except ImportError: short_entry_mode = lambda p=None: "limit_atr" # type: ignore tail_limit_params = lambda p=None: {} # type: ignore _entry_mode = str( request.args.get("entry_mode") or _def.get("entry_mode") or short_entry_mode() ).strip().lower() _limit_probe = dict(_def) _limit_probe["entry_mode"] = _entry_mode for _lk in ("limit_atr_mult", "limit_anchor", "limit_valid_bars", "limit_fill_slip_pct"): _lv = request.args.get(_lk) if _lv is not None and str(_lv).strip() != "": _limit_probe[_lk] = _lv _lp_tail = tail_limit_params(_limit_probe) if use_engine: params = { "entry_mode": _entry_mode, "limit_atr_mult": _lp_tail["mult"], "limit_anchor": _lp_tail["anchor"], "limit_valid_bars": _lp_tail["valid_bars"], "limit_fill_slip_pct": _lp_tail["fill_slip_pct"], "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, "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, "atr_sl_min_pct": float(request.args.get("atr_sl_min_pct", _def.get("atr_sl_min_pct", 0.5))), "atr_sl_max_pct": float(request.args.get("atr_sl_max_pct", _def.get("atr_sl_max_pct", 1.0))), "atr_tp_min_pct": float(request.args.get("atr_tp_min_pct", _def.get("atr_tp_min_pct", 0.3))), "atr_tp_max_pct": float(request.args.get("atr_tp_max_pct", _def.get("atr_tp_max_pct", 1.0))), "tail_vol_mult": float(request.args.get("tail_vol_mult", _def.get("tail_vol_mult", 0.0))), "tail_vol_win": int(float(request.args.get("tail_vol_win", _def.get("tail_vol_win", 5)))), "backtest_vol_fill_cap_pct": float(request.args.get( "backtest_vol_fill_cap_pct", _def.get("backtest_vol_fill_cap_pct", 0.0), )), "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, **eod_patch, "max_stocks": max_stocks, "total_budget_krw": total_budget_krw, "portfolio_mode": True, "ratchet_tiers": str( request.args.get("ratchet_tiers", _def.get("ratchet_tiers", "")) or "" ).strip(), "max_hold_bars": int(float( request.args.get("max_hold_bars", _def.get("max_hold_bars", 0)) or 0 )), "backtest_use_tick_db": _tail_bool_arg( request, "backtest_use_tick_db", _def.get("backtest_use_tick_db", False), ), "backtest_tick_fallback_ohlc": _tail_bool_arg( request, "backtest_tick_fallback_ohlc", _def.get("backtest_tick_fallback_ohlc", True), ), "trail_pct": trail_pct, "trail_arm_pct": trail_arm_pct, # 당일 누적손익 트레일 익절(daily_trail_*) — drop>0 일 때만 게이트 ON. # apply_daily_profit_halt_sim 이 engine_params 로 읽어 신규진입 차단. **_daily_trail_params_from_request(request), **tail_trigger_flags, } start_ymd = start_key[:8] end_ymd = end_key[:8] universe_by_slot, universe_source, universe_history_slots, _scan_iv = ( tbc.resolve_tail_universe( start_ymd, end_ymd, use_saved_history=use_saved_history_tail, ) ) params["scan_interval_min"] = _scan_iv params["timeframe"] = tail_tf # 백테 전용 필터 토글 (이 1회 백테에만 적용. 비우면 DB=실매값) _ob_tg = _backtest_filter_toggle(request.args.get("ob_filter")) if _ob_tg is not None: params["_orderbook_filter_enabled"] = _ob_tg _pg_tg = _backtest_filter_toggle(request.args.get("pg_filter")) if _pg_tg is not None: params["_program_filter_enabled"] = _pg_tg _spread_req = request.args.get("max_spread_pct") if _spread_req not in (None, ""): params["_ob_max_spread_pct"] = float(_spread_req) # kiwoom_0d 본체는 ob_body=1 일 때만. spread 값만으로 본체 강제하면 # log_backfill 판정 재생(파람서치·CLI --orderbook-filter on 기본)과 어긋남. _ob_body = str(request.args.get("ob_body", "0")).strip().lower() in ( "1", "true", "y", "yes", "on", ) if _ob_body: params["backtest_use_kiwoom_body_snapshot"] = True params["_backtest_use_kiwoom_body"] = True candles_by_code, _total_candles, has_holding_peak = tbc.load_tail_candles_by_code( db, start_key, end_key, tail_tf, rsi_period=rsi_period, ) bt_meta = { "db": db, "start_key": start_key, "end_key": end_key, "timeframe": tail_tf, } all_trades = tbc.run_tail_backtest_web_aligned( candles_by_code, params, universe_by_slot, slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax, max_stocks=max_stocks, total_budget_krw=total_budget_krw, meta_out=bt_meta, ) else: for code in codes: rows = db.conn.execute( "SELECT candle_time, open, high, low, close, volume " "FROM ws_candles " "WHERE timeframe=%s AND code=%s " "AND candle_time >= %s AND candle_time <= %s " "AND is_confirmed=1 " "ORDER BY candle_time ASC", [tail_tf, 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) 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 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) # ── 마지막 봉 여부 ──────────────────────────────────────── is_eod = is_strategy_eod_bar(c["candle_time"], params, "TAIL") # ───────────────────────────────────────────────────────── # 포지션 보유 중: 청산 체크 # ───────────────────────────────────────────────────────── if position is not None: max_p = max(position["max_price"], hi) position["max_price"] = max_p cur_c_info = {"high": hi, "low": lo, "close": cl, "candle_time": c["candle_time"]} _pos = dict(position) _pos["max_price"] = max_p res = te.check_sell_signal_live(_pos, cur_c_info, params, is_eod=is_eod) if res: reason, exit_price = res else: reason, exit_price = None, 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) 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 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 # 쿨다운: 마지막 청산 후 N분 이내 재진입 금지 if day in last_exit_dt: elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60 if elapsed < cooldown_min: 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: 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 if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min: 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 # ── 5. RSI 과열 방지 ──────────────────────────────────── rsi_val = rsis[i] if rsi_val is None or rsi_val >= rsi_threshold: continue # ── 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) wins = [t for t in all_trades if t["pnl"] > 0] losses = [t for t in all_trades if t["pnl"] < 0] total_pnl = sum(t["pnl"] for t in all_trades) avg_hold = (sum(t["hold_min"] for t in all_trades) / total) if total else 0 win_pnl = sum(t["pnl"] for t in wins) loss_pnl = sum(t["pnl"] for t in losses) pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0 # MDD peak, mdd, cum = 0.0, 0.0, 0.0 peak_cum_at = "" # 장중 누적손익 최고점에 도달한 시각(모멘텀 동일 표기) equity, daily_map = [], {} for t in sorted(all_trades, key=lambda x: x["exit_time"]): cum += t["pnl"] if cum > peak: peak = cum peak_cum_at = str(t.get("exit_time") or "") dd = peak - cum if dd > mdd: mdd = dd day = t["exit_time"][:8] equity.append({"date": f"{day[:4]}-{day[4:6]}-{day[6:]}", "cum_pnl": round(cum)}) daily_map[day] = daily_map.get(day, 0) + t["pnl"] daily_list = [{"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v)} for d, v in sorted(daily_map.items())] bot_pct = round(total_pnl / total_budget_krw * 100, 2) if total_budget_krw > 0 else 0.0 period_days = _backtest_period_days(start, end, fallback=len(daily_list) or 1) daily_avg_pct = round(bot_pct / period_days, 3) if period_days > 0 else 0.0 reasons: Dict[str, int] = {} for t in all_trades: reasons[t["reason"]] = reasons.get(t["reason"], 0) + 1 universe_warning = None budget_warning = None if total_budget_krw < max_stocks * slot_money * 0.95: budget_warning = ( f"총한도 {total_budget_krw:,.0f}원 < 동시{max_stocks}×1회투자 " f"{max_stocks * slot_money:,.0f}원 — 잔여금 소액매수·과다 회전 위험. " "실매 정렬: 총한도↑ 또는 동시보유↓" ) skip_stats = {} if use_engine: skip_stats = (bt_meta.get("skip_stats") or params.get("_portfolio_skip_stats") or {}) skipped_micro = int(skip_stats.get("skipped_micro_buys") or 0) min_inv_r = te._tail_min_invest_ratio_of_slot(params) if use_engine else 0.9 if skipped_micro > 0: micro_note = f"소액매수 스킵 {skipped_micro}건 (slot {min_inv_r * 100:.0f}% 미만)" budget_warning = f"{budget_warning} | {micro_note}" if budget_warning else micro_note if use_saved_history_tail and universe_source == "all": universe_warning = ( "저장 후보 이력을 요청했으나 해당 기간 이력이 없어 전종목(ws_candles)으로 실행되었습니다." ) elif not use_saved_history_tail and universe_source == "all": universe_warning = ( "전종목 모드(저장 이력 OFF). 거래 수가 많습니다. " "파라서치 기본(저장 이력)과 비교하려면 체크박스를 켜세요." ) elif universe_source == "history" and total > 25: universe_warning = ( f"저장 이력 {universe_history_slots}슬롯 사용 중 거래 {total}건 — " "매수시간·기간·파라미터가 파라서치와 다른지 확인하세요." ) tail_trades_out = _trades_recent_first(all_trades, 200) _enrich_trades_with_names(db, tail_trades_out) # 모멘텀과 동일하게 매도시각 순 누적손익·누적수익률·체결(틱/OHLC) 디버그 라벨 부착 _enrich_momentum_trades_debug(tail_trades_out, total_budget_krw=total_budget_krw) return jsonify({ "params": { "start": start, "end": end, "timeframe": tail_tf, "rsi_period": rsi_period, "rsi_threshold": rsi_threshold, "min_drop_rate": min_drop_rate * 100, "min_recovery_ratio": min_recovery_ratio * 100, "max_rec_3m": max_rec_3m * 100, "tail_ratio_min": tail_ratio_min, "tail_pct_min": tail_pct_min * 100, "sl_pct": sl_pct * 100, "tp_pct": tp_pct * 100, "shoulder_min_high": shoulder_min_high * 100, "shoulder_cut_pct": shoulder_cut_pct * 100, "trail_pct": trail_pct * 100, "trail_arm_pct": trail_arm_pct * 100, "slot_money": slot_money, "max_stocks": max_stocks, "total_budget_krw": total_budget_krw, "portfolio_mode": True, "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}", **eod_patch, "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(candles_by_code) if use_engine else len(codes), "universe_source": universe_source, "universe_history_slots": universe_history_slots, "universe": tail_univ_mode, "strategy_id": "SHORT", "entry_mode": _entry_mode, "limit_atr_mult": _lp_tail["mult"], "limit_anchor": _lp_tail["anchor"], "limit_valid_bars": _lp_tail["valid_bars"], "limit_fill_slip_pct": _lp_tail["fill_slip_pct"], **tail_trigger_flags, }, "summary": { "total_trades": total, "win_trades": len(wins), "loss_trades": len(losses), "win_rate": round(len(wins) / total * 100, 1) if total else 0, "total_pnl": round(total_pnl), "avg_hold_min": round(avg_hold, 1), "profit_factor": round(pf, 2), "max_drawdown": round(mdd), "peak_cum_pnl": round(peak), "peak_cum_at": peak_cum_at[:19] if peak_cum_at else "", "bot_pct": bot_pct, "daily_avg_pct": daily_avg_pct, "backtest_days": period_days, "universe_warning": universe_warning, "budget_warning": budget_warning, "tick_backtest": bt_meta.get("tick_backtest") if use_engine else None, "backtest_buy_source": bt_meta.get("backtest_buy_source") if use_engine else None, "tick_entry_sources": skip_stats.get("tick_entry_sources") if use_engine else None, "skip_stats": skip_stats if use_engine else None, }, "equity": equity, "daily": daily_list, "reasons": reasons, "trades": tail_trades_out, }) finally: db.close() @app.route("/api/backtest/breakout", methods=["GET"]) def api_backtest_breakout(): """돌파매매 백테스트 — ``run_breakout_backtest`` (라이브 BreakoutStrategy 와 동일 함수).""" _def = _bo_defaults_from_db() start = request.args.get("start", "") end = request.args.get("end", "") def _arg(key: str, default: Any, cast=float): raw = request.args.get(key) if raw in (None, ""): return default try: return cast(raw) except (ValueError, TypeError): return default ui = { "lookback_min": _arg("lookback_min", _def["lookback_min"], lambda v: int(float(v))), "vol_window": _arg("vol_window", _def["vol_window"], lambda v: int(float(v))), "vol_mult": _arg("vol_mult", _def["vol_mult"], float), "min_turnover_1m_pct": _arg( "min_turnover_1m_pct", _def.get("min_turnover_1m_pct", 0.05), float, ), "prev_chg_min": _arg("prev_chg_min", _def["prev_chg_min"], float), "prev_chg_max": _arg("prev_chg_max", _def["prev_chg_max"], float), "sl_pct": _arg("sl_pct", _def["sl_pct"], float), "sl_mode": _arg("sl_mode", _def.get("sl_mode", "fixed"), str).strip().lower(), "atr_period": _arg("atr_period", _def.get("atr_period", 14), lambda v: int(float(v))), "atr_sl_mult": _arg("atr_sl_mult", _def.get("atr_sl_mult", 2.0), float), "atr_sl_min_pct": _arg("atr_sl_min_pct", _def.get("atr_sl_min_pct", 0.8), float), "atr_sl_max_pct": _arg("atr_sl_max_pct", _def.get("atr_sl_max_pct", 6.0), float), "tp_pct": _arg("tp_pct", _def["tp_pct"], float), "trail_pct": _arg("trail_pct", _def["trail_pct"], float), "trail_arm_pct": _arg("trail_arm_pct", _def.get("trail_arm_pct", 0.0), float), "time_start_hm": _arg("time_start_hm", _def["time_start_hm"], lambda v: int(float(v))), "time_end_hm": _arg("time_end_hm", _def["time_end_hm"], lambda v: int(float(v))), "max_daily": _arg("max_daily", _def["max_daily"], lambda v: int(float(v))), "cooldown_min": _arg("cooldown_min", _def["cooldown_min"], float), "max_daily_chg": _arg("max_daily_chg", _def["max_daily_chg"], float), "min_price": _arg("min_price", _def["min_price"], float), # 가짜돌파(휩쏘) 필터 — 0=OFF "confirm_margin_pct": _arg("confirm_margin_pct", _def.get("confirm_margin_pct", 0.0), float), "body_min_pct": _arg("body_min_pct", _def.get("body_min_pct", 0.0), float), "max_loss_krw": _arg("max_loss_krw", _def["max_loss_krw"], lambda v: int(float(v))), "slot_money": _arg("slot_money", _def["slot_money"], lambda v: int(float(v))), "shoulder_min_high_pct": _arg( "shoulder_min_high_pct", _def.get("shoulder_min_high_pct", 0.5), float, ), "shoulder_cut_pct": _arg( "shoulder_cut_pct", _def.get("shoulder_cut_pct", 0.2), float, ), "max_hold_bars": _arg("max_hold_bars", _def.get("max_hold_bars", 0), lambda v: int(float(v))), "ratchet_tiers": _arg("ratchet_tiers", _def.get("ratchet_tiers", ""), str), "fee_rate_pct": _def.get("fee_rate_pct", 0.015), "sell_tax_pct": _def.get("sell_tax_pct", 0.18), "entry_mode": _arg("entry_mode", _def.get("entry_mode", "intrabar"), str).strip().lower(), "intrabar_slippage_pct": _arg( "intrabar_slippage_pct", _def.get("intrabar_slippage_pct", 0.0), float, ), } _uef = request.args.get("use_ema_filter") if _uef not in (None, ""): ui["use_ema_filter"] = str(_uef).strip().lower() in ("1", "true", "y", "yes", "on") else: ui["use_ema_filter"] = bool(_def.get("use_ema_filter", False)) ui["ema_fast_period"] = _arg("ema_fast_period", _def.get("ema_fast_period", 9), lambda v: int(float(v))) ui["ema_slow_period"] = _arg("ema_slow_period", _def.get("ema_slow_period", 21), lambda v: int(float(v))) ui.update(_eod_params_from_request(request, _def, default_hm="15:15")) engine = _bo_ui_to_engine_params(ui) # 백테 전용 필터 토글 (이 1회 백테에만 적용. 비우면 DB=실매값) _ob_tg = _backtest_filter_toggle(request.args.get("ob_filter")) if _ob_tg is not None: engine["_orderbook_filter_enabled"] = _ob_tg _pg_tg = _backtest_filter_toggle(request.args.get("pg_filter")) if _pg_tg is not None: engine["_program_filter_enabled"] = _pg_tg _spread_req = request.args.get("max_spread_pct") if _spread_req not in (None, ""): engine["_ob_max_spread_pct"] = float(_spread_req) engine["backtest_use_kiwoom_body_snapshot"] = True engine["_backtest_use_kiwoom_body"] = True db = _db() try: start_key = (start.replace("-", "") + "0000") if start else "20260101" end_key = (end.replace("-", "") + "2359") if end else "99991231" codes_raw = db.conn.execute( "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 " "AND candle_time >= %s AND candle_time <= %s ORDER BY code", [start_key, end_key], ).fetchall() 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 " "FROM ws_candles " "WHERE timeframe=1 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) < 5: continue codes_candles[code] = [dict(r) for r in rows] use_saved_history, bo_univ_mode, _ = _parse_backtest_universe_arg( request, default="history", sim_kind=None, ) universe_by_slot, universe_source, universe_history_slots, _scan_iv = ( _resolve_backtest_universe( db, start_key, end_key, use_saved_history, codes_candles, strategy_id="BREAKOUT", ) ) latest_env = db.get_latest_env() env_row = dict(latest_env["snapshot"]) if latest_env else {} fee_rate, sell_tax, slot_from_env = bbc.fee_and_slot_from_env(env_row) slot_money_v = float(ui.get("slot_money") or slot_from_env) max_stocks_req = _arg( "max_stocks", _def.get("max_stocks", 3), lambda v: int(float(v)), ) total_budget_req = _arg( "total_budget_krw", _def.get("total_budget_krw", 0), float, ) portfolio = bbc.resolve_breakout_portfolio_params( env_row, None, slot_money=slot_money_v, max_stocks=max_stocks_req if max_stocks_req > 0 else None, total_budget_krw=total_budget_req if total_budget_req > 0 else None, ) total_budget_v = float(portfolio["total_budget_krw"]) max_stocks_v = int(portfolio["max_stocks"]) engine["slot_money"] = slot_money_v engine["max_stocks"] = max_stocks_v engine["total_budget_krw"] = total_budget_v engine["portfolio_mode"] = True bt_meta: Dict[str, Any] = { "db": db, "start_key": start_key, "end_key": end_key, } all_trades = bbc.run_breakout_backtest_web_aligned( codes_candles, engine, universe_by_slot=universe_by_slot, slot_money=slot_money_v, fee_rate=fee_rate, sell_tax=sell_tax, max_stocks=max_stocks_v, total_budget_krw=total_budget_v, meta_out=bt_meta, ) # 당일 누적손익 트레일 익절 시뮬 (백테 탭 입력 → drop>0 일 때만 ON). _trail_p = _daily_trail_params_from_request(request) if _trail_p: from kis_trader.backtest.backtest_portfolio_common import apply_daily_profit_halt_sim all_trades = apply_daily_profit_halt_sim( all_trades, _trail_p, budget_krw=float(total_budget_v or 0), ) period_days = _backtest_period_days(start, end, fallback=1) stats = bbc.summarize_breakout_trades( all_trades, total_budget_krw=total_budget_v, period_days=period_days, ) total = int(stats["total_trades"]) total_pnl = int(stats["total_pnl"]) wins_n = int(stats["wins"]) losses_n = int(stats["losses"]) avg_hold = float(stats["avg_hold_min"]) pf = float(stats["pf"]) bot_pct = float(stats["bot_pct"]) daily_avg_pct = float(stats["daily_avg_pct"]) wins = [t for t in all_trades if t.get("pnl", 0) > 0] losses = [t for t in all_trades if t.get("pnl", 0) < 0] peak, mdd, cum = 0.0, 0.0, 0.0 equity: List[Dict[str, Any]] = [] daily_map: Dict[str, int] = {} for t in sorted(all_trades, key=lambda x: x.get("sell_time", "")): cum += t.get("pnl", 0) if cum > peak: peak = cum dd = peak - cum if dd > mdd: mdd = dd day = str(t.get("sell_time", ""))[:8] if day: equity.append({ "date": f"{day[:4]}-{day[4:6]}-{day[6:]}", "cum_pnl": round(cum), }) daily_map[day] = daily_map.get(day, 0) + t.get("pnl", 0) daily_list = [ {"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v)} for d, v in sorted(daily_map.items()) ] reasons: Dict[str, int] = {} for t in all_trades: rk = str(t.get("sell_reason") or "unknown") reasons[rk] = reasons.get(rk, 0) + 1 trades_out = _trades_recent_first(all_trades, 200) _enrich_trades_with_names(db, trades_out) ts_hm = int(ui["time_start_hm"]) te_hm = int(ui["time_end_hm"]) return jsonify({ "params": { **ui, "entry_mode": engine.get("entry_mode", breakout_entry_mode()), "max_stocks": max_stocks_v, "total_budget_krw": total_budget_v, "start": start, "end": end, "time_window": f"{ts_hm:04d}-{te_hm:04d}", "codes_analyzed": len(codes), "universe_source": universe_source, "universe_history_slots": universe_history_slots, "universe": bo_univ_mode, "strategy_id": "BREAKOUT", }, "summary": { "total_trades": total, "win_trades": wins_n, "loss_trades": losses_n, "win_rate": float(stats["win_rate"]), "total_pnl": total_pnl, "avg_hold_min": round(avg_hold, 1), "profit_factor": round(pf, 2), "max_drawdown": round(mdd), "bot_pct": bot_pct, "daily_avg_pct": daily_avg_pct, "backtest_days": period_days, "total_budget_krw": total_budget_v, "slot_money": slot_money_v, "max_stocks": max_stocks_v, "budget_warning": portfolio.get("budget_warning"), "backtest_buy_source": bt_meta.get("backtest_buy_source"), "tick_backtest": bt_meta.get("tick_backtest"), }, "equity": equity, "daily": daily_list, "reasons": reasons, "trades": trades_out, }) except Exception as e: logger.exception("breakout backtest failed") return jsonify({"error": str(e)}), 500 finally: db.close() @app.route("/api/backtest/range_break", methods=["GET"]) def api_backtest_range_break(): """박스권 돌파 백테스트 — ``run_range_break_backtest`` (라이브 RangeBreakStrategy 와 동일).""" _def = _rb_defaults_from_db() start = request.args.get("start", "") end = request.args.get("end", "") def _arg(key: str, default: Any, cast=float): raw = request.args.get(key) if raw in (None, ""): return default try: return cast(raw) except (ValueError, TypeError): return default ui = { "box_lookback_min": _arg("box_lookback_min", _def["box_lookback_min"], lambda v: int(float(v))), "box_max_width_pct": _arg("box_max_width_pct", _def["box_max_width_pct"], float), "box_min_width_pct": _arg("box_min_width_pct", _def["box_min_width_pct"], float), "setup_vol_max_mult": _arg("setup_vol_max_mult", _def["setup_vol_max_mult"], float), "setup_bear_bars_min": _arg("setup_bear_bars_min", _def["setup_bear_bars_min"], lambda v: int(float(v))), "vol_mult": _arg("vol_mult", _def["vol_mult"], float), "vol_window": _arg("vol_window", _def["vol_window"], lambda v: int(float(v))), "vol_baseline_win": _arg("vol_baseline_win", _def.get("vol_baseline_win", 30), lambda v: int(float(v))), "break_margin_pct": _arg("break_margin_pct", _def.get("break_margin_pct", 0.0), float), "body_min_pct": _arg("body_min_pct", _def.get("body_min_pct", 0.0), float), "sl_pct": _arg("sl_pct", _def["sl_pct"], float), "tp_pct": _arg("tp_pct", _def["tp_pct"], float), "trail_pct": _arg("trail_pct", _def["trail_pct"], float), "trail_arm_pct": _arg("trail_arm_pct", _def.get("trail_arm_pct", 1.5), float), "shoulder_min_high_pct": _arg("shoulder_min_high_pct", _def.get("shoulder_min_high_pct", 3.0), float), "shoulder_cut_pct": _arg("shoulder_cut_pct", _def.get("shoulder_cut_pct", 0.5), float), "time_start_hm": _arg("time_start_hm", _def["time_start_hm"], lambda v: int(float(v))), "time_end_hm": _arg("time_end_hm", _def["time_end_hm"], lambda v: int(float(v))), "max_daily": _arg("max_daily", _def["max_daily"], lambda v: int(float(v))), "cooldown_min": _arg("cooldown_min", _def["cooldown_min"], float), "max_daily_chg": _arg("max_daily_chg", _def["max_daily_chg"], float), "min_price": _arg("min_price", _def["min_price"], float), "high_chase_thr": _arg("high_chase_thr", _def.get("high_chase_thr", 0.96), float), "max_loss_krw": _arg("max_loss_krw", _def["max_loss_krw"], lambda v: int(float(v))), "slot_money": _arg("slot_money", _def["slot_money"], lambda v: int(float(v))), "max_hold_bars": _arg("max_hold_bars", _def.get("max_hold_bars", 0), lambda v: int(float(v))), "fee_rate_pct": _def.get("fee_rate_pct", 0.015), "sell_tax_pct": _def.get("sell_tax_pct", 0.18), } _uhf = request.args.get("use_high_chase_filter") if _uhf not in (None, ""): ui["use_high_chase_filter"] = str(_uhf).strip().lower() in ("1", "true", "y", "yes", "on") else: ui["use_high_chase_filter"] = bool(_def.get("use_high_chase_filter", True)) engine = _rb_ui_to_engine_params(ui) db = _db() try: start_key = (start.replace("-", "") + "0000") if start else "20260101" end_key = (end.replace("-", "") + "2359") if end else "99991231" codes_raw = db.conn.execute( "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 " "AND candle_time >= %s AND candle_time <= %s ORDER BY code", [start_key, end_key], ).fetchall() 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 " "FROM ws_candles " "WHERE timeframe=1 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) < 5: continue codes_candles[code] = [dict(r) for r in rows] use_saved_history, rb_univ_mode, _ = _parse_backtest_universe_arg( request, default="history", sim_kind=None, ) universe_by_slot, universe_source, universe_history_slots, _scan_iv = ( _resolve_backtest_universe( db, start_key, end_key, use_saved_history, codes_candles, strategy_id="RANGE_BREAK", ) ) latest_env = db.get_latest_env() env_row = dict(latest_env["snapshot"]) if latest_env else {} fee_rate, sell_tax, slot_from_env = rbc.fee_and_slot_from_env(env_row) slot_money_v = float(ui.get("slot_money") or slot_from_env) max_stocks_req = _arg( "max_stocks", _def.get("max_stocks", 3), lambda v: int(float(v)), ) total_budget_req = _arg( "total_budget_krw", _def.get("total_budget_krw", 0), float, ) portfolio = rbc.resolve_range_break_portfolio_params( env_row, None, slot_money=slot_money_v, max_stocks=max_stocks_req if max_stocks_req > 0 else None, total_budget_krw=total_budget_req if total_budget_req > 0 else None, ) total_budget_v = float(portfolio["total_budget_krw"]) max_stocks_v = int(portfolio["max_stocks"]) engine["slot_money"] = slot_money_v engine["max_stocks"] = max_stocks_v engine["total_budget_krw"] = total_budget_v engine["portfolio_mode"] = True bt_meta: Dict[str, Any] = {"db": db, "start_key": start_key, "end_key": end_key} all_trades = rbc.run_range_break_backtest_web_aligned( codes_candles, engine, universe_by_slot=universe_by_slot, slot_money=slot_money_v, fee_rate=fee_rate, sell_tax=sell_tax, max_stocks=max_stocks_v, total_budget_krw=total_budget_v, meta_out=bt_meta, ) period_days = _backtest_period_days(start, end, fallback=1) stats = rbc.summarize_range_break_trades( all_trades, total_budget_krw=total_budget_v, period_days=period_days, ) total = int(stats["total_trades"]) total_pnl = int(stats["total_pnl"]) wins_n = int(stats["wins"]) losses_n = int(stats["losses"]) avg_hold = float(stats["avg_hold_min"]) pf = float(stats["pf"]) bot_pct = float(stats["bot_pct"]) daily_avg_pct = float(stats["daily_avg_pct"]) peak, mdd, cum = 0.0, 0.0, 0.0 equity: List[Dict[str, Any]] = [] daily_map: Dict[str, int] = {} for t in sorted(all_trades, key=lambda x: x.get("sell_time", "")): cum += t.get("pnl", 0) if cum > peak: peak = cum dd = peak - cum if dd > mdd: mdd = dd day = str(t.get("sell_time", ""))[:8] if day: equity.append({ "date": f"{day[:4]}-{day[4:6]}-{day[6:]}", "cum_pnl": round(cum), }) daily_map[day] = daily_map.get(day, 0) + t.get("pnl", 0) daily_list = [ {"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v)} for d, v in sorted(daily_map.items()) ] reasons: Dict[str, int] = {} for t in all_trades: rk = str(t.get("sell_reason") or "unknown") reasons[rk] = reasons.get(rk, 0) + 1 trades_out = _trades_recent_first(all_trades, 200) _enrich_trades_with_names(db, trades_out) ts_hm = int(ui["time_start_hm"]) te_hm = int(ui["time_end_hm"]) return jsonify({ "params": { **ui, "max_stocks": max_stocks_v, "total_budget_krw": total_budget_v, "start": start, "end": end, "time_window": f"{ts_hm:04d}-{te_hm:04d}", "codes_analyzed": len(codes), "universe_source": universe_source, "universe_history_slots": universe_history_slots, "universe": rb_univ_mode, "strategy_id": "RANGE_BREAK", }, "summary": { "total_trades": total, "win_trades": wins_n, "loss_trades": losses_n, "win_rate": float(stats["win_rate"]), "total_pnl": total_pnl, "avg_hold_min": round(avg_hold, 1), "profit_factor": round(pf, 2), "max_drawdown": round(mdd), "bot_pct": bot_pct, "daily_avg_pct": daily_avg_pct, "backtest_days": period_days, "total_budget_krw": total_budget_v, "slot_money": slot_money_v, "max_stocks": max_stocks_v, "budget_warning": portfolio.get("budget_warning"), "backtest_buy_source": bt_meta.get("backtest_buy_source"), }, "equity": equity, "daily": daily_list, "reasons": reasons, "trades": trades_out, }) except Exception as e: logger.exception("range_break backtest failed") return jsonify({"error": str(e)}), 500 finally: db.close() # ──────────────────────────────────────────────────────────────────────────── # API: 홀딩 전략 — 관심종목 + 종목별 파라미터 + 캔들 수집 + 백테스트 + 파라미터 탐색 # ──────────────────────────────────────────────────────────────────────────── def _holding_db() -> TradeDB: db = _db() hb.ensure_holding_tables(db) return db def _updow_db() -> TradeDB: """UPDOW 탭·API: 분봉(holding_min_candles) + updow_stock_config.""" db = _db() uhc.ensure_updow_backtest_tables(db) return db @app.route("/api/holding/stocks", methods=["GET"]) def api_holding_stocks(): """관심종목 목록 + 종목별 현재 파라미터 + 보유 봉수 반환""" db = _holding_db() try: items = hb.load_watchlist() result = [] for item in items: code = item["code"] cfg = hb.get_stock_config(db, code) cfg["name"] = item.get("name", cfg.get("name", "")) # 보유 봉수 row = db.conn.execute( "SELECT COUNT(*) as cnt, MIN(candle_date) as mn, MAX(candle_date) as mx " "FROM holding_candles WHERE code=%s", [code] ).fetchone() cfg["candle_count"] = int(row["cnt"]) if row else 0 cfg["candle_min"] = str(row["mn"]) if row and row["mn"] else "" cfg["candle_max"] = str(row["mx"]) if row and row["mx"] else "" # 60분봉 현황 추가 ms = hb.get_min_candle_stats(db, code, tf_min=60) cfg["min60_count"] = ms["count"] cfg["min60_min"] = ms["min"] cfg["min60_max"] = ms["max"] result.append(cfg) return jsonify(result) finally: db.close() @app.route("/api/holding/config/", methods=["GET", "POST"]) def api_holding_config(code): """GET: 종목 파라미터 조회 | POST: 파라미터 저장""" db = _holding_db() try: if request.method == "GET": cfg = hb.get_stock_config(db, code) return jsonify(cfg) else: try: body = request.get_json(force=True) or {} name = body.pop("name", "") hb.set_stock_config(db, code, name, body) return jsonify({"ok": True}) except Exception as e: logger.exception("홀딩 설정 저장 실패 (%s)", code) return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/holding/candles/fetch", methods=["POST"]) def api_holding_fetch_candles(): """종목 일봉 캔들 KIS API로 수집 후 DB 저장""" body = request.get_json(force=True) or {} code = body.get("code", "") start_date = body.get("start", "2023-01-01") end_date = body.get("end", datetime.now().strftime("%Y-%m-%d")) if not code: return jsonify({"error": "code 필수"}), 400 db = _holding_db() try: app_key, app_secret, base_url, mock = hb._get_kis_token(db) rows = hb.fetch_daily_ohlcv(code, start_date, end_date, app_key, app_secret, base_url, mock=mock) saved = hb.store_candles(db, code, rows) return jsonify({"ok": True, "fetched": len(rows), "saved": saved}) except Exception as e: logger.error(f"캔들 수집 오류 ({code}): {e}") return jsonify({"error": str(e)}), 500 finally: db.close() @app.route("/api/holding/backtest", methods=["GET"]) def api_holding_backtest(): """홀딩 전략 백테스트 (종목별 파라미터 사용 or 요청 파라미터 오버라이드)""" code = request.args.get("code", "") start_date = request.args.get("start", "2023-01-01") end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) if not code: return jsonify({"error": "code 필수"}), 400 db = _holding_db() try: candles = hb.get_stored_candles(db, code, start_date, end_date) if len(candles) < 20: # 친절한 에러: DB 전체 보유 봉 수와 기간도 함께 안내 all_candles = hb.get_stored_candles(db, code) if not all_candles: hint = "📥 먼저 [캔들 수집] 버튼으로 데이터를 수집하세요." else: first = str(all_candles[0]["candle_date"])[:10] last = str(all_candles[-1]["candle_date"])[:10] hint = ( f"DB에 {len(all_candles)}봉 있음 ({first} ~ {last})\n" f"👉 백테스트 시작일을 '{first}' 이후로 설정하세요." ) return jsonify({ "error": f"봉 부족: {len(candles)}개 (최소 20개 필요)\n{hint}" }), 400 # 요청 파라미터로 DB 설정 오버라이드 가능 cfg = hb.get_stock_config(db, code) for ck in hb.DEFAULT_STOCK_CONFIG: v = request.args.get(ck) if v is not None: cfg[ck] = float(v) result = hb.run_backtest(candles, cfg) result["code"] = code result["candle_count"] = len(candles) result["params"] = {k: cfg[k] for k in hb.DEFAULT_STOCK_CONFIG} return jsonify(result) finally: db.close() @app.route("/api/holding/v1/backtest", methods=["GET"]) def api_holding_v1_backtest(): """홀딩 V1 (RSI 분할매수) 백테스트""" code = request.args.get("code", "") start_date = request.args.get("start", "") end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) if not code: return jsonify({"error": "code 필수"}), 400 # 카드 UI 파라미터 수집 (DEFAULT_V1_CONFIG 키 기준) cfg = {} for key in hv1.DEFAULT_V1_CONFIG.keys(): val = request.args.get(key) if val is not None: try: cfg[key] = float(val) except (ValueError, TypeError): pass db = _holding_db() try: candles = hb.get_stored_candles(db, code, start_date, end_date) if len(candles) < 10: return jsonify({"error": f"봉 부족: {len(candles)}개"}), 400 res = hv1.run_backtest_v1(candles, cfg) if "error" not in res: res["candle_rows"] = hb.build_daily_candle_display_rows(candles) return jsonify(res) finally: db.close() @app.route("/api/holding/v1/param_search", methods=["GET"]) def api_holding_v1_param_search(): """홀딩 V1 (RSI 분할매수) 파라미터 Grid Search""" code = request.args.get("code", "") start_date = request.args.get("start", "") end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) min_trades = max(0, min(50, int(request.args.get("min_trades", 1)))) if not code: return jsonify({"error": "code 필수"}), 400 # 카드 UI 파라미터를 base_cfg로 (그리드 외 파라미터 고정) base_cfg = {} for key in hv1.DEFAULT_V1_CONFIG.keys(): val = request.args.get(key) if val is not None: try: base_cfg[key] = float(val) except (ValueError, TypeError): pass db = _holding_db() try: candles = hb.get_stored_candles(db, code, start_date, end_date) if len(candles) < 20: return jsonify({"error": f"봉 부족: {len(candles)}개"}), 400 results = hv1.run_param_search_v1(candles, min_trades=min_trades, base_cfg=base_cfg if base_cfg else None) return jsonify({"code": code, "top": results[:30]}) finally: db.close() @app.route("/api/holding/param_search", methods=["GET"]) def api_holding_param_search(): """홀딩 전략 파라미터 Grid Search (단일 종목)""" code = request.args.get("code", "") start_date = request.args.get("start", "2023-01-01") end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) min_trades = max(0, min(50, int(request.args.get("min_trades", 1)))) if not code: return jsonify({"error": "code 필수"}), 400 db = _holding_db() try: candles = hb.get_stored_candles(db, code, start_date, end_date) if len(candles) < 20: return jsonify({"error": f"봉 부족: {len(candles)}개"}), 400 # 추세BT와 동일 축: DB 최신 저장값 전체를 base로 쓰고, URL(카드 입력)만 덮어씀. # (예: buy1_ratio 가 URL에 없으면 예전엔 DEFAULT 50%로만 탐색되어 수량·손익이 추세BT의 2배로 나오는 문제) stored = hb.get_stock_config(db, code) base_cfg: Dict[str, float] = {} for key in hb.DEFAULT_STOCK_CONFIG.keys(): raw = stored.get(key, hb.DEFAULT_STOCK_CONFIG[key]) try: base_cfg[key] = float(raw) except (TypeError, ValueError): base_cfg[key] = float(hb.DEFAULT_STOCK_CONFIG[key]) for key in hb.DEFAULT_STOCK_CONFIG.keys(): val = request.args.get(key) if val is not None: try: base_cfg[key] = float(val) except (ValueError, TypeError): pass results, meta = hb.run_param_search( candles, min_trades=min_trades, base_cfg=base_cfg ) return jsonify({"code": code, "top": results[:30], "meta": meta}) finally: db.close() # ──────────────────────────────────────────────────────────────────────────── # 메인 페이지 # ──────────────────────────────────────────────────────────────────────────── @app.route("/api/holding/min_candles/fetch", methods=["POST"]) def api_holding_min_candles_fetch(): """60분봉 수집 API: 백그라운드 스레드로 실행, 즉시 job_id 반환""" body = request.get_json(force=True, silent=True) or {} code = body.get("code", "") start = body.get("start", "") end = body.get("end", datetime.now().strftime("%Y-%m-%d")) tf = int(body.get("tf", 60)) if not code or not start: return jsonify({"error": "code, start 필수"}), 400 job_id = uuid.uuid4().hex[:8] _min_fetch_jobs[job_id] = { "status": "running", "code": code, "fetched": 0, # 수집한 1분봉 수 "saved": 0, # DB에 저장된 60분봉 수 "current_date": "", # 현재 처리 중인 날짜 "error": None, } def _run(): db = _holding_db() try: app_key, app_secret, base_url, mock = hb._get_kis_token(db) hb.fetch_and_store_min_candles( db, code, start, end, app_key, app_secret, base_url, tf_min=tf, mock=mock, progress=_min_fetch_jobs[job_id], ) _min_fetch_jobs[job_id]["status"] = "done" except Exception as e: logger.error(f"60분봉 수집 오류 ({code}): {e}") _min_fetch_jobs[job_id]["status"] = "error" _min_fetch_jobs[job_id]["error"] = str(e) finally: db.close() threading.Thread(target=_run, daemon=True).start() return jsonify({"job_id": job_id, "status": "started"}) @app.route("/api/holding/min_candles/status/") def api_holding_min_candles_status(job_id: str): """60분봉 수집 진행상황 폴링 엔드포인트""" job = _min_fetch_jobs.get(job_id) if not job: return jsonify({"error": "없는 job_id"}), 404 return jsonify(job) @app.route("/api/holding/min_candles/fetch_kiwoom", methods=["POST"]) def api_holding_min_candles_fetch_kiwoom(): """ 키움 REST API (ka10080) 로 분봉 수집 → holding_min_candles 저장 (tf 컬럼에 분 단위 저장). 기본 tf=60. 본문 tf 로 1/3/5/10/15/30/45/60 지원 (키움 tic_scope 와 동일). 필요 DB env_config 키: KIWOOM_APP_KEY, KIWOOM_APP_SECRET """ body = request.get_json(force=True, silent=True) or {} code = body.get("code", "") market_type = str(body.get("market_type", "KR")).strip().upper() or "KR" exchange = str(body.get("exchange", "KRX")).strip().upper() or ("KRX" if market_type == "KR" else "NASD") symbol = str(body.get("symbol", code)).strip().upper() or code market_type, exchange, symbol = uhc.resolve_market_meta(code, market_type, exchange, symbol) start = body.get("start", "") end = body.get("end", datetime.now().strftime("%Y-%m-%d")) try: tf = int(body.get("tf", 60)) except (TypeError, ValueError): tf = 60 if tf not in hb.KIWOOM_MINUTE_TICS: return jsonify({ "error": f"tf는 키움 ka10080 허용값만 가능: {list(hb.KIWOOM_MINUTE_TICS)} (요청: {tf})", }), 400 if not code or not start: return jsonify({"error": "code, start 필수"}), 400 job_id = uuid.uuid4().hex[:8] _min_fetch_jobs[job_id] = { "status": "running", "code": code, "market_type": market_type, "exchange": exchange, "symbol": symbol, "tf": tf, "source": "kis_overseas" if market_type != "KR" else "kiwoom", "fetched": 0, "saved": 0, "current_date": "", "error": None, } def _run(): db = _holding_db() try: row = db.conn.execute( "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" ).fetchone() if not row: raise RuntimeError("env_config 없음") r = dict(row) is_mock = str(r.get("KIS_MOCK", "true")).lower() in ("true", "1", "yes") kiwoom_key = "" kiwoom_secret = "" mode_label = "모의" if is_mock else "실전" if market_type == "KR": # KIS_MOCK 설정에 따라 키움 실전/모의 키 자동 선택 if is_mock: kiwoom_key = str(r.get("KIWOOM_APP_KEY_MOCK", "") or "").strip() kiwoom_secret = str(r.get("KIWOOM_APP_SECRET_MOCK", "") or "").strip() else: kiwoom_key = str(r.get("KIWOOM_APP_KEY_REAL", "") or "").strip() kiwoom_secret = str(r.get("KIWOOM_APP_SECRET_REAL", "") or "").strip() if not kiwoom_key or not kiwoom_secret: kiwoom_key = str(r.get("KIWOOM_APP_KEY", "") or "").strip() kiwoom_secret = str(r.get("KIWOOM_APP_SECRET", "") or "").strip() mode_label += "(레거시키)" if not kiwoom_key or not kiwoom_secret: raise RuntimeError( f"키움 {mode_label} API 키 미설정.\n" "DB env_config에 KIWOOM_APP_KEY_REAL(또는 KIWOOM_APP_KEY_MOCK) / " "KIWOOM_APP_SECRET_REAL(또는 KIWOOM_APP_SECRET_MOCK) 추가 필요" ) logger.info( f"키움 {tf}분봉 수집: {code} [{mode_label}/{market_type}:{exchange}] {start}~{end}" ) else: _min_fetch_jobs[job_id]["source"] = "kis_overseas" logger.info( f"KIS 해외 {tf}분봉 수집: {symbol} [{mode_label}/{market_type}:{exchange}] {start}~{end}" ) rows = hb.fetch_60min_via_kiwoom( code, start, end, kiwoom_key, kiwoom_secret, is_mock=is_mock, tf_min=tf, market_type=market_type, exchange=exchange, symbol=symbol, ) _min_fetch_jobs[job_id]["fetched"] = len(rows) if not rows: _min_fetch_jobs[job_id]["status"] = "done" _min_fetch_jobs[job_id]["saved"] = 0 return saved = 0 logger.info(f"키움 {tf}분봉 DB 저장 시작: {code} {len(rows)}봉") err_sample = None for row_data in rows: try: # MariaDB 문법: ON DUPLICATE KEY UPDATE (SQLite의 ON CONFLICT 아님) db.conn.execute( """ INSERT INTO holding_min_candles (code, candle_dt, tf, open, high, low, close, volume) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE open=VALUES(open), high=VALUES(high), low=VALUES(low), close=VALUES(close), volume=VALUES(volume) """, ( code, row_data["candle_date"], tf, row_data["open"], row_data["high"], row_data["low"], row_data["close"], row_data["volume"], ), ) saved += 1 except Exception as row_err: if err_sample is None: err_sample = str(row_err) # 첫 번째 오류만 샘플 보존 db.conn.commit() if err_sample: logger.warning(f"⚠️ 키움 {tf}분봉 일부 저장 실패 ({code}): {err_sample}") _min_fetch_jobs[job_id]["saved"] = saved _min_fetch_jobs[job_id]["current_date"] = rows[-1]["candle_date"] if rows else "" _min_fetch_jobs[job_id]["status"] = "done" logger.info(f"✅ 키움 {tf}분봉 저장 완료: {code} {saved}/{len(rows)}봉") except Exception as e: logger.error(f"❌ 키움 {tf}분봉 수집 오류 ({code}): {e}", exc_info=True) _min_fetch_jobs[job_id]["status"] = "error" _min_fetch_jobs[job_id]["error"] = str(e) finally: db.close() threading.Thread(target=_run, daemon=True).start() return jsonify({"job_id": job_id, "status": "started"}) @app.route("/api/holding/min_backtest", methods=["GET"]) def api_holding_min_backtest(): """60분봉 기반 백테스트 (run_backtest 재사용, candle_date=candle_dt 로 호환)""" code = request.args.get("code", "") start_date = request.args.get("start", "") end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) tf = int(request.args.get("tf", 60)) if not code: return jsonify({"error": "code 필수"}), 400 db = _holding_db() try: candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf) if not candles: stats = hb.get_min_candle_stats(db, code, tf_min=tf) if stats["count"] == 0: hint = f"📥 먼저 [60분봉 수집] 버튼으로 데이터를 수집하세요." else: hint = (f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n" f"👉 백테스트 시작일을 '{stats['min'][:10]}' 이후로 설정하세요.") return jsonify({"error": f"60분봉 없음\n{hint}"}), 400 if len(candles) < 20: stats = hb.get_min_candle_stats(db, code, tf_min=tf) hint = (f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n" f"👉 백테스트 날짜 범위를 넓혀 최소 20봉 이상 포함하세요.") return jsonify({ "error": f"봉 부족: {len(candles)}개 (최소 20개 필요)\n{hint}" }), 400 # 파라미터 오버라이드 (일봉 백테스트와 동일 방식) cfg = hb.get_stock_config(db, code) overrides = ["rsi_period","rsi_buy1","rsi_buy2","rsi_buy3","rsi_sell", "take_profit_pct","stop_loss_pct","buy1_ratio","buy2_ratio", "buy3_ratio","slot_money"] for k in overrides: v = request.args.get(k) if v is not None: cfg[k] = float(v) # tf_min=60 전달 → 52주 윈도우를 날짜 기반으로 정확히 계산 result = hb.run_backtest(candles, cfg, tf_min=tf) result["code"] = code result["candle_count"] = len(candles) result["tf"] = tf result["params"] = {k: cfg[k] for k in hb.DEFAULT_STOCK_CONFIG} return jsonify(result) finally: db.close() @app.route("/api/holding/min_stats", methods=["GET"]) def api_holding_min_stats(): """60분봉 보유 현황 (종목 카드에서 표시용)""" code = request.args.get("code", "") tf = int(request.args.get("tf", 60)) if not code: return jsonify({"error": "code 필수"}), 400 db = _holding_db() try: return jsonify(hb.get_min_candle_stats(db, code, tf_min=tf)) finally: db.close() # ═════════════════════════════════════════════════════════════════════════════ # UPDOWN 박스권 엔진 (신규) — kis_trader.engine.updown_box # 판별 evaluate_box · 진입 box_entry_signal · 청산 eval_box_exit_at_price # 백테 run_backtest_box · 파라서치 run_param_search_box_multi (실매 단일 소스) # 유니버스: 조건검색 → 박스필터(SCAN) → updown_watchlist(sticky, 최대 30) # ═════════════════════════════════════════════════════════════════════════════ def _box_cfg_with_overrides(args) -> Dict[str, Any]: """글로벌 박스 cfg(env UPDOWN_BOX_*) + 요청 쿼리 오버라이드.""" from kis_trader.engine.updown_box import get_box_cfg_from_env cfg = dict(get_box_cfg_from_env()) for k in list(cfg.keys()): v = args.get(k) if v is not None and str(v).strip() != "": if k == "ratchet_tiers": cfg[k] = str(v) # 다단 래칫은 "수익%:컷%,…" 문자열 그대로 continue try: cfg[k] = float(v) except (ValueError, TypeError): pass return cfg @app.route("/api/updown_box/config", methods=["GET"]) def api_updown_box_config(): """박스 글로벌 파라미터(env UPDOWN_BOX_*) + watchlist 통계.""" from kis_trader.engine.updown_box import get_box_cfg_from_env from kis_trader.strategies.updown_watchlist import ( active_count, ensure_updown_watchlist_table, watch_max, ) from kis_trader.utils.env import get_env_int db = _db() try: cfg = get_box_cfg_from_env() ensure_updown_watchlist_table(db) return jsonify({ "ok": True, "cfg": cfg, "watch_active": active_count(db), "watch_max": watch_max(db), "scan_tf_min": int(get_env_int("UPDOWN_SCAN_TF_MIN", 15)), }) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/updown_box/watchlist", methods=["GET"]) def api_updown_box_watchlist(): """updown_watchlist 전체(또는 active) 목록.""" from kis_trader.strategies.updown_watchlist import ( ensure_updown_watchlist_table, list_active_watchlist, list_all_watchlist, ) only_active = str(request.args.get("active", "0")).strip() in ("1", "true", "on") db = _db() try: ensure_updown_watchlist_table(db) rows = list_active_watchlist(db) if only_active else list_all_watchlist(db) return jsonify({"ok": True, "rows": rows}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/updown_box/watchlist/add", methods=["POST"]) def api_updown_box_watchlist_add(): """watchlist 수동 추가 (HTS 안 켜고 박스 후보 직접 등록).""" from kis_trader.strategies.updown_watchlist import ( add_manual, ensure_updown_watchlist_table, ) body = request.get_json(force=True, silent=True) or {} code = str(body.get("code", "")).strip() if len(code) != 6 or not code.isdigit(): return jsonify({"ok": False, "error": "6자리 종목코드 필요"}), 400 name = str(body.get("name", "")).strip() try: box_low = float(body.get("box_low") or 0) box_high = float(body.get("box_high") or 0) except (ValueError, TypeError): box_low = box_high = 0.0 db = _db() try: ensure_updown_watchlist_table(db) ok = add_manual(db, code, name, box_low=box_low, box_high=box_high) return jsonify({"ok": bool(ok)}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/updown_box/watchlist/remove", methods=["POST"]) def api_updown_box_watchlist_remove(): """watchlist 1건 삭제 (수동 제거).""" from kis_trader.strategies.updown_watchlist import ( ensure_updown_watchlist_table, remove_manual, ) body = request.get_json(force=True, silent=True) or {} code = str(body.get("code", "")).strip() if not code: return jsonify({"ok": False, "error": "code 필수"}), 400 db = _db() try: ensure_updown_watchlist_table(db) ok = remove_manual(db, code) return jsonify({"ok": bool(ok)}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/updown_box/backtest", methods=["GET"]) def api_updown_box_backtest(): """단일 종목 박스권 백테 (run_backtest_box) — 실매와 동일 엔진.""" from kis_trader.engine.updown_box import evaluate_box, run_backtest_box from kis_trader.utils.env import get_env_int code = request.args.get("code", "") start_date = request.args.get("start", "") end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) if not code: return jsonify({"error": "code 필수"}), 400 tf_raw = request.args.get("tf") try: tf = int(float(tf_raw)) if tf_raw not in (None, "") else get_env_int("UPDOWN_SCAN_TF_MIN", 15) except (TypeError, ValueError): tf = get_env_int("UPDOWN_SCAN_TF_MIN", 15) if tf not in hb.KIWOOM_MINUTE_TICS: return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나 (요청: {tf})"}), 400 db = _updow_db() try: candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf) if not candles: stats = hb.get_min_candle_stats(db, code, tf_min=tf) if stats["count"] == 0: hint = "📥 먼저 [키움 분봉 수집]으로 해당 tf 데이터를 수집하세요." else: hint = f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n👉 시작일을 '{stats['min'][:10]}' 이후로 설정하세요." return jsonify({"error": f"{tf}분봉 없음\n{hint}"}), 400 cfg = _box_cfg_with_overrides(request.args) min_need = int(cfg.get("min_bars", 20)) + 2 if len(candles) < min_need: return jsonify({"error": f"봉 부족: {len(candles)}개 (최소 {min_need})"}), 400 result = run_backtest_box(candles, cfg) # 현재(최근 봉 기준) 박스 판별 상태도 함께 (직관용) box_now = evaluate_box(candles, cfg) result["code"] = code result["tf"] = tf result["candle_count"] = len(candles) result["box_now"] = { "is_box": box_now.get("is_box"), "box_low": box_now.get("box_low"), "box_high": box_now.get("box_high"), "range_pct": box_now.get("range_pct"), "bb_bw": box_now.get("bb_bw"), "ma_slope": box_now.get("ma_slope"), "box_score": box_now.get("box_score"), "reason": box_now.get("reason"), } result["params"] = {k: cfg.get(k) for k in cfg} result["candle_rows"] = hb.build_daily_candle_display_rows(candles) return jsonify(result) finally: db.close() @app.route("/api/updown_box/param_search", methods=["GET"]) def api_updown_box_param_search(): """박스권 글로벌 파라미터 그리드 탐색 — 다종목 합산(run_param_search_box_multi). 종목 소스: ?codes=005930,000660 또는 ?from_watchlist=1 (active) 또는 ?code=단일. """ from kis_trader.engine.updown_box import ( default_box_param_grid, get_box_cfg_from_env, run_backtest_box, run_param_search_box_multi, ) from kis_trader.utils.env import get_env_int start_date = request.args.get("start", "") end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) min_trades = max(0, min(100, int(request.args.get("min_trades", 3)))) mode = str(request.args.get("mode", "fast") or "fast").strip().lower() rank_by = str(request.args.get("rank_by", "alpha") or "alpha").strip().lower() keep_neg = str(request.args.get("keep_negative_alpha", "0")).strip() in ("1", "true", "on") tf_raw = request.args.get("tf") try: tf = int(float(tf_raw)) if tf_raw not in (None, "") else get_env_int("UPDOWN_SCAN_TF_MIN", 15) except (TypeError, ValueError): tf = get_env_int("UPDOWN_SCAN_TF_MIN", 15) if tf not in hb.KIWOOM_MINUTE_TICS: return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나 (요청: {tf})"}), 400 db = _updow_db() try: codes: List[str] = [] if str(request.args.get("from_watchlist", "0")).strip() in ("1", "true", "on"): from kis_trader.strategies.updown_watchlist import ( ensure_updown_watchlist_table, list_active_watchlist, ) ensure_updown_watchlist_table(db) codes = [str(r.get("code") or "").strip() for r in list_active_watchlist(db)] else: raw = request.args.get("codes") or request.args.get("code") or "" codes = [c for c in str(raw).replace(" ", "").split(",") if c] codes = [c for c in codes if len(c) == 6 and c.isdigit()] if not codes: return jsonify({"error": "종목 없음 — codes=005930,000660 또는 from_watchlist=1"}), 400 base_cfg = get_box_cfg_from_env() min_bars = int(base_cfg.get("min_bars", 20)) + 2 candles_by_code: Dict[str, List[Dict[str, Any]]] = {} skipped: List[str] = [] for code in codes: cs = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf) if cs and len(cs) >= min_bars: candles_by_code[code] = cs else: skipped.append(code) if not candles_by_code: return jsonify({"error": f"{tf}분봉 데이터 가진 종목 없음 (수집 필요)"}), 400 # === 추세종목(α≤0) 자동 제외 — base_cfg 기준 봇손익 ≤ B&H 이면 박스 대상 아님 === dropped_trend: List[Dict[str, Any]] = [] if not keep_neg: kept: Dict[str, List[Dict[str, Any]]] = {} for code, cs in candles_by_code.items(): bt = run_backtest_box(cs, base_cfg) a = float(bt.get("alpha_pct", bt.get("total_pnl", 0)) or 0) if a > 0: kept[code] = cs else: dropped_trend.append({ "code": code, "bot_pnl": float(bt.get("total_pnl", 0) or 0), "bh_pct": float(bt.get("buy_hold_pct", 0) or 0), "alpha": round(a, 3), }) if not kept: return jsonify({ "error": "α>0 종목 0 — 박스전략 적합 종목이 없습니다 (모두 추세주). " "keep_negative_alpha=1 로 강제 가능", "dropped_trend": dropped_trend, }), 400 candles_by_code = kept grid = default_box_param_grid("full" if mode == "full" else "fast") try: combo_cap = int(float(get_env_int("UPDOWN_BOX_WEB_MAX_COMBOS", 500))) except (TypeError, ValueError): combo_cap = 500 results, meta = run_param_search_box_multi( candles_by_code, grid=grid, base_cfg=base_cfg, min_trades_total=min_trades, max_combos=max(0, combo_cap), rank_by=rank_by, ) meta["tf"] = tf meta["search_mode"] = mode meta["skipped_codes"] = skipped meta["dropped_trend"] = dropped_trend top = [{**r, "tf": tf} for r in results[:30]] return jsonify({"codes": list(candles_by_code.keys()), "top": top, "meta": meta}) finally: db.close() @app.route("/api/updown_box/apply", methods=["POST"]) def api_updown_box_apply(): """파라서치 결과(apply_cfg) → env_config UPDOWN_BOX_* 적용.""" from kis_trader.engine.updown_box import box_cfg_to_env_patch body = request.get_json(force=True, silent=True) or {} apply_cfg = body.get("apply_cfg") or {} if not isinstance(apply_cfg, dict) or not apply_cfg: return jsonify({"ok": False, "error": "apply_cfg 필요"}), 400 clean: Dict[str, Any] = {} for k, v in apply_cfg.items(): if k == "ratchet_tiers": clean[k] = str(v) # 다단 래칫 문자열 그대로 elif isinstance(v, (int, float)) or str(v).replace(".", "", 1).isdigit(): clean[k] = float(v) patch = box_cfg_to_env_patch(clean) if not patch: return jsonify({"ok": False, "error": "적용할 키 없음"}), 400 db = _db() try: snap = db.get_merged_env_snapshot() or {} for k, v in patch.items(): snap[k] = str(v) eid = db.insert_env_snapshot(snap) if eid is None: return jsonify({"ok": False, "error": "insert_env_snapshot 실패"}), 500 return jsonify({"ok": True, "env_id": eid, "applied": patch}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/updown_box/stock_list", methods=["GET"]) def api_updown_box_stock_list(): """종목 셀렉트용 목록 — watchlist(active+inactive) + 종목별 오버라이드 등록 종목 합집합.""" from kis_trader.strategies.updown_box_stock_cfg import list_box_stock_cfg from kis_trader.strategies.updown_watchlist import ( ensure_updown_watchlist_table, list_all_watchlist, ) db = _updow_db() try: ensure_updown_watchlist_table(db) merged: Dict[str, Dict[str, Any]] = {} for r in list_all_watchlist(db): c = str(r.get("code") or "").strip() if not c: continue merged[c] = {"code": c, "name": str(r.get("name") or c), "n_overrides": 0, "status": str(r.get("status") or "")} for it in list_box_stock_cfg(db): c = str(it.get("code") or "").strip() if not c: continue if c in merged: merged[c]["n_overrides"] = int(it.get("n_overrides") or 0) else: merged[c] = {"code": c, "name": str(it.get("name") or c), "n_overrides": int(it.get("n_overrides") or 0), "status": ""} items = sorted(merged.values(), key=lambda x: x["code"]) return jsonify({"items": items}) finally: db.close() @app.route("/api/updown_box/stock_cfg", methods=["GET"]) def api_updown_box_stock_cfg_get(): """종목별 박스 오버라이드 + 현재 글로벌값 — 종목 선택 시 인풋 채우기용. 응답: {code, name, overrides:{설정된 키만}, global:{박스 전 키 기본/글로벌}, effective:{병합 결과}} """ from kis_trader.engine.updown_box import get_box_cfg_from_env from kis_trader.strategies.updown_box_stock_cfg import ( BOX_ALL_KEYS, get_box_overrides, ) code = str(request.args.get("code") or "").strip() if not code: return jsonify({"error": "code 필수"}), 400 db = _updow_db() try: glob = get_box_cfg_from_env() ov = get_box_overrides(db, code) eff: Dict[str, Any] = {} for k in BOX_ALL_KEYS: eff[k] = ov[k] if k in ov else glob.get(k) glob_out = {k: glob.get(k) for k in BOX_ALL_KEYS} return jsonify({"code": code, "overrides": ov, "global": glob_out, "effective": eff}) finally: db.close() @app.route("/api/updown_box/stock_cfg", methods=["POST"]) def api_updown_box_stock_cfg_save(): """종목별 박스 오버라이드 저장(upsert). body: {code, name, overrides:{키:값}}. 값이 빈칸/None 이면 해당 키는 글로벌 상속(NULL)로 클리어. ratchet_tiers='' 는 래칫 OFF. """ from kis_trader.strategies.updown_box_stock_cfg import ( BOX_ALL_KEYS, delete_box_overrides, set_box_overrides, ) body = request.get_json(force=True, silent=True) or {} code = str(body.get("code") or "").strip() if not code: return jsonify({"ok": False, "error": "code 필수"}), 400 name = str(body.get("name") or code) raw_ov = body.get("overrides") or {} if not isinstance(raw_ov, dict): return jsonify({"ok": False, "error": "overrides dict 필요"}), 400 db = _updow_db() try: if str(body.get("clear") or "").strip() in ("1", "true", "on"): ok = delete_box_overrides(db, code) return jsonify({"ok": ok, "cleared": True}) # 허용 키만 통과 ov = {k: raw_ov[k] for k in BOX_ALL_KEYS if k in raw_ov} ok = set_box_overrides(db, code, name, ov) if not ok: return jsonify({"ok": False, "error": "저장 실패"}), 500 return jsonify({"ok": True, "code": code, "saved_keys": list(ov.keys())}) finally: db.close() @app.route("/api/permanent_subs", methods=["GET"]) def api_permanent_subs_list(): """영구구독(국내 WS + 해외 WS) 목록. 해외(US)는 ws_tr_key(D+거래소+심볼) 포함.""" import permanent_subs as ps db = _db() try: ps.ensure_permanent_subs_table(db) rows = ps.list_permanent_subs(db, enabled_only=False) out = [] for r in rows: mt = str(r.get("market_type") or "KR").strip().upper() ex = str(r.get("exchange") or "").strip().upper() sym = str(r.get("symbol") or r.get("code") or "").strip().upper() # 해외만 실시간 WS tr_key 표기, 국내는 종목코드 자체가 키 ws_tr_key = ps.us_ws_tr_key(ex, sym) if mt == "US" else str(r.get("code") or "") out.append({ "code": r.get("code"), "market_type": mt, "exchange": ex, "symbol": sym, "tf_min": r.get("tf_min"), "enabled": int(r.get("enabled", 1)), "note": r.get("note") or "", "ws_tr_key": ws_tr_key, }) return jsonify({"ok": True, "rows": out}) except Exception as e: return jsonify({"ok": False, "error": str(e), "rows": []}), 500 finally: db.close() @app.route("/api/permanent_subs/save", methods=["POST"]) def api_permanent_subs_save(): """영구구독 1건 등록/수정 (code UNIQUE upsert).""" import permanent_subs as ps body = request.get_json(force=True, silent=True) or {} code = str(body.get("code") or "").strip().upper() if not code: return jsonify({"ok": False, "error": "code 필수"}), 400 try: tf_min = int(float(body.get("tf_min") or 15)) except (TypeError, ValueError): tf_min = 15 db = _db() try: ps.upsert_permanent_sub( db, code, market_type=body.get("market_type"), exchange=body.get("exchange"), symbol=body.get("symbol"), tf_min=tf_min, enabled=bool(body.get("enabled", True)), note=str(body.get("note") or ""), ) return jsonify({"ok": True, "code": code}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/permanent_subs/delete", methods=["POST"]) def api_permanent_subs_delete(): """영구구독 1건 삭제.""" import permanent_subs as ps body = request.get_json(force=True, silent=True) or {} code = str(body.get("code") or "").strip().upper() if not code: return jsonify({"ok": False, "error": "code 필수"}), 400 db = _db() try: ok = ps.remove_permanent_sub(db, code) return jsonify({"ok": bool(ok), "code": code}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() # ───────────────────────────────────────────────────────────────────────────── # 웹 파라미터 탐색 (SCALP / MOMENTUM / BREAKOUT / SHORT) # ───────────────────────────────────────────────────────────────────────────── # 브라우저·프록시 타임아웃 방지: 대형 그리드 탐색은 CLI 만 사용. # kis_trader/backtest/param_search_scalping.py # kis_trader/backtest/param_search_momentum.py # kis_trader/backtest/param_search_breakout.py # kis_trader/backtest/tail_param_search.py # 웹: /api/env/params → 입력란에 DB 최신값 → 백테스트 API 만 호출. # UPDOWN 박스: /api/updown_box/param_search (글로벌 그리드, 다종목 합산). 홀딩: /api/holding/*/param_search. # DBBAND: /api/dbband/param_search (종목별 dbband_stock_config + env 그리드) def _dbband_db() -> TradeDB: from kis_trader.strategies import dbband_stock_cfg as dsc db = _db() dsc.ensure_dbband_backtest_tables(db) return db def _dbband_merged_engine_tf(db_hold: TradeDB, code: str) -> Tuple[Dict[str, Any], int, bool]: from kis_trader.strategies import dbband_stock_cfg as dsc dsc.ensure_dbband_stock_config_table(db_hold) base = bbe.get_dbband_defaults_from_db(db_hold) if _DBBAND_ENGINE_AVAILABLE else {} snap = db_hold.get_merged_env_snapshot() env_tf = int(base.get("timeframe") or snap.get("DBBAND_TIMEFRAME") or 15) has_row = dsc.get_dbband_stock_config_row(db_hold, code) is not None merged = dsc.load_dbband_engine_cfg(db_hold, code, base) tf = dsc.effective_dbband_tf_for_code(db_hold, code, env_tf) return merged, tf, has_row @app.route("/api/dbband/stocks", methods=["GET"]) def api_dbband_stocks(): from kis_trader.strategies import dbband_stock_cfg as dsc db = _dbband_db() try: return jsonify(dsc.list_dbband_stock_codes(db)) finally: db.close() @app.route("/api/dbband/config", methods=["GET"]) def api_dbband_config(): from kis_trader.strategies import dbband_stock_cfg as dsc code = (request.args.get("code") or "").strip() db = _dbband_db() try: base = bbe.get_dbband_defaults_from_db(db) if _DBBAND_ENGINE_AVAILABLE else {} if not code: return jsonify({ "env_fallback": dsc.engine_cfg_to_ui(base), "dbband_tf_min": int(base.get("timeframe") or 15), }) merged, tf_min, has_row = _dbband_merged_engine_tf(db, code) meta = dsc.get_dbband_stock_meta(db, code) or {} return jsonify({ "code": code, "name": meta.get("name") or code, "dbband_tf_min": tf_min, "dbband_stock_saved": has_row, "param_source": "dbband_stock_config" if has_row else "env-fallback", "engine": dsc.engine_cfg_to_ui(merged), "market_type": meta.get("market_type"), "exchange": meta.get("exchange"), "symbol": meta.get("symbol") or code, }) finally: db.close() @app.route("/api/dbband/save_holding", methods=["POST"]) def api_dbband_save_holding(): """선택 종목 ``dbband_stock_config`` INSERT — 웹·백테·실매 단일 소스.""" from kis_trader.strategies import dbband_stock_cfg as dsc body = request.get_json(force=True, silent=True) or {} code = str(body.get("code", "")).strip() if not code: return jsonify({"error": "code 필수"}), 400 market_type = str(body.get("market_type", "KR")).strip().upper() or "KR" exchange = str(body.get("exchange", "KRX")).strip().upper() or ("KRX" if market_type == "KR" else "NASD") symbol = str(body.get("symbol", code)).strip().upper() or code name = str(body.get("name", "")).strip() src = body.get("apply_cfg") if isinstance(body.get("apply_cfg"), dict) else body tf_raw = body.get("tf_min", body.get("tf")) try: tf_min = int(float(tf_raw)) if tf_raw is not None and str(tf_raw).strip() != "" else None except (TypeError, ValueError): tf_min = None db = _dbband_db() try: _, hold_tf, _ = _dbband_merged_engine_tf(db, code) if tf_min is None: tf_min = int(hold_tf) if tf_min not in hb.KIWOOM_MINUTE_TICS: return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나 (요청: {tf_min})"}), 400 nm = name or code meta = dsc.get_dbband_stock_meta(db, code) if not name and meta and meta.get("name"): nm = str(meta["name"]).strip() dsc.set_dbband_stock_config( db, code, nm, src, tf_min=tf_min, market_type=market_type, exchange=exchange, symbol=symbol, ) return jsonify({"ok": True, "code": code, "symbol": symbol, "dbband_tf_min": int(tf_min)}) except Exception as e: logger.error("DBBAND 종목 저장 오류: %s", e) return jsonify({"error": str(e)}), 500 finally: db.close() @app.route("/api/dbband/backtest", methods=["GET"]) def api_dbband_backtest(): """종목 1개 더블 BB 백테 — holding_min_candles + dbband_stock_config.""" if not _DBBAND_ENGINE_AVAILABLE: return jsonify({"error": "dbband_engine 미설치"}), 503 code = request.args.get("code", "").strip() start_date = request.args.get("start", "") end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) if not code: return jsonify({"error": "code 필수 (QQQM·069500 등 종목별 파라미터)"}), 400 db = _dbband_db() try: merged, hold_tf, holding_saved = _dbband_merged_engine_tf(db, code) tf_raw = request.args.get("tf") tf = int(float(tf_raw)) if tf_raw not in (None, "") else int(hold_tf) if tf not in hb.KIWOOM_MINUTE_TICS: return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나"}), 400 cfg = dict(merged) for ck in bbe.CFG_ENGINE_KEYS: v = request.args.get(ck) if v is None: continue if ck in ("side_mode", "entry_mode", "stop_mode", "tp_mode", "exit_mode"): cfg[ck] = str(v).strip().lower() elif ck == "use_trend_filter": cfg[ck] = str(v).lower() in ("1", "true", "y", "yes", "on") else: try: cfg[ck] = float(v) except (TypeError, ValueError): pass if request.args.get("sl_pct") not in (None, ""): cfg["sl_pct"] = abs(float(request.args.get("sl_pct"))) / 100.0 if request.args.get("tp_pct") not in (None, ""): cfg["tp_pct"] = abs(float(request.args.get("tp_pct"))) / 100.0 if request.args.get("shoulder_min_high") not in (None, ""): cfg["shoulder_min_high"] = abs(float(request.args.get("shoulder_min_high"))) / 100.0 if request.args.get("shoulder_cut_pct") not in (None, ""): cfg["shoulder_cut_pct"] = abs(float(request.args.get("shoulder_cut_pct"))) / 100.0 raw_candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf) candles = dbbc.normalize_stored_min_candles(raw_candles) if not candles: stats = hb.get_min_candle_stats(db, code, tf_min=tf) hint = "📥 [키움 분봉 수집] 또는 영구구독 WS로 분봉을 먼저 수집하세요." if stats.get("count"): hint = f"DB {stats['count']}봉 ({stats['min']}~{stats['max']}) — 시작일 조정" return jsonify({"error": f"{tf}분봉 없음\n{hint}"}), 400 min_need = max(int(cfg.get("trend_ma_period") or 200) + 10, 50) if len(candles) < min_need: return jsonify({"error": f"봉 부족: {len(candles)} < {min_need} (추세MA+워밍업)"}), 400 trades = bbe.run_dbband_backtest_single(candles, cfg) snap = db.get_merged_env_snapshot() portfolio = dbbc.resolve_dbband_portfolio_params(snap, cfg) slot_money = float(cfg.get("slot_money") or portfolio.get("slot_money") or 3_000_000) fee_rate, sell_tax, _ = dbbc.fee_and_slot_from_env_row(snap) from kis_trader.backtest.backtest_portfolio_common import backtest_slip_pct dbbc.attach_dbband_trade_pnl( trades, slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax, slip_pct=backtest_slip_pct(cfg), ) from kis_trader.strategies import dbband_stock_cfg as dsc meta = dsc.get_dbband_stock_meta(db, code) or {} nm = str(meta.get("name") or code).strip() or code ps = "dbband_stock_config" if holding_saved else "env-fallback" report = dbbc.build_dbband_backtest_report( trades, candles, cfg, code=code, name=nm, start_date=start_date, end_date=end_date, tf=tf, candle_count=len(candles), param_source=ps, portfolio=portfolio, ) return jsonify({ "ok": True, "code": code, "tf": tf, "candle_count": len(candles), "param_source": ps, "params": report["params"], "summary": report["summary"], "equity": report["equity"], "daily": report["daily"], "reasons": report["reasons"], "trades": trades, }) except Exception as e: logger.error("DBBAND 백테 오류: %s", e) return jsonify({"error": str(e)}), 500 finally: db.close() @app.route("/api/dbband/param_search", methods=["GET"]) def api_dbband_param_search(): """종목별 그리드 탐색 — ``dbband_stock_config`` 베이스 + env ``DBBAND_GRID_*``.""" if not _DBBAND_ENGINE_AVAILABLE: return jsonify({"error": "dbband_engine 미설치"}), 503 code = request.args.get("code", "").strip() if not code: return jsonify({"error": "code 필수"}), 400 start_date = request.args.get("start", "") end_date = request.args.get("end", datetime.now().strftime("%Y-%m-%d")) db = _dbband_db() try: from kis_trader.backtest.dbband_param_search import run_search_for_code merged, hold_tf, holding_saved = _dbband_merged_engine_tf(db, code) tf = int(request.args.get("tf") or hold_tf) top_n = max(1, min(100, int(request.args.get("top", 30)))) out = run_search_for_code( db, code, start_date, end_date, tf, base_cfg=merged, top_n=top_n, ) out["param_source"] = "dbband_stock_config" if holding_saved else "env-fallback" return jsonify(out) except Exception as e: logger.error("DBBAND 파라서치 오류: %s", e) return jsonify({"error": str(e)}), 500 finally: db.close() def _pf_parse_amt(v: Any) -> float: """KIS 금액 문자열 → float (쉼표 제거).""" try: return abs(float(str(v or 0).replace(",", ""))) except (TypeError, ValueError): return 0.0 def _pf_int_price(v: Any) -> int: """주식 가격·평가금 — 원 단위 정수 (소수점 제거).""" try: return int(round(float(v or 0))) except (TypeError, ValueError): return 0 def _portfolio_account_summary(order_mgr) -> Dict[str, Any]: """실계좌 대조 시 상단 요약 — 입금액(env) · 예수금 · 주식평가금 · 총자산.""" from kis_trader.utils.env import get_env_float td = float(get_env_float("TOTAL_DEPOSIT", 0) or 0) out: Dict[str, Any] = { "total_deposit": _pf_int_price(td) if td > 0 else None, "cash": None, "holdings_eval": None, "total_asset": None, } try: balance = order_mgr.client.get_account_balance() if not balance: return out out2 = balance.get("output2") or {} if isinstance(out2, list) and out2: out2 = out2[0] elif not isinstance(out2, dict): out2 = {} dnca = _pf_parse_amt(out2.get("dnca_tot_amt")) tot_evlu = _pf_parse_amt(out2.get("tot_evlu_amt")) holdings_eval = 0.0 out1 = balance.get("output1") or [] if isinstance(out1, dict): out1 = [out1] for it in out1: qty = _pf_parse_amt(it.get("hldg_qty") or it.get("HLDG_QTY")) if qty <= 0: continue evlu = _pf_parse_amt(it.get("evlu_amt") or it.get("EVLU_AMT")) if evlu > 0: holdings_eval += evlu else: pr = _pf_parse_amt(it.get("prpr") or it.get("PRPR")) holdings_eval += pr * qty if tot_evlu <= 0 and (dnca > 0 or holdings_eval > 0): tot_evlu = dnca + holdings_eval out["cash"] = _pf_int_price(dnca) if dnca > 0 else None out["holdings_eval"] = _pf_int_price(holdings_eval) if holdings_eval > 0 else None out["total_asset"] = _pf_int_price(tot_evlu) if tot_evlu > 0 else None except Exception as e: logger.warning("portfolio account summary 실패: %s", e) return out def _portfolio_item_from_row( row: Dict[str, Any], br: Dict[str, Any], *, with_broker: bool, ) -> Dict[str, Any]: code = str(row.get("code") or "") strat = str(row.get("strategy") or "") db_qty = int(row.get("current_qty") or 0) buy_px = float(row.get("avg_buy_price") or 0) br_avg = float(br.get("avg_price") or 0) if with_broker else 0.0 cur_px = _portfolio_price_fast(row, br) broker_qty = int(br.get("qty") or 0) if with_broker else None ref_buy = br_avg if (with_broker and br_avg > 0) else buy_px pnl_pct = ((cur_px - ref_buy) / ref_buy * 100.0) if ref_buy > 0 and cur_px > 0 else 0.0 eval_amt = cur_px * (broker_qty if with_broker and broker_qty else db_qty) if with_broker and broker_qty is not None: sync_ok = broker_qty >= db_qty if db_qty > 0 else broker_qty <= 0 else: sync_ok = True return { "code": code, "name": row.get("name") or br.get("name") or code, "strategy": strat, "untracked": False, "can_sell": True, "db_qty": db_qty, "broker_qty": broker_qty, "buy_price": _pf_int_price(buy_px), "broker_avg_price": _pf_int_price(br_avg) if (with_broker and br_avg > 0) else None, "current_price": _pf_int_price(cur_px), "pnl_pct": round(pnl_pct, 2), "eval_amt": _pf_int_price(eval_amt) if eval_amt else 0, "buy_date": row.get("buy_date"), "sync_ok": sync_ok, "sync_note": ( "" if sync_ok else f"실계좌 {broker_qty}주 vs DB {db_qty}주 — 수량 불일치(HTS 수동매매·동기화 필요)" ), } @app.route("/api/portfolio/active", methods=["GET"]) def api_portfolio_active(): """ 봇 DB(active_trades) + (broker=1) 실계좌 잔고 전체 — HTS 실시간잔고와 동일하게 표시. HTS 에서 팔기 전에 여기서 매도하면 OrderManager → close_trade 로 DB 동기화. """ strategy = (request.args.get("strategy") or "ALL").strip() with_broker = str(request.args.get("broker", "0")).lower() in ("1", "true", "yes") order_mgr, _mc = _portfolio_infra() broker: Dict[str, Dict] = {} if with_broker: broker = order_mgr.get_broker_holdings(force=False) rows = _list_active_trades_rows( None if strategy.upper() == "ALL" else strategy, for_portfolio=True, ) # 미등록 종목 origin 분류용 — 봇이 주문한 적 있는 코드(orders BUY) + 수동 보호목록 bot_bought_codes, manual_hold_codes = _portfolio_origin_sets() # 홀딩봇(HOLDING) 등 이 탭에서 제외된 전략이 active_trades 로 관리 중인 코드. # 이 탭 목록에선 빠지지만 실계좌엔 있어 untracked 로 보이므로 '봇고아' 오분류 방지. excluded_strategy_codes: set = set() try: _ex_sql = ", ".join(["%s"] * len(PORTFOLIO_EXCLUDED_STRATEGY_IDS)) _cur = _db().conn.execute( f"SELECT DISTINCT code FROM active_trades WHERE strategy IN ({_ex_sql})", tuple(PORTFOLIO_EXCLUDED_STRATEGY_IDS), ) for _r in (_cur.fetchall() or []): _c = str(_r.get("code") or "").strip() if _c: excluded_strategy_codes.add(_c) except Exception as e: logger.warning("제외 전략 보유코드 조회 실패: %s", e) items: List[Dict[str, Any]] = [] if with_broker: active_by_code: Dict[str, List[Dict[str, Any]]] = {} for row in rows: c = str(row.get("code") or "") active_by_code.setdefault(c, []).append(row) all_codes = sorted(set(broker.keys()) | set(active_by_code.keys())) for code in all_codes: br = broker.get(code) or {} at_rows = active_by_code.get(code) or [] if at_rows: for row in at_rows: items.append(_portfolio_item_from_row(row, br, with_broker=True)) else: bqty = int(br.get("qty") or 0) if bqty <= 0: continue br_avg = float(br.get("avg_price") or 0) cur_px = _portfolio_price_fast({}, br) pnl_pct = ( ((cur_px - br_avg) / br_avg * 100.0) if br_avg > 0 and cur_px > 0 else 0.0 ) # origin 분류: 홀딩봇 관리분 최우선(이 탭 제외 전략) → 수동보호목록 # → orders BUY 기록 있으면 봇 고아 → 없으면 수동매수 추정 if code in excluded_strategy_codes: origin, origin_note = "holding", "홀딩봇(HOLDING) 관리 — 별도 봇, 여기서 매도 안 함" elif code in manual_hold_codes: origin, origin_note = "manual", "수동 보호목록(MANUAL_HOLD_CODES)" elif code in bot_bought_codes: origin, origin_note = "bot", "봇 주문기록 있음 — 체결됐으나 active_trades 미기록(고아)" else: origin, origin_note = "manual", "봇 주문기록 없음 — 수동매수 추정(보호)" items.append({ "code": code, "name": br.get("name") or code, "strategy": "", "untracked": True, "origin": origin, "can_sell": False, "db_qty": 0, "broker_qty": bqty, "buy_price": 0, "broker_avg_price": _pf_int_price(br_avg) if br_avg > 0 else None, "current_price": _pf_int_price(cur_px), "pnl_pct": round(pnl_pct, 2), "eval_amt": _pf_int_price(cur_px * bqty) if cur_px > 0 else 0, "buy_date": None, "sync_ok": False, "sync_note": origin_note, }) else: for row in rows: code = str(row.get("code") or "") items.append( _portfolio_item_from_row(row, {}, with_broker=False) ) mock = False try: mock = bool(order_mgr.client.mock) except Exception: pass account_summary = _portfolio_account_summary(order_mgr) if with_broker else None return jsonify({ "ok": True, "items": items, "count": len(items), "kis_mock": mock, "broker_codes": len(broker) if with_broker else None, "with_broker": with_broker, "account": account_summary, "strategy_ids": KIS_TRADER_STRATEGY_IDS, "filter_prefix": strategy_prefix_for_filter(strategy) or "ALL", }) @app.route("/api/portfolio/sell", methods=["POST"]) def api_portfolio_sell(): """ 시장가 전량 매도 — kis_trader OrderManager._place_sell 과 동일 경로. body: { "code", "strategy", "reason"?(optional) } """ from kis_trader.execution.order_manager import OrderRequest from kis_trader.utils.env import get_env_from_db data = request.get_json(silent=True) or {} code = str(data.get("code") or "").strip() strategy = str(data.get("strategy") or "").strip() if not code or not strategy: return jsonify({"ok": False, "error": "code 와 strategy 가 필요합니다."}), 400 db = _db() cur = db.conn.execute( "SELECT * FROM active_trades WHERE code=%s AND strategy=%s", (code, strategy), ) row = cur.fetchone() if not row: return jsonify({ "ok": False, "error": f"active_trades 에 없음: {code} [{strategy}]", }), 404 qty = int(row.get("current_qty") or 0) if qty <= 0: return jsonify({"ok": False, "error": "매도 수량(current_qty)이 0 입니다."}), 400 order_mgr, market_client = _portfolio_infra() buy_px = float(row.get("avg_buy_price") or 0) cur_px = _portfolio_live_price(code, row, market_client) profit_pct = ((cur_px - buy_px) / buy_px) if buy_px > 0 and cur_px > 0 else 0.0 default_reason = get_env_from_db("WEB_MANUAL_SELL_REASON", "웹동기화(HTS대체)") reason = str(data.get("reason") or default_reason or "웹동기화(HTS대체)") # active_trades PK(strategy) 는 행 값 그대로 — close_trade 가 trade_history 에 canonical 저장. req = OrderRequest( strategy_id=strategy, code=code, name=str(row.get("name") or code), side="SELL", qty=qty, price_ref=cur_px, reason=reason, buy_price=buy_px, profit_pct=profit_pct, ) try: result = order_mgr.place(req) except Exception as e: logger.exception("portfolio sell %s %s", code, strategy) return jsonify({"ok": False, "error": str(e)}), 500 if result.success: return jsonify({ "ok": True, "ord_no": result.ord_no, "filled_qty": result.filled_qty, "filled_avg_price": result.filled_avg_price, "reason": reason, }) return jsonify({ "ok": False, "error": result.reason or "매도 실패", "detail": result.reason, }), 400 @app.route("/api/portfolio/sell_untracked_all", methods=["POST"]) def api_portfolio_sell_untracked_all(): """전략 미등록(active_trades 없음) 실계좌 보유분 전량 시장가 일괄매도. 안전 원칙: - active_trades 에 있는 종목(전 전략, HOLDING 포함)은 **절대 건드리지 않음** (봇 관리분 보호). 봇이 신호/장마감으로 정상 청산할 대상이므로 제외. - 미등록 중에서도 **봇 고아(orders 에 BUY 기록 있음)만** 매도. 수동매수(orders 기록 없음) 및 MANUAL_HOLD_CODES 지정분은 **보호(제외)**. - DB(active_trades/trade_history)는 변경 없음 — 애초에 레코드가 없는 보유분. - 잔고 조회 실패 시 안전상 중단(유령 오판 방지). - 종목 간 sleep(429 방지) 은 BULK_SELL_INTERVAL_SEC 로 제어(하드코딩 금지). """ import time as _t import random as _r from kis_trader.utils.env import get_env_float order_mgr, _mc = _portfolio_infra() # 1) 실계좌 잔고 (fresh) — 실패 시 안전상 중단 broker = order_mgr.get_broker_holdings(force=True) if not getattr(order_mgr, "_holdings_last_fetch_ok", False): return jsonify({ "ok": False, "error": "실계좌 잔고 조회 실패 — 안전상 일괄매도 중단", }), 503 # 2) active_trades 전 종목(제외 필터 없이) = 봇 관리분 → 보호 집합 db = _db() tracked: set = set() try: cur = db.conn.execute("SELECT DISTINCT code FROM active_trades") for row in (cur.fetchall() or []): c = str(row.get("code") or "").strip() if c: tracked.add(c) except Exception as e: logger.error("active_trades 코드 조회 실패: %s", e) return jsonify({"ok": False, "error": f"active_trades 조회 실패: {e}"}), 500 # 2b) origin 판별 집합 (봇 주문기록 / 수동 보호목록) bot_bought_codes, manual_hold_codes = _portfolio_origin_sets() # 3) 매도 대상 = 미등록 & 봇 고아(orders BUY 있음) & 수동보호목록 아님 # 수동매수(주문기록 없음) 및 MANUAL_HOLD_CODES 는 protected 로 분류해 제외. targets: List[tuple] = [] protected: List[Dict[str, Any]] = [] for code, info in (broker or {}).items(): c = str(code).strip() qty = int((info or {}).get("qty") or 0) name = str((info or {}).get("name") or c) if qty <= 0 or c in tracked: continue # 봇 관리분 또는 0주 — 대상 아님(보호 표시도 불필요) if c in manual_hold_codes: protected.append({"code": c, "name": name, "qty": qty, "why": "수동보호목록"}) continue if c not in bot_bought_codes: protected.append({"code": c, "name": name, "qty": qty, "why": "수동매수추정(주문기록없음)"}) continue targets.append((c, qty, name)) if not targets: return jsonify({ "ok": True, "sold": [], "failed": [], "protected": protected, "sold_count": 0, "failed_count": 0, "protected_count": len(protected), "msg": "매도 대상(봇 고아) 종목이 없습니다." + ( f" (수동/보호 {len(protected)}종목 제외)" if protected else "" ), }) # 4) 순차 시장가 매도 (SafeRequest 스로틀 + 추가 sleep 으로 429 방지) interval = get_env_float("BULK_SELL_INTERVAL_SEC", 0.3) or 0.3 sold: List[Dict[str, Any]] = [] failed: List[Dict[str, Any]] = [] for c, qty, name in targets: try: ord_no = order_mgr.client.sell_market_order(c, qty) if ord_no: sold.append({"code": c, "name": name, "qty": qty, "ord_no": ord_no}) logger.info("🧹 [미등록일괄매도] %s(%s) %d주 시장가 접수 ODNO=%s", name, c, qty, ord_no) else: msg = str(getattr(order_mgr.client, "_last_sell_msg1", "") or "주문 실패") failed.append({"code": c, "name": name, "qty": qty, "error": msg}) logger.warning("🧹 [미등록일괄매도] %s(%s) 실패: %s", name, c, msg) except Exception as e: failed.append({"code": c, "name": name, "qty": qty, "error": str(e)}) logger.exception("미등록 일괄매도 예외 %s", c) # 종목 간 간격 (마지막 종목 뒤에는 불필요하지만 단순화) _t.sleep(max(0.0, interval) + _r.uniform(0.0, 0.1)) # 매도 후 잔고 캐시 무효화 → 다음 조회 정확성 try: order_mgr.invalidate_holdings_cache() except Exception: pass return jsonify({ "ok": True, "sold": sold, "failed": failed, "protected": protected, "sold_count": len(sold), "failed_count": len(failed), "protected_count": len(protected), }) @app.route("/api/portfolio/reconcile_orphans", methods=["POST"]) def api_portfolio_reconcile_orphans(): """봇 고아(active_trades 미기록) 수동 복구 — 장마감 배치와 동일 로직. 실계좌 잔고 ↔ active_trades/orders 대조 후, orders BUY 기록이 있는 미기록 보유분만 active_trades 에 upsert. 수동매수·MANUAL_HOLD_CODES 제외. 매도는 하지 않음(DB 동기화만). """ from kis_trader.execution.orphan_reconcile import reconcile_orphan_positions order_mgr, _mc = _portfolio_infra() try: result = reconcile_orphan_positions(order_mgr) except Exception as e: logger.exception("수동 고아복구 실패") return jsonify({"ok": False, "error": str(e)}), 500 if result.get("error"): return jsonify({"ok": False, "error": result["error"]}), 503 return jsonify({ "ok": True, "reconciled": result.get("reconciled") or [], "reconciled_count": int(result.get("reconciled_count") or 0), "failed": result.get("failed") or [], "failed_count": int(result.get("failed_count") or 0), "skipped_manual_count": len(result.get("skipped_manual") or []), "skipped_tracked_count": len(result.get("skipped_tracked") or []), "skipped_no_order_count": len(result.get("skipped_no_order") or []), }) @app.route("/api/env/params", methods=["GET"]) def api_env_params(): """ config_scalp / config_momentum / config_short … + env_config 병합 스냅샷에서 UI 초기값 반환. 실매(get_env_from_db) · 웹 · 파라서치가 동일 merged 소스를 사용한다. % 단위 변환 및 음수 → 양수 변환까지 수행해 JS가 바로 input.value에 넣을 수 있도록 함. 값이 DB에 없으면 null 반환 → JS에서 기존 HTML 기본값 유지. """ db = _db() try: snap = db.get_merged_env_snapshot() def fv(key): """DB 값을 float으로 파싱, 없으면 None""" v = snap.get(key) if v is None or v == "": return None try: return float(v) except (ValueError, TypeError): return None def smart_pct(key): """ DB 저장 형식이 소수(0.03) 또는 퍼센트(3.0) 중 어느 쪽이든 UI에 항상 퍼센트 단위(3.0)로 반환. - 절댓값 < 0.5 → 소수 형식 → ×100 - 절댓값 >= 0.5 → 이미 퍼센트 형식 → 그대로 - STOP_LOSS_PCT처럼 음수 저장된 경우 → 양수 변환 """ v = fv(key) if v is None: return None av = abs(v) if av == 0: return 0.0 result = av if av >= 0.5 else round(av * 100, 3) return round(result, 3) def sec_to_min(key): """초 → 분, None 유지""" 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 # 모멘텀·스캘핑·돌파 — config_* + env_config 병합 (파라서치 JSON 덮어쓰기 없음) from kis_trader.engine.momentum_engine import get_momentum_defaults_from_db as _mom_def_db _mom_ui = _momentum_ui_defaults_from_db(_mom_def_db()) _scalp_ui = _scalp_ui_defaults_from_db() _bo_ui = _bo_defaults_from_db() _rb_ui = _rb_defaults_from_db() # 당일 누적손익 다단 트레일 현재값 + 프리셋 목록(꼬리와 공유) — 모멘텀·돌파 _dt_presets = str(snap.get("BT_DAILY_TRAIL_PRESETS") or "").strip() for _ui, _pfx in ((_mom_ui, "MOMENTUM"), (_bo_ui, "BREAKOUT")): if isinstance(_ui, dict): _ui["daily_trail_tiers"] = str(snap.get(f"{_pfx}_DAILY_PROFIT_TRAIL_TIERS") or "").strip() _ui["daily_profit_mode"] = ( str(snap.get(f"{_pfx}_DAILY_PROFIT_MODE") or "trailing").strip().lower() or "trailing" ) _ui["daily_trail_presets"] = _dt_presets return jsonify({ "scalp": _scalp_ui, "tail": _tail_ui_defaults_from_db(snap), "momentum": _mom_ui, "breakout": _bo_ui, "range_break": _rb_ui, "dbband": _dbband_ui_defaults_from_db(snap) if _DBBAND_ENGINE_AVAILABLE else {}, }) finally: db.close() @app.route("/api/live_config", methods=["GET"]) def api_live_config_get(): """실매 운영 설정 탭 — 스키마 + 현재값 + 당일 익절·손익 상태.""" from kis_trader.web.live_config_schema import ( LIVE_STRATEGY_IDS, build_live_config_groups, read_snap_value, snap_value_to_ui, ) day = (request.args.get("date") or "").strip()[:10] if not day: day = datetime.now().strftime("%Y-%m-%d") db = _db() try: snap = db.get_merged_env_snapshot() or {} try: env_db_cols = db._env_config_column_set() except Exception: env_db_cols = set() groups_out: List[Dict[str, Any]] = [] for g in build_live_config_groups(): fields_out = [] for f in g.get("fields") or []: key = str(f.get("key") or "") ftype = str(f.get("type") or "text") ui_val = read_snap_value(snap, key, ftype) if ui_val is None and f.get("default") is not None: ui_val = f.get("default") tbl = _classify_key_table(key) if tbl == "env_config" and key not in env_db_cols: tbl = "env_config_ext" fields_out.append({ **f, "value": ui_val, "raw": snap.get(key), "table": tbl, }) groups_out.append({ "id": g.get("id"), "title": g.get("title"), "hint": g.get("hint"), "fields": fields_out, }) status = _build_live_config_status(db, day, snap) return jsonify({ "ok": True, "date": day, "as_of": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "groups": groups_out, "status": status, }) except Exception as e: logger.exception("live_config GET 실패") return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/live_config/save", methods=["POST"]) def api_live_config_save(): """운영 설정 탭 — 허용 키만 patch 후 insert_env_snapshot.""" from kis_trader.web.live_config_schema import ( all_live_config_keys, build_live_config_groups, expand_live_config_save_patch, ui_value_to_db, ) body = request.get_json(force=True, silent=True) or {} patch_in = body.get("patch") or body.get("values") or body if not isinstance(patch_in, dict) or not patch_in: return jsonify({"ok": False, "error": "patch 객체 필요"}), 400 allowed = set(all_live_config_keys()) key_types: Dict[str, str] = {} for g in build_live_config_groups(): for f in g.get("fields") or []: key_types[str(f.get("key"))] = str(f.get("type") or "text") patch: Dict[str, str] = {} rejected: List[str] = [] for k, v in patch_in.items(): key = str(k).strip() if key not in allowed: rejected.append(key) continue ftype = key_types.get(key, "text") patch[key] = ui_value_to_db(v, ftype) patch = expand_live_config_save_patch(patch) if not patch: return jsonify({ "ok": False, "error": "저장할 유효 키 없음", "rejected": rejected, }), 400 db = _db() try: snap = db.get_merged_env_snapshot() or {} for k, v in patch.items(): snap[k] = v env_id = db.insert_env_snapshot(snap) if env_id is None: return jsonify({"ok": False, "error": "insert_env_snapshot 실패"}), 500 saved_by_table: Dict[str, List[str]] = {} for k in patch: tbl = _classify_key_table(k) saved_by_table.setdefault(tbl, []).append(k) # overflow 키 표시 try: db_cols = db._env_config_column_set() for k in patch: if _classify_key_table(k) == "env_config" and k not in db_cols: saved_by_table.setdefault("env_config_ext", []).append(k) except Exception: pass return jsonify({ "ok": True, "env_id": env_id, "saved_keys": list(patch.keys()), "saved_by_table": saved_by_table, "rejected": rejected, }) except Exception as e: logger.exception("live_config 저장 실패") return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() def _classify_key_table(key: str) -> str: from config_schema import classify_config_key return classify_config_key(key) def _build_live_config_status( db: TradeDB, day_iso: str, snap: Dict[str, str], ) -> Dict[str, Any]: """당일 봇 실현손익 + 일일 익절 달성(매수중단) 여부.""" from kis_trader.engine.daily_profit_halt import ( load_global_profit_target, load_strategy_profit_target, resolve_global_operating_budget_krw, resolve_strategy_budget_krw, _target_configured, _target_reached, ) from kis_trader.web.live_config_schema import LIVE_STRATEGY_IDS dash = _build_actual_dashboard(db, day_iso) totals = dash.get("totals") or {} strat_rows = { str(r.get("strategy_id")): r for r in (dash.get("strategies") or []) } active_sids = [ sid for sid in LIVE_STRATEGY_IDS if _strategy_enabled_from_snapshot(snap, sid) ] gcfg = load_global_profit_target() gpnl = float(totals.get("realized_pnl_krw") or 0) gbudget = resolve_global_operating_budget_krw(active_sids) g_hit = _target_reached(gpnl, gcfg, gbudget) if _target_configured(gcfg) else False strategies_out: List[Dict[str, Any]] = [] for sid in LIVE_STRATEGY_IDS: row = strat_rows.get(sid) or {} spnl = float(row.get("realized_pnl_krw") or 0) scfg = load_strategy_profit_target(sid) sbudget = resolve_strategy_budget_krw(sid) s_hit = ( _target_reached(spnl, scfg, sbudget) if _target_configured(scfg) else False ) strategies_out.append({ "strategy_id": sid, "label": _ACTUAL_DASHBOARD_LABELS.get(sid, sid), "enabled": _strategy_enabled_from_snapshot(snap, sid), "realized_pnl_krw": int(round(spnl)), "budget_limit_krw": int(round(sbudget or row.get("budget_limit_krw") or 0)), "return_pct": float(row.get("return_pct") or 0), "profit_target_hit": s_hit, "buy_halted": g_hit or s_hit, }) return { "global": { "realized_pnl_krw": int(round(gpnl)), "budget_krw": int(round(gbudget)), "return_pct": float(totals.get("return_pct") or 0), "profit_target_hit": g_hit, "buy_halted": g_hit, "target_enabled": bool(gcfg.get("enabled")), "target_krw": float(gcfg.get("krw") or 0), "target_pct": float(gcfg.get("pct") or 0), }, "strategies": strategies_out, "notes": { "pnl_source": "trade_history 당일 실현 (수수료·세금 반영, 봇 실현과 동일)", "halt_scope": "신규 매수만 중단 — 보유 종목 손절·익절 유지", }, } @app.route("/") def index(): return render_template("backtest.html") if __name__ == "__main__": def _warm_portfolio(): try: _portfolio_infra() logger.info("보유·매도 API 워밍업 완료") except Exception as e: logger.warning("보유·매도 워밍업 스킵: %s", e) threading.Thread(target=_warm_portfolio, daemon=True).start() app.run(host="0.0.0.0", port=5050, debug=False, threaded=True)