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