#!/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, subprocess, time from datetime import datetime, timedelta from pathlib import Path from typing import Any, List, Dict, Optional, Tuple, Generator sys.path.insert(0, os.path.dirname(__file__)) from kis_trader.utils.legacy_root import ensure_legacy_root ensure_legacy_root() 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, Response, jsonify, request, render_template, stream_with_context 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 # 백테 candle/tick/ob 소스 — 요청당만 적용 (전역 os.environ 누수 방지) _BT_SOURCE_ENV_MAP = ( ("candle_source", "CANDLE_SOURCE"), ("tick_source", "TICK_SOURCE"), ("ob_source", "OB_SOURCE"), ) def _apply_backtest_source_env_from_request(req) -> None: """요청 query 에 소스가 있으면 설정, 없으면 키 제거 → LIVE(병합) 기본.""" args = getattr(req, "args", None) for arg, env_key in _BT_SOURCE_ENV_MAP: raw = args.get(arg) if args is not None else None if raw not in (None, ""): os.environ[env_key] = str(raw).strip() else: os.environ.pop(env_key, None) @app.teardown_request def _teardown_backtest_source_env(_exc=None): """이전 요청의 CANDLE/TICK/OB_SOURCE 가 다음 요청에 남지 않도록 정리.""" for _, env_key in _BT_SOURCE_ENV_MAP: os.environ.pop(env_key, None) # 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) # ──────────────────────────────────────────────────────────────────────────── # 헬퍼 함수 # ──────────────────────────────────────────────────────────────────────────── import threading _db_local = threading.local() def _db() -> TradeDB: if not hasattr(_db_local, 'db'): _db_local.db = TradeDB() return _db_local.db def _default_trading_day_ymd() -> str: """주말·휴장이면 이전 장운영일 — 웹/API 날짜 인풋·기본 end/day.""" try: from kis_trader.utils.kr_trading_day import default_kr_trading_end return default_kr_trading_end() except Exception: return datetime.now().strftime("%Y-%m-%d") 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 != '' AND name != code " "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) # 3) 그래도 없으면 실계좌 잔고(보유중인 경우만 이름 확인 가능)에서 보완 후 stock_meta 캐싱. # - 키움 조건검색 실시간 응답엔 종목명이 없어(코드=이름 임시값) 여기서 채워야 # 다음부터는 캐시 hit. inquire-price 는 hts_kor_isnm 미제공이라 잔고 API(prdt_name) 사용. # - TTL 캐시된 잔고 조회라 REST 폭주 없음(force=False). still_missing = [c for c in codes if c not in code_to_name] if still_missing: try: order_mgr, _ = _portfolio_infra() holdings = order_mgr.get_broker_holdings(force=False) or {} except Exception as exc: holdings = {} logger.debug("종목명 잔고 조회용 client 준비 실패: %s", exc) for c in still_missing: n = str((holdings.get(c) or {}).get("name") or "").strip() if n and n != c: code_to_name[c] = n db.upsert_stock_meta(c, name=n) for t in trades: c = str(t.get("code") or "").strip() if c in code_to_name: t["name"] = code_to_name[c] else: try: from kis_trader.utils.stock_name import resolve_stock_display_name t["name"] = resolve_stock_display_name(db, c, fallback=c) except Exception: t["name"] = 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 _format_trade_time_display(raw: Any) -> str: """웹 거래표 — YYYYMMDDHHMM(봉)/ISO 모두 초(SS) 포함 표시.""" if raw is None: return "" s = str(raw).strip() if not s: return "" if len(s) >= 19 and s[4] == "-" and ":" in s: return s[:19] if len(s) >= 16 and s[4] == "-" and s.count(":") == 1: return s[:16] + ":00" d = s.replace("-", "").replace(":", "").replace(" ", "").replace("T", "") if len(d) >= 14: return ( f"{d[:4]}-{d[4:6]}-{d[6:8]} {d[8:10]}:{d[10:12]}:{d[12:14]}" ) if len(d) >= 12: return f"{d[:4]}-{d[4:6]}-{d[6:8]} {d[8:10]}:{d[10:12]}:00" if len(d) >= 8: return f"{d[:4]}-{d[4:6]}-{d[6:8]}" return s def _enrich_trade_times_for_web(trade: Dict[str, Any]) -> None: """entry/exit·buy/sell 시각 필드를 웹 표시용(초 포함)으로 통일.""" for key in ( "entry_time", "exit_time", "buy_time", "sell_time", "buy_date", "sell_date", ): if trade.get(key) is not None and str(trade.get(key)).strip(): trade[key] = _format_trade_time_display(trade[key]) if not trade.get("buy_time") and trade.get("entry_time"): trade["buy_time"] = trade["entry_time"] if not trade.get("sell_time") and trade.get("exit_time"): trade["sell_time"] = trade["exit_time"] if not trade.get("buy_date") and trade.get("buy_time"): trade["buy_date"] = trade["buy_time"] if not trade.get("sell_date") and trade.get("sell_time"): trade["sell_date"] = trade["sell_time"] if not trade.get("entry_time") and trade.get("buy_time"): trade["entry_time"] = trade["buy_time"] if not trade.get("exit_time") and trade.get("sell_time"): trade["exit_time"] = trade["sell_time"] def _trade_with_int_prices(trade: Dict) -> Dict: """웹 거래표 매수가·매도가 — 소수점 제거 + 시각 초 표시.""" out = dict(trade) _enrich_trade_times_for_web(out) 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 _finalize_trades_ui( db: Any, trades: List[Dict], limit: int = 200, *, strategy_hint: str = "", ) -> List[Dict]: """최신순 슬라이스 + 매수/매도 시점 호가 부착 — 실매·가상 거래표 공용.""" out = _trades_recent_first(trades, limit) if not out: return out try: from kis_trader.backtest.trade_orderbook_enrich import ( enrich_trades_with_orderbook, ) enrich_trades_with_orderbook(db, out, strategy_hint=strategy_hint) except Exception as e: logger.warning("orderbook enrich 스킵: %s", e) return out def _momentum_source_label(kind: str, src: str) -> str: s = str(src or "").strip().lower() # ws_ticks:kis / ws_ticks:kiwoom — 옵투나·백테 벤더 추적 vendor = "" if s.startswith("ws_ticks:"): vendor = s.split(":", 1)[1].strip() s = "ws_ticks" if kind == "entry": if s in ("ws_ticks", "tick"): return f"틱진입({vendor})" if vendor else "틱진입" if s in ("ohlc_open", "open"): return "시가" if s in ("ohlc_low", "ohlc_fallback", "ohlc_high", "ohlc_intrabar"): return "진입OHLC" return s or "-" if s in ("ws_ticks", "tick"): return f"틱청산({vendor})" if vendor else "틱청산" if s in ("ohlc_bar", "ohlc_fallback"): return "청산OHLC" if s in ("eod_flat", "wallclock_eod", "wallclock_last"): return "틱공백EOD" return s or "-" def _parity_exception_causes(t: Dict[str, Any]) -> List[str]: """정합 예외 경로 라벨 — UI 빨간 행용 (시가 align 은 정상, OHLC폴백·틱공백EOD 만).""" causes: List[str] = [] es = str(t.get("entry_source") or "").strip().lower() xs = str(t.get("exit_source") or "").strip().lower() reason = str(t.get("sell_reason") or t.get("reason") or "") if es in ("ohlc_low", "ohlc_fallback", "ohlc_high", "ohlc_intrabar", "ohlc_bar"): causes.append("진입OHLC") if xs in ("ohlc_bar", "ohlc_fallback"): causes.append("청산OHLC") if xs in ("eod_flat", "wallclock_eod"): causes.append("틱공백EOD") if "ghost_purge" in reason.lower(): causes.append("ghost_purge") return causes def _enrich_momentum_trades_debug( trades: List[Dict], *, total_budget_krw: float, with_tick_debug: bool = True, ) -> 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 t.get("realized_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 causes = _parity_exception_causes(t) t["parity_exception"] = bool(causes) t["parity_cause"] = "+".join(causes) if causes else "" if with_tick_debug: 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}" if causes: t["debug_tick"] = f"{t['debug_tick']} · {t['parity_cause']}" def _peak_cum_from_trades(trades: List[Dict]) -> Tuple[float, str]: """매도시각 순 장중 누적 최고액·도달 시각.""" peak = 0.0 peak_at = "" cum = 0.0 for t in sorted(trades or [], key=_trade_exit_sort_key): cum += float(t.get("pnl") or t.get("realized_pnl") or 0) if cum > peak: peak = cum peak_at = str( t.get("exit_time") or t.get("sell_time") or t.get("sell_date") or "" ) return peak, peak_at[:19] if peak_at else "" def _codes_from_candles_map( candles_by_code: Optional[Dict[str, List[Dict]]], ) -> List[str]: """ load_*_candles_by_code / load_ls_candles_by_code 결과 → 분석 종목 리스트. LS(ls_ws_candles)·키움(ws_candles) 모두 반환형은 ``{code: [봉…]}`` 로 동일하다. 테이블·datetime 컬럼만 다르고, 웹 변수명(codes)은 갈라지지 않는다. 67eab24(LS 이력) 이후 인라인 ``codes = […]`` 가 로더 안으로 들어가 응답의 ``len(codes)`` 가 NameError 나던 회귀를 막기 위해, 로더 직후 반드시 ``codes = _codes_from_candles_map(…)`` 로 다시 바인딩한다. """ if not candles_by_code: return [] return [str(c) for c in candles_by_code.keys() if c] 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, history_source: 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분봉 키. ``history_source``: kiwoom(기본) | ls - ``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 from kis_trader.backtest.universe_timeline import ( universe_exit_debounce_sec_for_strategy, ) from kis_trader.backtest.universe_history_source import ( history_source_label, resolve_backtest_universe_history_source, ) # EXIT 디바운스 = 전략 공통 (CONDITION_EXIT_GRACE / 전략별 오버라이드) # SCALP만 특수처리하면 웹↔Optuna 유니버스 두께가 어긋남 (7/20 스캘핑 −1만 사례) debounce_sec = universe_exit_debounce_sec_for_strategy(strategy_id) 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, ) strict = momentum_backtest_universe_strict_enabled() strict_lag = momentum_backtest_universe_strict_lag_min() hs = resolve_backtest_universe_history_source(history_source) 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, history_source=hs, ) if history: return history, history_source_label(hs, strict=strict), 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]]: """ 백테스트 유니버스 쿼리 통일 — history/sim/all. - ``universe=history`` : 저장 후보 이력(슬롯·코드) - ``universe=sim`` : 시뮬 유니버스 (scalping_engine, sim_kind 필수) - ``universe=all`` : ws_candles 전 종목 (필터 없음) 이력 테이블(키움/LS)은 ``universe_history_source`` 별도 쿼리 (``_parse_universe_history_source_arg``). 레거시 별칭: 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 def _parse_universe_history_source_arg(request) -> str: """키움 target_candidates_history vs LS ls_candidates_history.""" from kis_trader.backtest.universe_history_source import ( resolve_backtest_universe_history_source, ) raw = ( request.args.get("universe_history_source") or request.args.get("hist_src") or request.args.get("history_source") or None ) return resolve_backtest_universe_history_source(raw) # ──────────────────────────────────────────────────────────────────────────── # 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] # 잔고 동기화·유령정리(ghost_purge) 레코드는 실체결이 아님 → 성과·목록에서 제외 filtered_out = 0 cleaned: List[Dict] = [] for t in trades: if _is_forced_ghost_trade(t): 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 # 해외(US_*)는 달러 소수 유지 — 정수원 반올림 금지 _is_us_strat = str(strategy or "").upper().startswith("US_") open_trades.append({ "code": row.get("code"), "name": row.get("name"), "strategy": row.get("strategy"), "buy_price": (round(buy_px, 4) if _is_us_strat else _int_display_price(buy_px)), "sell_price": (round(cur_px, 4) if _is_us_strat else _int_display_price(cur_px)), "qty": qty, "realized_pnl": None, "unrealized_pnl": (round(unrealized, 4) if _is_us_strat else 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] # 누적손익·누적% (청산 완료, 매도시각 순 — 백테 거래표와 동일) # 한도: get_latest_env 스냅샷이 비면 SCALP 600만으로 떨어질 수 있어 merged 우선 try: from kis_trader.utils.env import get_merged_env_dict, invalidate_merged_env_cache invalidate_merged_env_cache() env_row = dict(get_merged_env_dict() or {}) except Exception: latest_env = db.get_latest_env() env_row = dict(latest_env["snapshot"]) if latest_env else {} if not env_row: 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.backtest_portfolio_common import resolve_portfolio_params from kis_trader.backtest.momentum_backtest_common import ( resolve_momentum_portfolio_params, ) strat_u = (strategy or "SCALP").upper() if strat_u == "MOMENTUM": port = resolve_momentum_portfolio_params(env_row, {}) total_budget_krw = float(port.get("total_budget_krw") or 0) elif strat_u == "US_MOMENTUM": # 해외 전용 한도 — 없으면 모멘텀 포트 폴백 from kis_trader.utils.env import get_env_float as _gef total_budget_krw = float(_gef("US_MOMENTUM_TOTAL_BUDGET", 0) or 0) if total_budget_krw <= 0: port = resolve_momentum_portfolio_params(env_row, {}) total_budget_krw = float(port.get("total_budget_krw") or 0) else: # SHORT/TAIL → 꼬리 한도(120만). SCALP 키로 폴백되면 600만 오표시. port_strat = _portfolio_strategy_key(strat_u) if port_strat not in ("TAIL", "SHORT", "SCALP", "BREAKOUT", "RANGE_BREAK", "UPDOW", "DBBAND"): port_strat = "SCALP" _apply_backtest_source_env_from_request(request) port = resolve_portfolio_params(env_row, strategy=port_strat) 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 "") _enrich_trades_with_names(db, trades) tick_live: Dict[str, Any] = {} try: from kis_trader.backtest.breakout_tick_loader import build_tick_coverage_meta_for_day cov_codes = { str(t.get("code") or "").strip() for t in closed_trades if str(t.get("code") or "").strip() } if cov_codes and start and end: # 돌파=1분 · 꼬리=3분 · 그 외 1분 (실매 기록 기준 커버 참고) tf_map = {"SHORT": 3, "TAIL": 3} tf = int(tf_map.get((strategy or "").upper(), 1)) tick_live = build_tick_coverage_meta_for_day( db, cov_codes, start, end, timeframe=tf, trades=closed_trades, ) or {} except Exception as _cov_ex: logger.warning("api_actual tick coverage: %s", _cov_ex) tick_live = {} payload = { "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), "start": start, "end": end, }, "meta": { "filtered_forced_rows": filtered_out, "closed_count": len(closed_trades), "open_count": len(open_trades), }, "tick_live": tick_live, "equity": equity, "daily": daily_list, "reasons": reasons, "top_codes": top_list, "trades": _finalize_trades_ui( db, trades, 200, strategy_hint=str(strategy or ""), ), } # 해외 전략만 표시용 USD + 당일 환율 원 환산 (국내 핸들러 로직 변경 없음) if str(strategy or "").upper().startswith("US_"): try: from kis_trader.utils.usd_krw_fx import enrich_payload_with_usd_krw_fx enrich_payload_with_usd_krw_fx(payload) except Exception as _fx_ex: logger.warning("api_actual FX enrich: %s", _fx_ex) payload["currency"] = "USD" return jsonify(payload) finally: db.close() @app.route("/api/paper/actual", methods=["GET"]) def api_paper_actual(): """페이퍼 전용 실거래 조회 — trade_history_paper + active_trades_paper. 실매 ``/api/actual`` · 보유·매도 탭과 절대 섞지 않음. """ from kis_trader.database.paper_store import ( ensure_paper_tables, get_active_trades_paper, list_trade_history_paper, ) strategy = request.args.get("strategy", "US_MOMENTUM") start = request.args.get("start", "") end = request.args.get("end", "") like_pattern = strategy_like_pattern(strategy) or "US_MOMENTUM%" db = _db() try: ensure_paper_tables(db) closed_trades = list_trade_history_paper( db, strategy_like=like_pattern, start=start, end=end ) for t in closed_trades: for k in ("buy_date", "sell_date"): if t.get(k): t[k] = str(t[k]) t["is_open"] = False t["paper"] = True open_map = get_active_trades_paper( db, strategy_id=None if (strategy or "").upper() == "ALL" else strategy, ) open_trades: List[Dict] = [] now_dt = datetime.now() for code, row in open_map.items(): if (strategy or "").upper() != "ALL": if str(row.get("strategy") or "").upper() != str(strategy).upper(): # LIKE prefix: US_MOMENTUM vs US if not str(row.get("strategy") or "").upper().startswith( str(strategy).upper().rstrip("%") ): continue bd_str = str(row.get("buy_date") or "") if start and bd_str and bd_str < start + " 00:00:00": continue if end and bd_str 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": code, "name": row.get("name"), "strategy": row.get("strategy"), "buy_price": buy_px, "sell_price": cur_px, "qty": qty, "realized_pnl": None, "unrealized_pnl": round(unrealized, 4), "profit_rate": round(profit_rate, 2), "hold_minutes": hold_min, "buy_date": bd_str, "sell_date": None, "sell_reason": "보유중(paper)", "is_open": True, "paper": True, }) trades = open_trades + closed_trades 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.0 payload = { "ok": True, "paper": True, "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, 4), "avg_hold_min": round(avg_hold, 1), "open_count": len(open_trades), }, "params": { "strategy": strategy, "start": start, "end": end, }, "trades": trades, } # 페이퍼 기본 = 해외 US — 표시용 달러 + 당일 환율 원 환산 if str(strategy or "").upper().startswith("US_") or not strategy: try: from kis_trader.utils.usd_krw_fx import enrich_payload_with_usd_krw_fx enrich_payload_with_usd_krw_fx(payload) except Exception as _fx_ex: logger.warning("api_paper_actual FX enrich: %s", _fx_ex) payload["currency"] = "USD" return jsonify(payload) finally: db.close() @app.route("/api/fx/usd_krw", methods=["GET"]) def api_fx_usd_krw(): """표시용 USD→KRW 일자별 환율. dates=YYYY-MM-DD,YYYY-MM-DD 또는 start&end.""" from kis_trader.utils.env import get_env_int from kis_trader.utils.usd_krw_fx import ( get_display_usd_krw_rate, get_usd_krw_rates, norm_fx_ymd, ) dates_raw = (request.args.get("dates") or "").strip() start = (request.args.get("start") or "").strip() end = (request.args.get("end") or "").strip() days: List[str] = [] if dates_raw: for part in dates_raw.replace(";", ",").split(","): y = norm_fx_ymd(part) if y: days.append(y) if start or end: s = norm_fx_ymd(start) e = norm_fx_ymd(end) or s if s and e: try: a = datetime.strptime(s, "%Y-%m-%d").date() b = datetime.strptime(e, "%Y-%m-%d").date() if b < a: a, b = b, a days.extend([a.isoformat(), b.isoformat()]) max_span = int(get_env_int("FX_USD_KRW_RANGE_MAX_DAYS", 31)) span = (b - a).days if 0 < span <= max_span: cur = a while cur <= b: days.append(cur.isoformat()) cur += timedelta(days=1) except Exception: pass if not days: # 오늘 1건 — HTTP 없이 표시용 today = datetime.now().strftime("%Y-%m-%d") rate = get_display_usd_krw_rate(today) return jsonify({ "ok": True, "pair": "USD/KRW", "rates": {today: round(float(rate), 4)}, "display_rate": round(float(rate), 4), "source": "cache_or_fallback", }) rates = get_usd_krw_rates(days) # 빠진 날은 표시용 폴백으로 채움 (UI 병기용) filled = dict(rates) for y in days: if y not in filled or not filled.get(y): filled[y] = round(float(get_display_usd_krw_rate(y)), 4) disp = get_display_usd_krw_rate(days[-1] if days else "") return jsonify({ "ok": True, "pair": "USD/KRW", "rates": filled, "display_rate": round(float(disp), 4), "source": "cache_or_fallback", }) # ──────────────────────────────────────────────────────────────────────────── # API: 실거래 당일 운영 대시보드 (전략별 + 합계) # ──────────────────────────────────────────────────────────────────────────── # ※ RANGE_BREAK·DBBAND 는 숨김 전략 → 대시보드 집계 제외. SCALP 포함. # 오늘 운영 탭 UI: UPDOW·LS 두 줄 숨김 (_DASHBOARD_TAB_*). _ACTUAL_DASHBOARD_STRATEGIES: Tuple[str, ...] = ( "SCALP", "SHORT", "MOMENTUM", "UPDOW", "BREAKOUT", ) # 오늘 운영 탭에만 노출 (60분 하락매수·LS 제외 · 증권사=키움·한투) _DASHBOARD_TAB_STRATEGIES: Tuple[str, ...] = ( "SCALP", "SHORT", "MOMENTUM", "BREAKOUT", ) # 증권사별 블록 순서 — 키움 위 · 한투(KIS) 아래 _DASHBOARD_TAB_BROKERS: Tuple[str, ...] = ("kiwoom", "kis") _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: """실체결이 아닌 정리용 레코드 — 실거래 분석·대시보드 집계에서 제외. - ghost_purge:* : 브로커 잔고 0주 유령 정리 (PnL=0 장부용) - 잔고없음(강제정리) / 잔고동기화(외부매도) + 매도가 0 """ reason = str(row.get("sell_reason") or "") if "ghost_purge" in reason.lower(): return True 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 = _default_trading_day_ymd() 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 _universe_source_bucket(live_source: str) -> str: """실매 UNIVERSE_SOURCE → 오늘 운영 증권사 버킷 (kiwoom | kis | '').""" live = (live_source or "").strip().lower() if live == "kiwoom_condition": return "kiwoom" if live in ("condition", "ranking"): return "kis" return "" def _broker_source_label(broker: str) -> str: """오늘 운영 탭 증권사 표시명.""" b = (broker or "").strip().lower() if b == "kiwoom": return "키움" if b == "kis": return "한투(KIS)" return broker or "—" def _empty_dashboard_row( strategy_id: str, snap: Dict[str, str], *, source: str = "", live_source: str = "", ) -> Dict[str, Any]: """전략별 당일 운영 1행. source=kiwoom|kis 이면 증권사별 블록 안 전략 1행. 실매 UNIVERSE_SOURCE 와 같은 증권사 행에만 매매·한도·ON 을 표시한다. """ src = (source or "").strip().lower() live = (live_source or "").strip() live_bucket = _universe_source_bucket(live) is_live = bool(src) and bool(live_bucket) and (src == live_bucket) enabled = _strategy_enabled_from_snapshot(snap, strategy_id) if (not src or is_live) else False limit = _strategy_budget_limit_krw(snap, strategy_id) if (not src or is_live) else 0.0 return { "strategy_id": strategy_id, "label": _ACTUAL_DASHBOARD_LABELS.get(strategy_id, strategy_id), "source": src, # kiwoom | kis | ""(레거시 단일행) "source_label": _broker_source_label(src) if src else "", "live_universe_source": live, "is_live_source": bool(is_live) if src else True, "enabled": enabled, "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 _empty_universe_history_row( strategy_id: str, source: str, *, live_source: str = "", ) -> Dict[str, Any]: """조건검색 이력(키움 target_candidates_history) 1행 — 슬롯·종목 0 기본.""" src = (source or "kiwoom").strip().lower() live = (live_source or "").strip() is_live = _universe_source_bucket(live) == "kiwoom" return { "strategy_id": strategy_id, "label": _ACTUAL_DASHBOARD_LABELS.get(strategy_id, strategy_id), "source": src, "source_label": _broker_source_label(src), "table": "target_candidates_history", "live_universe_source": live_source or "", "is_live_source": bool(is_live), "slots": 0, "codes": 0, "rows": 0, "first_at": "", "last_at": "", "stale": False, } _UNIVERSE_HISTORY_TABLES = { "target_candidates_history": "target_candidates_history", "ls_candidates_history": "ls_candidates_history", } def _universe_history_stats_by_sid( db: TradeDB, table: str, day_iso: str, ) -> Dict[str, Dict[str, Any]]: """당일 조건검색 이력 — strategy_id 별 슬롯·종목·행수·최초/최근. slot_key 당일 prefix 만 사용 (idx_slot / idx_tch_slot_sid). OR + event_time LIKE 는 79만행 스캔이라 쓰지 않음. PyMySQL ``%`` 포맷 충돌 방지: LIKE 는 반드시 ``%s`` 바인딩. """ day = (day_iso or "").strip()[:10] if len(day) != 10: return {} tbl = _UNIVERSE_HISTORY_TABLES.get(str(table or "").strip()) if not tbl: return {} slot_like = f"{day.replace('-', '')}%" sql = ( f"SELECT strategy_id, " f"COUNT(*) AS n, " f"COUNT(DISTINCT code) AS codes, " f"COUNT(DISTINCT slot_key) AS slots, " f"MIN(event_time) AS first_at, " f"MAX(event_time) AS last_at " f"FROM {tbl} " f"WHERE slot_key LIKE %s " f"AND strategy_id IS NOT NULL AND strategy_id <> '' " f"GROUP BY strategy_id" ) out: Dict[str, Dict[str, Any]] = {} try: raw = db.conn.execute(sql, (slot_like,)).fetchall() except Exception as e: logger.warning("universe history stats 실패 (%s): %s", tbl, e) return {} for r in raw: d = dict(r) sid = canonical_strategy_id(d.get("strategy_id")) if not sid: continue out[sid] = { "slots": int(d.get("slots") or 0), "codes": int(d.get("codes") or 0), "rows": int(d.get("n") or 0), "first_at": str(d.get("first_at") or ""), "last_at": str(d.get("last_at") or ""), } return out def _build_universe_history_rows( db: TradeDB, snap: Dict[str, str], day_iso: str, ) -> List[Dict[str, Any]]: """전략별 키움 조건검색 이력 1줄 — target_candidates_history 원툴.""" kiwoom = _universe_history_stats_by_sid(db, "target_candidates_history", day_iso) rows: List[Dict[str, Any]] = [] for sid in _DASHBOARD_TAB_STRATEGIES: live_src = str(snap.get(f"{sid}_UNIVERSE_SOURCE") or "").strip() row = _empty_universe_history_row(sid, "kiwoom", live_source=live_src) st = kiwoom.get(sid) or {} if st: row["slots"] = int(st.get("slots") or 0) row["codes"] = int(st.get("codes") or 0) row["rows"] = int(st.get("rows") or 0) row["first_at"] = str(st.get("first_at") or "") row["last_at"] = str(st.get("last_at") or "") rows.append(row) return rows def _build_dashboard_trade_core( db: TradeDB, day_iso: str, snap: Dict[str, str], strategy_ids: Tuple[str, ...], ) -> Dict[str, Any]: """당일 매매·뽀찌·실현손익 집계 (조건검색 이력·증권사 펼침 없음). 오늘 운영 탭과 운영 설정 손익 카드가 같은 숫자를 쓰도록 공유. """ day_start, day_end = _day_bounds_kst(day_iso) day = day_start[:10] sids = tuple(strategy_ids or ()) rows_by_sid: Dict[str, Dict[str, Any]] = { sid: _empty_dashboard_row(sid, snap) for sid in sids } 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)) live_rows: List[Dict[str, Any]] = [] for sid in sids: base = rows_by_sid[sid] base["turnover_krw"] = int(base["buy_turnover_krw"]) + int(base["sell_turnover_krw"]) peak = _daily_peak_budget_krw(sid, day_start, day_end, closed_overlap, open_rows) base["budget_used_krw"] = peak limit = float(base["budget_limit_krw"] or 0) pnl = float(base["realized_pnl_krw"] or 0) base["budget_usage_pct"] = round(peak / limit * 100, 2) if limit > 0 else 0.0 base["return_pct"] = round(pnl / limit * 100, 3) if limit > 0 else 0.0 live_rows.append(base) total_limit = sum( float(r["budget_limit_krw"] or 0) for r in live_rows if r.get("enabled") ) total_used = sum(float(r["budget_used_krw"] or 0) for r in live_rows) total_now = sum(float(r["budget_now_krw"] or 0) for r in live_rows) total_pnl = sum(float(r["realized_pnl_krw"] or 0) for r in live_rows) total_buy = sum(int(r["buy_turnover_krw"] or 0) for r in live_rows) total_sell = sum(int(r["sell_turnover_krw"] or 0) for r in live_rows) 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 live_rows), "open_positions": sum(int(r["open_positions"] or 0) for r in live_rows), "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 { "day": day, "day_start": day_start, "day_end": day_end, "rows_by_sid": rows_by_sid, "live_rows": live_rows, "totals": totals, } def _build_actual_dashboard(db: TradeDB, day_iso: str) -> Dict[str, Any]: """당일(KST) 전략별 거래대금·뽀찌 사용·실현수익률 — 백테 bot_pct 분모와 동일(운용한도).""" snap = db.get_merged_env_snapshot() or {} core = _build_dashboard_trade_core(db, day_iso, snap, _DASHBOARD_TAB_STRATEGIES) day = str(core.get("day") or "") rows_by_sid = core.get("rows_by_sid") or {} live_rows = core.get("live_rows") or [] totals = core.get("totals") or {} strategy_list: List[Dict[str, Any]] = [] for broker in _DASHBOARD_TAB_BROKERS: for sid in _DASHBOARD_TAB_STRATEGIES: base = rows_by_sid.get(sid) or _empty_dashboard_row(sid, snap) live_src = str(snap.get(f"{sid}_UNIVERSE_SOURCE") or "").strip() live_bucket = _universe_source_bucket(live_src) is_live = bool(live_bucket) and broker == live_bucket if is_live: row = dict(base) row["source"] = broker row["source_label"] = _broker_source_label(broker) row["live_universe_source"] = live_src row["is_live_source"] = True else: row = _empty_dashboard_row( sid, snap, source=broker, live_source=live_src ) row["broker"] = broker strategy_list.append(row) universe_history = _build_universe_history_rows(db, snap, day) ls_hist_on = str(snap.get("LS_CONDITION_HISTORY_ENABLED") or "").strip().lower() in ( "1", "true", "yes", "on", ) return { "date": day, "as_of": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "strategies": strategy_list, "totals": totals, "universe_history": universe_history, "ls_condition_history_enabled": ls_hist_on, "notes": { "turnover": "당일 매수·매도 체결금액 합(매수일·매도일 각각 집계, 동일일 왕복 시 양쪽 합산)", "budget_used": "당일 최대 동시 투입(뽀찌 피크) — 매수·매도 시각 순으로 재구성, 청산 후에도 당일 사용량 유지", "budget_now": "현재 보유 중 투입금(active_trades) — 실시간 스냅샷", "return_pct": "당일 실현손익 ÷ ON 전략 운용한도 합 (백테 bot_pct와 동일 분모)", "strategy_source": ( "증권사별 블록(키움·한투) — UPDOW·LS 숨김. " "매매·ON·운용한도는 실매 UNIVERSE_SOURCE 와 같은 증권사 행. 합계는 전략당 1회" ), "universe_history": ( "키움 target_candidates_history 원툴 — 전략별 1줄" ), }, } @app.route("/api/actual/dashboard", methods=["GET"]) def api_actual_dashboard(): from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day day = (request.args.get("date") or "").strip()[:10] if not day: day = _default_trading_day_ymd() else: try: day = clamp_to_prev_kr_trading_day(day) except ValueError: day = _default_trading_day_ymd() 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() @app.route("/api/condition_job_events", methods=["GET"]) def api_condition_job_events(): """조건검색 진입/재진입/이탈 이벤트 (condition_job_events).""" from kis_trader.network.condition_job_events import query_condition_job_events start = (request.args.get("start") or "").strip()[:19] end = (request.args.get("end") or "").strip()[:19] if not start or not end: day = _default_trading_day_ymd() start = start or day end = end or day try: limit = int(request.args.get("limit") or 500) except (TypeError, ValueError): limit = 500 db = _db() try: rows = query_condition_job_events( db, start=start, end=end, strategy_id=(request.args.get("strategy_id") or "").strip(), broker=(request.args.get("broker") or "").strip(), job_flag=(request.args.get("job_flag") or "").strip(), code=(request.args.get("code") or "").strip(), limit=limit, ) return jsonify({"ok": True, "rows": rows, "n": len(rows)}) except Exception as e: logger.exception("condition_job_events 조회 실패") return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/condition_parity", methods=["GET"]) def api_condition_parity(): """실매소스 vs history vs job — 유니버스0 가독성 요약.""" from kis_trader.network.condition_job_events import build_condition_parity_report day = (request.args.get("date") or request.args.get("start") or "").strip()[:10] if not day: day = _default_trading_day_ymd() db = _db() try: payload = build_condition_parity_report(db, day=day) return jsonify({"ok": True, **payload}) except Exception as e: logger.exception("condition_parity 실패") return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/feed_collect_stats", methods=["GET"]) def api_feed_collect_stats(): """일자별 틱·호가·filter_eval 수집량 (벤더별). 통계 탭. 기본=빠른 COUNT. heavy=1 이면 나이컷·종목교집합(느림). 최근 N일 일괄집계 없음. """ from kis_trader.web.feed_collect_stats import build_feed_collect_stats day = (request.args.get("date") or request.args.get("day") or "").strip()[:10] if not day: day = _default_trading_day_ymd() heavy_raw = (request.args.get("heavy") or request.args.get("detail") or "0").strip().lower() heavy = heavy_raw in ("1", "true", "yes", "y", "on") db = _db() try: payload = build_feed_collect_stats(db, day=day, heavy=heavy) return jsonify({"ok": True, **payload}) except Exception as e: logger.exception("feed_collect_stats 실패") return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() # ──────────────────────────────────────────────────────────────────────────── # API: 스캘핑 가격 재현 백테스트 (ws_candles 기반) # ──────────────────────────────────────────────────────────────────────────── def _t2dt(t: str) -> datetime: from kis_trader.utils.trade_time import parse_trade_datetime return parse_trade_datetime(t) 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, resolve_breakout_skip_hts_scan_dupes, 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_* 전용. - ``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( ("MOMENTUM_HIGH_CHASE_THR", "HIGH_CHASE_THR", "SCALP_HIGH_PRICE_CHASE_THRESHOLD", "HIGH_PRICE_CHASE_THRESHOLD"), float(_d.get("high_chase_thr", 0.96)), ) max_daily_chg = pickf( ("MOMENTUM_MAX_DAILY_CHG", "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("MOMENTUM_USE_DEFENSE_FILTERS") if _udf in (None, "", "None"): _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",), 50.0), "mom_rsi_max": pickf(("MOMENTUM_RSI_MAX",), 80.0), "mom_vol_mult": pickf(("MOMENTUM_VOL_MULT",), 1.05), "mom_vol_win": picki(("MOMENTUM_VOL_WIN",), 5), # HTS K — 전일종가 대비 최소등락(%). 기본 0.2 "e_min_chg_pct": pickf(("MOMENTUM_E_MIN_CHG_PCT",), 0.2), "mom_time_end_hm": picki(("MOMENTUM_TIME_END_HM",), 1530), "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("MOMENTUM_COOLDOWN_SEC"))) // 60) if env.get("MOMENTUM_COOLDOWN_SEC") not in (None, "", "None") else ( 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",), 0.45, ), # 폼에 입력란 없음 — 실매/백테 엔진이 DB에서 직접 읽음. UI 안내용. "ob_min_bid_ask_ratio": pickf(("MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO",), 1.0), "ob_ask_max_mult": pickf(("MOMENTUM_ORDERBOOK_ENTRY_ASK_MAX_MULT",), 50.0), "whipsaw_filter_enabled": pick_bool(("MOMENTUM_WHIPSAW_FILTER_ENABLED",), False), "whipsaw_subbar_sec": picki(("MOMENTUM_WHIPSAW_SUBBAR_SEC",), 30), "whipsaw_lookback_sec": picki(("MOMENTUM_WHIPSAW_LOOKBACK_SEC",), 60), "whipsaw_dip_pct": pickf(("MOMENTUM_WHIPSAW_DIP_PCT",), 0.005), "eod_enabled": pick_bool(("MOMENTUM_EOD_ENABLED",), True), "eod_hm": ( str(env.get("MOMENTUM_EOD_HM") or "15:20").strip() if env.get("MOMENTUM_EOD_HM") not in (None, "", "None") else "15:20" ), **_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), "exit_ob_enabled": bool(d.get("exit_ob_enabled", False)), "exit_ob_ratio_min": float(d.get("exit_ob_ratio_min") or 0.4), "exit_ob_ma_window": int(d.get("exit_ob_ma_window") or 5), "exit_ob_min_profit_pct": _momentum_ratio_to_ui_pct( d.get("exit_ob_min_profit_pct"), 0.5, ), "exit_ob_min_hold_bars": int(d.get("exit_ob_min_hold_bars") or 3), "stop_ob_enabled": bool(d.get("stop_ob_enabled", False)), "stop_ob_ratio_min": float(d.get("stop_ob_ratio_min") or 0.4), "stop_ob_ma_window": int(d.get("stop_ob_ma_window") or 5), "stop_ob_min_loss_pct": _momentum_ratio_to_ui_pct( d.get("stop_ob_min_loss_pct"), 0.3, ), "stop_ob_min_hold_bars": int(d.get("stop_ob_min_hold_bars") or 2), } 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"), "e_min_chg_pct": _ui("e_min_chg_pct"), "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"), "backtest_skip_pre_subscribe": bool(d.get("backtest_skip_pre_subscribe", False)), "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"), "exit_ob_enabled": bool(d.get("exit_ob_enabled", False)), "exit_ob_ratio_min": float(d.get("exit_ob_ratio_min") or 0.4), "exit_ob_ma_window": int(float(d.get("exit_ob_ma_window") or 5)), "exit_ob_min_profit_pct": _momentum_ratio_to_ui_pct( d.get("exit_ob_min_profit_pct"), 0.5, ), "exit_ob_min_hold_bars": int(float(d.get("exit_ob_min_hold_bars") or 3)), "stop_ob_enabled": bool(d.get("stop_ob_enabled", False)), "stop_ob_ratio_min": float(d.get("stop_ob_ratio_min") or 0.4), "stop_ob_ma_window": int(float(d.get("stop_ob_ma_window") or 5)), "stop_ob_min_loss_pct": _momentum_ratio_to_ui_pct( d.get("stop_ob_min_loss_pct"), 0.3, ), "stop_ob_min_hold_bars": int(float(d.get("stop_ob_min_hold_bars") or 2)), } 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 _us_momentum_ui_defaults_from_db(snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """US_MOMENTUM_* (env_config) → 해외 모멘텀 탭 초기값. 국내 MOMENTUM_* 와 분리.""" if snap is None: try: db = _db() try: env: Dict[str, Any] = dict(db.get_merged_env_snapshot() or {}) finally: db.close() except Exception: env = {} else: env = dict(snap or {}) 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: 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_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) cd_sec = picki(("US_MOMENTUM_COOLDOWN_SEC",), 300) min_margin = pickf(("US_MOMENTUM_MIN_PROFIT_PCT",), 0.2) return { "mom_rsi_min": pickf(("US_MOMENTUM_RSI_MIN",), 50.0), "mom_rsi_max": pickf(("US_MOMENTUM_RSI_MAX",), 90.0), "mom_vol_mult": pickf(("US_MOMENTUM_VOL_MULT",), 1.5), "mom_vol_win": picki(("US_MOMENTUM_VOL_WIN",), 5), "time_start": picki(("US_MOMENTUM_TIME_START",), 2230), "mom_time_start_hm": picki(("US_MOMENTUM_TIME_START",), 2230), "time_end": picki(("US_MOMENTUM_TIME_END",), 500), "mom_time_end_hm": picki(("US_MOMENTUM_TIME_END",), 500), "sell_time_end": picki(("US_MOMENTUM_SELL_TIME_END",), 630), "sl_pct": pick_sl_tp_pct(("US_MOMENTUM_STOP_LOSS_PCT",), 1.5), "tp_pct": pick_sl_tp_pct(("US_MOMENTUM_TAKE_PROFIT_PCT",), 2.5), "tp_max_pct": pick_sl_tp_pct(("US_MOMENTUM_TP_MAX_PCT",), 2.0), "shoulder_min_high": pick_sl_tp_pct(("US_MOMENTUM_SHOULDER_MIN_HIGH_PCT",), 0.5), "shoulder_cut_pct": pick_sl_tp_pct(("US_MOMENTUM_SHOULDER_CUT_PCT",), 0.3), "trail_pct": pick_sl_tp_pct(("US_MOMENTUM_TRAIL_PCT",), 0.0), "trail_arm_pct": pick_sl_tp_pct(("US_MOMENTUM_TRAIL_ARM_PCT",), 0.0), "max_hold_bars": picki(("US_MOMENTUM_MAX_HOLD_BARS",), 0), "ratchet_tiers": str(env.get("US_MOMENTUM_RATCHET_TIERS") or "").strip(), "cooldown_min": max(0, int(cd_sec // 60)), "max_daily": picki(("US_MOMENTUM_MAX_DAILY",), 5), "slot_money": picki(("US_MOMENTUM_SLOT_MONEY", "US_MOMENTUM_MAX_BUY_AMOUNT"), 200000), "slots": picki(("US_MOMENTUM_MAX_STOCKS",), 4), "mom_slots": picki(("US_MOMENTUM_MAX_STOCKS",), 4), "total_budget_krw": picki(("US_MOMENTUM_TOTAL_BUDGET",), 800000), "high_chase_thr": pickf(("US_MOMENTUM_HIGH_CHASE_THR",), 0.96), "max_daily_chg": pickf(("US_MOMENTUM_MAX_DAILY_CHG",), 20.0), "min_price": pickf(("US_MOMENTUM_MIN_PRICE",), 1.0), "max_loss_krw": picki(("US_MOMENTUM_MAX_LOSS_PER_TRADE",), 0), "min_margin": min_margin, "mom_max_from_open_pct": pickf(("US_MOMENTUM_MAX_FROM_OPEN_PCT",), 999.0), "mom_min_from_open_pct": pickf(("US_MOMENTUM_MIN_FROM_OPEN_PCT",), -999.0), "use_defense_filters": pick_bool(("US_MOMENTUM_USE_DEFENSE_FILTERS",), True), "use_high_chase_filter": pick_bool(("US_MOMENTUM_USE_HIGH_CHASE_FILTER",), False), "use_daily_range_filter": pick_bool(("US_MOMENTUM_USE_DAILY_RANGE_FILTER",), False), "use_ema_filter": pick_bool(("US_MOMENTUM_USE_EMA_FILTER",), True), "use_rsi_max_filter": pick_bool(("US_MOMENTUM_USE_RSI_MAX_FILTER",), False), "pattern_breakout": pick_bool(("US_MOMENTUM_PATTERN_BREAKOUT",), True), "pattern_pullback": pick_bool(("US_MOMENTUM_PATTERN_PULLBACK",), True), "chase_lookback_min": picki(("US_MOMENTUM_CHASE_LOOKBACK_MIN",), 10), "pullback_lookback_min": picki(("US_MOMENTUM_PULLBACK_LOOKBACK_MIN",), 15), "pullback_min_pct": pickf(("US_MOMENTUM_PULLBACK_MIN_PCT",), 0.3), "pullback_max_pct": pickf(("US_MOMENTUM_PULLBACK_MAX_PCT",), 3.0), "setup_vol_max_mult": pickf(("US_MOMENTUM_SETUP_VOL_MAX_MULT",), 0.8), "setup_bear_bars_min": picki(("US_MOMENTUM_SETUP_BEAR_BARS_MIN",), 1), "ema_fast_period": picki(("US_MOMENTUM_EMA_FAST_PERIOD",), 9), "ema_slow_period": picki(("US_MOMENTUM_EMA_SLOW_PERIOD",), 21), "eod_enabled": pick_bool(("US_MOMENTUM_EOD_ENABLED",), False), "eod_hm": str(env.get("US_MOMENTUM_EOD_HM") or "05:00").strip() or "05:00", "daily_profit_enabled": pick_bool(("US_MOMENTUM_DAILY_PROFIT_TARGET_ENABLED",), False), "daily_trail_tiers": str(env.get("US_MOMENTUM_DAILY_PROFIT_TRAIL_TIERS") or "").strip(), "daily_profit_mode": ( str(env.get("US_MOMENTUM_DAILY_PROFIT_MODE") or "trailing").strip().lower() or "trailing" ), } 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, "rsi_period": int(float(env.get("SCALP_RSI_PERIOD") or _d.get("rsi_period", 3))), "vol_mult": float(_d.get("vol_mult", 0)), "time_start_hm": int(float(env.get("SCALP_TIME_START") or env.get("TIME_START") or _d.get("time_start_hm", 900))), "time_end_hm": int(float(env.get("SCALP_TIME_END") or env.get("TIME_END") or _d.get("time_end_hm", 1530))), "max_daily": int(float(env.get("SCALP_MAX_DAILY") or _d.get("max_daily", 3))), "max_stocks": int(float(env.get("SCALP_MAX_STOCKS") or env.get("MAX_STOCKS") or _d.get("max_stocks", 3))), "total_budget_krw": float(env.get("SCALP_TOTAL_BUDGET_KRW") or _d.get("total_budget_krw", 0) or 0), "skip_hts_scan_dupes": se.resolve_scalp_skip_hts_scan_dupes(env), "require_reversal_candle": bool(_d.get("require_reversal_candle", True)), "eod_enabled": ( str(env.get("SCALP_EOD_ENABLED") or "1").strip().lower() in ("1", "true", "t", "y", "yes", "on") if env.get("SCALP_EOD_ENABLED") not in (None, "", "None") else bool(_d.get("eod_enabled", True)) ), "eod_hm": ( str(env.get("SCALP_EOD_HM") or "15:25").strip() if env.get("SCALP_EOD_HM") not in (None, "", "None") else str(_d.get("eod_hm") or "15:25") ), # 호가·휩쏘 — 백테 탭 읽기전용 표시 (Optuna 적용값 확인). 폼에서 수정 불가. **_ob_whip_ui_fields_from_env(env, "SCALP"), } def _ob_whip_ui_fields_from_env(env: Dict[str, Any], prefix: str) -> Dict[str, Any]: """전략 prefix(SCALP/MOMENTUM/…) 호가·휩쏘 → 백테 탭 읽기전용 UI 필드. Optuna 적용값이 DB에 들어갔는지 눈으로 확인용. 폼에서 수정하지 않는다. """ p = str(prefix or "").strip().upper() def _truthy(key: str, default: bool = False) -> bool: v = env.get(key) if v in (None, "", "None"): return default return str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on") def _f(key: str, default: float) -> float: v = env.get(key) if v in (None, "", "None"): return float(default) try: return float(v) except (ValueError, TypeError): return float(default) def _i(key: str, default: int) -> int: v = env.get(key) if v in (None, "", "None"): return int(default) try: return int(float(v)) except (ValueError, TypeError): return int(default) # ask 배수: ENTRY_ASK_MAX_MULT (실키) 우선 ask_mult = _f(f"{p}_ORDERBOOK_ENTRY_ASK_MAX_MULT", -1.0) if ask_mult < 0: ask_mult = _f(f"{p}_ORDERBOOK_ASK_MAX_MULT", 50.0) return { "ob_filter_enabled": _truthy(f"{p}_ORDERBOOK_FILTER_ENABLED", False), "max_spread_pct": _f(f"{p}_ORDERBOOK_MAX_SPREAD_PCT", 0.45), "ob_min_bid_ask_ratio": _f(f"{p}_ORDERBOOK_MIN_BID_ASK_RATIO", 1.0), "ob_ask_max_mult": ask_mult, "whipsaw_filter_enabled": _truthy(f"{p}_WHIPSAW_FILTER_ENABLED", False), "whipsaw_subbar_sec": _i(f"{p}_WHIPSAW_SUBBAR_SEC", 30), "whipsaw_lookback_sec": _i(f"{p}_WHIPSAW_LOOKBACK_SEC", 60), "whipsaw_dip_pct": _f(f"{p}_WHIPSAW_DIP_PCT", 0.005), } 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))), "skip_hts_scan_dupes": resolve_breakout_skip_hts_scan_dupes(env), "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",), 0.45, float, ), # 잔량·ask·휩쏘 — 읽기전용 UI (Optuna 적용 확인) **{k: v for k, v in _ob_whip_ui_fields_from_env(env, "BREAKOUT").items() if k != "max_spread_pct" and k != "ob_filter_enabled"}, "exit_ob_enabled": ( str(env.get("BREAKOUT_EXIT_OB_ENABLED") or "").strip().lower() in ("1", "true", "t", "y", "yes", "on") ), "exit_ob_ratio_min": pick(("BREAKOUT_EXIT_OB_RATIO_MIN",), 0.4, float), "exit_ob_ma_window": pick(("BREAKOUT_EXIT_OB_MA_WINDOW",), 5, lambda v: int(float(v))), "exit_ob_min_profit_pct": pct_ui(pick(("BREAKOUT_EXIT_OB_MIN_PROFIT_PCT",), 0.005, float)), "exit_ob_min_hold_bars": pick(("BREAKOUT_EXIT_OB_MIN_HOLD_BARS",), 3, lambda v: int(float(v))), "stop_ob_enabled": ( str(env.get("BREAKOUT_STOP_OB_ENABLED") or "").strip().lower() in ("1", "true", "t", "y", "yes", "on") ), "stop_ob_ratio_min": pick(("BREAKOUT_STOP_OB_RATIO_MIN",), 0.4, float), "stop_ob_ma_window": pick(("BREAKOUT_STOP_OB_MA_WINDOW",), 5, lambda v: int(float(v))), "stop_ob_min_loss_pct": pct_ui(pick(("BREAKOUT_STOP_OB_MIN_LOSS_PCT",), 0.003, float)), "stop_ob_min_hold_bars": pick(("BREAKOUT_STOP_OB_MIN_HOLD_BARS",), 2, lambda v: int(float(v))), } 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 _backtest_env_timeline_from_request(req: Any = None) -> bool: """웹 백테 env 타임라인 — 기본 OFF. ``env_timeline=1`` (또는 true/on) 일 때만 ON. ON: 봉 시각별 과거 env_config 스냅샷으로 파람 덮어씀(실매 재현). OFF: 폼/요청 파람을 구간 전체 고정(파람 평가·Optuna 정합). """ if req is None: req = request raw = None try: raw = req.args.get("env_timeline") except Exception: raw = None if raw is None or str(raw).strip() == "": try: body = req.get_json(silent=True) or {} if isinstance(body, dict): raw = body.get("env_timeline") except Exception: raw = None if raw is None or str(raw).strip() == "": return False return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") def _eod_params_from_request( req: Any, defaults: Dict[str, Any], *, default_enabled: bool = True, default_hm: str = "15:20", ) -> 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, *, prefix: str = "SHORT", default_enabled: Optional[bool] = None, ) -> Dict[str, Any]: """백테 탭 '당일 누적손익 트레일 익절' → 시뮬 파라미터. - ``daily_profit_enabled`` = 마스터 스위치 (실매 ``{prefix}_DAILY_PROFIT_TARGET_ENABLED``). - 다단/단일 숫자는 규칙일 뿐 — 스위치 OFF면 게이트 OFF (숫자만으로 자동 ON 하지 않음). - 쿼리에 enabled 없으면 ``default_enabled``(보통 DB) 사용. """ def _truthy(raw: Any, default: bool = False) -> bool: if raw is None or raw == "": return default return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") raw_en = req.args.get("daily_profit_enabled") if raw_en is None and default_enabled is None: # DB 실매값 추종 from kis_trader.utils.env import get_env_from_db sid = str(prefix or "SHORT").strip().upper() if sid == "TAIL": sid = "SHORT" raw_db = get_env_from_db(f"{sid}_DAILY_PROFIT_TARGET_ENABLED", "false") enabled = _truthy(raw_db, False) elif raw_en is None: enabled = bool(default_enabled) else: enabled = _truthy(raw_en, False) mode = str(req.args.get("daily_profit_mode") or "trailing").strip().lower() or "trailing" tiers = str(req.args.get("daily_trail_tiers") or "").strip() try: drop = float(req.args.get("daily_trail_drop_pct") or 0) except (TypeError, ValueError): drop = 0.0 try: arm_krw = float(req.args.get("daily_trail_arm_krw") or 0) except (TypeError, ValueError): arm_krw = 0.0 out: Dict[str, Any] = { "daily_profit_enabled": enabled, "_daily_profit_strategy_id": str(prefix or "SHORT").upper(), "_backtest_daily_profit_trail": bool(enabled), "daily_profit_mode": mode, } # B: 리스크버짓 — 쿼리 우선, 없으면 DB 전략키 (기본 false) raw_rb = req.args.get("daily_risk_budget_enabled") if raw_rb is None or raw_rb == "": try: from kis_trader.utils.env import get_env_bool sid = str(prefix or "SHORT").strip().upper() if sid == "TAIL": sid = "SHORT" rb_on = bool(get_env_bool(f"{sid}_DAILY_PROFIT_RISK_BUDGET_ENABLED", False)) except Exception: rb_on = False else: rb_on = _truthy(raw_rb, False) out["daily_risk_budget_enabled"] = rb_on out["_backtest_daily_profit_risk_budget"] = bool(rb_on) and bool(enabled) if not enabled: return out if tiers and tiers.lower() != "off": out["daily_trail_tiers"] = tiers elif drop > 0: out["daily_trail_drop_pct"] = drop out["daily_trail_arm_krw"] = arm_krw return out def _daily_trail_save_patch(body: Dict[str, Any], prefix: str) -> Dict[str, str]: """ 당일 누적손익 다단 트레일 — 봇저장 패치. ``daily_profit_enabled`` → ``{prefix}_DAILY_PROFIT_TARGET_ENABLED`` (마스터). ``daily_risk_budget_enabled`` → ``{prefix}_DAILY_PROFIT_RISK_BUDGET_ENABLED`` (B, 기본 OFF). 다단 문자열은 규칙만 저장 — 값 있다고 ENABLED를 강제 true 하지 않음. """ out: Dict[str, str] = {} if "daily_profit_enabled" in body: en = body.get("daily_profit_enabled") on = ( en is True or str(en).strip().lower() in ("1", "true", "t", "y", "yes", "on") ) out[f"{prefix}_DAILY_PROFIT_TARGET_ENABLED"] = "true" if on else "false" if "daily_risk_budget_enabled" in body: en = body.get("daily_risk_budget_enabled") on = ( en is True or str(en).strip().lower() in ("1", "true", "t", "y", "yes", "on") ) out[f"{prefix}_DAILY_PROFIT_RISK_BUDGET_ENABLED"] = "true" if on else "false" if "daily_trail_tiers" in body: 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" ) else: out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = "" elif "daily_profit_mode" in body: out[f"{prefix}_DAILY_PROFIT_MODE"] = ( str(body.get("daily_profit_mode") or "trailing").strip().lower() or "trailing" ) return out def _strategy_trigger_filter_enabled( env: Dict[str, Any], *, prefix: str, kind: str, global_key: str, ) -> bool: """전략별 TRIGGER 필터 ON/OFF. ORDERBOOK=전략키만(없으면 OFF). PROGRAM=전략→글로벌.""" 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") if kind == "ORDERBOOK": return False # 글로벌 ORDERBOOK_FILTER_ENABLED 폐기 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(): _apply_backtest_source_env_from_request(request) # 기본값 = 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"])) # 쿼리 생략 시 DB(엔진 단일 소스) — 하드코딩 25/75 금지 (BG잡·CLI가 폼 없이 호출) rsi_oversold = float(request.args.get("rsi_oversold", _def["rsi_oversold"])) rsi_overbought = float(request.args.get("rsi_overbought", _def["rsi_overbought"])) mode = (request.args.get("mode") or "reversal").strip().lower() if mode not in ("reversal", "momentum"): mode = "reversal" # 해외 US 는 /api/backtest/us_momentum — 이 핸들러에 market=US if 넣지 말 것 if (request.args.get("market") or "").strip().upper() == "US": return jsonify({ "error": "해외 모멘텀은 /api/backtest/us_momentum 을 사용하세요", }), 400 _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: # _def["sl_pct"] 는 이미 비율(0.034=3.4%) — UI % 기본값 1.5 하드코딩 금지 sl_pct = float(_def["sl_pct"]) _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(_def["tp_pct"]) # UI·DB에서 손절을 음수 퍼센트로 줄 때(예: -1.2) 엔진 sl_pct 가 음수로 들어가 # stop 가격이 진입가 위로 뒤집히는 문제 방지 (scalping_engine 도 abs 처리함). sl_pct = abs(sl_pct) tp_pct = abs(tp_pct) _drop_req = request.args.get("drop_rate") if _drop_req not in (None, ""): drop_rate = float(_drop_req) / 100 else: drop_rate = float(_def["drop_rate"]) _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") # EOD — 실매 SCALP_EOD_* (기본 15:25). force_eod_exit 는 eod_enabled 미지정 시에만 레거시 _eod_src = _mom_def if _mom_def is not None else _def eod_patch = _eod_params_from_request( request, _eod_src, default_enabled=True, default_hm="15:25", ) if request.args.get("eod_enabled") in (None, ""): _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", ) # ── 모드 분기: reversal vs momentum (모멘텀 탭은 /api/backtest/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"]))) _e_min = request.args.get("e_min_chg_pct") e_min_chg_pct = float(_e_min) if _e_min not in (None, "") else float( _mom_def.get("e_min_chg_pct", 0.2), ) _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))) _e_min = request.args.get("e_min_chg_pct") e_min_chg_pct = float(_e_min) if _e_min not in (None, "") else 0.2 _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" # 국내 핸들러 — history_source=ls 이면 ls_ws_candles (모멘텀 모드는 키움 유지) _univ_hs = _parse_universe_history_source_arg(request) from kis_trader.backtest.scalping_backtest_common import load_scalp_candles_by_code _candle_hs = "kiwoom" if str(mode).strip().lower() == "momentum" else _univ_hs codes_candles, _ = load_scalp_candles_by_code( db, start_key, end_key, rsi_period=rsi_period, history_source=_candle_hs, ) # LS/키움 공통 — 로더는 codes 리스트를 반환하지 않음 (NameError 재발 방지) codes = _codes_from_candles_map(codes_candles) # 방어로직: 쿼리 인자로 넘어오면 우선 사용 (웹 입력란), 없으면 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)), "skip_hts_scan_dupes": _tail_bool_arg( request, "skip_hts_scan_dupes", _def.get( "skip_hts_scan_dupes", te.resolve_tail_skip_hts_scan_dupes() if _TAIL_ENGINE_AVAILABLE else False, ), ), "require_reversal_candle": _tail_bool_arg( request, "require_reversal_candle", _def.get("require_reversal_candle", True), ), # 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, "e_min_chg_pct": e_min_chg_pct, "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), ) params["backtest_skip_pre_subscribe"] = _tail_bool_arg( request, "backtest_skip_pre_subscribe", (_mom_def or {}).get("backtest_skip_pre_subscribe", False), ) _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 "") ) # L3 호가 보조컷 (진입 호가필터와 별개 · 기본 OFF) _eob = request.args.get("exit_ob_enabled") params["exit_ob_enabled"] = ( str(_eob).strip().lower() in ("1", "true", "t", "y", "yes", "on") if _eob not in (None, "") else bool(_mom_eng.get("exit_ob_enabled", False)) ) _eob_r = request.args.get("exit_ob_ratio_min") params["exit_ob_ratio_min"] = ( float(_eob_r) if _eob_r not in (None, "") else float(_mom_eng.get("exit_ob_ratio_min") or 0.4) ) _eob_w = request.args.get("exit_ob_ma_window") params["exit_ob_ma_window"] = ( int(float(_eob_w)) if _eob_w not in (None, "") else int(_mom_eng.get("exit_ob_ma_window") or 5) ) _eob_p = request.args.get("exit_ob_min_profit_pct") params["exit_ob_min_profit_pct"] = ( abs(float(_eob_p)) / 100.0 if _eob_p not in (None, "") else float(_mom_eng.get("exit_ob_min_profit_pct") or 0.005) ) _eob_h = request.args.get("exit_ob_min_hold_bars") params["exit_ob_min_hold_bars"] = ( int(float(_eob_h)) if _eob_h not in (None, "") else int(_mom_eng.get("exit_ob_min_hold_bars") or 3) ) # 손절호가 (수익구간과 별개 · 기본 OFF) _sob = request.args.get("stop_ob_enabled") params["stop_ob_enabled"] = ( str(_sob).strip().lower() in ("1", "true", "t", "y", "yes", "on") if _sob not in (None, "") else bool(_mom_eng.get("stop_ob_enabled", False)) ) _sob_r = request.args.get("stop_ob_ratio_min") params["stop_ob_ratio_min"] = ( float(_sob_r) if _sob_r not in (None, "") else float(_mom_eng.get("stop_ob_ratio_min") or 0.4) ) _sob_w = request.args.get("stop_ob_ma_window") params["stop_ob_ma_window"] = ( int(float(_sob_w)) if _sob_w not in (None, "") else int(_mom_eng.get("stop_ob_ma_window") or 5) ) _sob_p = request.args.get("stop_ob_min_loss_pct") params["stop_ob_min_loss_pct"] = ( abs(float(_sob_p)) / 100.0 if _sob_p not in (None, "") else float(_mom_eng.get("stop_ob_min_loss_pct") or 0.003) ) _sob_h = request.args.get("stop_ob_min_hold_bars") params["stop_ob_min_hold_bars"] = ( int(float(_sob_h)) if _sob_h not in (None, "") else int(_mom_eng.get("stop_ob_min_hold_bars") or 2) ) _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", False)) ) _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) use_saved_history, universe_mode, sim_kind_univ = _parse_backtest_universe_arg( request, default="history", 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, history_source=_univ_hs, ) ) params["scan_interval_min"] = _scan_iv # scan_at 타임라인 ↔ 슬롯 dict 동일 이력소스 (키움/LS) params["_universe_history_source"] = _univ_hs 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, "backtest_env_timeline": _backtest_env_timeline_from_request(request), } 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, ) # 당일 누적손익 트레일 익절 시뮬 — 마스터 스위치(daily_profit_enabled) 기준. # trades 에 pnl·buy_time·sell_time 부착 완료 후 신규진입 차단. (모멘텀 포함) _trail_pfx = "MOMENTUM" if str(mode or "").strip().lower() == "momentum" else "SCALP" _trail_p = _daily_trail_params_from_request(request, prefix=_trail_pfx) if _trail_p.get("_backtest_daily_profit_trail"): 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())] # 가상거래에도 종목명·누적손익 표시 (실거래와 동일) _enrich_momentum_trades_debug( all_virtual_trades, total_budget_krw=total_budget_v, with_tick_debug=(mode == "momentum"), ) trades_out = _finalize_trades_ui( db, all_virtual_trades, 200, strategy_hint=str(mode or "SCALP").upper(), ) _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, # 엔진에 실제 적용된 값 — 배너가 누락 시 falsy→방어:OFF 로 오표기되던 버그 수정 "use_defense_filters": bool(use_defense_filters), "use_macd_cross": bool(use_macd_cross), "skip_hts_scan_dupes": bool(params.get("skip_hts_scan_dupes")), "require_reversal_candle": bool(params.get("require_reversal_candle", True)), "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, "e_min_chg_pct": e_min_chg_pct if mode == "momentum" else None, "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, "backtest_skip_pre_subscribe": ( params.get("backtest_skip_pre_subscribe") 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), "peak_cum_at": (peak_cum_at[:19] if peak_cum_at else ""), "tick_backtest": bt_meta.get("tick_backtest"), "backtest_buy_source": bt_meta.get("backtest_buy_source"), "skip_stats": bt_meta.get("skip_stats") or ( (bt_meta.get("engine_params") or {}).get("_portfolio_skip_stats") ), }, "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 20), "symbol_daily_loss_limit_krw": float(d.get("symbol_daily_loss_limit_krw") or 30000), "symbol_daily_loss_limit_pct": float(d.get("symbol_daily_loss_limit_pct") or 1.5), "reentry_min_edge_krw": float(d.get("reentry_min_edge_krw") or 0), "reentry_require_nonneg": bool(d.get("reentry_require_nonneg", False)), "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", te.resolve_tail_skip_hts_scan_dupes(snap) if _TAIL_ENGINE_AVAILABLE else False, ), "cand_limit": int(d.get("cand_limit") or 0), "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", True), "backtest_use_tick_exit": d.get("backtest_use_tick_exit", True), "backtest_tick_fallback_ohlc": d.get("backtest_tick_fallback_ohlc", False), "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 d.get("max_spread_pct") or 0.45 ), # 잔량·ask·휩쏘 — 읽기전용 UI **{k: v for k, v in _ob_whip_ui_fields_from_env(snap or {}, "TAIL").items() if k != "max_spread_pct" and k != "ob_filter_enabled"}, # 당일 누적손익 다단 트레일(SHORT 일일익절) 현재값 + 사용자 저장 프리셋 목록(세미콜론 구분) "daily_profit_enabled": ( str(snap.get("SHORT_DAILY_PROFIT_TARGET_ENABLED") or "false").strip().lower() in ("1", "true", "t", "y", "yes", "on") ), "daily_risk_budget_enabled": ( str(snap.get("SHORT_DAILY_PROFIT_RISK_BUDGET_ENABLED") or "false").strip().lower() in ("1", "true", "t", "y", "yes", "on") ), "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", "daily_trail_arm_krw": int(float(snap.get("SHORT_DAILY_PROFIT_TRAIL_ARM_KRW") or 0) or 0), "daily_trail_drop_pct": float(snap.get("SHORT_DAILY_PROFIT_TRAIL_DROP_PCT") or 0) or 0.0, "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:20").strip() or "15:20", } 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 _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/us_momentum", methods=["GET"]) def api_backtest_us_momentum(): """ 해외 모멘텀 전용 백테. ※ 국내 /api/backtest/momentum · api_backtest_scalping 에 market=US 분기 넣지 말 것. """ from kis_trader.backtest.us_momentum_web_backtest import run_us_momentum_web_backtest args = request.args.to_dict(flat=True) start = (args.get("start") or "").strip() end = (args.get("end") or "").strip() if not start or not end: return jsonify({"error": "start/end 필요"}), 400 db = _db() try: ui_def = _us_momentum_ui_defaults_from_db() out = run_us_momentum_web_backtest( args, db=db, ui_def=ui_def, enrich_trades_fn=_enrich_momentum_trades_debug, enrich_names_fn=_enrich_trades_with_names, trades_recent_fn=_trades_recent_first, period_days_fn=_backtest_period_days, ) return jsonify(out) except Exception as e: logger.exception("us_momentum backtest failed: %s", e) return jsonify({"error": str(e)}), 500 finally: try: db.close() except Exception: pass @app.route("/api/backtest/momentum", methods=["GET"]) def api_backtest_momentum(): """모멘텀 전용 백테스트 — momentum_engine + momentum_backtest_common (SCALP reversal 분리).""" # 해외는 전용 엔드포인트 — 국내 핸들러에 market=US if 금지 if (request.args.get("market") or "").strip().upper() == "US": return jsonify({ "error": "해외 모멘텀은 /api/backtest/us_momentum 을 사용하세요 (국내 API 분기 금지)", }), 400 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/us_momentum/save_config", methods=["POST"]) def api_backtest_us_momentum_save_config(): """해외 모멘텀 탭 → US_MOMENTUM_* 만 (국내 MOMENTUM_/SCALP_ 미오염).""" body = request.get_json(force=True, silent=True) or {} try: patch = _us_momentum_tab_save_patch(body) if not patch: return jsonify({"error": "저장할 필드 없음"}), 400 # 안전: US_ 접두만 bad = [k for k in patch if not str(k).startswith("US_MOMENTUM_")] if bad: return jsonify({"error": f"비-US 키 차단: {bad[:5]}"}), 400 db = _db() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} _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 저장 실패"}), 500 from config_schema import classify_config_key saved_by_table: Dict[str, List[str]] = {} for k in patch: saved_by_table.setdefault(classify_config_key(k), []).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/us_momentum/stocks", methods=["GET"]) def api_us_momentum_stocks(): """영구구독 US + us_momentum_stock_config 행 목록.""" try: db = _db() try: from kis_trader.strategies.us_momentum_stock_cfg import ( ensure_us_momentum_stock_config_table, list_us_momentum_stock_rows, seed_us_momentum_stock_from_permanent, row_to_ui_dict, ) from permanent_subs import codes_by_market ensure_us_momentum_stock_config_table(db) if request.args.get("seed") in ("1", "true", "yes"): seed_us_momentum_stock_from_permanent(db) cfg_rows = {str(r.get("code") or "").upper(): row_to_ui_dict(r) for r in list_us_momentum_stock_rows(db)} perm = codes_by_market(db, "US", enabled_only=False) or [] out = [] seen = set() for p in perm: code = str(p.get("code") or p.get("symbol") or "").upper() if not code or code in seen: continue seen.add(code) cfg = cfg_rows.get(code) or {} out.append({ "code": code, "exchange": cfg.get("exchange") or p.get("exchange") or "NASD", "symbol": cfg.get("symbol") or p.get("symbol") or code, "name": cfg.get("name") or p.get("note") or code, "enabled": bool(p.get("enabled", True)), "stock_group": cfg.get("stock_group") or "STOCK", "has_stock_cfg": code in cfg_rows, "cfg": cfg if code in cfg_rows else None, }) for code, cfg in cfg_rows.items(): if code in seen: continue out.append({ "code": code, "exchange": cfg.get("exchange") or "NASD", "symbol": cfg.get("symbol") or code, "name": cfg.get("name") or code, "enabled": True, "stock_group": cfg.get("stock_group") or "STOCK", "has_stock_cfg": True, "cfg": cfg, }) return jsonify({"stocks": out, "count": len(out)}) finally: db.close() except Exception as e: logger.error("api_us_momentum_stocks: %s", e) return jsonify({"error": str(e)}), 500 @app.route("/api/us_momentum/stock_cfg", methods=["GET", "POST", "DELETE"]) def api_us_momentum_stock_cfg(): """종목별 TRIGGER/청산 핀 — GET ?code= / POST body / DELETE ?code=.""" try: from kis_trader.strategies.us_momentum_stock_cfg import ( delete_us_momentum_stock_config, get_us_momentum_stock_row, row_to_ui_dict, upsert_us_momentum_stock_config, ) db = _db() try: if request.method == "GET": code = (request.args.get("code") or "").strip().upper() if not code: return jsonify({"error": "code 필수"}), 400 row = get_us_momentum_stock_row(db, code) return jsonify({ "code": code, "has_stock_cfg": bool(row), "cfg": row_to_ui_dict(row) if row else None, }) if request.method == "DELETE": code = (request.args.get("code") or "").strip().upper() if not code: body = request.get_json(force=True, silent=True) or {} code = str(body.get("code") or "").strip().upper() if not code: return jsonify({"error": "code 필수"}), 400 ok = delete_us_momentum_stock_config(db, code) return jsonify({"ok": ok, "code": code}) body = request.get_json(force=True, silent=True) or {} code = str(body.get("code") or "").strip().upper() if not code: return jsonify({"error": "code 필수"}), 400 fields = {} for k in ( "sl_pct", "tp_pct", "tp_max_pct", "shoulder_min_high_pct", "shoulder_cut_pct", "trail_pct", "trail_arm_pct", "ratchet_tiers", "max_hold_bars", "cooldown_sec", "max_daily", "slot_money", "rsi_min", "rsi_max", "vol_mult", "vol_win", "chase_lookback_min", "pullback_lookback_min", "pullback_min_pct", "pullback_max_pct", "setup_vol_max_mult", "setup_bear_bars_min", "high_chase_thr", "max_daily_chg", "min_price", "ema_fast_period", "ema_slow_period", "use_defense_filters", "use_high_chase_filter", "use_daily_range_filter", "use_ema_filter", "use_rsi_max_filter", "pattern_breakout", "pattern_pullback", ): if k not in body: continue v = body.get(k) if v is None or v == "": fields[k] = None elif k.startswith("use_") or k.startswith("pattern_"): fields[k] = 1 if str(v).lower() in ("1", "true", "yes", "on") else 0 elif k == "ratchet_tiers": fields[k] = str(v).strip() else: try: fields[k] = float(v) if "." in str(v) or k.endswith("_pct") or k in ( "slot_money", "vol_mult", "rsi_min", "rsi_max", "high_chase_thr", "max_daily_chg", "min_price", "pullback_min_pct", "pullback_max_pct", "setup_vol_max_mult", ) else int(float(v)) except (TypeError, ValueError): fields[k] = v ok = upsert_us_momentum_stock_config( db, code, exchange=str(body.get("exchange") or "NASD"), symbol=str(body.get("symbol") or code), name=str(body.get("name") or code), stock_group=str(body.get("stock_group") or "STOCK"), fields=fields, ) row = get_us_momentum_stock_row(db, code) if ok else None return jsonify({ "ok": ok, "code": code, "cfg": row_to_ui_dict(row) if row else None, }) finally: db.close() except Exception as e: logger.error("api_us_momentum_stock_cfg: %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("e_min_chg_pct") if x is not None: patch["MOMENTUM_E_MIN_CHG_PCT"] = str(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) x = gv("tp_pct") if x is not None: patch["MOMENTUM_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0) x = gv("tp_max_pct") if x is not None: patch["MOMENTUM_TP_MAX_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_min_high") if x is not None: patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_cut_pct") if x is not None: patch["MOMENTUM_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0) x = gv("cooldown_min") if x is not None: patch["MOMENTUM_COOLDOWN_SEC"] = str(int(float(x) * 60)) x = gv("max_daily") if x is not None: patch["MOMENTUM_MAX_DAILY"] = str(int(float(x))) 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 patch["MOMENTUM_HIGH_CHASE_THR"] = str(ratio) x = gv("max_daily_chg") if x is not None: patch["MOMENTUM_MAX_DAILY_CHG"] = str(float(x)) x = gv("min_price") if x is not None: patch["MOMENTUM_MIN_PRICE"] = str(float(x)) x = gv("max_loss_krw") if x is not None: patch["MOMENTUM_MAX_LOSS_PER_TRADE_KRW"] = str(int(float(x))) x = gv("min_margin") if x is not None: patch["MOMENTUM_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")) 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")) if "backtest_skip_pre_subscribe" in body: patch["MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE"] = _env_bool_10( body.get("backtest_skip_pre_subscribe"), ) 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 "exit_ob_enabled" in body: patch["MOMENTUM_EXIT_OB_ENABLED"] = _env_bool_10(body.get("exit_ob_enabled")) x = gv("exit_ob_ratio_min") if x is not None: patch["MOMENTUM_EXIT_OB_RATIO_MIN"] = str(float(x)) x = gv("exit_ob_ma_window") if x is not None: patch["MOMENTUM_EXIT_OB_MA_WINDOW"] = str(int(float(x))) x = gv("exit_ob_min_profit_pct") if x is not None: # UI % → 엔진 분율 patch["MOMENTUM_EXIT_OB_MIN_PROFIT_PCT"] = str(abs(float(x)) / 100.0) x = gv("exit_ob_min_hold_bars") if x is not None: patch["MOMENTUM_EXIT_OB_MIN_HOLD_BARS"] = str(int(float(x))) if "stop_ob_enabled" in body: patch["MOMENTUM_STOP_OB_ENABLED"] = _env_bool_10(body.get("stop_ob_enabled")) x = gv("stop_ob_ratio_min") if x is not None: patch["MOMENTUM_STOP_OB_RATIO_MIN"] = str(float(x)) x = gv("stop_ob_ma_window") if x is not None: patch["MOMENTUM_STOP_OB_MA_WINDOW"] = str(int(float(x))) x = gv("stop_ob_min_loss_pct") if x is not None: patch["MOMENTUM_STOP_OB_MIN_LOSS_PCT"] = str(abs(float(x)) / 100.0) x = gv("stop_ob_min_hold_bars") if x is not None: patch["MOMENTUM_STOP_OB_MIN_HOLD_BARS"] = str(int(float(x))) 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 _us_momentum_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]: """해외 모멘텀 탭 → US_MOMENTUM_* 만 (국내 MOMENTUM_/SCALP_ 이중저장 금지).""" 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["US_MOMENTUM_RSI_MIN"] = str(float(x)) x = gv("mom_rsi_max") if x is not None: patch["US_MOMENTUM_RSI_MAX"] = str(float(x)) x = gv("mom_vol_mult") if x is not None: patch["US_MOMENTUM_VOL_MULT"] = str(float(x)) x = gv("mom_vol_win") if x is not None: patch["US_MOMENTUM_VOL_WIN"] = str(int(float(x))) x = gv("time_start") if x is not None: patch["US_MOMENTUM_TIME_START"] = str(int(float(x))) x = gv("time_end") if x is not None: patch["US_MOMENTUM_TIME_END"] = str(int(float(x))) x = gv("sell_time_end") if x is not None: patch["US_MOMENTUM_SELL_TIME_END"] = str(int(float(x))) x = gv("sl_pct") if x is not None: patch["US_MOMENTUM_STOP_LOSS_PCT"] = str(abs(float(x)) / 100.0) x = gv("tp_pct") if x is not None: patch["US_MOMENTUM_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0) x = gv("tp_max_pct") if x is not None: patch["US_MOMENTUM_TP_MAX_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_min_high") if x is not None: patch["US_MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_cut_pct") if x is not None: patch["US_MOMENTUM_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0) x = gv("trail_pct") if x is not None: patch["US_MOMENTUM_TRAIL_PCT"] = str(abs(float(x)) / 100.0) x = gv("trail_arm_pct") if x is not None: patch["US_MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0) x = gv("max_hold_bars") if x is not None: patch["US_MOMENTUM_MAX_HOLD_BARS"] = str(int(float(x))) x = gv("ratchet_tiers") if x is not None: patch["US_MOMENTUM_RATCHET_TIERS"] = str(x).strip() x = gv("cooldown_min") if x is not None: patch["US_MOMENTUM_COOLDOWN_SEC"] = str(int(float(x) * 60)) x = gv("max_daily") if x is not None: patch["US_MOMENTUM_MAX_DAILY"] = str(int(float(x))) x = gv("slots") if x is not None: patch["US_MOMENTUM_MAX_STOCKS"] = str(int(float(x))) x = gv("slot_money") if x is not None: sms = str(int(float(x))) patch["US_MOMENTUM_SLOT_MONEY"] = sms patch["US_MOMENTUM_MAX_BUY_AMOUNT"] = sms x = gv("total_budget_krw") if x is not None: patch["US_MOMENTUM_TOTAL_BUDGET"] = str(int(float(x))) x = gv("high_chase_thr") if x is not None: hx = float(x) patch["US_MOMENTUM_HIGH_CHASE_THR"] = str(hx if 0 < hx <= 1 else hx / 100.0) x = gv("max_daily_chg") if x is not None: patch["US_MOMENTUM_MAX_DAILY_CHG"] = str(float(x)) x = gv("min_price") if x is not None: patch["US_MOMENTUM_MIN_PRICE"] = str(float(x)) x = gv("max_loss_krw") if x is not None: patch["US_MOMENTUM_MAX_LOSS_PER_TRADE"] = str(int(float(x))) x = gv("min_margin") if x is not None: patch["US_MOMENTUM_MIN_PROFIT_PCT"] = str(float(x)) x = gv("mom_max_from_open_pct") if x is not None: patch["US_MOMENTUM_MAX_FROM_OPEN_PCT"] = str(float(x)) x = gv("mom_min_from_open_pct") if x is not None: patch["US_MOMENTUM_MIN_FROM_OPEN_PCT"] = str(float(x)) if "use_defense_filters" in body: patch["US_MOMENTUM_USE_DEFENSE_FILTERS"] = _env_bool_10(body.get("use_defense_filters")) if "use_high_chase_filter" in body: patch["US_MOMENTUM_USE_HIGH_CHASE_FILTER"] = _env_bool_10(body.get("use_high_chase_filter")) if "use_daily_range_filter" in body: patch["US_MOMENTUM_USE_DAILY_RANGE_FILTER"] = _env_bool_10(body.get("use_daily_range_filter")) if "use_ema_filter" in body: patch["US_MOMENTUM_USE_EMA_FILTER"] = _env_bool_10(body.get("use_ema_filter")) if "use_rsi_max_filter" in body: patch["US_MOMENTUM_USE_RSI_MAX_FILTER"] = _env_bool_10(body.get("use_rsi_max_filter")) if "pattern_breakout" in body: patch["US_MOMENTUM_PATTERN_BREAKOUT"] = _env_bool_10(body.get("pattern_breakout")) if "pattern_pullback" in body: patch["US_MOMENTUM_PATTERN_PULLBACK"] = _env_bool_10(body.get("pattern_pullback")) x = gv("chase_lookback_min") if x is not None: patch["US_MOMENTUM_CHASE_LOOKBACK_MIN"] = str(int(float(x))) x = gv("pullback_lookback_min") if x is not None: patch["US_MOMENTUM_PULLBACK_LOOKBACK_MIN"] = str(int(float(x))) x = gv("pullback_min_pct") if x is not None: patch["US_MOMENTUM_PULLBACK_MIN_PCT"] = str(float(x)) x = gv("pullback_max_pct") if x is not None: patch["US_MOMENTUM_PULLBACK_MAX_PCT"] = str(float(x)) x = gv("setup_vol_max_mult") if x is not None: patch["US_MOMENTUM_SETUP_VOL_MAX_MULT"] = str(float(x)) x = gv("setup_bear_bars_min") if x is not None: patch["US_MOMENTUM_SETUP_BEAR_BARS_MIN"] = str(int(float(x))) x = gv("ema_fast_period") if x is not None: patch["US_MOMENTUM_EMA_FAST_PERIOD"] = str(int(float(x))) x = gv("ema_slow_period") if x is not None: patch["US_MOMENTUM_EMA_SLOW_PERIOD"] = str(int(float(x))) if "eod_enabled" in body: patch["US_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["US_MOMENTUM_EOD_HM"] = eod_s patch.update(_daily_trail_save_patch(body, "US_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("rsi_period") if x is not None: patch["SCALP_RSI_PERIOD"] = str(int(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("vol_mult") if x is not None: # 거래량 배수 — 0=OFF. 공용 VOL_MULTIPLIER (실매 reload_config / 엔진 동일 키) patch["VOL_MULTIPLIER"] = str(float(x)) x = gv("shoulder_min_high") if x is not None: patch["SCALP_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_cut_pct") if x is not None: patch["SCALP_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0) 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 patch["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = str(ratio) x = gv("max_daily_chg") if x is not None: patch["SCALP_MAX_DAILY_CHANGE_PCT"] = str(float(x)) 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: patch["SCALP_MAX_LOSS_PER_TRADE_KRW"] = str(int(float(x))) x = gv("min_margin") if x is not None: patch["SCALP_MIN_PROFIT_PCT"] = str(float(x)) 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")) x = gv("time_start_hm") if x is not None: patch["SCALP_TIME_START"] = str(int(float(x))) x = gv("time_end_hm") if x is not None: patch["SCALP_TIME_END"] = str(int(float(x))) x = gv("max_daily") if x is not None: patch["SCALP_MAX_DAILY"] = str(int(float(x))) x = gv("slot_money") if x is not None: patch["SCALP_MAX_BUY_AMOUNT"] = str(int(float(x))) x = gv("max_stocks") if x is not None: patch["SCALP_MAX_STOCKS"] = str(int(float(x))) x = gv("total_budget_krw") if x is not None: patch["SCALP_TOTAL_BUDGET_KRW"] = str(int(float(x))) if "skip_hts_scan_dupes" in body: patch["SCALP_SKIP_HTS_SCAN_DUPES"] = _env_bool_10(body.get("skip_hts_scan_dupes")) if "require_reversal_candle" in body: patch["SCALP_REQUIRE_REVERSAL_CANDLE"] = _env_bool_10(body.get("require_reversal_candle")) if "eod_enabled" in body: patch["SCALP_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: patch["SCALP_EOD_HM"] = eod_s 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) # 래칫 — 빈 문자열도 OFF 로 명시 저장 if "ratchet_tiers" in body: patch["BREAKOUT_RATCHET_TIERS"] = str(body.get("ratchet_tiers") or "").strip() 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))) if "skip_hts_scan_dupes" in body: patch["BREAKOUT_SKIP_HTS_SCAN_DUPES"] = _env_bool_10(body.get("skip_hts_scan_dupes")) # 수익구간 호가매도 / 손절호가 (기본 OFF · UI% → 분율) if "exit_ob_enabled" in body: patch["BREAKOUT_EXIT_OB_ENABLED"] = _env_bool_10(body.get("exit_ob_enabled")) x = gv("exit_ob_ratio_min") if x is not None: patch["BREAKOUT_EXIT_OB_RATIO_MIN"] = str(float(x)) x = gv("exit_ob_ma_window") if x is not None: patch["BREAKOUT_EXIT_OB_MA_WINDOW"] = str(int(float(x))) x = gv("exit_ob_min_profit_pct") if x is not None: patch["BREAKOUT_EXIT_OB_MIN_PROFIT_PCT"] = str(abs(float(x)) / 100.0) x = gv("exit_ob_min_hold_bars") if x is not None: patch["BREAKOUT_EXIT_OB_MIN_HOLD_BARS"] = str(int(float(x))) if "stop_ob_enabled" in body: patch["BREAKOUT_STOP_OB_ENABLED"] = _env_bool_10(body.get("stop_ob_enabled")) x = gv("stop_ob_ratio_min") if x is not None: patch["BREAKOUT_STOP_OB_RATIO_MIN"] = str(float(x)) x = gv("stop_ob_ma_window") if x is not None: patch["BREAKOUT_STOP_OB_MA_WINDOW"] = str(int(float(x))) x = gv("stop_ob_min_loss_pct") if x is not None: patch["BREAKOUT_STOP_OB_MIN_LOSS_PCT"] = str(abs(float(x)) / 100.0) x = gv("stop_ob_min_hold_bars") if x is not None: patch["BREAKOUT_STOP_OB_MIN_HOLD_BARS"] = 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(), 요청으로 덮어쓰기. """ _apply_backtest_source_env_from_request(request) _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", 20))) symbol_daily_loss_limit_krw = float( request.args.get("symbol_daily_loss_limit_krw", _def.get("symbol_daily_loss_limit_krw", 30000)) ) symbol_daily_loss_limit_pct = float( request.args.get("symbol_daily_loss_limit_pct", _def.get("symbol_daily_loss_limit_pct", 1.5)) ) reentry_min_edge_krw = float( request.args.get("reentry_min_edge_krw", _def.get("reentry_min_edge_krw", 0)) ) eod_patch = _eod_params_from_request(request, _def, default_hm="15:20") _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", te.resolve_tail_skip_hts_scan_dupes() if _TAIL_ENGINE_AVAILABLE else False, ), ), "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, "symbol_daily_loss_limit_krw": symbol_daily_loss_limit_krw, "symbol_daily_loss_limit_pct": symbol_daily_loss_limit_pct, "reentry_min_edge_krw": reentry_min_edge_krw, "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, # 실매 SHORT_CAND_LIMIT 동일 적용 — 그 시각 유니버스 상위 N개만 검사(0=무제한). "cand_limit": int(float( request.args.get("cand_limit", _def.get("cand_limit", 0)) or 0 )), "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", True), ), "backtest_use_tick_exit": _tail_bool_arg( request, "backtest_use_tick_exit", _def.get("backtest_use_tick_exit", True), ), "backtest_tick_fallback_ohlc": _tail_bool_arg( request, "backtest_tick_fallback_ohlc", _def.get("backtest_tick_fallback_ohlc", False), ), "trail_pct": trail_pct, "trail_arm_pct": trail_arm_pct, # 당일 누적손익 트레일 익절 — 마스터 스위치(daily_profit_enabled) 기준. # apply_daily_profit_halt_sim 이 engine_params 로 읽어 신규진입 차단. **_daily_trail_params_from_request(request, prefix="SHORT"), **tail_trigger_flags, } start_ymd = start_key[:8] end_ymd = end_key[:8] _univ_hs = _parse_universe_history_source_arg(request) 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, history_source=_univ_hs, ) ) params["scan_interval_min"] = _scan_iv params["timeframe"] = tail_tf params["_universe_history_source"] = _univ_hs # 백테 전용 필터 토글 (이 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, ) # 엔진 경로도 codes 바인딩 — 응답 len(codes) NameError·키움/LS 혼동 방지 codes = _codes_from_candles_map(candles_by_code) bt_meta = { "db": db, "start_key": start_key, "end_key": end_key, "timeframe": tail_tf, "backtest_env_timeline": _backtest_env_timeline_from_request(request), } 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_all = list(all_trades) _enrich_trades_with_names(db, tail_trades_all) # 전체 거래 기준 누적손익 부착 후 최신순 슬라이스 (실매·모멘텀과 동일) _enrich_momentum_trades_debug( tail_trades_all, total_budget_krw=total_budget_krw, with_tick_debug=True, ) tail_trades_out = _finalize_trades_ui( db, tail_trades_all, 200, strategy_hint="TAIL", ) 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(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"], "cand_limit": params.get("cand_limit", 0) if use_engine else 0, **tail_trigger_flags, "skip_hts_scan_dupes_effective": bt_meta.get("skip_hts_scan_dupes_effective"), "skip_hts_scan_dupes_requested": bt_meta.get("skip_hts_scan_dupes_requested"), }, "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 와 동일 함수).""" _apply_backtest_source_env_from_request(request) _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: 키 없음→DB, 빈문자/off→OFF (빈값을 DB 5.5:2.5 로 되돌리면 Optuna 정합 깨짐) "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, ), "skip_hts_scan_dupes": _tail_bool_arg( request, "skip_hts_scan_dupes", _def.get( "skip_hts_scan_dupes", resolve_breakout_skip_hts_scan_dupes(), ), ), "exit_ob_enabled": _tail_bool_arg( request, "exit_ob_enabled", _def.get("exit_ob_enabled", False), ), "exit_ob_ratio_min": _arg( "exit_ob_ratio_min", _def.get("exit_ob_ratio_min", 0.4), float, ), "exit_ob_ma_window": _arg( "exit_ob_ma_window", _def.get("exit_ob_ma_window", 5), lambda v: int(float(v)), ), "exit_ob_min_profit_pct": _arg( "exit_ob_min_profit_pct", _def.get("exit_ob_min_profit_pct", 0.5), float, ), "exit_ob_min_hold_bars": _arg( "exit_ob_min_hold_bars", _def.get("exit_ob_min_hold_bars", 3), lambda v: int(float(v)), ), "stop_ob_enabled": _tail_bool_arg( request, "stop_ob_enabled", _def.get("stop_ob_enabled", False), ), "stop_ob_ratio_min": _arg( "stop_ob_ratio_min", _def.get("stop_ob_ratio_min", 0.4), float, ), "stop_ob_ma_window": _arg( "stop_ob_ma_window", _def.get("stop_ob_ma_window", 5), lambda v: int(float(v)), ), "stop_ob_min_loss_pct": _arg( "stop_ob_min_loss_pct", _def.get("stop_ob_min_loss_pct", 0.3), float, ), "stop_ob_min_hold_bars": _arg( "stop_ob_min_hold_bars", _def.get("stop_ob_min_hold_bars", 2), lambda v: int(float(v)), ), } _rat_req = request.args.get("ratchet_tiers") if _rat_req is None: ui["ratchet_tiers"] = str(_def.get("ratchet_tiers", "") or "") else: _rat_s = str(_rat_req).strip() # off/0/false/빈칸 = 래칫 OFF (모멘텀 탭과 동일) if _rat_s.lower() in ("", "off", "0", "false", "none"): ui["ratchet_tiers"] = "" else: ui["ratchet_tiers"] = _rat_s _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" _univ_hs = _parse_universe_history_source_arg(request) from kis_trader.backtest.breakout_backtest_common import load_breakout_candles_by_code codes_candles, _ = load_breakout_candles_by_code( db, start_key, end_key, lookback_min=int(engine.get("lookback_min") or 1), vol_window=int(engine.get("vol_window") or 7), history_source=_univ_hs, ) # LS/키움 공통 — 로더는 codes 리스트를 반환하지 않음 (NameError 재발 방지) codes = _codes_from_candles_map(codes_candles) 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", history_source=_univ_hs, ) ) # scan_at 타임라인 ↔ 슬롯 dict 동일 이력소스 # (engine_params 는 아래 ui/engine 병합 시 복사되므로 engine에도 넣음) engine["_universe_history_source"] = _univ_hs 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, "backtest_env_timeline": _backtest_env_timeline_from_request(request), } 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, ) # 당일 누적손익 트레일 익절 시뮬 — 마스터 스위치(daily_profit_enabled) 기준. _trail_p = _daily_trail_params_from_request(request, prefix="BREAKOUT") if _trail_p.get("_backtest_daily_profit_trail"): 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 peak_cum_at = "" equity: List[Dict[str, Any]] = [] daily_map: Dict[str, int] = {} for t in sorted(all_trades, key=_trade_exit_sort_key): cum += float(t.get("pnl") or 0) if cum > peak: peak = cum peak_cum_at = str(t.get("sell_time") or t.get("exit_time") or "") dd = peak - cum if dd > mdd: mdd = dd day = str(t.get("sell_time", "") or t.get("exit_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) + int(t.get("pnl") or 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 _enrich_momentum_trades_debug( all_trades, total_budget_krw=total_budget_v, with_tick_debug=True, ) trades_out = _finalize_trades_ui( db, all_trades, 200, strategy_hint="BREAKOUT", ) _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"), "peak_cum_pnl": round(peak), "peak_cum_at": peak_cum_at[:19] if peak_cum_at else "", }, "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] # min_bars 통과분만 분석 종목 (DISTINCT raw codes 와 혼동 금지) codes = _codes_from_candles_map(codes_candles) 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", history_source=_parse_universe_history_source_arg(request), ) ) 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 _env_tl = _backtest_env_timeline_from_request(request) bt_meta: Dict[str, Any] = { "db": db, "start_key": start_key, "end_key": end_key, "backtest_env_timeline": _env_tl, } 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 peak_cum_at = "" equity: List[Dict[str, Any]] = [] daily_map: Dict[str, int] = {} for t in sorted(all_trades, key=_trade_exit_sort_key): cum += float(t.get("pnl") or 0) if cum > peak: peak = cum peak_cum_at = str(t.get("sell_time") or t.get("exit_time") or "") dd = peak - cum if dd > mdd: mdd = dd day = str(t.get("sell_time", "") or t.get("exit_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) + int(t.get("pnl") or 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 _enrich_momentum_trades_debug( all_trades, total_budget_krw=total_budget_v, with_tick_debug=False, ) trades_out = _finalize_trades_ui( db, all_trades, 200, strategy_hint="RANGE_BREAK", ) _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"), "peak_cum_pnl": round(peak), "peak_cum_at": peak_cum_at[:19] if peak_cum_at else "", }, "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", _default_trading_day_ymd()) 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", _default_trading_day_ymd()) 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", _default_trading_day_ymd()) 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", _default_trading_day_ymd()) 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", _default_trading_day_ymd()) 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", _default_trading_day_ymd()) 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", _default_trading_day_ymd()) 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: r = db.get_auth_env() if not r: raise RuntimeError("env_auth_config 없음") 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_auth_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", _default_trading_day_ymd()) 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", _default_trading_day_ymd()) 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", _default_trading_day_ymd()) 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() # ── US 모멘텀: 해외 1분봉 → ws_candles.market=US ────────────────────────── _us_candle_fetch_jobs: Dict[str, Dict[str, Any]] = {} @app.route("/api/ws_candles/fetch_us", methods=["POST"]) def api_ws_candles_fetch_us(): """ 영구구독 US 전 종목(또는 body.codes) KIS 해외 분봉 → ws_candles (market=US). 기본 tf=1. rollup_3m=true 면 1m→3m 롤업 후 저장 (국내 갭보정과 동일 철학). """ import threading import uuid as _uuid body = request.get_json(force=True, silent=True) or {} start = str(body.get("start") or "").strip() end = str(body.get("end") or "").strip() or start try: tf = int(body.get("tf", 1)) except (TypeError, ValueError): tf = 1 if tf not in (1, 3, 5, 15, 30, 60): return jsonify({"error": f"tf 허용: 1/3/5/15/30/60 (요청 {tf})"}), 400 rollup_3m = str(body.get("rollup_3m", True)).lower() in ("1", "true", "yes", "on") if not start: return jsonify({"error": "start 필수"}), 400 codes_req = body.get("codes") job_id = _uuid.uuid4().hex[:8] _us_candle_fetch_jobs[job_id] = { "status": "running", "saved": 0, "codes": 0, "rollup_3m": 0, "current": "", "error": None, } def _run(): import holding_bot as hb from permanent_subs import codes_by_market from kis_trader.engine.candle_rollup import rollup_1m_bars_to_tf db = _db() try: if codes_req: rows = [ { "code": str(c).upper(), "exchange": "NASD", "symbol": str(c).upper(), } for c in codes_req ] else: rows = codes_by_market(db, "US", enabled_only=True) if not rows: raise RuntimeError("영구구독 US 종목 없음") job = _us_candle_fetch_jobs[job_id] job["codes"] = len(rows) total_saved = 0 total_rollup = 0 for r in rows: code = str(r.get("code") or "").upper() ex = str(r.get("exchange") or "NASD").upper() sym = str(r.get("symbol") or code).upper() job["current"] = f"{code}/{sym}" bars = hb.fetch_60min_via_kiwoom( code=code, start_date=start, end_date=end, kiwoom_key="", kiwoom_secret="", is_mock=False, tf_min=tf, market_type="US", exchange=ex, symbol=sym, ) norm_1m = [] for b in bars or []: cd = str(b.get("candle_date") or "") digits = "".join(ch for ch in cd if ch.isdigit()) ctime = (digits + "0000")[:12] if len(ctime) < 12: continue row_bar = { "candle_time": ctime, "open": float(b.get("open") or 0), "high": float(b.get("high") or 0), "low": float(b.get("low") or 0), "close": float(b.get("close") or 0), "volume": int(b.get("volume") or 0), } if row_bar["close"] <= 0: continue norm_1m.append(row_bar) db.upsert_ws_candle( code=code, timeframe=tf, candle_time=ctime, open_=row_bar["open"], high=row_bar["high"], low=row_bar["low"], close=row_bar["close"], volume=row_bar["volume"], is_confirmed=1, source="kis_us_rest", market="US", ) total_saved += 1 if rollup_3m and tf == 1 and norm_1m: rolled = rollup_1m_bars_to_tf(norm_1m, 3) or [] for rb in rolled: ctime = str(rb.get("candle_time") or "")[:12] if len(ctime) < 12: continue db.upsert_ws_candle( code=code, timeframe=3, candle_time=ctime, open_=float(rb.get("open") or 0), high=float(rb.get("high") or 0), low=float(rb.get("low") or 0), close=float(rb.get("close") or 0), volume=int(rb.get("volume") or 0), is_confirmed=1, source="rollup_1m", market="US", ) total_rollup += 1 job["saved"] = total_saved job["rollup_3m"] = total_rollup job["status"] = "done" job["current"] = "" except Exception as e: logger.exception("US ws_candles fetch 실패: %s", e) _us_candle_fetch_jobs[job_id]["status"] = "error" _us_candle_fetch_jobs[job_id]["error"] = str(e) finally: try: db.close() except Exception: pass threading.Thread(target=_run, daemon=True, name=f"us-candle-{job_id}").start() return jsonify({"ok": True, "job_id": job_id}) @app.route("/api/ws_candles/fetch_us/status/", methods=["GET"]) def api_ws_candles_fetch_us_status(job_id: str): job = _us_candle_fetch_jobs.get(job_id) if not job: return jsonify({"error": "없는 job_id"}), 404 return jsonify(job) @app.route("/api/permanent_subs", methods=["GET"]) def api_permanent_subs_list(): """영구구독(국내 WS + 해외 WS) 목록. 해외(US)는 ws_tr_key(D+거래소+심볼) 포함.""" import permanent_subs as ps from kis_trader.utils.env import get_env_int from kis_trader.utils.stock_name import resolve_stock_display_name from kis_trader.utils.usd_krw_fx import get_display_usd_krw_rate # light=1: 자동새로고침 — 종목명 REST 스킵 (느림 방지) light = str(request.args.get("light") or "").strip().lower() in ( "1", "true", "yes", "y", "on", ) db = _db() try: ps.ensure_permanent_subs_table(db) # 기존 60/15 혼재 → 기준봉(기본 1분)으로 정리 (1분이면 상위봉 롤업) try: nfix = ps.normalize_all_tf_to_base(db) if nfix: logger.info("영구구독 tf_min 기준봉 통일 %d건", nfix) except Exception as e: logger.debug("영구구독 tf 정규화 스킵: %s", e) base_tf = int(get_env_int("PERM_SUB_BASE_TF_MIN", 1)) if base_tf < 1: base_tf = 1 rows = ps.list_permanent_subs(db, enabled_only=False) # 현재가: ws_candles 만 (틱 전수스캔·외부 FX HTTP 금지) try: quotes = ps.last_quotes_for_codes( db, [str(r.get("code") or "") for r in rows], tf_min=base_tf, ) except Exception as e: logger.debug("영구구독 현재가 스킵: %s", e) quotes = {} try: refresh_sec = int(get_env_int("PERM_SUB_UI_REFRESH_SEC", 15)) except (TypeError, ValueError): refresh_sec = 15 if refresh_sec < 5: refresh_sec = 5 fx_rate = float(get_display_usd_krw_rate()) fx_ymd = datetime.now().strftime("%Y-%m-%d") # KR 종목명 일괄 보강용 클라이언트 (루프마다 새로 만들지 않음) name_client = None out = [] for r in rows: mt = str(r.get("market_type") or "KR").strip().upper() ex = str(r.get("exchange") or "").strip().upper() code = str(r.get("code") or "").strip().upper() sym = str(r.get("symbol") or code).strip().upper() ws_tr_key = ps.us_ws_tr_key(ex, sym) if mt == "US" else code name = "" if mt == "KR" and code: try: name = resolve_stock_display_name(db, code, fallback="") if name == code: name = "" except Exception: name = "" if (not name) and (not light): try: if name_client is None: from kis_trader.execution.kis_client import KISClient # 시세/종목정보 = 실전 도메인 (모의 search-stock-info 500) name_client = KISClient(mock=False) fetched = name_client.inquire_stock_name(code) if fetched and fetched != code: name = fetched if hasattr(db, "upsert_stock_meta"): try: db.upsert_stock_meta(code, name=name) except Exception: pass except Exception as e: logger.debug("영구구독 종목명 REST 스킵 %s: %s", code, e) elif mt == "US": name = sym q = quotes.get(code) or {} try: px = float(q.get("price") or 0) except (TypeError, ValueError): px = 0.0 price_krw = None if mt == "US" and px > 0 and fx_rate > 0: price_krw = int(round(px * fx_rate)) out.append({ "code": code, "name": name or "", "market_type": mt, "exchange": ex, "symbol": sym, "tf_min": base_tf, "enabled": int(r.get("enabled", 1)), "note": r.get("note") or "", "ws_tr_key": ws_tr_key, "price": px, "price_krw": price_krw, "price_src": q.get("price_src") or "", "candle_time": q.get("candle_time") or "", "tick_time": q.get("tick_time") or "", "chg_pct": q.get("chg_pct"), "volume": int(q.get("volume") or 0), "quote_updated_at": q.get("updated_at") or "", }) return jsonify({ "ok": True, "rows": out, "base_tf_min": base_tf, "refresh_sec": refresh_sec, "fx_rate": round(fx_rate, 4), "fx_ymd": fx_ymd, "fx_pair": "USD/KRW", "fx_note": "표시용(캐시/폴백) · 체결환율 아님", "master_enabled": bool(ps.subscribe_master_enabled()), }) 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 from kis_trader.utils.env import get_env_int 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 # 기준봉 고정 (요청 tf 무시) — 1분 저장 + 롤업 try: tf_min = int(get_env_int("PERM_SUB_BASE_TF_MIN", 1)) except (TypeError, ValueError): tf_min = 1 if tf_min < 1: tf_min = 1 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, "tf_min": tf_min}) 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() @app.route("/api/permanent_subs/master", methods=["POST"]) def api_permanent_subs_master(): """영구구독 마스터 스위치 — 행 유지, 구독만 on/off. 봇 재시작 필요.""" from kis_trader.utils.env import invalidate_merged_env_cache body = request.get_json(force=True, silent=True) or {} raw = body.get("enabled", True) if isinstance(raw, str): enabled = raw.strip().lower() in ("1", "true", "yes", "on", "y") else: enabled = bool(raw) val = "true" if enabled else "false" db = _db() try: snap = db.get_merged_env_snapshot() or {} snap["PERMANENT_SUBSCRIBE_ENABLED"] = val env_id = db.insert_env_snapshot(snap) if env_id is None: return jsonify({"ok": False, "error": "insert_env_snapshot 실패"}), 500 try: invalidate_merged_env_cache() except Exception: pass return jsonify({"ok": True, "enabled": enabled, "env_id": env_id}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/permanent_subs/fill_candles", methods=["POST"]) def api_permanent_subs_fill_candles(): """키움 ka10080 → ls_ws_candles INSERT IGNORE. ws_candles 에 넣지 않음. KR만.""" import permanent_subs as ps from kis_trader.utils.env import get_env_int body = request.get_json(force=True, silent=True) or {} one = str(body.get("code") or "").strip().upper() db = _db() try: if one: codes = [one] else: codes = [ str(r.get("code") or "").strip() for r in ps.list_permanent_subs(db, enabled_only=True) if str(r.get("market_type") or "KR").strip().upper() == "KR" ] tf_min = int(get_env_int("PERM_SUB_BASE_TF_MIN", 1)) if tf_min < 1: tf_min = 1 result = ps.fill_ls_candles_from_kiwoom(db, codes, tf_min=tf_min) return jsonify(result) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() # ───────────────────────────────────────────────────────────────────────────── # DART 수주 공시 SCAN / 워치 / 백테 # ───────────────────────────────────────────────────────────────────────────── @app.route("/api/dart/disclosures", methods=["GET"]) def api_dart_disclosures(): """최근 공시 목록 (웹 탭 실시간 폴링).""" limit = int(request.args.get("limit", 50) or 50) limit = max(1, min(200, limit)) db = _db() try: try: from kis_trader.scan.dart_order_tracker import ensure_dart_disclosure_columns ensure_dart_disclosure_columns(db) except Exception: pass rows = db.conn.execute( """ SELECT rcept_no, corp_code, corp_name, stock_code, report_nm, rcept_dt, url, first_seen_at, mm_sent, filter_ok, sales_pct, filter_reason FROM dart_disclosures ORDER BY first_seen_at DESC LIMIT %s """, (limit,), ).fetchall() watch = [] try: from kis_trader.scan.dart_watchlist import list_active_watch watch = list_active_watch(db) except Exception: pass snap = db.get_merged_env_snapshot() or {} return jsonify({ "ok": True, "rows": [dict(r) for r in rows], "watch": watch, "flags": { "scan": str(snap.get("DART_SCAN_ENABLED") or snap.get("DART_ENABLED") or "true").lower() in ("1", "true", "yes", "on"), "subscribe": str(snap.get("DART_SUBSCRIBE_ENABLED") or "false").lower() in ("1", "true", "yes", "on"), "trade": str(snap.get("DART_TRADE_ENABLED") or "false").lower() in ("1", "true", "yes", "on"), "strategy": str(snap.get("STRATEGY_DART_ENABLED") or "false").lower() in ("1", "true", "yes", "on"), "watch_max": int(float(snap.get("DART_WATCH_MAX") or 15)), "watch_ttl_hours": int(float(snap.get("DART_WATCH_TTL_HOURS") or 24)), "quality_filter": str(snap.get("DART_QUALITY_FILTER_ENABLED") or "true").lower() in ("1", "true", "yes", "on"), "min_sales_pct": float(snap.get("DART_MIN_SALES_PCT") or 5.0), "require_theme": str(snap.get("DART_REQUIRE_THEME") or "true").lower() in ("1", "true", "yes", "on"), }, }) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/dart/config", methods=["POST"]) def api_dart_config(): """SCAN/구독/매매 스위치·워치 한도 저장 → config_dart.""" body = request.get_json(force=True, silent=True) or {} patch = {} for k in ( "DART_SCAN_ENABLED", "DART_SUBSCRIBE_ENABLED", "DART_TRADE_ENABLED", "STRATEGY_DART_ENABLED", "DART_WATCH_MAX", "DART_WATCH_TTL_HOURS", "DART_POLL_SEC", "DART_LOOKBACK_DAYS", "DART_QUALITY_FILTER_ENABLED", "DART_MIN_SALES_PCT", "DART_REQUIRE_THEME", ): if k in body: patch[k] = str(body[k]).strip() if not patch: return jsonify({"ok": False, "error": "no keys"}), 400 db = _db() try: db.insert_env_snapshot(patch) return jsonify({"ok": True, "saved": list(patch.keys())}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/backtest/dart", methods=["GET"]) def api_backtest_dart(): from kis_trader.backtest.dart_backtest_common import run_dart_backtest_web_aligned from kis_trader.engine import dart_engine as de start = (request.args.get("start") or "").strip() end = (request.args.get("end") or "").strip() if not start or not end: return jsonify({"ok": False, "error": "start/end 필수"}), 400 db = _db() try: env_row = db.get_merged_env_snapshot() or {} params = de.get_dart_defaults_from_db(env_row=env_row) # 쿼리 오버라이드 for qk, pk in ( ("rsi_oversold", "rsi_oversold"), ("rsi_reclaim", "rsi_reclaim"), ("sl_pct", "sl_pct"), ("tp_pct", "tp_pct"), ("vol_mult", "vol_mult"), ("event_window_bars", "event_window_bars"), ("slot_money", "slot_money"), ): if request.args.get(qk) not in (None, ""): params[pk] = float(request.args.get(qk)) if qk != "event_window_bars" else int(float(request.args.get(qk))) result = run_dart_backtest_web_aligned( start=start, end=end, params=params, env_row=env_row, ) return jsonify(result) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 finally: db.close() @app.route("/api/backtest/dart/defaults", methods=["GET"]) def api_backtest_dart_defaults(): from kis_trader.engine import dart_engine as de db = _db() try: env_row = db.get_merged_env_snapshot() or {} return jsonify({"ok": True, "params": de.get_dart_defaults_from_db(env_row=env_row)}) 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", _default_trading_day_ymd()) 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 sk = start_date.replace("-", "") + "0000" ek = end_date.replace("-", "") + "2359" _env_tl = _backtest_env_timeline_from_request(request) cfg["backtest_env_timeline"] = _env_tl from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params attach_backtest_env_timeline_to_params( cfg, {"db": db, "start_key": sk, "end_key": ek, "backtest_env_timeline": _env_tl}, "DBBAND", ) 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", _default_trading_day_ymd()) 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, balance: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """실계좌 대조 시 상단 요약 — 입금액(env) · 예수금 · 주식평가금 · 총자산. balance 를 넘기면 inquire-balance 를 다시 치지 않음 (보유 맵과 동일 응답 재사용). """ 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: if not isinstance(balance, dict): balance = getattr(order_mgr.client, "_last_inquire_balance", None) if not isinstance(balance, dict): 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) ) # name 보완: active_trades.name 이 코드와 동일하거나 비어있는 경우 DB 종목명 조회 try: from kis_trader.utils.stock_name import resolve_stock_display_name _db_ref = _db() for it in items: if not it.get("name") or it["name"] == it["code"]: it["name"] = resolve_stock_display_name(_db_ref, it["code"], fallback=it["code"]) except Exception as _e: logger.debug("보유매도 종목명 보완 실패: %s", _e) mock = False try: mock = bool(order_mgr.client.mock) except Exception: pass account_summary = ( _portfolio_account_summary( order_mgr, balance=getattr(order_mgr.client, "_last_inquire_balance", None), ) 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", }) def _portfolio_live_quote_enabled() -> bool: from kis_trader.utils.env import get_env_bool return bool(get_env_bool("PORTFOLIO_LIVE_QUOTE_ENABLED", True)) def _portfolio_live_quote_ms() -> int: from kis_trader.utils.env import get_env_int return max(200, int(get_env_int("PORTFOLIO_LIVE_QUOTE_MS", 400) or 400)) def _portfolio_live_quote_max_age_sec() -> float: from kis_trader.utils.env import get_env_float return max(1.0, float(get_env_float("PORTFOLIO_LIVE_QUOTE_MAX_AGE_SEC", 120.0) or 120.0)) def _parse_live_quote_codes(raw: str) -> List[str]: out: List[str] = [] seen = set() for part in str(raw or "").replace(";", ",").split(","): c = part.strip() if not c or c in seen: continue seen.add(c) out.append(c) if len(out) >= 80: break return out @app.route("/api/trade_replay_candles", methods=["GET"]) def api_trade_replay_candles(): """거래내역 DB차트 — ws_candles 당일 분봉 + 진입/청산 마커용. 증권사 REST 추가 호출 없음. market=US 는 ws_candles.market=US. """ code = str(request.args.get("code") or "").strip() if not code: return jsonify({"ok": False, "error": "code 필요"}), 400 market = str(request.args.get("market") or "KR").strip().upper() or "KR" try: tf = max(1, int(request.args.get("tf") or 1)) except (TypeError, ValueError): tf = 1 buy_raw = str(request.args.get("buy") or "").strip() sell_raw = str(request.args.get("sell") or "").strip() day = str(request.args.get("day") or "").strip() def _ymd8(raw: str) -> str: s = ( str(raw or "") .replace("-", "") .replace(":", "") .replace(" ", "") .replace("T", "") ) return s[:8] if len(s) >= 8 else "" def _hm_key(raw: str) -> str: s = ( str(raw or "") .replace("-", "") .replace(":", "") .replace(" ", "") .replace("T", "") ) if len(s) >= 12: return s[:12] if len(s) == 8: return s + "0900" return "" ymd = _ymd8(day) or _ymd8(buy_raw) or _ymd8(sell_raw) if not ymd: return jsonify({"ok": False, "error": "day/buy 날짜 필요"}), 400 lo = ymd + "0000" hi = ymd + "2359" buy_key = _hm_key(buy_raw) sell_key = _hm_key(sell_raw) db = _db() try: rows = db.conn.execute( "SELECT candle_time, open, high, low, close, volume " "FROM ws_candles WHERE market=%s AND code=%s AND timeframe=%s " "AND candle_time >= %s AND candle_time <= %s " "ORDER BY candle_time ASC", (market, code, tf, lo, hi), ).fetchall() candles = [] for r in rows: ct = str(r.get("candle_time") or "") candles.append({ "t": ct, "o": float(r.get("open") or 0), "h": float(r.get("high") or 0), "l": float(r.get("low") or 0), "c": float(r.get("close") or 0), "v": float(r.get("volume") or 0), }) return jsonify({ "ok": True, "code": code, "market": market, "timeframe": tf, "day": f"{ymd[:4]}-{ymd[4:6]}-{ymd[6:8]}", "buy_key": buy_key, "sell_key": sell_key, "candles": candles, "n": len(candles), }) except Exception as e: logger.exception("trade_replay_candles: %s", e) return jsonify({"ok": False, "error": str(e)}), 500 finally: try: db.close() except Exception: pass def _recv_ts_age_ms(recv_ts: str) -> Optional[int]: s = str(recv_ts or "").strip() if not s: return None for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"): try: dt = datetime.strptime(s[:26], fmt) return max(0, int((datetime.now() - dt).total_seconds() * 1000)) except ValueError: continue return None def _build_portfolio_live_quotes(codes: List[str]) -> Dict[str, Any]: """ ws_ticks / ws_ticks_us 최신 SELECT — 증권사 REST/WS 추가 호출 없음. 국내 6자리 → KR, 그 외 → US. day_chg_pct = 전일(직전세션) 종가 대비 당일 등락% (HTS 전일대비와 동일 취지). """ kr = [c for c in codes if c.isdigit() and len(c) == 6] us = [c for c in codes if c not in set(kr)] db = _db() merged: Dict[str, Dict[str, Any]] = {} prev_map: Dict[str, float] = {} if kr: merged.update(db.get_latest_ws_tick_quotes(kr, market="KR")) prev_map.update(db.get_prev_close_map(kr, market="KR")) if us: merged.update(db.get_latest_ws_tick_quotes(us, market="US")) prev_map.update(db.get_prev_close_map(us, market="US")) max_age = _portfolio_live_quote_max_age_sec() quotes: Dict[str, Dict[str, Any]] = {} for code, q in merged.items(): age_ms = _recv_ts_age_ms(str(q.get("recv_ts") or "")) stale = age_ms is not None and (age_ms / 1000.0) > max_age px = float(q.get("price") or 0) prev = float(prev_map.get(code) or 0) day_chg = None if px > 0 and prev > 0: day_chg = round((px - prev) / prev * 100.0, 4) quotes[code] = { "price": px, "ts": q.get("recv_ts") or "", "tick_time": q.get("tick_time") or "", "source": q.get("source") or "", "currency": q.get("currency") or "KRW", "market": q.get("market") or "KR", "age_ms": age_ms, "stale": bool(stale), "prev_close": prev if prev > 0 else None, "day_chg_pct": day_chg, } return { "ok": True, "enabled": True, "quotes": quotes, "max_age_sec": max_age, "interval_ms": _portfolio_live_quote_ms(), } @app.route("/api/portfolio/live_quotes", methods=["GET"]) def api_portfolio_live_quotes(): """보유·실거래「보유중」표시용 — DB 틱 스냅샷만 (매매 경로 비접촉).""" if not _portfolio_live_quote_enabled(): return jsonify({ "ok": True, "enabled": False, "quotes": {}, "interval_ms": _portfolio_live_quote_ms(), }) codes = _parse_live_quote_codes(request.args.get("codes") or "") if not codes: return jsonify({ "ok": True, "enabled": True, "quotes": {}, "interval_ms": _portfolio_live_quote_ms(), }) return jsonify(_build_portfolio_live_quotes(codes)) @app.route("/api/portfolio/live_quotes_stream", methods=["GET"]) def api_portfolio_live_quotes_stream(): """SSE: 주기적으로 live_quotes 푸시. 브라우저 탭 닫으면 루프 종료.""" if not _portfolio_live_quote_enabled(): def _off() -> Generator[str, None, None]: yield "data: " + json.dumps({ "ok": True, "enabled": False, "quotes": {}, }, ensure_ascii=False) + "\n\n" return Response( stream_with_context(_off()), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) codes = _parse_live_quote_codes(request.args.get("codes") or "") interval_s = _portfolio_live_quote_ms() / 1000.0 def _gen() -> Generator[str, None, None]: while True: try: payload = _build_portfolio_live_quotes(codes) if codes else { "ok": True, "enabled": True, "quotes": {}, "interval_ms": _portfolio_live_quote_ms(), } yield "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n" except GeneratorExit: break except Exception as e: logger.debug("live_quotes_stream: %s", e) yield "data: " + json.dumps({ "ok": False, "error": str(e)[:200], }, ensure_ascii=False) + "\n\n" time.sleep(interval_s) return Response( stream_with_context(_gen()), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @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(): """미등록 전량매도는 제거됨 — 실수로 호출해도 주문 안 나감.""" return jsonify({ "ok": False, "error": "미등록 전량매도는 제거됨. 고아/유령은 「잔고=DB 맞추기」만 사용 (매도 없음).", }), 410 @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), "synced": result.get("synced") or [], "synced_count": int(result.get("synced_count") or 0), "ghost_purged": result.get("ghost_purged") or [], "ghost_purged_count": int(result.get("ghost_purged_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 기본값 유지. ?fresh=1 — Optuna 적용 직후 폼 리로드용. RAM env 캐시를 비우고 DB에서 다시 읽는다. (새로고침 없이 호가·휩쏘가 DB와 같게 보이도록) """ _fresh = str(request.args.get("fresh") or "").strip().lower() if _fresh in ("1", "true", "t", "yes", "y", "on"): try: from kis_trader.utils.env import invalidate_merged_env_cache invalidate_merged_env_cache() except Exception: pass 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 from kis_trader.utils.kr_trading_day import trading_dates_payload _mom_ui = _momentum_ui_defaults_from_db(_mom_def_db()) _us_mom_ui = _us_momentum_ui_defaults_from_db(snap) _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"), (_us_mom_ui, "US_MOMENTUM")): if isinstance(_ui, dict): _ui["daily_profit_enabled"] = ( str(snap.get(f"{_pfx}_DAILY_PROFIT_TARGET_ENABLED") or "false").strip().lower() in ("1", "true", "t", "y", "yes", "on") ) _ui["daily_risk_budget_enabled"] = ( str(snap.get(f"{_pfx}_DAILY_PROFIT_RISK_BUDGET_ENABLED") or "false").strip().lower() in ("1", "true", "t", "y", "yes", "on") ) _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 try: _lb = int(float(str(snap.get("PARAM_SEARCH_DEFAULT_LOOKBACK_DAYS") or "7"))) except (ValueError, TypeError): _lb = 7 return jsonify({ "scalp": _scalp_ui, "tail": _tail_ui_defaults_from_db(snap), "momentum": _mom_ui, "us_momentum": _us_mom_ui, "breakout": _bo_ui, "range_break": _rb_ui, "dbband": _dbband_ui_defaults_from_db(snap) if _DBBAND_ENGINE_AVAILABLE else {}, # 주말·휴장 → 이전 장운영일 (웹 날짜 인풋 기본값) "dates": trading_dates_payload(max(1, _lb)), }) 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 = _default_trading_day_ymd() else: try: from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day day = clamp_to_prev_kr_trading_day(day) except ValueError: day = _default_trading_day_ymd() db = _db() try: snap = db.get_merged_env_snapshot() or {} status = _build_live_config_status(db, day, snap) status_only = str(request.args.get("status_only") or "").strip().lower() in ( "1", "true", "yes", "on", ) if status_only: return jsonify({ "ok": True, "date": day, "as_of": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "status": status, }) 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, }) 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 core = _build_dashboard_trade_core(db, day_iso, snap, _DASHBOARD_TAB_STRATEGIES) totals = core.get("totals") or {} strat_rows = dict(core.get("rows_by_sid") 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": "신규 매수만 중단 — 보유 종목 손절·익절 유지", }, } # ── Optuna 웹 잡 (타임아웃 회피: start 즉시 반환 + status 폴링) ───────────── @app.route("/api/optuna/start", methods=["POST"]) def api_optuna_start(): """Optuna subprocess 시작. apply-best 없음. body: strategies[] 또는 strategy, start, end, trials, mode[, symbol]. symbol 있으면 us_momentum 종목 cfg Optuna (1종목). """ from kis_trader.backtest import optuna_web_jobs as owj body = request.get_json(silent=True) or {} # form 도 허용 strategy = (body.get("strategy") or request.form.get("strategy") or "").strip() strategies = body.get("strategies") if strategies is None and request.form.get("strategies"): strategies = request.form.get("strategies") start = (body.get("start") or request.form.get("start") or "").strip() end = (body.get("end") or request.form.get("end") or "").strip() mode = (body.get("mode") or request.form.get("mode") or "tpe").strip() symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() hist_src = ( body.get("universe_history_source") or request.form.get("universe_history_source") or "" ) hist_src = str(hist_src).strip().lower() or None tick_src = (body.get("tick_source") or request.form.get("tick_source") or "").strip() ob_src = (body.get("ob_source") or request.form.get("ob_source") or "").strip() candle_src = (body.get("candle_source") or request.form.get("candle_source") or "").strip() entry_modes = body.get("entry_modes") if entry_modes is None and request.form.get("entry_modes"): entry_modes = request.form.get("entry_modes") sl_modes = body.get("sl_modes") if sl_modes is None and request.form.get("sl_modes"): sl_modes = request.form.get("sl_modes") ob_modes = body.get("ob_modes") if ob_modes is None and request.form.get("ob_modes"): ob_modes = request.form.get("ob_modes") try: trials = int(body.get("trials") or request.form.get("trials") or 200) except (TypeError, ValueError): trials = 200 study_trials = None raw_st = body.get("study_trials") if raw_st is None: raw_st = request.form.get("study_trials") try: if raw_st is not None and str(raw_st).strip() != "": study_trials = int(raw_st) except (TypeError, ValueError): study_trials = None study_name_override = ( body.get("study_name") or request.form.get("study_name") or "" ).strip() or None try: meta = owj.start_optuna_job( strategy=strategy or None, strategies=strategies, start=start, end=end, trials=trials, mode=mode, symbol=symbol or None, universe_history_source=hist_src, candle_source=candle_src or None, tick_source=tick_src or None, ob_source=ob_src or None, entry_modes=entry_modes, sl_modes=sl_modes, ob_modes=ob_modes, study_trials=study_trials, study_name_override=study_name_override, ) return jsonify({"ok": True, "job": meta}) except Exception as e: logger.warning("optuna start failed: %s", e) return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/optuna/status/", methods=["GET"]) def api_optuna_status(job_id: str): from kis_trader.backtest import optuna_web_jobs as owj meta = owj.load_job(job_id) if not meta: return jsonify({"ok": False, "error": "없는 job_id"}), 404 return jsonify({"ok": True, "job": owj.refresh_job_status(meta)}) @app.route("/api/optuna/jobs", methods=["GET"]) def api_optuna_jobs(): from kis_trader.backtest import optuna_web_jobs as owj # 순차(국내4≈5행·풀옵션≈10행) + import 가 쌓여도 전략이 안 잘리게 limit = request.args.get("limit", 40, type=int) try: limit = int(limit or 40) except (TypeError, ValueError): limit = 40 limit = max(1, min(80, limit)) sort = (request.args.get("sort") or "started").strip().lower() if sort not in ("started", "finished"): sort = "started" # CLI/순차 스크립트 JSON 도 웹 목록에 보이게 (기본 ON) import_cli = request.args.get("import_cli", "1") imported = [] if str(import_cli).strip() not in ("0", "false", "False", "no"): try: imported = owj.import_recent_cli_results(limit_per_strategy=3) except Exception as e: logger.warning("optuna import_cli: %s", e) jobs = [] for m in owj.list_jobs(limit, sort=sort): try: if owj._job_needs_status_refresh(m): m = owj.refresh_job_status(m) except Exception: pass jobs.append(owj.job_list_row(m)) # refresh 가 save 해도 정렬 키는 started/finished — 응답 직전에 한 번 더 정렬 jobs.sort(key=lambda x: owj._job_sort_ts(x, sort), reverse=True) return jsonify({ "ok": True, "jobs": jobs[:limit], "imported_n": len(imported), "sort": sort, "limit": limit, }) @app.route("/api/optuna/import-cli", methods=["POST"]) def api_optuna_import_cli(): """results/optuna_*_tpe_*.json → 웹 잡 목록 등록.""" from kis_trader.backtest import optuna_web_jobs as owj body = request.get_json(silent=True) or {} try: lim = int(body.get("limit_per_strategy") or 3) except (TypeError, ValueError): lim = 3 try: imported = owj.import_recent_cli_results(limit_per_strategy=lim) return jsonify({ "ok": True, "imported_n": len(imported), "jobs": imported, }) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/optuna/active", methods=["GET"]) def api_optuna_active(): """상단 바용: 실행 중 잡 + (참고) CLI 외부 프로세스.""" from kis_trader.backtest import optuna_web_jobs as owj running = owj.find_running_jobs() external = owj.any_optuna_python_running() latest = None try: lp = Path(__file__).resolve().parent / "logs" / "optuna_web_latest_job.txt" if lp.is_file(): jid = lp.read_text(encoding="utf-8").strip() if jid: meta = owj.load_job(jid) if meta: latest = owj.refresh_job_status(meta) except Exception: latest = None return jsonify({ "ok": True, "running": running, "latest": latest, "external": external, }) @app.route("/api/optuna/continue", methods=["POST"]) def api_optuna_continue(): """같은 study 로 남은 횟수 웹 잡.""" from kis_trader.backtest import optuna_web_jobs as owj body = request.get_json(silent=True) or {} job_id = (body.get("job_id") or "").strip() if not job_id: return jsonify({"ok": False, "error": "job_id 필요"}), 400 try: meta = owj.continue_optuna_job(job_id) return jsonify({"ok": True, "job": meta}) except Exception as e: logger.warning("optuna continue failed: %s", e) return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/optuna/confirm", methods=["POST"]) def api_optuna_confirm(): """현재 완료 수로 목표 확정 + 후처리.""" from kis_trader.backtest import optuna_web_jobs as owj body = request.get_json(silent=True) or {} job_id = (body.get("job_id") or "").strip() if not job_id: return jsonify({"ok": False, "error": "job_id 필요"}), 400 try: out = owj.confirm_optuna_study(job_id) return jsonify(out if isinstance(out, dict) else {"ok": True, "job": out}) except Exception as e: logger.warning("optuna confirm failed: %s", e) return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/optuna/stop/", methods=["POST"]) def api_optuna_stop(job_id: str): from kis_trader.backtest import optuna_web_jobs as owj try: meta = owj.stop_optuna_job(job_id) return jsonify({"ok": True, "job": meta}) except FileNotFoundError: return jsonify({"ok": False, "error": "없는 job_id"}), 404 except Exception as e: return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/optuna/defaults", methods=["GET"]) def api_optuna_defaults(): """날짜 기본값(거래일).""" from kis_trader.utils.kr_trading_day import ( default_kr_trading_end, default_kr_trading_start, ) end = default_kr_trading_end() start = default_kr_trading_start(1, end=end) # 최근 2거래일 느낌: end-1일 보정 return jsonify({ "ok": True, "start": start, "end": end, "trials": 200, "mode": "tpe", "strategies": ["momentum", "tail", "breakout", "scalp", "all"], "note": "apply-best 없음. 탐색 게이트 WR/PF=0, 사후 results_gated.", }) @app.route("/api/optuna/candidate", methods=["GET"]) def api_optuna_candidate(): """보기: gated/learn/mode 후보 파라미터 미리보기.""" from kis_trader.backtest import optuna_web_jobs as owj job_id = (request.args.get("job_id") or "").strip() or None result_json = (request.args.get("result_json") or "").strip() or None source = (request.args.get("source") or "gated").strip() try: rank = int(request.args.get("rank") or 1) except (TypeError, ValueError): rank = 1 try: out = owj.get_candidate_detail( job_id=job_id, result_json=result_json, source=source, rank=rank, ) return jsonify(out) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/optuna/apply", methods=["POST"]) def api_optuna_apply(): """완료 잡의 gated/mode/learn 후보 → DB 적용 (confirm 후 호출). body.symbol 있으면 us_momentum → stock_config 행만. 성공·실패 모두 logs/optuna_apply_audit.jsonl 에 남김. """ from kis_trader.backtest import optuna_web_jobs as owj body = request.get_json(silent=True) or {} job_id = (body.get("job_id") or "").strip() or None result_json = (body.get("result_json") or "").strip() or None source = (body.get("source") or "gated").strip() symbol = (body.get("symbol") or "").strip() or None exchange = (body.get("exchange") or "").strip() or None stock_group = (body.get("stock_group") or "").strip() or None try: rank = int(body.get("rank") or 1) except (TypeError, ValueError): rank = 1 force = bool(body.get("allow_non_positive_pnl")) upto = str(body.get("upto") or "base").strip().lower() or "base" try: out = owj.apply_optuna_result( job_id=job_id, result_json=result_json, source=source, rank=rank, allow_non_positive_pnl=force, symbol=symbol, exchange=exchange, stock_group=stock_group, upto=upto, ) return jsonify(out) except Exception as e: logger.warning("optuna apply failed: %s", e) try: owj.record_optuna_apply_audit( ok=False, strategy="", source=source, rank=rank, upto=upto, job_id=job_id, result_json=result_json or "", error=str(e), ) except Exception: pass return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/optuna/postprocess/rerun", methods=["POST"]) def api_optuna_postprocess_rerun(): """이 잡 result JSON 후처리 재실행 (백그라운드). 실매 DB 미적용.""" from kis_trader.backtest import optuna_web_jobs as owj body = request.get_json(silent=True) or {} job_id = (body.get("job_id") or "").strip() if not job_id: return jsonify({"ok": False, "error": "job_id 필요"}), 400 try: out = owj.start_postprocess_rerun(job_id) return jsonify(out) except Exception as e: logger.warning("optuna postprocess rerun failed: %s", e) return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/optuna/postprocess", methods=["POST"]) def api_optuna_postprocess(): import subprocess import os try: script_path = os.path.join(os.path.dirname(__file__), "scripts", "run_optuna_postprocess.sh") log_path = os.path.join(os.path.dirname(__file__), "logs", "optuna_postprocess.log") with open(log_path, "w") as f: subprocess.Popen(["nohup", "bash", script_path], stdout=f, stderr=subprocess.STDOUT, start_new_session=True) return jsonify({"ok": True, "message": "후처리가 백그라운드에서 시작되었습니다."}) except Exception as e: logger.warning("optuna postprocess failed: %s", e) return jsonify({"ok": False, "error": str(e)}), 400 # ── 전략 백테 CLI 잡 (꼬리·스캘핑·돌파·모멘텀 공용) ───────────────────────── @app.route("/api/backtest/job/start", methods=["POST"]) def api_bt_job_start(): """백그라운드 백테 시작. strategy=tail|scalp|breakout|momentum.""" from kis_trader.backtest import bt_web_jobs as bj body = request.get_json(silent=True) or {} strategy = (body.get("strategy") or "tail").strip().lower() start = (body.get("start") or "").strip() end = (body.get("end") or "").strip() try: tf_raw = body.get("timeframe") or body.get("tf") tf = int(tf_raw) if tf_raw not in (None, "") else None except (TypeError, ValueError): tf = None univ = (body.get("universe") or "history").strip().lower() hist_src = (body.get("universe_history_source") or "").strip().lower() or None tick_db = body.get("backtest_use_tick_db") tick_exit = body.get("backtest_use_tick_exit") if tick_db is not None: tick_db = str(tick_db).strip().lower() in ("1", "true", "t", "y", "yes", "on") if tick_exit is not None: tick_exit = str(tick_exit).strip().lower() in ("1", "true", "t", "y", "yes", "on") # scalp/breakout 등 폼에 호가체크 없으면 auto(=DB). off 강제 금지 ob = (body.get("orderbook_filter") or "auto").strip().lower() or "auto" env_tl = str(body.get("env_timeline") or "").strip().lower() in ( "1", "true", "t", "y", "yes", "on", ) try: job = bj.start_bt_job( strategy=strategy, start=start, end=end, timeframe=tf, universe=univ, universe_history_source=hist_src, tick_db=tick_db, tick_exit=tick_exit, orderbook_filter=ob, env_timeline=env_tl, ) return jsonify({"ok": True, "job": job}) except Exception as e: logger.warning("bt job start failed: %s", e) return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/backtest/job/status", methods=["GET"]) def api_bt_job_status(): from kis_trader.backtest import bt_web_jobs as bj job_id = (request.args.get("job_id") or "").strip() if not job_id: return jsonify({"ok": False, "error": "job_id 필요"}), 400 job = bj.refresh_job(job_id) if not job: return jsonify({"ok": False, "error": "job 없음"}), 404 return jsonify({"ok": True, "job": job}) @app.route("/api/backtest/job/stop", methods=["POST"]) def api_bt_job_stop(): from kis_trader.backtest import bt_web_jobs as bj body = request.get_json(silent=True) or {} job_id = (body.get("job_id") or request.args.get("job_id") or "").strip() if not job_id: return jsonify({"ok": False, "error": "job_id 필요"}), 400 try: job = bj.stop_bt_job(job_id) return jsonify({"ok": True, "job": job}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 400 @app.route("/api/backtest/job/result", methods=["GET"]) def api_bt_job_result(): from kis_trader.backtest import bt_web_jobs as bj job_id = (request.args.get("job_id") or "").strip() if not job_id: return jsonify({"ok": False, "error": "job_id 필요"}), 400 job = bj.refresh_job(job_id) if not job: return jsonify({"ok": False, "error": "job 없음"}), 404 rj = job.get("result_json") if not rj or not Path(str(rj)).is_file(): return jsonify({"ok": False, "error": "결과 파일 없음", "job": job}), 404 try: data = json.loads(Path(str(rj)).read_text(encoding="utf-8")) except Exception as e: return jsonify({"ok": False, "error": f"결과 파싱 실패: {e}"}), 400 web_shaped = bool( data.get("summary") and (data.get("equity") is not None or data.get("trades") is not None) ) out = { "ok": True, "job_id": job_id, "strategy": job.get("strategy") or data.get("strategy") or "tail", "result_json": str(rj), "summary": data.get("summary") or {}, "trades": data.get("trades") or [], "meta": data.get("meta") or {}, "params": data.get("params") or {}, "params_used": data.get("params_used") or {}, "equity": data.get("equity") or [], "daily": data.get("daily") or [], "reasons": data.get("reasons") or {}, "timeframe": data.get("timeframe") or job.get("timeframe"), "universe_source": data.get("universe_source") or ((data.get("params") or {}).get("universe_source")) or job.get("universe"), "data": data if web_shaped else data.get("data"), "raw": data, } return jsonify(out) # 하위호환: 꼬리 전용 URL @app.route("/api/backtest/tail/job/start", methods=["POST"]) def api_tail_bt_job_start(): body = dict(request.get_json(silent=True) or {}) body["strategy"] = "tail" # 기존 클라이언트가 timeframe 기본을 기대 if body.get("timeframe") in (None, ""): body["timeframe"] = 3 with app.test_request_context( "/api/backtest/job/start", method="POST", json=body, ): return api_bt_job_start() @app.route("/api/backtest/tail/job/status", methods=["GET"]) def api_tail_bt_job_status(): return api_bt_job_status() @app.route("/api/backtest/tail/job/stop", methods=["POST"]) def api_tail_bt_job_stop(): return api_bt_job_stop() @app.route("/api/backtest/tail/job/result", methods=["GET"]) def api_tail_bt_job_result(): return api_bt_job_result() # ── 운영/모의(KIS_MOCK) 토글 — 매매 클라이언트만. 시세 실키는 유지. ───────── _KIS_TRADER_UNIT = "kis_trader_main.service" def _systemctl_kis_trader(verb: str, *, use_sudo: bool = True, timeout: int = 30) -> Tuple[int, str]: """kis_trader_main.service 전용 systemctl (mm_butler 와 동일 화이트리스트).""" cmd: List[str] = [] if use_sudo: cmd.append("sudo") cmd.extend(["/bin/systemctl", verb, _KIS_TRADER_UNIT]) try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) out = ((r.stdout or "") + (r.stderr or "")).strip() return int(r.returncode), out except subprocess.TimeoutExpired: return 124, "systemctl 시간 초과" except FileNotFoundError as e: return 127, f"systemctl 불가: {e}" except Exception as e: return 1, str(e) def _kis_mock_from_db() -> bool: from kis_trader.utils.env import get_env_bool, invalidate_merged_env_cache invalidate_merged_env_cache() return bool(get_env_bool("KIS_MOCK", True)) def _kis_mock_status_payload() -> Dict[str, Any]: from kis_trader.utils.env import get_env_bool, get_env_from_db, invalidate_merged_env_cache invalidate_merged_env_cache() kis_mock = _kis_mock_from_db() rc, status = _systemctl_kis_trader("is-active", use_sudo=False, timeout=10) # ── 소스 필터 표시용 (백테 디버그 설정) ────────────────────────────── from kis_trader.backtest.bt_candle_source import resolve_bt_candle_source_label candle_src = resolve_bt_candle_source_label() tick_src = (get_env_from_db("TICK_SOURCE", "") or "전체").strip() ob_src = (get_env_from_db("OB_SOURCE", "") or "전체").strip() # ── 라이브 프로바이더 (실매 데이터 공급사) ──────────────────────────── tick_provider = (get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom").strip().lower() ob_provider = (get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower() # KIWOOM_MOCK: "" 또는 미설정 = 실전 (false) kw_mock_raw = (get_env_from_db("KIWOOM_MOCK", "") or "").strip().lower() kiwoom_mock = kw_mock_raw in ("1", "true", "t", "yes", "y", "on") # KIS 시세 WS는 ws_manager.py 에서 is_mock=False 고정 (실전 서버 전용) kis_price_mock = False # ── 틱 DB 적재 ──────────────────────────────────────────────────────── tick_db_enabled = bool(get_env_bool("WS_TICK_SAVE_ENABLED", True)) tick_db_scope = (get_env_from_db("WS_TICK_RECORD_SCOPE", "candidates") or "candidates").strip() bot_active = (status or "").strip() == "active" return { "ok": True, "kis_mock": kis_mock, "label": "모의" if kis_mock else "실전", "bot_active": bot_active, "bot_status": (status or "").strip() or "unknown", "unit": _KIS_TRADER_UNIT, "is_active_rc": rc, # 소스 필터 (백테 디버그) "candle_source": candle_src if candle_src.startswith("LIVE(") else ( candle_src.upper() if candle_src else "전체" ), "tick_source": tick_src.upper() if tick_src != "전체" else "전체", "ob_source": ob_src.upper() if ob_src != "전체" else "전체", # 라이브 프로바이더 + 모의실전 "tick_provider": tick_provider, "ob_provider": ob_provider, "kiwoom_mock": kiwoom_mock, "kis_price_mock": kis_price_mock, # 틱 DB 적재 "tick_db_enabled": tick_db_enabled, "tick_db_scope": tick_db_scope, } @app.route("/api/ops/kis_mock", methods=["GET"]) def api_ops_kis_mock_get(): """매매 모의/실전(KIS_MOCK) + 봇 active 상태.""" try: return jsonify(_kis_mock_status_payload()) except Exception as e: logger.exception("ops kis_mock GET 실패") return jsonify({"ok": False, "error": str(e)}), 500 @app.route("/api/ops/kis_mock", methods=["POST"]) def api_ops_kis_mock_set(): """KIS_MOCK DB 저장 후 kis_trader_main 재시작 (매매 client 재생성). 시세 market_client(실키) 정책은 봇 기동 로직 그대로. 웹 프로세스는 재시작하지 않음. """ body = request.get_json(silent=True) or {} raw = body.get("kis_mock", None) if raw is None: return jsonify({"ok": False, "error": "kis_mock 필수"}), 400 if isinstance(raw, bool): want_mock = raw else: want_mock = str(raw).strip().lower() in ("1", "true", "t", "yes", "y", "on") db_ok = False env_id = None try: from kis_trader.utils.env import invalidate_merged_env_cache db = TradeDB() try: env_id = db.insert_env_snapshot({ "KIS_MOCK": "true" if want_mock else "false", }) db_ok = bool(env_id) finally: db.close() invalidate_merged_env_cache() except Exception as e: logger.exception("ops kis_mock DB 저장 실패") return jsonify({ "ok": False, "db_ok": False, "restart_ok": False, "error": f"DB 저장 실패: {e}", }), 500 if not db_ok: return jsonify({ "ok": False, "db_ok": False, "restart_ok": False, "error": "insert_env_snapshot 실패", }), 500 rc, out = _systemctl_kis_trader("restart", use_sudo=True, timeout=45) # 기동 여유 import time as _time _time.sleep(2.0) rc2, status = _systemctl_kis_trader("is-active", use_sudo=False, timeout=10) bot_active = (status or "").strip() == "active" restart_ok = (rc == 0 and bot_active) payload = { "ok": bool(db_ok and restart_ok), "db_ok": db_ok, "env_id": env_id, "kis_mock": want_mock, "label": "모의" if want_mock else "실전", "restart_ok": restart_ok, "restart_rc": rc, "restart_out": out, "bot_active": bot_active, "bot_status": (status or "").strip() or "unknown", "is_active_rc": rc2, "unit": _KIS_TRADER_UNIT, "note": "시세는 실키 유지 · 매매/계좌만 KIS_MOCK", } if not restart_ok: payload["error"] = ( f"DB는 저장됨(KIS_MOCK={'true' if want_mock else 'false'}) " f"하지만 봇 재시작 실패(rc={rc}, status={status}). " f"수동: sudo systemctl restart {_KIS_TRADER_UNIT}" ) return jsonify(payload), 500 return jsonify(payload) @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)