Files
kis_bot/backtest_web.py
2026-07-30 18:05:07 +09:00

9220 lines
387 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
backtest_web.py — 매매 성과 분석 & 백테스트 웹 대시보드
==========================================================
실행: python3 backtest_web.py
접속: http://localhost:5050
탭1. 실거래 분석 → trade_history 기반 (SCALP / SHORT / MOMENTUM / UPDOW / BREAKOUT / HOLDING 등)
탭2. 오늘 운영 → 당일 전략별·합계 거래대금·뽀찌(운용한도) 사용량·총수익률 (봇 재시작 불필요)
탭2b. 운영 설정 → 일일익절·전략ON/OFF·운용한도·TRIGGER 필터 등 env 실시간 조회·저장
탭3. 보유·매도 → active_trades + 실계좌 대조, OrderManager 시장가 매도 (HTS 수동매도 DB 꼬임 방지)
탭3. 스캘핑 백테스트 → ws_candles 1분봉 가격 재현(Price-Replay) 백테스트
탭4. 꼬리잡기 백테스트 → ws_candles 3분봉 기반, tail_engine 연동 (V3 방어 파라미터 지원)
"""
import sys, os, math, json, logging, threading, uuid, subprocess, time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, List, Dict, Optional, Tuple, Generator
sys.path.insert(0, os.path.dirname(__file__))
from database import TradeDB
import holding_bot as hb
import kis_holding_ver1 as hv1 # V1: RSI 3단계 분할매수 (횡보장 전략)
from kis_trader.strategies import updow_holding_cfg as uhc # _updow_db(분봉 BT 테이블 보장)·영구구독 메타 resolve
from kis_trader.utils.strategy_ids import (
EXCLUDED_STRATEGY_IDS,
KIS_TRADER_STRATEGY_IDS,
PORTFOLIO_EXCLUDED_STRATEGY_IDS,
canonical_strategy_id,
is_bot_strategy,
strategy_like_pattern,
strategy_prefix_for_filter,
)
_LEGACY_STRATEGY_SQL = ", ".join(["%s"] * len(EXCLUDED_STRATEGY_IDS))
_PORTFOLIO_EXCLUDED_SQL = ", ".join(["%s"] * len(PORTFOLIO_EXCLUDED_STRATEGY_IDS))
from flask import Flask, Response, jsonify, request, render_template, stream_with_context
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("backtest_web")
# TradeDB 초기화/종료 반복 로그 억제 (백테스트 루프에서 수백 번 찍히는 것 방지)
logging.getLogger("TradeDB").setLevel(logging.WARNING)
app = Flask(__name__)
app.config["TEMPLATES_AUTO_RELOAD"] = True
# 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 _default_trading_day_ymd() -> str:
"""주말·휴장이면 이전 장운영일 — 웹/API 날짜 인풋·기본 end/day."""
try:
from kis_trader.utils.kr_trading_day import default_kr_trading_end
return default_kr_trading_end()
except Exception:
return datetime.now().strftime("%Y-%m-%d")
def _get_fee_defaults() -> dict:
"""
DB 병합 스냅샷(env_config 공통)에서 수수료/세금 기본값 로드.
"""
try:
from kis_trader.utils.env import get_merged_env_dict
r = get_merged_env_dict()
if r:
return {
"fee_rate": float(r.get("FEE_RATE_PCT") or 0.015),
"sell_tax": float(r.get("SELL_TAX_RATE_PCT") or 0.18),
}
except Exception:
pass
return {"fee_rate": 0.015, "sell_tax": 0.18}
def _strategy_env(strategy_id: str) -> Dict[str, Any]:
"""config_{strategy} + env_config 병합 — 웹·백테 단일 소스."""
from kis_trader.utils.env import get_strategy_env_dict
return get_strategy_env_dict(strategy_id)
def _compute_rsi_series(closes: list, period: int = 3) -> list:
"""RSI 시리즈 계산 (Wilder 스무딩)"""
rsi_list = [None] * len(closes)
if len(closes) < period + 1:
return rsi_list
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
gains = [max(d, 0) for d in deltas]
losses = [max(-d, 0) for d in deltas]
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
for i in range(period, len(closes)):
idx = i - 1 # delta 배열 기준
if i > period:
avg_gain = (avg_gain * (period - 1) + gains[idx]) / period
avg_loss = (avg_loss * (period - 1) + losses[idx]) / period
rs = avg_gain / avg_loss if avg_loss > 0 else float('inf')
rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0
rsi_list[i] = rsi_val
return rsi_list
# ────────────────────────────────────────────────────────────────────────────
# 가상거래에 종목명 부여 (stock_meta 조회, 실거래와 동일하게 표시)
# ────────────────────────────────────────────────────────────────────────────
def _enrich_trades_with_names(db, trades: list) -> None:
"""trades 리스트 내 각 거래에 'name' 필드 추가.
1) ``stock_meta`` (키움 테마 스크립트 등으로 채운 마스터)
2) 없으면 ``target_candidates_history`` 최근 스냅샷 name (조건검색 적재)
3) 둘 다 없으면 code 그대로
"""
if not trades:
return
codes = list({str(t.get("code")).strip() for t in trades if t.get("code")})
if not codes:
return
code_to_name: Dict[str, str] = {}
try:
placeholders = ", ".join(["%s"] * len(codes))
rows = db.conn.execute(
"SELECT code, name FROM stock_meta WHERE code IN (" + placeholders + ")",
codes,
).fetchall()
for r in rows:
c = str(r["code"]).strip()
n = (r.get("name") or "").strip()
if n and n != c:
code_to_name[c] = n
except Exception as exc:
logger.debug("stock_meta 이름 조회 실패: %s", exc)
missing = [c for c in codes if c not in code_to_name]
if missing:
try:
ph = ", ".join(["%s"] * len(missing))
rows = db.conn.execute(
"SELECT code, name FROM target_candidates_history "
"WHERE code IN (" + ph + ") AND name IS NOT NULL AND name != '' AND name != code "
"ORDER BY COALESCE(event_time, scan_time) DESC, id DESC",
missing,
).fetchall()
for r in rows:
c = str(r["code"]).strip()
if c in code_to_name:
continue
n = (r.get("name") or "").strip()
if n and n != c:
code_to_name[c] = n
except Exception as exc:
logger.debug("target_candidates_history 이름 조회 실패: %s", exc)
# 3) 그래도 없으면 실계좌 잔고(보유중인 경우만 이름 확인 가능)에서 보완 후 stock_meta 캐싱.
# - 키움 조건검색 실시간 응답엔 종목명이 없어(코드=이름 임시값) 여기서 채워야
# 다음부터는 캐시 hit. inquire-price 는 hts_kor_isnm 미제공이라 잔고 API(prdt_name) 사용.
# - TTL 캐시된 잔고 조회라 REST 폭주 없음(force=False).
still_missing = [c for c in codes if c not in code_to_name]
if still_missing:
try:
order_mgr, _ = _portfolio_infra()
holdings = order_mgr.get_broker_holdings(force=False) or {}
except Exception as exc:
holdings = {}
logger.debug("종목명 잔고 조회용 client 준비 실패: %s", exc)
for c in still_missing:
n = str((holdings.get(c) or {}).get("name") or "").strip()
if n and n != c:
code_to_name[c] = n
db.upsert_stock_meta(c, name=n)
for t in trades:
c = str(t.get("code") or "").strip()
if c in code_to_name:
t["name"] = code_to_name[c]
else:
try:
from kis_trader.utils.stock_name import resolve_stock_display_name
t["name"] = resolve_stock_display_name(db, c, fallback=c)
except Exception:
t["name"] = c
def _norm_ts(t: Dict[str, Any], keys) -> str:
"""거래 dict 에서 키 후보 중 첫 유효값을 14자리 타임스탬프 문자열로 정규화."""
for k in keys:
v = t.get(k)
if v is not None and str(v).strip():
s = str(v).replace("-", "").replace(":", "").replace(" ", "").replace("T", "")
return s[:14].ljust(14, "0")
return ""
def _trade_exit_sort_key(t: Dict[str, Any]):
"""가상/실거래 행 — 매도(청산) 시각 정렬용 (엄격한 전순서).
매도시각만으로는 같은 분(09:02:00)에 청산된 거래가 동점이 되어,
오름차순(누적손익 계산)과 내림차순(화면 표시)의 동점 처리가 어긋나면서
누적손익 컬럼이 표시순과 따로 노는 버그가 있었다. 매수시각·종목·가격을
보조키로 추가해 동점을 제거 → 두 정렬이 정확히 거울상이 되도록 한다.
"""
exit_ts = _norm_ts(t, ("exit_time", "sell_time", "sell_date", "buy_time", "entry_time", "buy_date"))
entry_ts = _norm_ts(t, ("entry_time", "buy_time", "buy_date"))
return (
exit_ts,
entry_ts,
str(t.get("code") or ""),
str(t.get("buy_price") or t.get("entry_price") or ""),
str(t.get("qty") or t.get("quantity") or ""),
)
def _int_display_price(v: Any) -> int:
"""거래내역 표시용 — 주식 가격 정수(원)."""
try:
return int(round(float(v or 0)))
except (TypeError, ValueError):
return 0
def _format_trade_time_display(raw: Any) -> str:
"""웹 거래표 — YYYYMMDDHHMM(봉)/ISO 모두 초(SS) 포함 표시."""
if raw is None:
return ""
s = str(raw).strip()
if not s:
return ""
if len(s) >= 19 and s[4] == "-" and ":" in s:
return s[:19]
if len(s) >= 16 and s[4] == "-" and s.count(":") == 1:
return s[:16] + ":00"
d = s.replace("-", "").replace(":", "").replace(" ", "").replace("T", "")
if len(d) >= 14:
return (
f"{d[:4]}-{d[4:6]}-{d[6:8]} {d[8:10]}:{d[10:12]}:{d[12:14]}"
)
if len(d) >= 12:
return f"{d[:4]}-{d[4:6]}-{d[6:8]} {d[8:10]}:{d[10:12]}:00"
if len(d) >= 8:
return f"{d[:4]}-{d[4:6]}-{d[6:8]}"
return s
def _enrich_trade_times_for_web(trade: Dict[str, Any]) -> None:
"""entry/exit·buy/sell 시각 필드를 웹 표시용(초 포함)으로 통일."""
for key in (
"entry_time", "exit_time", "buy_time", "sell_time", "buy_date", "sell_date",
):
if trade.get(key) is not None and str(trade.get(key)).strip():
trade[key] = _format_trade_time_display(trade[key])
if not trade.get("buy_time") and trade.get("entry_time"):
trade["buy_time"] = trade["entry_time"]
if not trade.get("sell_time") and trade.get("exit_time"):
trade["sell_time"] = trade["exit_time"]
if not trade.get("buy_date") and trade.get("buy_time"):
trade["buy_date"] = trade["buy_time"]
if not trade.get("sell_date") and trade.get("sell_time"):
trade["sell_date"] = trade["sell_time"]
if not trade.get("entry_time") and trade.get("buy_time"):
trade["entry_time"] = trade["buy_time"]
if not trade.get("exit_time") and trade.get("sell_time"):
trade["exit_time"] = trade["sell_time"]
def _trade_with_int_prices(trade: Dict) -> Dict:
"""웹 거래표 매수가·매도가 — 소수점 제거 + 시각 초 표시."""
out = dict(trade)
_enrich_trade_times_for_web(out)
for k in (
"buy_price", "sell_price", "entry_price", "exit_price",
"entry", "exit", "avg_price",
"pnl", "realized_pnl", "unrealized_pnl", "cum_pnl",
):
if k in out and out[k] is not None and out[k] != "":
out[k] = _int_display_price(out[k])
return out
def _trades_recent_first(trades: List[Dict], limit: int = 200) -> List[Dict]:
"""매도 시각 기준 최신순 상위 limit 건 (가상 거래 내역 표시용)."""
if not trades:
return []
ordered = sorted(trades, key=_trade_exit_sort_key, reverse=True)
cap = max(0, int(limit))
sliced = ordered[:cap] if cap else ordered
return [_trade_with_int_prices(t) for t in sliced]
def _momentum_source_label(kind: str, src: str) -> str:
s = str(src or "").strip().lower()
if kind == "entry":
if s in ("ws_ticks", "tick"):
return "틱진입"
if s in ("ohlc_open", "open"):
return "시가"
if s in ("ohlc_low", "ohlc_fallback", "ohlc_high", "ohlc_intrabar"):
return "진입OHLC"
return s or "-"
if s in ("ws_ticks", "tick"):
return "틱청산"
if s in ("ohlc_bar", "ohlc_fallback"):
return "청산OHLC"
if s in ("eod_flat", "wallclock_eod", "wallclock_last"):
return "틱공백EOD"
return s or "-"
def _parity_exception_causes(t: Dict[str, Any]) -> List[str]:
"""정합 예외 경로 라벨 — UI 빨간 행용 (시가 align 은 정상, OHLC폴백·틱공백EOD 만)."""
causes: List[str] = []
es = str(t.get("entry_source") or "").strip().lower()
xs = str(t.get("exit_source") or "").strip().lower()
reason = str(t.get("sell_reason") or t.get("reason") or "")
if es in ("ohlc_low", "ohlc_fallback", "ohlc_high", "ohlc_intrabar", "ohlc_bar"):
causes.append("진입OHLC")
if xs in ("ohlc_bar", "ohlc_fallback"):
causes.append("청산OHLC")
if xs in ("eod_flat", "wallclock_eod"):
causes.append("틱공백EOD")
if "ghost_purge" in reason.lower():
causes.append("ghost_purge")
return causes
def _enrich_momentum_trades_debug(
trades: List[Dict],
*,
total_budget_krw: float,
with_tick_debug: bool = True,
) -> None:
"""매도 시각 순 누적손익·누적% (선택: 틱/OHLC 디버그 라벨). 전 전략 거래표 공용."""
if not trades:
return
ordered = sorted(trades, key=_trade_exit_sort_key)
cum = 0.0
tb = float(total_budget_krw or 0)
for t in ordered:
pnl = float(t.get("pnl") or t.get("realized_pnl") or 0)
cum += pnl
t["cum_pnl"] = int(round(cum))
t["cum_return_pct"] = round(cum / tb * 100.0, 2) if tb > 0 else 0.0
causes = _parity_exception_causes(t)
t["parity_exception"] = bool(causes)
t["parity_cause"] = "+".join(causes) if causes else ""
if with_tick_debug:
el = _momentum_source_label("entry", str(t.get("entry_source") or ""))
xl = _momentum_source_label("exit", str(t.get("exit_source") or ""))
t["entry_source_label"] = el
t["exit_source_label"] = xl
t["debug_tick"] = f"{el}{xl}"
if causes:
t["debug_tick"] = f"{t['debug_tick']} · {t['parity_cause']}"
def _peak_cum_from_trades(trades: List[Dict]) -> Tuple[float, str]:
"""매도시각 순 장중 누적 최고액·도달 시각."""
peak = 0.0
peak_at = ""
cum = 0.0
for t in sorted(trades or [], key=_trade_exit_sort_key):
cum += float(t.get("pnl") or t.get("realized_pnl") or 0)
if cum > peak:
peak = cum
peak_at = str(
t.get("exit_time") or t.get("sell_time") or t.get("sell_date") or ""
)
return peak, peak_at[:19] if peak_at else ""
def _resolve_backtest_universe(
db: TradeDB,
start_key: str,
end_key: str,
use_saved_history: bool,
codes_candles: Optional[Dict[str, List[Dict]]] = None,
sim_kind: Optional[str] = None,
scan_interval_min: int = 5,
strategy_id: Optional[str] = None,
history_source: Optional[str] = None,
) -> Tuple[Optional[Dict[str, List[str]]], str, int, int]:
"""
백테스트 유니버스 소스 통합 (param_search·신봇 실매와 동일 조회).
- ``use_saved_history=True`` + ``strategy_id``:
``TradeDBExt.get_universe_by_candle_time`` — 전략별 event_time → 1분봉 키.
``history_source``: kiwoom(기본) | ls
- ``sim_kind`` reversal/momentum: 이력 없거나 미사용 시 ``scalping_engine`` 시뮬 (5분 슬롯).
- 둘 다 아니면 ``None`` → ws_candles 전 종목 (꼬리·돌파).
Returns:
(universe_by_slot, source_label, history_bin_count, engine_scan_interval_min)
"""
start_ymd = start_key[:8]
end_ymd = end_key[:8]
sim_interval = scan_interval_min
if use_saved_history and strategy_id:
try:
from kis_trader.database.db_manager import get_db as _get_ext_db
from kis_trader.backtest.universe_timeline import (
universe_exit_debounce_sec_for_strategy,
)
from kis_trader.backtest.universe_history_source import (
history_source_label,
resolve_backtest_universe_history_source,
)
# EXIT 디바운스 = 전략 공통 (CONDITION_EXIT_GRACE / 전략별 오버라이드)
# SCALP만 특수처리하면 웹↔Optuna 유니버스 두께가 어긋남 (7/20 스캘핑 1만 사례)
debounce_sec = universe_exit_debounce_sec_for_strategy(strategy_id)
strict = False
strict_lag = 1
if strategy_id == "MOMENTUM":
from kis_trader.backtest.momentum_backtest_common import (
momentum_backtest_universe_strict_enabled,
momentum_backtest_universe_strict_lag_min,
)
strict = momentum_backtest_universe_strict_enabled()
strict_lag = momentum_backtest_universe_strict_lag_min()
hs = resolve_backtest_universe_history_source(history_source)
history = _get_ext_db().get_universe_by_candle_time(
strategy_id=strategy_id,
start_ymd=start_ymd,
end_ymd=end_ymd,
strict=strict,
strict_lag_minutes=strict_lag,
exit_debounce_sec=debounce_sec,
history_source=hs,
)
if history:
return history, history_source_label(hs, strict=strict), len(history), 1
except Exception as exc:
logger.debug(
"유니버스 이력 조회 실패(strategy_id=%s): %s", strategy_id, exc,
)
if sim_kind and codes_candles:
top_n = int(os.environ.get("UPDATE_UNIVERSE_TOP_N", "20"))
min_score = float(os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0"))
if sim_kind == "momentum":
from kis_trader.engine import momentum_engine as _me_uni
slot_map = _me_uni.build_universe_simulation_momentum(
codes_candles,
top_n=top_n,
min_score=min_score,
scan_interval_min=sim_interval,
)
else:
slot_map = se.build_universe_simulation(
codes_candles,
top_n=top_n,
min_score=min_score,
scan_interval_min=sim_interval,
)
return slot_map, "sim", 0, sim_interval
return None, "all", 0, 1
def _parse_backtest_universe_arg(
request,
*,
default: str = "history",
sim_kind: Optional[str] = None,
) -> Tuple[bool, str, Optional[str]]:
"""
백테스트 유니버스 쿼리 통일 — history/sim/all.
- ``universe=history`` : 저장 후보 이력(슬롯·코드)
- ``universe=sim`` : 시뮬 유니버스 (scalping_engine, sim_kind 필수)
- ``universe=all`` : ws_candles 전 종목 (필터 없음)
이력 테이블(키움/LS)은 ``universe_history_source`` 별도 쿼리
(``_parse_universe_history_source_arg``).
레거시 별칭: scalp_universe, mom_universe, tail_universe, bo_universe 등
"""
raw = (
request.args.get("universe")
or request.args.get("scalp_universe")
or request.args.get("mom_universe")
or request.args.get("tail_universe")
or request.args.get("tl_universe")
or request.args.get("bo_universe")
or request.args.get("breakout_universe")
or request.args.get("bt_universe")
or default
).strip().lower()
if raw == "history":
return True, "history", sim_kind
if raw == "sim":
return False, "sim", sim_kind
return False, "all", None
def _parse_universe_history_source_arg(request) -> str:
"""키움 target_candidates_history vs LS ls_candidates_history."""
from kis_trader.backtest.universe_history_source import (
resolve_backtest_universe_history_source,
)
raw = (
request.args.get("universe_history_source")
or request.args.get("hist_src")
or request.args.get("history_source")
or None
)
return resolve_backtest_universe_history_source(raw)
# ────────────────────────────────────────────────────────────────────────────
# API: 실거래 분석
# ────────────────────────────────────────────────────────────────────────────
@app.route("/api/actual", methods=["GET"])
def api_actual():
strategy = request.args.get("strategy", "SHORT")
start = request.args.get("start", "")
end = request.args.get("end", "")
db = _db()
try:
# ── 전략 ID: kis_trader.utils.strategy_ids 와 동일 (SCALP/SHORT/UPDOW … + 구식명 접두어)
like_pattern = strategy_like_pattern(strategy) or "SHORT%"
params = [like_pattern, *EXCLUDED_STRATEGY_IDS]
sql = (
"SELECT * FROM trade_history WHERE strategy LIKE %s "
f"AND strategy NOT IN ({_LEGACY_STRATEGY_SQL})"
)
if start:
sql += " AND sell_date >= %s"
params.append(start + " 00:00:00")
if end:
sql += " AND sell_date <= %s"
params.append(end + " 23:59:59")
sql += " ORDER BY sell_date ASC"
rows = db.conn.execute(sql, params).fetchall()
trades = [dict(r) for r in rows]
# 잔고 동기화·유령정리(ghost_purge) 레코드는 실체결이 아님 → 성과·목록에서 제외
filtered_out = 0
cleaned: List[Dict] = []
for t in trades:
if _is_forced_ghost_trade(t):
filtered_out += 1
continue
cleaned.append(t)
closed_trades = cleaned
# ── 보유 중(active_trades) = 매수만 된 포지션 — 목록에 표시, 손익 집계는 제외 ──
open_rows = _list_active_trades_rows(
None if (strategy or "").upper() == "ALL" else strategy,
for_portfolio=False,
)
open_trades: List[Dict] = []
now_dt = datetime.now()
for row in open_rows:
bd_raw = row.get("buy_date")
if not bd_raw:
continue
bd_str = str(bd_raw)
if start and bd_str < start + " 00:00:00":
continue
if end and bd_str > end + " 23:59:59":
continue
buy_px = float(row.get("avg_buy_price") or 0)
cur_px = float(row.get("current_price") or buy_px)
qty = int(row.get("current_qty") or 0)
if qty <= 0:
continue
try:
buy_time = datetime.strptime(bd_str[:19], "%Y-%m-%d %H:%M:%S")
hold_min = int((now_dt - buy_time).total_seconds() / 60)
except Exception:
hold_min = 0
unrealized = (cur_px - buy_px) * qty if buy_px > 0 else 0.0
profit_rate = ((cur_px - buy_px) / buy_px * 100.0) if buy_px > 0 else 0.0
# 해외(US_*)는 달러 소수 유지 — 정수원 반올림 금지
_is_us_strat = str(strategy or "").upper().startswith("US_")
open_trades.append({
"code": row.get("code"),
"name": row.get("name"),
"strategy": row.get("strategy"),
"buy_price": (round(buy_px, 4) if _is_us_strat else _int_display_price(buy_px)),
"sell_price": (round(cur_px, 4) if _is_us_strat else _int_display_price(cur_px)),
"qty": qty,
"realized_pnl": None,
"unrealized_pnl": (round(unrealized, 4) if _is_us_strat else round(unrealized)),
"profit_rate": round(profit_rate, 2),
"hold_minutes": hold_min,
"buy_date": bd_str,
"sell_date": None,
"sell_reason": "보유중",
"is_open": True,
})
# 표시용: 보유(최신 매수일) + 청산 완료(매도일)
trades = open_trades + closed_trades
trades.sort(
key=lambda t: str(t.get("sell_date") or t.get("buy_date") or ""),
reverse=False,
)
# 날짜 직렬화
for t in closed_trades:
for k in ("buy_date", "sell_date"):
if t.get(k):
t[k] = str(t[k])
for t in open_trades:
if t.get("buy_date"):
t["buy_date"] = str(t["buy_date"])
# 누적 손익·승률 등은 청산 완료 건만 집계
equity = []
cum_pnl = 0.0
for t in closed_trades:
cum_pnl += float(t.get("realized_pnl") or 0)
equity.append({
"date": t["sell_date"][:10] if t.get("sell_date") else "",
"cum_pnl": round(cum_pnl),
"pnl": round(float(t.get("realized_pnl") or 0)),
})
# 요약 통계 (청산 완료만)
total = len(closed_trades)
wins = [t for t in closed_trades if float(t.get("realized_pnl") or 0) > 0]
losses = [t for t in closed_trades if float(t.get("realized_pnl") or 0) < 0]
total_pnl = sum(float(t.get("realized_pnl") or 0) for t in closed_trades)
avg_hold = (
sum(float(t.get("hold_minutes") or 0) for t in closed_trades) / total
) if total else 0
win_pnl = sum(float(t.get("realized_pnl") or 0) for t in wins)
loss_pnl = sum(float(t.get("realized_pnl") or 0) for t in losses)
profit_factor = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
# 최대 낙폭(MDD)
peak, mdd = 0.0, 0.0
cum = 0.0
for t in closed_trades:
cum += float(t.get("realized_pnl") or 0)
if cum > peak:
peak = cum
dd = peak - cum
if dd > mdd:
mdd = dd
# 매도 이유별 집계 (청산 완료만)
reasons: Dict[str, int] = {}
for t in closed_trades:
r = t.get("sell_reason") or "기타"
reasons[r] = reasons.get(r, 0) + 1
# 일별 P&L
daily: Dict[str, float] = {}
for t in closed_trades:
day = (t.get("sell_date") or "")[:10]
if day:
daily[day] = daily.get(day, 0) + float(t.get("realized_pnl") or 0)
daily_list = [{"date": d, "pnl": round(v)} for d, v in sorted(daily.items())]
# 종목별 상위 손익
code_pnl: Dict[str, float] = {}
code_name: Dict[str, str] = {}
for t in closed_trades:
c = t["code"]
code_pnl[c] = code_pnl.get(c, 0) + float(t.get("realized_pnl") or 0)
code_name[c] = t.get("name") or c
top_codes = sorted(code_pnl.items(), key=lambda x: x[1], reverse=True)[:10]
top_list = [{"code": c, "name": code_name[c], "pnl": round(v)} for c, v in top_codes]
# 누적손익·누적% (청산 완료, 매도시각 순 — 백테 거래표와 동일)
# 한도: get_latest_env 스냅샷이 비면 SCALP 600만으로 떨어질 수 있어 merged 우선
try:
from kis_trader.utils.env import get_merged_env_dict, invalidate_merged_env_cache
invalidate_merged_env_cache()
env_row = dict(get_merged_env_dict() or {})
except Exception:
latest_env = db.get_latest_env()
env_row = dict(latest_env["snapshot"]) if latest_env else {}
if not env_row:
latest_env = db.get_latest_env()
env_row = dict(latest_env["snapshot"]) if latest_env else {}
total_budget_krw = 0.0
try:
from kis_trader.backtest.backtest_portfolio_common import resolve_portfolio_params
from kis_trader.backtest.momentum_backtest_common import (
resolve_momentum_portfolio_params,
)
strat_u = (strategy or "SCALP").upper()
if strat_u == "MOMENTUM":
port = resolve_momentum_portfolio_params(env_row, {})
total_budget_krw = float(port.get("total_budget_krw") or 0)
elif strat_u == "US_MOMENTUM":
# 해외 전용 한도 — 없으면 모멘텀 포트 폴백
from kis_trader.utils.env import get_env_float as _gef
total_budget_krw = float(_gef("US_MOMENTUM_TOTAL_BUDGET", 0) or 0)
if total_budget_krw <= 0:
port = resolve_momentum_portfolio_params(env_row, {})
total_budget_krw = float(port.get("total_budget_krw") or 0)
else:
# SHORT/TAIL → 꼬리 한도(120만). SCALP 키로 폴백되면 600만 오표시.
port_strat = _portfolio_strategy_key(strat_u)
if port_strat not in ("TAIL", "SHORT", "SCALP", "BREAKOUT", "RANGE_BREAK", "UPDOW", "DBBAND"):
port_strat = "SCALP"
port = resolve_portfolio_params(env_row, strategy=port_strat)
total_budget_krw = float(port.get("total_budget_krw") or 0)
except Exception:
total_budget_krw = 0.0
peak_cum = 0.0
peak_cum_at = ""
cum_trace = 0.0
for t in sorted(closed_trades, key=lambda x: str(x.get("sell_date") or "")):
cum_trace += float(t.get("realized_pnl") or 0)
t["cum_pnl"] = int(round(cum_trace))
t["cum_return_pct"] = (
round(cum_trace / total_budget_krw * 100.0, 2)
if total_budget_krw > 0 else 0.0
)
t["debug_tick"] = "실매체결"
if cum_trace > peak_cum:
peak_cum = cum_trace
peak_cum_at = str(t.get("sell_date") or "")
_enrich_trades_with_names(db, trades)
tick_live: Dict[str, Any] = {}
try:
from kis_trader.backtest.breakout_tick_loader import build_tick_coverage_meta_for_day
cov_codes = {
str(t.get("code") or "").strip()
for t in closed_trades
if str(t.get("code") or "").strip()
}
if cov_codes and start and end:
# 돌파=1분 · 꼬리=3분 · 그 외 1분 (실매 기록 기준 커버 참고)
tf_map = {"SHORT": 3, "TAIL": 3}
tf = int(tf_map.get((strategy or "").upper(), 1))
tick_live = build_tick_coverage_meta_for_day(
db, cov_codes, start, end,
timeframe=tf, trades=closed_trades,
) or {}
except Exception as _cov_ex:
logger.warning("api_actual tick coverage: %s", _cov_ex)
tick_live = {}
payload = {
"summary": {
"total_trades": total,
"win_trades": len(wins),
"loss_trades": len(losses),
"win_rate": round(len(wins) / total * 100, 1) if total else 0,
"total_pnl": round(total_pnl),
"avg_hold_min": round(avg_hold, 1),
"profit_factor": round(profit_factor, 2),
"max_drawdown": round(mdd),
"peak_cum_pnl": round(peak_cum),
"peak_cum_at": peak_cum_at[:19] if peak_cum_at else "",
},
"params": {
"strategy": strategy,
"total_budget_krw": round(total_budget_krw),
"start": start,
"end": end,
},
"meta": {
"filtered_forced_rows": filtered_out,
"closed_count": len(closed_trades),
"open_count": len(open_trades),
},
"tick_live": tick_live,
"equity": equity,
"daily": daily_list,
"reasons": reasons,
"top_codes": top_list,
"trades": _trades_recent_first(trades, 200),
}
# 해외 전략만 표시용 USD + 당일 환율 원 환산 (국내 핸들러 로직 변경 없음)
if str(strategy or "").upper().startswith("US_"):
try:
from kis_trader.utils.usd_krw_fx import enrich_payload_with_usd_krw_fx
enrich_payload_with_usd_krw_fx(payload)
except Exception as _fx_ex:
logger.warning("api_actual FX enrich: %s", _fx_ex)
payload["currency"] = "USD"
return jsonify(payload)
finally:
db.close()
@app.route("/api/paper/actual", methods=["GET"])
def api_paper_actual():
"""페이퍼 전용 실거래 조회 — trade_history_paper + active_trades_paper.
실매 ``/api/actual`` · 보유·매도 탭과 절대 섞지 않음.
"""
from kis_trader.database.paper_store import (
ensure_paper_tables,
get_active_trades_paper,
list_trade_history_paper,
)
strategy = request.args.get("strategy", "US_MOMENTUM")
start = request.args.get("start", "")
end = request.args.get("end", "")
like_pattern = strategy_like_pattern(strategy) or "US_MOMENTUM%"
db = _db()
try:
ensure_paper_tables(db)
closed_trades = list_trade_history_paper(
db, strategy_like=like_pattern, start=start, end=end
)
for t in closed_trades:
for k in ("buy_date", "sell_date"):
if t.get(k):
t[k] = str(t[k])
t["is_open"] = False
t["paper"] = True
open_map = get_active_trades_paper(
db,
strategy_id=None if (strategy or "").upper() == "ALL" else strategy,
)
open_trades: List[Dict] = []
now_dt = datetime.now()
for code, row in open_map.items():
if (strategy or "").upper() != "ALL":
if str(row.get("strategy") or "").upper() != str(strategy).upper():
# LIKE prefix: US_MOMENTUM vs US
if not str(row.get("strategy") or "").upper().startswith(
str(strategy).upper().rstrip("%")
):
continue
bd_str = str(row.get("buy_date") or "")
if start and bd_str and bd_str < start + " 00:00:00":
continue
if end and bd_str and bd_str > end + " 23:59:59":
continue
buy_px = float(row.get("avg_buy_price") or 0)
cur_px = float(row.get("current_price") or buy_px)
qty = int(row.get("current_qty") or 0)
if qty <= 0:
continue
try:
buy_time = datetime.strptime(bd_str[:19], "%Y-%m-%d %H:%M:%S")
hold_min = int((now_dt - buy_time).total_seconds() / 60)
except Exception:
hold_min = 0
unrealized = (cur_px - buy_px) * qty if buy_px > 0 else 0.0
profit_rate = ((cur_px - buy_px) / buy_px * 100.0) if buy_px > 0 else 0.0
open_trades.append({
"code": code,
"name": row.get("name"),
"strategy": row.get("strategy"),
"buy_price": buy_px,
"sell_price": cur_px,
"qty": qty,
"realized_pnl": None,
"unrealized_pnl": round(unrealized, 4),
"profit_rate": round(profit_rate, 2),
"hold_minutes": hold_min,
"buy_date": bd_str,
"sell_date": None,
"sell_reason": "보유중(paper)",
"is_open": True,
"paper": True,
})
trades = open_trades + closed_trades
total = len(closed_trades)
wins = [t for t in closed_trades if float(t.get("realized_pnl") or 0) > 0]
losses = [t for t in closed_trades if float(t.get("realized_pnl") or 0) < 0]
total_pnl = sum(float(t.get("realized_pnl") or 0) for t in closed_trades)
avg_hold = (
sum(float(t.get("hold_minutes") or 0) for t in closed_trades) / total
) if total else 0.0
payload = {
"ok": True,
"paper": True,
"summary": {
"total_trades": total,
"win_trades": len(wins),
"loss_trades": len(losses),
"win_rate": round(len(wins) / total * 100, 1) if total else 0,
"total_pnl": round(total_pnl, 4),
"avg_hold_min": round(avg_hold, 1),
"open_count": len(open_trades),
},
"params": {
"strategy": strategy,
"start": start,
"end": end,
},
"trades": trades,
}
# 페이퍼 기본 = 해외 US — 표시용 달러 + 당일 환율 원 환산
if str(strategy or "").upper().startswith("US_") or not strategy:
try:
from kis_trader.utils.usd_krw_fx import enrich_payload_with_usd_krw_fx
enrich_payload_with_usd_krw_fx(payload)
except Exception as _fx_ex:
logger.warning("api_paper_actual FX enrich: %s", _fx_ex)
payload["currency"] = "USD"
return jsonify(payload)
finally:
db.close()
@app.route("/api/fx/usd_krw", methods=["GET"])
def api_fx_usd_krw():
"""표시용 USD→KRW 일자별 환율. dates=YYYY-MM-DD,YYYY-MM-DD 또는 start&end."""
from kis_trader.utils.env import get_env_int
from kis_trader.utils.usd_krw_fx import (
get_display_usd_krw_rate,
get_usd_krw_rates,
norm_fx_ymd,
)
dates_raw = (request.args.get("dates") or "").strip()
start = (request.args.get("start") or "").strip()
end = (request.args.get("end") or "").strip()
days: List[str] = []
if dates_raw:
for part in dates_raw.replace(";", ",").split(","):
y = norm_fx_ymd(part)
if y:
days.append(y)
if start or end:
s = norm_fx_ymd(start)
e = norm_fx_ymd(end) or s
if s and e:
try:
a = datetime.strptime(s, "%Y-%m-%d").date()
b = datetime.strptime(e, "%Y-%m-%d").date()
if b < a:
a, b = b, a
days.extend([a.isoformat(), b.isoformat()])
max_span = int(get_env_int("FX_USD_KRW_RANGE_MAX_DAYS", 31))
span = (b - a).days
if 0 < span <= max_span:
cur = a
while cur <= b:
days.append(cur.isoformat())
cur += timedelta(days=1)
except Exception:
pass
if not days:
# 오늘 1건 — HTTP 없이 표시용
today = datetime.now().strftime("%Y-%m-%d")
rate = get_display_usd_krw_rate(today)
return jsonify({
"ok": True,
"pair": "USD/KRW",
"rates": {today: round(float(rate), 4)},
"display_rate": round(float(rate), 4),
"source": "cache_or_fallback",
})
rates = get_usd_krw_rates(days)
# 빠진 날은 표시용 폴백으로 채움 (UI 병기용)
filled = dict(rates)
for y in days:
if y not in filled or not filled.get(y):
filled[y] = round(float(get_display_usd_krw_rate(y)), 4)
disp = get_display_usd_krw_rate(days[-1] if days else "")
return jsonify({
"ok": True,
"pair": "USD/KRW",
"rates": filled,
"display_rate": round(float(disp), 4),
"source": "cache_or_fallback",
})
# ────────────────────────────────────────────────────────────────────────────
# API: 실거래 당일 운영 대시보드 (전략별 + 합계)
# ────────────────────────────────────────────────────────────────────────────
# ※ RANGE_BREAK·DBBAND 는 숨김 전략 → 대시보드 집계 제외. SCALP 포함.
_ACTUAL_DASHBOARD_STRATEGIES: Tuple[str, ...] = (
"SCALP",
"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:
"""실체결이 아닌 정리용 레코드 — 실거래 분석·대시보드 집계에서 제외.
- ghost_purge:* : 브로커 잔고 0주 유령 정리 (PnL=0 장부용)
- 잔고없음(강제정리) / 잔고동기화(외부매도) + 매도가 0
"""
reason = str(row.get("sell_reason") or "")
if "ghost_purge" in reason.lower():
return True
try:
sell_price = float(row.get("sell_price") or 0)
except Exception:
sell_price = 0.0
is_forced = ("잔고없음(강제정리)" in reason) or ("잔고동기화(외부매도)" in reason)
return is_forced and sell_price <= 0
def _day_bounds_kst(day_iso: str) -> Tuple[str, str]:
d = (day_iso or "").strip()[:10]
if len(d) != 10:
d = _default_trading_day_ymd()
return f"{d} 00:00:00", f"{d} 23:59:59"
def _strategy_enabled_from_snapshot(snap: Dict[str, str], strategy_id: str) -> bool:
from kis_trader.utils.env import get_env_bool
key = f"STRATEGY_{strategy_id}_ENABLED"
default = _STRATEGY_ENABLED_DEFAULTS.get(strategy_id, False)
# 스냅샷이 있으면 DB 값 우선 (get_env_bool은 os.environ 폴백 포함)
if snap and key in snap:
raw = str(snap.get(key) or "").strip().lower()
if raw in ("1", "true", "yes", "on"):
return True
if raw in ("0", "false", "no", "off"):
return False
return get_env_bool(key, default)
def _strategy_budget_limit_krw(snap: Dict[str, str], strategy_id: str) -> float:
from kis_trader.backtest.backtest_portfolio_common import resolve_portfolio_params
pf_key = _portfolio_strategy_key(strategy_id)
pf = resolve_portfolio_params(snap, strategy=pf_key)
return float(pf.get("total_budget_krw") or 0)
def _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():
from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day
day = (request.args.get("date") or "").strip()[:10]
if not day:
day = _default_trading_day_ymd()
else:
try:
day = clamp_to_prev_kr_trading_day(day)
except ValueError:
day = _default_trading_day_ymd()
db = _db()
try:
payload = _build_actual_dashboard(db, day)
return jsonify({"ok": True, **payload})
except Exception as e:
logger.exception("actual dashboard 실패")
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
# ────────────────────────────────────────────────────────────────────────────
# API: 스캘핑 가격 재현 백테스트 (ws_candles 기반)
# ────────────────────────────────────────────────────────────────────────────
def _t2dt(t: str) -> datetime:
from kis_trader.utils.trade_time import parse_trade_datetime
return parse_trade_datetime(t)
def _backtest_period_days(start: str, end: str, fallback: int = 1) -> int:
"""백테스트 기간 일수(시작·종료일 포함)."""
try:
s = (start or "").replace("-", "")[:8]
e = (end or "").replace("-", "")[:8]
if len(s) == 8 and len(e) == 8:
d0 = datetime.strptime(s, "%Y%m%d")
d1 = datetime.strptime(e, "%Y%m%d")
return max(1, (d1 - d0).days + 1)
except (ValueError, TypeError):
pass
return max(1, fallback)
from kis_trader.engine import scalping_engine as se
from kis_trader.engine import momentum_engine as me
from kis_trader.backtest import tail_backtest_common as tbc
from kis_trader.backtest import momentum_backtest_common as mbc
from kis_trader.backtest import breakout_backtest_common as bbc
from kis_trader.backtest import range_break_backtest_common as rbc
from kis_trader.backtest import scalping_backtest_common as sbc
# 돌파매매 — strategies 안의 모듈 함수를 그대로 재사용 (실매매-백테 100% 일치)
from kis_trader.strategies.range_break import range_break_ui_to_engine_params
from kis_trader.strategies.breakout import (
breakout_entry_mode,
breakout_invest_amount_krw,
breakout_ui_to_engine_params,
check_buy_signal_breakout_live as _bo_check_buy,
check_sell_signal_breakout_live as _bo_check_sell,
normalize_breakout_max_loss_krw,
resolve_breakout_skip_hts_scan_dupes,
run_breakout_backtest as _bo_run_backtest,
)
from kis_trader.strategies.base import is_strategy_eod_bar
def _momentum_ui_defaults_from_db(_d: Dict[str, Any]) -> Dict[str, Any]:
"""config_momentum + env_config 병합에서 모멘텀 웹·백테 초기값.
- MOMENTUM_* 전용.
- ``insert_env_snapshot`` / param_search_apply → config_momentum 저장.
"""
env: Dict[str, Any] = _strategy_env("MOMENTUM")
def pickf(keys: Tuple[str, ...], default: float) -> float:
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
return float(v)
except (ValueError, TypeError):
continue
return float(default)
def picki(keys: Tuple[str, ...], default: int) -> int:
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
return int(float(v))
except (ValueError, TypeError):
continue
return int(default)
def pick_sl_tp_pct(keys: Tuple[str, ...], default_pct: float) -> float:
"""DB 소수(0.015) 또는 퍼센트(1.5) → 화면·쿼리스트링용 퍼센트 숫자."""
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
x = abs(float(v))
if x == 0:
return 0.0
return x if x >= 0.5 else round(x * 100, 3)
except (ValueError, TypeError):
continue
return float(default_pct)
def pick_trail_ui(col: str, default_ratio: float) -> float:
"""DB 소수 비율(0.02=2%) 또는 퍼센트 숫자(2) → UI 퍼센트 (pick_sl_tp_pct 와 동일 임계)."""
v = env.get(col)
if v not in (None, "", "None"):
try:
x = abs(float(v))
if x == 0:
return 0.0
return x if x >= 0.5 else round(x * 100.0, 6)
except (ValueError, TypeError):
pass
return round(float(default_ratio) * 100, 3)
sl_pct_disp = pick_sl_tp_pct(
("MOMENTUM_STOP_LOSS_PCT", "SCALP_STOP_LOSS_PCT"), 1.5,
)
tp_pct_disp = pick_sl_tp_pct(
("MOMENTUM_TAKE_PROFIT_PCT", "SCALP_TAKE_PROFIT_PCT"), 2.5,
)
tp_max_disp = pick_sl_tp_pct(
("MOMENTUM_TP_MAX_PCT", "SCALP_TP_MAX_PCT"), 2.0,
)
high_chase = pickf(
("MOMENTUM_HIGH_CHASE_THR", "HIGH_CHASE_THR", "SCALP_HIGH_PRICE_CHASE_THRESHOLD", "HIGH_PRICE_CHASE_THRESHOLD"),
float(_d.get("high_chase_thr", 0.96)),
)
max_daily_chg = pickf(
("MOMENTUM_MAX_DAILY_CHG", "MAX_DAILY_CHG", "SCALP_MAX_DAILY_CHANGE_PCT", "MAX_DAILY_CHANGE_PCT"),
float(_d.get("max_daily_chg", 20.0)),
)
min_price = pickf(
("MOMENTUM_MIN_PRICE", "MIN_STOCK_PRICE", "SCALP_MIN_PRICE", "MIN_PRICE_TAIL"),
float(_d.get("min_price", 1000.0)),
)
max_loss_krw = picki(
("MOMENTUM_MAX_LOSS_PER_TRADE_KRW", "SCALP_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"),
int(_d.get("max_loss_krw", 200000)),
)
# DB SCALP_MIN_PROFIT_PCT 는 보통 0.2(=0.2% 표시) / 엔진 min_margin 은 비율
min_margin_disp = round(float(_d.get("min_margin", 0.002)) * 100, 3)
_mmp = env.get("SCALP_MIN_PROFIT_PCT")
if _mmp not in (None, "", "None"):
try:
min_margin_disp = float(_mmp)
except (ValueError, TypeError):
pass
_mommp = env.get("MOMENTUM_MIN_PROFIT_PCT")
if _mommp not in (None, "", "None"):
try:
min_margin_disp = float(_mommp)
except (ValueError, TypeError):
pass
_udf = env.get("MOMENTUM_USE_DEFENSE_FILTERS")
if _udf in (None, "", "None"):
_udf = env.get("SCALP_USE_DEFENSE_FILTERS")
if _udf not in (None, "", "None"):
use_def = str(_udf).strip().lower() in ("1", "true", "y", "yes", "on")
else:
use_def = bool(_d.get("use_defense_filters", True))
slot_money = pickf(
("MOMENTUM_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
float(_d.get("slot_money", 200_000)),
)
mom_slots = picki(("MOMENTUM_MAX_STOCKS", "SCALP_MAX_STOCKS"), 20)
total_budget_krw = int(
float(
pickf(
("MOMENTUM_TOTAL_BUDGET_KRW", "SCALP_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
2_000_000.0,
)
or 0
)
)
# 백테·파라serch 와 동일 포트폴리오 해석 (웹 입력 = CLI --slot-money/--max-stocks/--total-budget)
try:
portfolio = sbc.resolve_scalp_portfolio_params(
env, _d, strategy="MOMENTUM",
slot_money=slot_money, max_stocks=mom_slots, total_budget_krw=total_budget_krw or None,
)
slot_money = float(portfolio["slot_money"])
mom_slots = int(portfolio["max_stocks"])
total_budget_krw = int(float(portfolio["total_budget_krw"]))
except Exception:
portfolio = {}
portfolio_ui_warning = None
# max_loss/손절% 로 자동 계산된 1천만 등이 DB에 저장된 경우 — 1회투자 > 총한도 → 백테 0건
if total_budget_krw > 0 and slot_money > total_budget_krw:
from kis_trader.utils.env import get_env_int
portfolio_ui_warning = (
f"DB 1회투자({slot_money:,.0f}원) > 총운용한도({total_budget_krw:,.0f}원). "
"max_loss÷손절% 자동계산값이 잘못 들어갔을 수 있어 "
"파라serch CLI 기본(1회20만·동시20·한도200만)으로 표시를 교정했습니다."
)
slot_money = float(get_env_int("MOMENTUM_SEARCH_SLOT_MONEY_KRW", 200_000))
mom_slots = int(get_env_int("MOMENTUM_SEARCH_MAX_STOCKS", 20))
total_budget_krw = int(get_env_int("MOMENTUM_SEARCH_TOTAL_BUDGET_KRW", 2_000_000))
# 시가 대비 과열/과매도 컷 — ``param_search_momentum`` coarse 그리드와 동일 개념.
# DB 미설정 시 max=30 (%): coarse 탐색이 끝물 방지에 쓰는 값과 웹 백테를 맞춤 (999=OFF).
mom_max_open = pickf(("MOMENTUM_MAX_FROM_OPEN_PCT",), 30.0)
mom_min_open = pickf(("MOMENTUM_MIN_FROM_OPEN_PCT",), -999.0)
def pick_bool(keys: Tuple[str, ...], default: bool) -> bool:
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
return str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on")
return bool(default)
use_ema_filter = pick_bool(("MOMENTUM_USE_EMA_FILTER",), True)
use_rsi_max_filter = pick_bool(("MOMENTUM_USE_RSI_MAX_FILTER",), False)
pattern_breakout = pick_bool(("MOMENTUM_PATTERN_BREAKOUT",), True)
pattern_pullback = pick_bool(("MOMENTUM_PATTERN_PULLBACK",), True)
ema_fast_period = picki(("MOMENTUM_EMA_FAST_PERIOD",), 9)
ema_slow_period = picki(("MOMENTUM_EMA_SLOW_PERIOD",), 21)
chase_lookback_min = picki(("MOMENTUM_CHASE_LOOKBACK_MIN",), 10)
pullback_lookback_min = picki(("MOMENTUM_PULLBACK_LOOKBACK_MIN",), 15)
pullback_min_pct = pickf(("MOMENTUM_PULLBACK_MIN_PCT",), 0.3)
pullback_max_pct = pickf(("MOMENTUM_PULLBACK_MAX_PCT",), 3.0)
setup_vol_max_mult = pickf(("MOMENTUM_SETUP_VOL_MAX_MULT",), 0.8)
setup_bear_bars_min = picki(("MOMENTUM_SETUP_BEAR_BARS_MIN",), 1)
def pick_shoulder_frac(keys: Tuple[str, ...], default_ratio: float) -> float:
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
x = abs(float(v))
if x == 0:
return 0.0
return round(x * 100, 3) if x < 0.5 else round(x, 3)
except (ValueError, TypeError):
continue
dr = abs(float(default_ratio))
return round(dr * 100, 3) if dr < 0.5 else round(dr, 3)
# ── 호가/프로그램 필터 — 글로벌 vs 모멘텀 전용 분해 (웹 안내 표시용) ──
# 우선순위: 모멘텀 전용키에 값이 있으면 그 값, 비어있으면 글로벌키를 상속.
# (실매·백테 모두 orderbook_filter.orderbook_filter_enabled 가 같은 규칙)
def _filt_truthy(raw: Any) -> bool:
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
_ob_strat_raw = env.get("MOMENTUM_ORDERBOOK_FILTER_ENABLED")
_pg_strat_raw = env.get("MOMENTUM_PROGRAM_FILTER_ENABLED")
_ob_strat_set = _ob_strat_raw not in (None, "", "None")
_pg_strat_set = _pg_strat_raw not in (None, "", "None")
return {
"mom_rsi_min": pickf(("MOMENTUM_RSI_MIN",), 50.0),
"mom_rsi_max": pickf(("MOMENTUM_RSI_MAX",), 80.0),
"mom_vol_mult": pickf(("MOMENTUM_VOL_MULT",), 1.05),
"mom_vol_win": picki(("MOMENTUM_VOL_WIN",), 5),
"mom_time_end_hm": picki(("MOMENTUM_TIME_END_HM",), 1530),
"mom_time_start_hm": picki(
("MOMENTUM_TIME_START", "SCALP_TIME_START", "TIME_START"), 900,
),
"sl_pct": sl_pct_disp,
"tp_pct": tp_pct_disp,
"tp_max_pct": tp_max_disp,
"shoulder_min_high": pick_shoulder_frac(
("MOMENTUM_SHOULDER_MIN_HIGH_PCT", "SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"),
float(_d.get("shoulder_min_high", 0.005)),
),
"shoulder_cut_pct": pick_shoulder_frac(
("MOMENTUM_SHOULDER_CUT_PCT", "SCALP_SHOULDER_CUT_PCT", "SHOULDER_CUT_PCT"),
float(_d.get("shoulder_cut_pct", 0.003)),
),
"trail_trigger": pick_trail_ui("SCALP_ATR_UP_MULT", float(_d.get("trail_trigger", 0.007))),
"trail_stop": pick_trail_ui("SCALP_ATR_DOWN_MULT", float(_d.get("trail_stop", 0.004))),
"cooldown_min": (
max(0, int(float(env.get("MOMENTUM_COOLDOWN_SEC"))) // 60)
if env.get("MOMENTUM_COOLDOWN_SEC") not in (None, "", "None")
else (
max(0, int(float(env.get("SCALP_COOLDOWN_SEC"))) // 60)
if env.get("SCALP_COOLDOWN_SEC") not in (None, "", "None")
else float(_d.get("cooldown_min", 10))
)
),
"max_daily": picki(("MOMENTUM_MAX_DAILY", "SCALP_MAX_DAILY"), 5),
"slot_money": slot_money,
"mom_slots": mom_slots,
"total_budget_krw": total_budget_krw,
"portfolio_ui_warning": portfolio_ui_warning,
"high_chase_thr": high_chase,
"max_daily_chg": max_daily_chg,
"min_price": min_price,
"max_loss_krw": max_loss_krw,
"min_margin": min_margin_disp,
"use_defense_filters": use_def,
"mom_max_from_open_pct": mom_max_open,
"mom_min_from_open_pct": mom_min_open,
"use_ema_filter": use_ema_filter,
"use_rsi_max_filter": use_rsi_max_filter,
"pattern_breakout": pattern_breakout,
"pattern_pullback": pattern_pullback,
"chase_lookback_min": chase_lookback_min,
"pullback_lookback_min": pullback_lookback_min,
"pullback_min_pct": pullback_min_pct,
"pullback_max_pct": pullback_max_pct,
"setup_vol_max_mult": setup_vol_max_mult,
"setup_bear_bars_min": setup_bear_bars_min,
"ema_fast_period": ema_fast_period,
"ema_slow_period": ema_slow_period,
"ob_filter_enabled": _strategy_trigger_filter_enabled(
env, prefix="MOMENTUM", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED",
),
"pg_filter_enabled": _strategy_trigger_filter_enabled(
env, prefix="MOMENTUM", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED",
),
# 글로벌값 / 전용 명시여부 — 웹 안내문("글로벌 X · 실매 Y")용 (체크값엔 영향 없음)
"ob_global_enabled": _filt_truthy(env.get("ORDERBOOK_FILTER_ENABLED")),
"ob_strategy_explicit": _ob_strat_set,
"pg_global_enabled": _filt_truthy(env.get("PROGRAM_FILTER_ENABLED")),
"pg_strategy_explicit": _pg_strat_set,
# 호가 스프레드 상한(%) — 0.45 = 0.45%. kiwoom_0d 본체 재계산용 (6/25~ 유효)
"max_spread_pct": pickf(
("MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT",),
0.45,
),
"eod_enabled": pick_bool(("MOMENTUM_EOD_ENABLED",), True),
"eod_hm": (
str(env.get("MOMENTUM_EOD_HM") or "15:20").strip()
if env.get("MOMENTUM_EOD_HM") not in (None, "", "None")
else "15:20"
),
**_momentum_exit_ui_from_engine(),
}
def _momentum_ratio_to_ui_pct(val: Any, default: float = 0.0) -> float:
"""엔진 비율(0.015) 또는 UI 퍼센트(1.5) → 폼 표시 %."""
if val is None or val == "":
return float(default)
try:
x = abs(float(val))
if x == 0:
return 0.0
return round(x * 100, 3) if x < 0.5 else round(x, 3)
except (ValueError, TypeError):
return float(default)
def _momentum_exit_ui_from_engine() -> Dict[str, Any]:
"""전용 청산 필드 — momentum_engine 단일 소스."""
try:
d = me.get_momentum_defaults_from_db()
except Exception:
d = {}
return {
"trail_pct": _momentum_ratio_to_ui_pct(d.get("trail_pct"), 0.0),
"trail_arm_pct": _momentum_ratio_to_ui_pct(d.get("trail_arm_pct"), 0.0),
"max_hold_bars": int(d.get("max_hold_bars") or 0),
"ratchet_tiers": str(d.get("ratchet_tiers") or ""),
"use_high_chase_filter": bool(d.get("use_high_chase_filter", False)),
"use_daily_range_filter": bool(d.get("use_daily_range_filter", False)),
"use_ema_filter": bool(d.get("use_ema_filter", True)),
"use_rsi_max_filter": bool(d.get("use_rsi_max_filter", False)),
"pattern_breakout": bool(d.get("pattern_breakout", True)),
"pattern_pullback": bool(d.get("pattern_pullback", True)),
"chase_lookback_min": int(d.get("chase_lookback_min") or 10),
"pullback_lookback_min": int(d.get("pullback_lookback_min") or 15),
"pullback_min_pct": float(d.get("pullback_min_pct") or 0.3),
"pullback_max_pct": float(d.get("pullback_max_pct") or 3.0),
"ema_fast_period": int(d.get("ema_fast_period") or 9),
"ema_slow_period": int(d.get("ema_slow_period") or 21),
}
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"),
"backtest_skip_pre_subscribe": bool(d.get("backtest_skip_pre_subscribe", False)),
"use_ema_filter": d.get("use_ema_filter"),
"use_rsi_max_filter": d.get("use_rsi_max_filter"),
"pattern_breakout": d.get("pattern_breakout"),
"pattern_pullback": d.get("pattern_pullback"),
"chase_lookback_min": d.get("chase_lookback_min"),
"pullback_lookback_min": d.get("pullback_lookback_min"),
"pullback_min_pct": d.get("pullback_min_pct"),
"pullback_max_pct": d.get("pullback_max_pct"),
"ema_fast_period": d.get("ema_fast_period"),
"ema_slow_period": d.get("ema_slow_period"),
}
if slot_raw not in (None, ""):
out["slot_money"] = int(float(slot_raw))
if slots_raw not in (None, ""):
out["mom_slots"] = int(float(slots_raw))
if budget_raw not in (None, ""):
out["total_budget_krw"] = int(float(budget_raw))
return out
def _load_momentum_search_json(path: Optional[str] = None) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
"""최신 search_momentum_*.json 또는 지정 경로 로드."""
if path and os.path.isfile(path):
try:
with open(path, "r", encoding="utf-8") as f:
return path, json.load(f)
except (OSError, json.JSONDecodeError):
return path, None
try:
from kis_trader.backtest.param_search_momentum import _latest_json
p = _latest_json("search_momentum_")
except ImportError:
p = None
if not p or not os.path.isfile(p):
return None, None
try:
with open(p, "r", encoding="utf-8") as f:
return p, json.load(f)
except (OSError, json.JSONDecodeError):
return p, None
def _us_momentum_ui_defaults_from_db(snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""US_MOMENTUM_* (env_config) → 해외 모멘텀 탭 초기값. 국내 MOMENTUM_* 와 분리."""
if snap is None:
try:
db = _db()
try:
env: Dict[str, Any] = dict(db.get_merged_env_snapshot() or {})
finally:
db.close()
except Exception:
env = {}
else:
env = dict(snap or {})
def pickf(keys: Tuple[str, ...], default: float) -> float:
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
return float(v)
except (ValueError, TypeError):
continue
return float(default)
def picki(keys: Tuple[str, ...], default: int) -> int:
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
return int(float(v))
except (ValueError, TypeError):
continue
return int(default)
def pick_sl_tp_pct(keys: Tuple[str, ...], default_pct: float) -> float:
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
x = abs(float(v))
if x == 0:
return 0.0
return x if x >= 0.5 else round(x * 100, 3)
except (ValueError, TypeError):
continue
return float(default_pct)
def pick_bool(keys: Tuple[str, ...], default: bool) -> bool:
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
return str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on")
return bool(default)
cd_sec = picki(("US_MOMENTUM_COOLDOWN_SEC",), 300)
min_margin = pickf(("US_MOMENTUM_MIN_PROFIT_PCT",), 0.2)
return {
"mom_rsi_min": pickf(("US_MOMENTUM_RSI_MIN",), 50.0),
"mom_rsi_max": pickf(("US_MOMENTUM_RSI_MAX",), 90.0),
"mom_vol_mult": pickf(("US_MOMENTUM_VOL_MULT",), 1.5),
"mom_vol_win": picki(("US_MOMENTUM_VOL_WIN",), 5),
"time_start": picki(("US_MOMENTUM_TIME_START",), 2230),
"mom_time_start_hm": picki(("US_MOMENTUM_TIME_START",), 2230),
"time_end": picki(("US_MOMENTUM_TIME_END",), 500),
"mom_time_end_hm": picki(("US_MOMENTUM_TIME_END",), 500),
"sell_time_end": picki(("US_MOMENTUM_SELL_TIME_END",), 630),
"sl_pct": pick_sl_tp_pct(("US_MOMENTUM_STOP_LOSS_PCT",), 1.5),
"tp_pct": pick_sl_tp_pct(("US_MOMENTUM_TAKE_PROFIT_PCT",), 2.5),
"tp_max_pct": pick_sl_tp_pct(("US_MOMENTUM_TP_MAX_PCT",), 2.0),
"shoulder_min_high": pick_sl_tp_pct(("US_MOMENTUM_SHOULDER_MIN_HIGH_PCT",), 0.5),
"shoulder_cut_pct": pick_sl_tp_pct(("US_MOMENTUM_SHOULDER_CUT_PCT",), 0.3),
"trail_pct": pick_sl_tp_pct(("US_MOMENTUM_TRAIL_PCT",), 0.0),
"trail_arm_pct": pick_sl_tp_pct(("US_MOMENTUM_TRAIL_ARM_PCT",), 0.0),
"max_hold_bars": picki(("US_MOMENTUM_MAX_HOLD_BARS",), 0),
"ratchet_tiers": str(env.get("US_MOMENTUM_RATCHET_TIERS") or "").strip(),
"cooldown_min": max(0, int(cd_sec // 60)),
"max_daily": picki(("US_MOMENTUM_MAX_DAILY",), 5),
"slot_money": picki(("US_MOMENTUM_SLOT_MONEY", "US_MOMENTUM_MAX_BUY_AMOUNT"), 200000),
"slots": picki(("US_MOMENTUM_MAX_STOCKS",), 4),
"mom_slots": picki(("US_MOMENTUM_MAX_STOCKS",), 4),
"total_budget_krw": picki(("US_MOMENTUM_TOTAL_BUDGET",), 800000),
"high_chase_thr": pickf(("US_MOMENTUM_HIGH_CHASE_THR",), 0.96),
"max_daily_chg": pickf(("US_MOMENTUM_MAX_DAILY_CHG",), 20.0),
"min_price": pickf(("US_MOMENTUM_MIN_PRICE",), 1.0),
"max_loss_krw": picki(("US_MOMENTUM_MAX_LOSS_PER_TRADE",), 0),
"min_margin": min_margin,
"mom_max_from_open_pct": pickf(("US_MOMENTUM_MAX_FROM_OPEN_PCT",), 999.0),
"mom_min_from_open_pct": pickf(("US_MOMENTUM_MIN_FROM_OPEN_PCT",), -999.0),
"use_defense_filters": pick_bool(("US_MOMENTUM_USE_DEFENSE_FILTERS",), True),
"use_high_chase_filter": pick_bool(("US_MOMENTUM_USE_HIGH_CHASE_FILTER",), False),
"use_daily_range_filter": pick_bool(("US_MOMENTUM_USE_DAILY_RANGE_FILTER",), False),
"use_ema_filter": pick_bool(("US_MOMENTUM_USE_EMA_FILTER",), True),
"use_rsi_max_filter": pick_bool(("US_MOMENTUM_USE_RSI_MAX_FILTER",), False),
"pattern_breakout": pick_bool(("US_MOMENTUM_PATTERN_BREAKOUT",), True),
"pattern_pullback": pick_bool(("US_MOMENTUM_PATTERN_PULLBACK",), True),
"chase_lookback_min": picki(("US_MOMENTUM_CHASE_LOOKBACK_MIN",), 10),
"pullback_lookback_min": picki(("US_MOMENTUM_PULLBACK_LOOKBACK_MIN",), 15),
"pullback_min_pct": pickf(("US_MOMENTUM_PULLBACK_MIN_PCT",), 0.3),
"pullback_max_pct": pickf(("US_MOMENTUM_PULLBACK_MAX_PCT",), 3.0),
"setup_vol_max_mult": pickf(("US_MOMENTUM_SETUP_VOL_MAX_MULT",), 0.8),
"setup_bear_bars_min": picki(("US_MOMENTUM_SETUP_BEAR_BARS_MIN",), 1),
"ema_fast_period": picki(("US_MOMENTUM_EMA_FAST_PERIOD",), 9),
"ema_slow_period": picki(("US_MOMENTUM_EMA_SLOW_PERIOD",), 21),
"eod_enabled": pick_bool(("US_MOMENTUM_EOD_ENABLED",), False),
"eod_hm": str(env.get("US_MOMENTUM_EOD_HM") or "05:00").strip() or "05:00",
"daily_profit_enabled": pick_bool(("US_MOMENTUM_DAILY_PROFIT_TARGET_ENABLED",), False),
"daily_trail_tiers": str(env.get("US_MOMENTUM_DAILY_PROFIT_TRAIL_TIERS") or "").strip(),
"daily_profit_mode": (
str(env.get("US_MOMENTUM_DAILY_PROFIT_MODE") or "trailing").strip().lower()
or "trailing"
),
}
def _scalp_ui_defaults_from_db() -> Dict[str, Any]:
"""config_scalp + env_config 병합에서 스캘핑 reversal 웹·백테 초기값."""
_d = se.get_scalping_defaults_from_db()
env = _strategy_env("SCALP")
def pct_ui(db_key: str, ratio_default: float) -> float:
v = env.get(db_key)
if v not in (None, "", "None"):
try:
x = abs(float(v))
if x == 0:
return 0.0
return x if x >= 0.5 else round(x * 100, 3)
except (ValueError, TypeError):
pass
r = abs(float(ratio_default))
return round(r * 100, 3) if r < 0.5 else round(r, 3)
def shoulder_ui(ratio_default: float) -> float:
for k in ("SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"):
v = env.get(k)
if v not in (None, "", "None"):
try:
x = abs(float(v))
return round(x * 100, 3) if x < 0.5 else round(x, 3)
except (ValueError, TypeError):
continue
r = abs(float(ratio_default))
return round(r * 100, 3) if r < 0.5 else round(r, 3)
def shoulder_cut_ui(ratio_default: float) -> float:
for k in ("SCALP_SHOULDER_CUT_PCT", "SHOULDER_CUT_PCT"):
v = env.get(k)
if v not in (None, "", "None"):
try:
x = abs(float(v))
return round(x * 100, 3) if x < 0.5 else round(x, 3)
except (ValueError, TypeError):
continue
r = abs(float(ratio_default))
return round(r * 100, 3) if r < 0.5 else round(r, 3)
_udf = env.get("SCALP_USE_DEFENSE_FILTERS")
if _udf not in (None, "", "None"):
use_def = str(_udf).strip().lower() in ("1", "true", "y", "yes", "on")
else:
use_def = bool(_d.get("use_defense_filters", True))
_um = env.get("SCALP_USE_MACD_CROSS")
if _um not in (None, "", "None"):
use_macd = str(_um).strip().lower() in ("1", "true", "y", "yes", "on")
else:
use_macd = bool(_d.get("use_macd_cross", False))
_mdl = env.get("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT")
if _mdl not in (None, "", "None"):
try:
v = float(_mdl)
min_drop_loss_ui = v * 100 if v < 1 else v
except (ValueError, TypeError):
min_drop_loss_ui = float(_d.get("min_drop_pct_for_loss_cut", 0.015)) * 100
else:
min_drop_loss_ui = float(_d.get("min_drop_pct_for_loss_cut", 0.015)) * 100
sec = env.get("SCALP_COOLDOWN_SEC")
if sec not in (None, "", "None"):
cooldown_min = max(0, int(float(sec)) // 60)
else:
cooldown_min = int(_d.get("cooldown_min", 10))
return {
"rsi_oversold": int(float(env.get("SCALP_RSI_OVERSOLD") or _d.get("rsi_oversold", 25))),
"rsi_overbought": int(float(env.get("SCALP_RSI_OVERBOUGHT") or _d.get("rsi_overbought", 75))),
"sl_pct": pct_ui("SCALP_STOP_LOSS_PCT", _d.get("sl_pct", 0.015)),
"tp_pct": pct_ui("SCALP_TAKE_PROFIT_PCT", _d.get("tp_pct", 0.015)),
"tp_max_pct": pct_ui("SCALP_TP_MAX_PCT", _d.get("tp_max_pct", 0.02)),
"drop_rate": pct_ui("SCALP_MIN_DROP_RATE", _d.get("drop_rate", 0.015)),
"shoulder_min_high": shoulder_ui(_d.get("shoulder_min_high", 0.005)),
"shoulder_cut_pct": shoulder_cut_ui(_d.get("shoulder_cut_pct", 0.003)),
"trail_trigger": pct_ui("SCALP_ATR_UP_MULT", _d.get("trail_trigger", 0.007)),
"trail_stop": pct_ui("SCALP_ATR_DOWN_MULT", _d.get("trail_stop", 0.004)),
"cooldown_min": cooldown_min,
"slot_money": float(env.get("SLOT_MONEY_DEFAULT") or _d.get("slot_money", 3_000_000)),
"high_chase_thr": float(
env.get("SCALP_HIGH_PRICE_CHASE_THRESHOLD")
or env.get("HIGH_CHASE_THR")
or _d.get("high_chase_thr", 0.96)
),
"max_daily_chg": float(
env.get("SCALP_MAX_DAILY_CHANGE_PCT")
or env.get("MAX_DAILY_CHG")
or _d.get("max_daily_chg", 20.0)
),
"min_price": float(env.get("SCALP_MIN_PRICE") or _d.get("min_price", 1000.0)),
"max_loss_krw": int(float(
env.get("SCALP_MAX_LOSS_PER_TRADE_KRW")
or env.get("MAX_LOSS_PER_TRADE_KRW")
or _d.get("max_loss_krw", 200_000)
)),
"min_drop_pct_for_loss_cut": min_drop_loss_ui,
"min_margin": float(env.get("SCALP_MIN_PROFIT_PCT") or _d.get("min_margin", 0.2)),
"use_defense_filters": use_def,
"use_macd_cross": use_macd,
"rsi_period": int(float(env.get("SCALP_RSI_PERIOD") or _d.get("rsi_period", 3))),
"vol_mult": float(_d.get("vol_mult", 0)),
"time_start_hm": int(float(env.get("SCALP_TIME_START") or env.get("TIME_START") or _d.get("time_start_hm", 900))),
"time_end_hm": int(float(env.get("SCALP_TIME_END") or env.get("TIME_END") or _d.get("time_end_hm", 1530))),
"max_daily": int(float(env.get("SCALP_MAX_DAILY") or _d.get("max_daily", 3))),
"max_stocks": int(float(env.get("SCALP_MAX_STOCKS") or env.get("MAX_STOCKS") or _d.get("max_stocks", 3))),
"total_budget_krw": float(env.get("SCALP_TOTAL_BUDGET_KRW") or _d.get("total_budget_krw", 0) or 0),
"skip_hts_scan_dupes": se.resolve_scalp_skip_hts_scan_dupes(env),
"require_reversal_candle": bool(_d.get("require_reversal_candle", True)),
"eod_enabled": (
str(env.get("SCALP_EOD_ENABLED") or "1").strip().lower()
in ("1", "true", "t", "y", "yes", "on")
if env.get("SCALP_EOD_ENABLED") not in (None, "", "None")
else bool(_d.get("eod_enabled", True))
),
"eod_hm": (
str(env.get("SCALP_EOD_HM") or "15:25").strip()
if env.get("SCALP_EOD_HM") not in (None, "", "None")
else str(_d.get("eod_hm") or "15:25")
),
}
def _bo_golden_end_to_hm(s: str) -> int:
"""'10:30' 또는 HHMM → 1030 (실패 시 1030)."""
try:
raw = str(s or "").strip()
if ":" in raw:
hh, mm = raw.split(":", 1)
return int(hh) * 100 + int(mm)
if raw.isdigit():
return int(raw[:4]) if len(raw) >= 4 else int(raw)
except Exception:
pass
return 1030
def _bo_defaults_from_db() -> Dict[str, Any]:
"""돌파 백테·웹 폼 — config_breakout + env_config 병합."""
fee = _get_fee_defaults()
env = _strategy_env("BREAKOUT")
def pick(keys: Tuple[str, ...], default: Any, cast=float):
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
return cast(v)
except (ValueError, TypeError):
continue
return default
sl_r = pick(("BREAKOUT_STOP_LOSS_PCT",), -0.02, float)
tp_r = pick(("BREAKOUT_TAKE_PROFIT_PCT",), 0.05, float)
tr_r = pick(("BREAKOUT_TRAIL_PCT",), 0.015, float)
tra_r = pick(("BREAKOUT_TRAIL_ARM_PCT",), 0.0, float)
smh_r = pick(("BREAKOUT_SHOULDER_MIN_HIGH_PCT",), 0.02, float)
sc_r = pick(("BREAKOUT_SHOULDER_CUT_PCT",), 0.01, float)
def pct_ui(ratio: float) -> float:
av = abs(float(ratio))
if av == 0:
return 0.0
return round(av * 100, 3) if av < 0.5 else round(av, 3)
time_end_raw = env.get("BREAKOUT_TIME_END")
if time_end_raw not in (None, "", "None"):
try:
time_end_hm = int(float(time_end_raw))
except (ValueError, TypeError):
time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30")))
else:
time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30")))
cd_sec = pick(("BREAKOUT_COOLDOWN_SEC",), 0.0, float)
if cd_sec and cd_sec > 0:
cooldown_min = int(cd_sec / 60) if cd_sec > 120 else int(cd_sec)
else:
re_sec = pick(("REENTRY_COOLDOWN_SEC",), 1800.0, float)
cooldown_min = int(re_sec / 60) if re_sec > 120 else int(re_sec)
sl_pct_ui = pct_ui(sl_r)
max_loss_raw = pick(
("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"),
200_000,
lambda v: int(float(v)),
)
max_loss_krw = normalize_breakout_max_loss_krw(max_loss_raw)
slot_cap = pick(
("BREAKOUT_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
2_000_000,
lambda v: int(float(v)),
)
slot_money = int(breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_cap))
portfolio = bbc.resolve_breakout_portfolio_params(
env, None, slot_money=float(slot_money),
)
max_stocks_v = int(portfolio["max_stocks"])
total_budget_v = int(float(portfolio["total_budget_krw"]))
eod_raw = str(env.get("BREAKOUT_EOD_HM") or "15:15").strip()
if eod_raw in ("", "None"):
eod_hm = "15:15"
elif ":" in eod_raw:
eod_hm = eod_raw
elif len(eod_raw) == 4 and eod_raw.isdigit():
eod_hm = f"{eod_raw[:2]}:{eod_raw[2:]}"
else:
eod_hm = eod_raw
eod_enabled_raw = env.get("BREAKOUT_EOD_ENABLED")
if eod_enabled_raw in (None, "", "None"):
eod_enabled = True
else:
eod_enabled = str(eod_enabled_raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
return {
"lookback_min": pick(("BREAKOUT_LOOKBACK_MIN",), 1, lambda v: int(float(v))),
"vol_window": pick(("BREAKOUT_VOL_WIN",), 1, lambda v: int(float(v))),
"vol_mult": pick(("BREAKOUT_VOL_MULT",), 0.0, float),
"min_turnover_1m_pct": pick(("BREAKOUT_MIN_TURNOVER_1M_PCT",), 0.05, float),
"prev_chg_min": pick(("BREAKOUT_PREV_CHG_MIN",), 1.0, float),
"prev_chg_max": pick(("BREAKOUT_PREV_CHG_MAX",), 10.0, float),
"sl_pct": sl_pct_ui,
"tp_pct": pct_ui(tp_r),
"trail_pct": pct_ui(tr_r),
"trail_arm_pct": pct_ui(tra_r),
"shoulder_min_high_pct": pct_ui(smh_r),
"shoulder_cut_pct": pct_ui(sc_r),
# ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정%)
"sl_mode": str(
env.get("BREAKOUT_SL_MODE") or "fixed"
).strip().lower() or "fixed",
"atr_period": pick(("BREAKOUT_ATR_PERIOD",), 14, lambda v: int(float(v))),
"atr_sl_mult": pick(("BREAKOUT_ATR_SL_MULT",), 2.0, float),
"atr_sl_min_pct": pick(("BREAKOUT_ATR_SL_MIN_PCT",), 0.8, float),
"atr_sl_max_pct": pick(("BREAKOUT_ATR_SL_MAX_PCT",), 6.0, float),
"max_hold_bars": pick(("BREAKOUT_MAX_HOLD_BARS",), 0, lambda v: int(float(v))),
"ratchet_tiers": str(env.get("BREAKOUT_RATCHET_TIERS") or ""),
"time_start_hm": pick(("BREAKOUT_TIME_START",), 900, lambda v: int(float(v))),
"time_end_hm": time_end_hm,
"eod_enabled": eod_enabled,
"eod_hm": eod_hm,
"max_daily": pick(("BREAKOUT_MAX_DAILY",), 1, lambda v: int(float(v))),
"cooldown_min": cooldown_min,
"max_daily_chg": pick(("BREAKOUT_MAX_DAILY_CHG",), 15.0, float),
"min_price": pick(("BREAKOUT_MIN_PRICE", "MIN_STOCK_PRICE"), 1000.0, float),
# 가짜돌파(휩쏘) 필터 — 0=OFF
"confirm_margin_pct": pick(("BREAKOUT_CONFIRM_MARGIN_PCT",), 0.0, float),
"body_min_pct": pick(("BREAKOUT_BODY_MIN_PCT",), 0.0, float),
"max_loss_krw": max_loss_krw,
"slot_money": slot_money,
"max_stocks": max_stocks_v,
"total_budget_krw": total_budget_v,
"fee_rate_pct": fee.get("fee_rate", 0.015),
"sell_tax_pct": fee.get("sell_tax", 0.18),
"entry_mode": str(
env.get("BREAKOUT_ENTRY_MODE") or "intrabar"
).strip().lower(),
"intrabar_slippage_pct": float(
pick(("BREAKOUT_INTRABAR_SLIPPAGE_PCT",), 0.0, float)
),
"use_ema_filter": (
str(env.get("BREAKOUT_USE_EMA_FILTER")).strip().lower()
in ("1", "true", "t", "y", "yes", "on")
if env.get("BREAKOUT_USE_EMA_FILTER") not in (None, "", "None")
else False
),
"ema_fast_period": pick(("BREAKOUT_EMA_FAST_PERIOD",), 9, lambda v: int(float(v))),
"ema_slow_period": pick(("BREAKOUT_EMA_SLOW_PERIOD",), 21, lambda v: int(float(v))),
"skip_hts_scan_dupes": resolve_breakout_skip_hts_scan_dupes(env),
"ob_filter_enabled": _strategy_trigger_filter_enabled(
env, prefix="BREAKOUT", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED",
),
"pg_filter_enabled": _strategy_trigger_filter_enabled(
env, prefix="BREAKOUT", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED",
),
"max_spread_pct": pick(
("BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT",), 0.45, float,
),
}
def _breakout_optimal_from_search() -> Dict[str, Any]:
"""최신 search_breakout_*.json 1위 merged_params → 웹 폼 키."""
try:
from kis_trader.backtest.param_search_breakout import _latest_json
except ImportError:
return {}
path = _latest_json("search_breakout_")
if not path or not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return {}
top = data.get("top") or []
if not top:
return {}
item = top[0]
merged = dict(item.get("merged_params") or {})
grid = item.get("params") or {}
if isinstance(grid, dict):
merged.update(grid)
# CLI 포트폴리오·매수시작만 meta 반영 (time_end는 그리드 1위 우선 — session 1530 덮어쓰기 방지)
for k in ("slot_money", "max_stocks", "total_budget_krw", "time_start_hm"):
if data.get(k) is not None:
merged[k] = data[k]
if grid.get("time_end_hm") is not None:
merged["time_end_hm"] = grid["time_end_hm"]
out: Dict[str, Any] = {}
key_map = (
"lookback_min", "vol_window", "vol_mult", "prev_chg_min", "prev_chg_max",
"sl_pct", "tp_pct", "trail_pct", "shoulder_min_high_pct", "shoulder_cut_pct",
"sl_mode", "atr_period", "atr_sl_mult", "atr_sl_min_pct", "atr_sl_max_pct",
"time_start_hm", "time_end_hm", "max_daily", "cooldown_min", "max_daily_chg",
"min_price", "max_loss_krw", "slot_money", "max_stocks", "total_budget_krw",
)
for k in key_map:
v = merged.get(k)
if v is not None and v != "":
out[k] = v
return out
def _breakout_ui_defaults(*, prefer_search_json: bool = False) -> Dict[str, Any]:
"""돌파 탭 입력란용 — 기본은 config_breakout + env_config (봇·실매와 동일).
``prefer_search_json=True`` 일 때만 최신 search_breakout_*.json 1위로 덮어씀 (백테 탐색용).
💾 봇에 설정저장 / ``/api/env/params`` 는 DB만 사용해야 저장값이 보인다.
"""
base = _bo_defaults_from_db()
if not prefer_search_json:
return base
opt = _breakout_optimal_from_search()
for k, v in opt.items():
if v is not None:
base[k] = v
return base
def _bo_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]:
"""웹 폼(%) → 엔진 (``breakout_ui_to_engine_params`` — param_search 와 동일)."""
fee = _get_fee_defaults()
merged = dict(ui)
merged.setdefault("fee_rate_pct", fee.get("fee_rate", 0.015))
merged.setdefault("sell_tax_pct", fee.get("sell_tax", 0.18))
return breakout_ui_to_engine_params(merged)
def _rb_defaults_from_db() -> Dict[str, Any]:
"""박스권 돌파 백테·웹 폼 — config_range_break + env_config 병합."""
fee = _get_fee_defaults()
env = _strategy_env("RANGE_BREAK")
def pick(keys: Tuple[str, ...], default: Any, cast=float):
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
return cast(v)
except (ValueError, TypeError):
continue
return default
def pct_ui(ratio: float) -> float:
av = abs(float(ratio))
if av == 0:
return 0.0
return round(av * 100, 3) if av < 0.5 else round(av, 3)
sl_r = pick(("RANGE_BREAK_STOP_LOSS_PCT",), -0.03, float)
tp_r = pick(("RANGE_BREAK_TAKE_PROFIT_PCT",), 0.10, float)
tr_r = pick(("RANGE_BREAK_TRAIL_PCT",), 0.015, float)
tra_r = pick(("RANGE_BREAK_TRAIL_ARM_PCT",), 0.015, float)
smh_r = pick(("RANGE_BREAK_SHOULDER_MIN_HIGH_PCT",), 0.03, float)
sc_r = pick(("RANGE_BREAK_SHOULDER_CUT_PCT",), 0.005, float)
cd_sec = pick(("RANGE_BREAK_COOLDOWN_SEC",), 1800.0, float)
cooldown_min = int(cd_sec / 60) if cd_sec > 120 else int(cd_sec)
sl_pct_ui = pct_ui(sl_r)
max_loss_raw = pick(
("RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"),
200_000,
lambda v: int(float(v)),
)
max_loss_krw = normalize_breakout_max_loss_krw(max_loss_raw)
slot_cap = pick(
("RANGE_BREAK_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
200_000,
lambda v: int(float(v)),
)
slot_money = int(breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_cap))
portfolio = rbc.resolve_range_break_portfolio_params(
env, None, slot_money=float(slot_money),
)
use_hc = env.get("RANGE_BREAK_USE_HIGH_CHASE_FILTER")
if use_hc not in (None, "", "None"):
use_high_chase = str(use_hc).strip().lower() in ("1", "true", "t", "y", "yes", "on")
else:
use_high_chase = True
return {
"box_lookback_min": pick(("RANGE_BREAK_BOX_LOOKBACK_MIN",), 30, lambda v: int(float(v))),
"box_max_width_pct": pick(("RANGE_BREAK_BOX_MAX_WIDTH_PCT",), 2.5, float),
"box_min_width_pct": pick(("RANGE_BREAK_BOX_MIN_WIDTH_PCT",), 0.3, float),
"setup_vol_max_mult": pick(("RANGE_BREAK_SETUP_VOL_MAX_MULT",), 0.8, float),
"setup_bear_bars_min": pick(("RANGE_BREAK_SETUP_BEAR_BARS_MIN",), 1, lambda v: int(float(v))),
"vol_mult": pick(("RANGE_BREAK_VOL_MULT",), 2.0, float),
"vol_window": pick(("RANGE_BREAK_VOL_WIN",), 7, lambda v: int(float(v))),
"vol_baseline_win": pick(("RANGE_BREAK_VOL_BASELINE_WIN",), 30, lambda v: int(float(v))),
"break_margin_pct": pick(("RANGE_BREAK_BREAK_MARGIN_PCT",), 0.0, float),
"body_min_pct": pick(("RANGE_BREAK_BODY_MIN_PCT",), 0.0, float),
"sl_pct": sl_pct_ui,
"tp_pct": pct_ui(tp_r),
"trail_pct": pct_ui(tr_r),
"trail_arm_pct": pct_ui(tra_r),
"shoulder_min_high_pct": pct_ui(smh_r),
"shoulder_cut_pct": pct_ui(sc_r),
"max_hold_bars": pick(("RANGE_BREAK_MAX_HOLD_BARS",), 0, lambda v: int(float(v))),
"time_start_hm": pick(("RANGE_BREAK_TIME_START",), 1030, lambda v: int(float(v))),
"time_end_hm": pick(("RANGE_BREAK_TIME_END_HM",), 1520, lambda v: int(float(v))),
"max_daily": pick(("RANGE_BREAK_MAX_DAILY",), 1, lambda v: int(float(v))),
"cooldown_min": cooldown_min,
"max_daily_chg": pick(("RANGE_BREAK_MAX_DAILY_CHG",), 25.0, float),
"min_price": pick(("RANGE_BREAK_MIN_PRICE", "MIN_STOCK_PRICE"), 1000.0, float),
"high_chase_thr": pick(("RANGE_BREAK_HIGH_CHASE_THR",), 0.96, float),
"use_high_chase_filter": use_high_chase,
"max_loss_krw": max_loss_krw,
"slot_money": slot_money,
"max_stocks": int(portfolio["max_stocks"]),
"total_budget_krw": int(float(portfolio["total_budget_krw"])),
"fee_rate_pct": fee.get("fee_rate", 0.015),
"sell_tax_pct": fee.get("sell_tax", 0.18),
}
def _rb_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]:
fee = _get_fee_defaults()
merged = dict(ui)
merged.setdefault("fee_rate_pct", fee.get("fee_rate", 0.015))
merged.setdefault("sell_tax_pct", fee.get("sell_tax", 0.18))
return range_break_ui_to_engine_params(merged)
def _backtest_filter_toggle(raw: Any) -> Optional[bool]:
"""백테 폼 필터 토글 쿼리값 → True/False, 비었으면 None(=DB/실매값 사용).
켜고/끄고 돌리는 비교는 이 1회 백테에만 적용된다. DB(실매)는 안 건드린다.
"""
if raw is None or str(raw).strip() == "":
return None
return str(raw).strip().lower() in ("1", "true", "y", "yes", "on")
def _backtest_env_timeline_from_request(req: Any = None) -> bool:
"""웹 백테 env 타임라인 — 기본 OFF.
``env_timeline=1`` (또는 true/on) 일 때만 ON.
ON: 봉 시각별 과거 env_config 스냅샷으로 파람 덮어씀(실매 재현).
OFF: 폼/요청 파람을 구간 전체 고정(파람 평가·Optuna 정합).
"""
if req is None:
req = request
raw = None
try:
raw = req.args.get("env_timeline")
except Exception:
raw = None
if raw is None or str(raw).strip() == "":
try:
body = req.get_json(silent=True) or {}
if isinstance(body, dict):
raw = body.get("env_timeline")
except Exception:
raw = None
if raw is None or str(raw).strip() == "":
return False
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
def _eod_params_from_request(
req: Any,
defaults: Dict[str, Any],
*,
default_enabled: bool = True,
default_hm: str = "15:20",
) -> Dict[str, Any]:
"""웹 백테 쿼리 → 실매와 동일 ``eod_enabled`` / ``eod_hm`` (비우면 DB 기본값)."""
raw_en = req.args.get("eod_enabled")
if raw_en in (None, ""):
eod_enabled = bool(defaults.get("eod_enabled", default_enabled))
else:
eod_enabled = str(raw_en).strip().lower() in ("1", "true", "t", "y", "yes", "on")
raw_hm = req.args.get("eod_hm")
if raw_hm not in (None, ""):
eod_hm = str(raw_hm).strip() or default_hm
else:
eod_hm = str(defaults.get("eod_hm") or default_hm).strip() or default_hm
return {"eod_enabled": eod_enabled, "eod_hm": eod_hm}
def _daily_trail_params_from_request(
req: Any,
*,
prefix: str = "SHORT",
default_enabled: Optional[bool] = None,
) -> Dict[str, Any]:
"""백테 탭 '당일 누적손익 트레일 익절' → 시뮬 파라미터.
- ``daily_profit_enabled`` = 마스터 스위치 (실매 ``{prefix}_DAILY_PROFIT_TARGET_ENABLED``).
- 다단/단일 숫자는 규칙일 뿐 — 스위치 OFF면 게이트 OFF (숫자만으로 자동 ON 하지 않음).
- 쿼리에 enabled 없으면 ``default_enabled``(보통 DB) 사용.
"""
def _truthy(raw: Any, default: bool = False) -> bool:
if raw is None or raw == "":
return default
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
raw_en = req.args.get("daily_profit_enabled")
if raw_en is None and default_enabled is None:
# DB 실매값 추종
from kis_trader.utils.env import get_env_from_db
sid = str(prefix or "SHORT").strip().upper()
if sid == "TAIL":
sid = "SHORT"
raw_db = get_env_from_db(f"{sid}_DAILY_PROFIT_TARGET_ENABLED", "false")
enabled = _truthy(raw_db, False)
elif raw_en is None:
enabled = bool(default_enabled)
else:
enabled = _truthy(raw_en, False)
mode = str(req.args.get("daily_profit_mode") or "trailing").strip().lower() or "trailing"
tiers = str(req.args.get("daily_trail_tiers") or "").strip()
try:
drop = float(req.args.get("daily_trail_drop_pct") or 0)
except (TypeError, ValueError):
drop = 0.0
try:
arm_krw = float(req.args.get("daily_trail_arm_krw") or 0)
except (TypeError, ValueError):
arm_krw = 0.0
out: Dict[str, Any] = {
"daily_profit_enabled": enabled,
"_daily_profit_strategy_id": str(prefix or "SHORT").upper(),
"_backtest_daily_profit_trail": bool(enabled),
"daily_profit_mode": mode,
}
# B: 리스크버짓 — 쿼리 우선, 없으면 DB 전략키 (기본 false)
raw_rb = req.args.get("daily_risk_budget_enabled")
if raw_rb is None or raw_rb == "":
try:
from kis_trader.utils.env import get_env_bool
sid = str(prefix or "SHORT").strip().upper()
if sid == "TAIL":
sid = "SHORT"
rb_on = bool(get_env_bool(f"{sid}_DAILY_PROFIT_RISK_BUDGET_ENABLED", False))
except Exception:
rb_on = False
else:
rb_on = _truthy(raw_rb, False)
out["daily_risk_budget_enabled"] = rb_on
out["_backtest_daily_profit_risk_budget"] = bool(rb_on) and bool(enabled)
if not enabled:
return out
if tiers and tiers.lower() != "off":
out["daily_trail_tiers"] = tiers
elif drop > 0:
out["daily_trail_drop_pct"] = drop
out["daily_trail_arm_krw"] = arm_krw
return out
def _daily_trail_save_patch(body: Dict[str, Any], prefix: str) -> Dict[str, str]:
"""
당일 누적손익 다단 트레일 — 봇저장 패치.
``daily_profit_enabled`` → ``{prefix}_DAILY_PROFIT_TARGET_ENABLED`` (마스터).
``daily_risk_budget_enabled`` → ``{prefix}_DAILY_PROFIT_RISK_BUDGET_ENABLED`` (B, 기본 OFF).
다단 문자열은 규칙만 저장 — 값 있다고 ENABLED를 강제 true 하지 않음.
"""
out: Dict[str, str] = {}
if "daily_profit_enabled" in body:
en = body.get("daily_profit_enabled")
on = (
en is True
or str(en).strip().lower() in ("1", "true", "t", "y", "yes", "on")
)
out[f"{prefix}_DAILY_PROFIT_TARGET_ENABLED"] = "true" if on else "false"
if "daily_risk_budget_enabled" in body:
en = body.get("daily_risk_budget_enabled")
on = (
en is True
or str(en).strip().lower() in ("1", "true", "t", "y", "yes", "on")
)
out[f"{prefix}_DAILY_PROFIT_RISK_BUDGET_ENABLED"] = "true" if on else "false"
if "daily_trail_tiers" in body:
tiers = str(body.get("daily_trail_tiers") or "").strip()
if tiers and tiers.lower() != "off":
out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = tiers
out[f"{prefix}_DAILY_PROFIT_MODE"] = (
str(body.get("daily_profit_mode") or "trailing").strip().lower() or "trailing"
)
else:
out[f"{prefix}_DAILY_PROFIT_TRAIL_TIERS"] = ""
elif "daily_profit_mode" in body:
out[f"{prefix}_DAILY_PROFIT_MODE"] = (
str(body.get("daily_profit_mode") or "trailing").strip().lower() or "trailing"
)
return out
def _strategy_trigger_filter_enabled(
env: Dict[str, Any],
*,
prefix: str,
kind: str,
global_key: str,
) -> bool:
"""전략별 TRIGGER 필터 ON/OFF. ORDERBOOK=전략키만(없으면 OFF). PROGRAM=전략→글로벌."""
sk = f"{prefix}_{kind}_FILTER_ENABLED"
raw = env.get(sk)
if raw not in (None, "", "None"):
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
if kind == "ORDERBOOK":
return False # 글로벌 ORDERBOOK_FILTER_ENABLED 폐기
raw_g = env.get(global_key)
if raw_g not in (None, "", "None"):
return str(raw_g).strip().lower() in ("1", "true", "t", "y", "yes", "on")
return True
@app.route("/api/backtest/scalping", methods=["GET"])
def api_backtest_scalping():
# 기본값 = 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"
# 해외 US 는 /api/backtest/us_momentum — 이 핸들러에 market=US if 넣지 말 것
if (request.args.get("market") or "").strip().upper() == "US":
return jsonify({
"error": "해외 모멘텀은 /api/backtest/us_momentum 을 사용하세요",
}), 400
_mom_def: Optional[Dict[str, Any]] = (
_momentum_ui_defaults_from_db(_def) if mode == "momentum" else None
)
_sl_req = request.args.get("sl_pct")
if _sl_req not in (None, ""):
sl_pct = float(_sl_req) / 100
elif _mom_def is not None:
sl_pct = float(_mom_def["sl_pct"]) / 100
else:
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")
# EOD — 실매 SCALP_EOD_* (기본 15:25). force_eod_exit 는 eod_enabled 미지정 시에만 레거시
_eod_src = _mom_def if _mom_def is not None else _def
eod_patch = _eod_params_from_request(
request, _eod_src, default_enabled=True, default_hm="15:25",
)
if request.args.get("eod_enabled") in (None, ""):
_legacy_force_eod = request.args.get("force_eod_exit")
if _legacy_force_eod not in (None, ""):
eod_patch["eod_enabled"] = str(_legacy_force_eod).strip().lower() in (
"1", "true", "y", "yes", "on",
)
# ── 모드 분기: reversal vs momentum (모멘텀 탭은 /api/backtest/momentum) ──
# 실매매 봇이 ``check_buy_signal_momentum_live`` 를 사용 중이면 백테스트도
# ``mode=momentum`` 으로 호출해야 동일한 규칙으로 비교할 수 있다.
# (mode / _mom_def 는 상단에서 이미 확정)
# 모멘텀 진입 전용 파라미터 (mode=reversal 일 때는 무시)
if _mom_def is not None:
mom_rsi_min = float(request.args.get("mom_rsi_min", _mom_def["mom_rsi_min"]))
mom_rsi_max = float(request.args.get("mom_rsi_max", _mom_def["mom_rsi_max"]))
mom_vol_mult = float(request.args.get("mom_vol_mult", _mom_def["mom_vol_mult"]))
mom_vol_win = int(float(request.args.get("mom_vol_win", _mom_def["mom_vol_win"])))
_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"
# 국내 핸들러 — history_source=ls 이면 ls_ws_candles (모멘텀 모드는 키움 유지)
_univ_hs = _parse_universe_history_source_arg(request)
from kis_trader.backtest.scalping_backtest_common import load_scalp_candles_by_code
_candle_hs = "kiwoom" if str(mode).strip().lower() == "momentum" else _univ_hs
codes_candles, _ = load_scalp_candles_by_code(
db, start_key, end_key, rsi_period=rsi_period,
history_source=_candle_hs,
)
# 방어로직: 쿼리 인자로 넘어오면 우선 사용 (웹 입력란), 없으면 DB 기본값
_high_chase = request.args.get("high_chase_thr")
_max_daily_ch = request.args.get("max_daily_chg")
_min_pr = request.args.get("min_price")
_max_loss = request.args.get("max_loss_krw")
_min_marg = request.args.get("min_margin")
if _mom_def is not None:
high_chase_thr = float(_high_chase) if _high_chase not in (None, "") else float(
_mom_def["high_chase_thr"],
)
max_daily_chg = float(_max_daily_ch) if _max_daily_ch not in (None, "") else float(
_mom_def["max_daily_chg"],
)
min_price = float(_min_pr) if _min_pr not in (None, "") else float(_mom_def["min_price"])
max_loss_krw = int(float(_max_loss)) if _max_loss not in (None, "") else int(
_mom_def["max_loss_krw"],
)
min_margin = (
float(_min_marg) / 100
if _min_marg not in (None, "")
else float(_mom_def["min_margin"]) / 100
)
else:
high_chase_thr = float(_high_chase) if _high_chase not in (None, "") else _def.get(
"high_chase_thr", 0.96,
)
max_daily_chg = float(_max_daily_ch) if _max_daily_ch not in (None, "") else _def.get(
"max_daily_chg", 20.0,
)
min_price = float(_min_pr) if _min_pr not in (None, "") else _def.get("min_price", 1000.0)
max_loss_krw = int(float(_max_loss)) if _max_loss not in (None, "") else int(
_def.get("max_loss_krw", 200000),
)
# min_margin: 웹에서 % 단위(0.2 등)로 오면 0.002로 변환
min_margin = float(_min_marg) / 100 if _min_marg not in (None, "") else _def.get(
"min_margin", 0.002,
)
params = {
"rsi_period": rsi_period,
"rsi_oversold": rsi_oversold,
"rsi_overbought": rsi_overbought,
"sl_pct": sl_pct,
"tp_pct": tp_pct,
"tp_max_pct": tp_max_pct,
"drop_rate": drop_rate,
"slot_money": slot_money,
"fee_rate": fee_rate,
"sell_tax": sell_tax,
"cooldown_min": cooldown_min,
"shoulder_min_high": shoulder_min_high,
"shoulder_cut_pct": shoulder_cut_pct,
"min_hold_sec": min_hold_sec,
"time_start_hm": time_start_hm,
"time_end_hm": time_end_hm,
"max_daily": max_daily,
"vol_mult": vol_mult,
"high_chase_thr": high_chase_thr,
"max_daily_chg": max_daily_chg,
"min_price": min_price,
"max_loss_krw": max_loss_krw,
"min_drop_pct_for_loss_cut": _def.get("min_drop_pct_for_loss_cut", 0.015),
"min_margin": min_margin,
"use_defense_filters": use_defense_filters,
"use_macd_cross": use_macd_cross,
**eod_patch,
"macd_fast": int(_def.get("macd_fast", 12)),
"macd_slow": int(_def.get("macd_slow", 26)),
"macd_signal": int(_def.get("macd_signal", 5)),
"stoch_k_period": int(_def.get("stoch_k_period", 5)),
"stoch_d_period": int(_def.get("stoch_d_period", 3)),
"stoch_slow": int(_def.get("stoch_slow", 3)),
"skip_hts_scan_dupes": _tail_bool_arg(
request, "skip_hts_scan_dupes", _def.get(
"skip_hts_scan_dupes",
te.resolve_tail_skip_hts_scan_dupes() if _TAIL_ENGINE_AVAILABLE else False,
),
),
"require_reversal_candle": _tail_bool_arg(
request, "require_reversal_candle", _def.get("require_reversal_candle", True),
),
# scan_interval_min — 유니버스 해석 후 덮어씀 (이력=1분, 시뮬=5분)
"scan_interval_min": 5,
# 모멘텀 진입 전용 (mode=momentum 에서만 사용)
"mom_rsi_min": mom_rsi_min,
"mom_rsi_max": mom_rsi_max,
"mom_vol_mult": mom_vol_mult,
"mom_vol_win": mom_vol_win,
"mom_time_end_hm": mom_time_end_hm,
}
# 백테 전용 필터 토글 (폼 체크박스 → 이 1회 백테에만 적용. 비우면 DB=실매값 사용)
_ob_tg = _backtest_filter_toggle(request.args.get("ob_filter"))
if _ob_tg is not None:
params["_orderbook_filter_enabled"] = _ob_tg
_pg_tg = _backtest_filter_toggle(request.args.get("pg_filter"))
if _pg_tg is not None:
params["_program_filter_enabled"] = _pg_tg
# 호가 스프레드 상한(%) — kiwoom_0d 본체 재계산 (6/25~ 유효, 그 외 log_backfill 폴백)
_spread_req = request.args.get("max_spread_pct")
if _spread_req not in (None, ""):
params["_ob_max_spread_pct"] = float(_spread_req)
params["backtest_use_kiwoom_body_snapshot"] = True
params["_backtest_use_kiwoom_body"] = True
if mode == "momentum":
_mmax = request.args.get("mom_max_from_open_pct")
_mmin = request.args.get("mom_min_from_open_pct")
params["mom_max_from_open_pct"] = float(_mmax) if _mmax not in (None, "") else float(
_mom_def.get("mom_max_from_open_pct", 999.0),
)
params["mom_min_from_open_pct"] = float(_mmin) if _mmin not in (None, "") else float(
_mom_def.get("mom_min_from_open_pct", -999.0),
)
params["backtest_skip_pre_subscribe"] = _tail_bool_arg(
request,
"backtest_skip_pre_subscribe",
(_mom_def or {}).get("backtest_skip_pre_subscribe", False),
)
_mom_eng = me.get_momentum_defaults_from_db()
_tr_req = request.args.get("trail_pct")
params["trail_pct"] = (
abs(float(_tr_req)) / 100.0
if _tr_req not in (None, "")
else float(_mom_eng.get("trail_pct") or 0.0)
)
_ta_req = request.args.get("trail_arm_pct")
params["trail_arm_pct"] = (
abs(float(_ta_req)) / 100.0
if _ta_req not in (None, "")
else float(_mom_eng.get("trail_arm_pct") or 0.0)
)
_mh_req = request.args.get("max_hold_bars")
params["max_hold_bars"] = (
int(float(_mh_req))
if _mh_req not in (None, "")
else int(_mom_eng.get("max_hold_bars") or 0)
)
_rat_req = request.args.get("ratchet_tiers")
params["ratchet_tiers"] = (
str(_rat_req).strip()
if _rat_req is not None
else str(_mom_eng.get("ratchet_tiers") or "")
)
_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)
use_saved_history, universe_mode, sim_kind_univ = _parse_backtest_universe_arg(
request,
default="history",
sim_kind=("momentum" if mode == "momentum" else "reversal"),
)
_hist_strategy_id = "MOMENTUM" if mode == "momentum" else "SCALP"
universe_by_slot, universe_source, universe_history_slots, _scan_iv = (
_resolve_backtest_universe(
db,
start_key,
end_key,
use_saved_history,
codes_candles,
sim_kind=sim_kind_univ,
scan_interval_min=5,
strategy_id=_hist_strategy_id,
history_source=_univ_hs,
)
)
params["scan_interval_min"] = _scan_iv
# scan_at 타임라인 ↔ 슬롯 dict 동일 이력소스 (키움/LS)
params["_universe_history_source"] = _univ_hs
latest_env = db.get_latest_env()
env_row = dict(latest_env["snapshot"]) if latest_env else {}
strat_id = "MOMENTUM" if mode == "momentum" else "SCALP"
fee_rate_v, sell_tax_v, slot_from_env = sbc.fee_and_slot_from_env(
env_row, strategy=strat_id,
)
slot_money_v = float(slot_money or slot_from_env)
_mx = request.args.get("max_stocks") or request.args.get("slots")
max_stocks_req = (
int(float(_mx)) if _mx not in (None, "") else None
)
_tb = request.args.get("total_budget_krw")
total_budget_req = (
float(_tb) if _tb not in (None, "") else None
)
portfolio = sbc.resolve_scalp_portfolio_params(
env_row,
None,
strategy=strat_id,
slot_money=slot_money_v,
max_stocks=max_stocks_req,
total_budget_krw=total_budget_req,
)
max_stocks_v = int(portfolio["max_stocks"])
total_budget_v = float(portfolio["total_budget_krw"])
slot_money_v = float(portfolio["slot_money"])
bt_meta: Dict[str, Any] = {
"db": db,
"start_key": start_key,
"end_key": end_key,
"backtest_env_timeline": _backtest_env_timeline_from_request(request),
}
all_virtual_trades = sbc.run_scalping_backtest_web_aligned(
codes_candles,
params,
universe_by_slot,
slot_money=slot_money_v,
fee_rate=fee_rate_v,
sell_tax=sell_tax_v,
max_stocks=max_stocks_v,
total_budget_krw=total_budget_v,
mode=mode,
meta_out=bt_meta,
)
# 당일 누적손익 트레일 익절 시뮬 — 마스터 스위치(daily_profit_enabled) 기준.
# trades 에 pnl·buy_time·sell_time 부착 완료 후 신규진입 차단. (모멘텀 포함)
_trail_pfx = "MOMENTUM" if str(mode or "").strip().lower() == "momentum" else "SCALP"
_trail_p = _daily_trail_params_from_request(request, prefix=_trail_pfx)
if _trail_p.get("_backtest_daily_profit_trail"):
from kis_trader.backtest.backtest_portfolio_common import apply_daily_profit_halt_sim
all_virtual_trades = apply_daily_profit_halt_sim(
all_virtual_trades, _trail_p, budget_krw=float(total_budget_v or 0),
)
# ─────────────────────────────────────────────────────────────────
# 결과 집계
# ─────────────────────────────────────────────────────────────────
period_days = _backtest_period_days(start, end, fallback=1)
stats = sbc.summarize_scalp_trades(
all_virtual_trades,
total_budget_krw=total_budget_v,
period_days=period_days,
)
total = int(stats["total_trades"])
total_pnl = int(stats["total_pnl"])
wins_n = int(stats["wins"])
losses_n = int(stats["losses"])
avg_hold = float(stats["avg_hold_min"])
pf = float(stats["pf"])
bot_pct = float(stats["bot_pct"])
daily_avg_pct = float(stats["daily_avg_pct"])
equity = []
cum = 0.0
peak_cum = 0.0
peak_cum_at = ""
for t in sorted(all_virtual_trades, key=lambda x: x.get("sell_time", "")):
cum += t.get("pnl", 0)
if cum > peak_cum:
peak_cum = cum
peak_cum_at = str(t.get("sell_time") or "")
st = str(t.get("sell_time", ""))
day = st[:8]
if len(day) == 8:
day_fmt = f"{day[:4]}-{day[4:6]}-{day[6:]}"
else:
day_fmt = day
equity.append({"date": day_fmt, "cum_pnl": round(cum), "pnl": t.get("pnl", 0)})
peak, mdd, cum = 0.0, 0.0, 0.0
for t in all_virtual_trades:
cum += t.get("pnl", 0)
if cum > peak:
peak = cum
dd = peak - cum
if dd > mdd:
mdd = dd
reasons: Dict[str, int] = {}
for t in all_virtual_trades:
rk = str(t.get("sell_reason") or "unknown")
reasons[rk] = reasons.get(rk, 0) + 1
daily: Dict[str, float] = {}
for t in all_virtual_trades:
d8 = str(t.get("sell_time", ""))[:8]
daily[d8] = daily.get(d8, 0) + t.get("pnl", 0)
daily_list = [{"date": d[:4]+"-"+d[4:6]+"-"+d[6:], "pnl": round(v)}
for d, v in sorted(daily.items())]
# 가상거래에도 종목명·누적손익 표시 (실거래와 동일)
_enrich_momentum_trades_debug(
all_virtual_trades,
total_budget_krw=total_budget_v,
with_tick_debug=(mode == "momentum"),
)
trades_out = _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,
"backtest_skip_pre_subscribe": (
params.get("backtest_skip_pre_subscribe") if mode == "momentum" else None
),
"exit_priority": (
"ratchet/shoulder→trail→sl→time→loss_cap→tp_max→eod"
if mode == "momentum" else None
),
"start": start,
"end": end,
},
"summary": {
"total_trades": total,
"win_trades": wins_n,
"loss_trades": losses_n,
"win_rate": float(stats["win_rate"]),
"total_pnl": total_pnl,
"avg_hold_min": round(avg_hold, 1),
"profit_factor": round(pf, 2),
"max_drawdown": round(mdd),
"bot_pct": bot_pct,
"daily_avg_pct": daily_avg_pct,
"backtest_days": period_days,
"budget_warning": portfolio.get("budget_warning"),
"peak_cum_pnl": round(peak_cum),
"peak_cum_at": (peak_cum_at[:19] if peak_cum_at else ""),
"tick_backtest": bt_meta.get("tick_backtest"),
"backtest_buy_source": bt_meta.get("backtest_buy_source"),
"skip_stats": bt_meta.get("skip_stats") or (
(bt_meta.get("engine_params") or {}).get("_portfolio_skip_stats")
),
},
"equity": equity,
"daily": daily_list,
"reasons": reasons,
"trades": trades_out,
})
except Exception as e:
logger.exception("scalping backtest failed mode=%s", mode)
return jsonify({"error": str(e)}), 500
finally:
db.close()
# ────────────────────────────────────────────────────────────────────────────
# API: 꼬리잡기 가격 재현 백테스트 (ws_candles 3분봉 기반, tail_engine 공통 로직 사용)
# ────────────────────────────────────────────────────────────────────────────
try:
from kis_trader.engine import tail_engine as te
_TAIL_ENGINE_AVAILABLE = True
except ImportError:
_TAIL_ENGINE_AVAILABLE = False
def _get_tail_defaults_for_backtest():
"""꼬리잡기 백테스트 기본값: DB(config_short 병합) 단일 소스. 엔진 없으면 빈 dict."""
if not _TAIL_ENGINE_AVAILABLE:
return {}
return te.get_tail_defaults_from_db()
def _tail_frac_to_ui_pct(v: Any) -> Optional[float]:
"""엔진 비율(0.003) 또는 퍼센트(3.0) → 웹 입력 퍼센트."""
if v is None or v == "":
return None
try:
x = abs(float(v))
if x == 0:
return 0.0
return round(x * 100, 3) if x < 0.5 else round(x, 3)
except (ValueError, TypeError):
return None
def _tail_ratio_to_ui_pct(v: Any, default_pct: float) -> float:
"""0~1 비율 또는 퍼센트 → 폼 표시 % (max_rec_3m, high_chase)."""
if v is None or v == "":
return default_pct
try:
x = float(v)
if 0 < x <= 1:
return round(x * 100, 2)
if x > 1:
return round(x, 2)
except (ValueError, TypeError):
pass
return default_pct
def _tail_engine_dict_to_ui(
d: Dict[str, Any],
snap: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""tail_engine defaults 또는 파라서치 merged → 웹 입력란 값."""
snap = snap or {}
if not d:
return {}
md_loss = d.get("min_drop_pct_for_loss_cut", 0.015)
try:
md_loss_f = float(md_loss)
md_loss_ui = md_loss_f * 100 if md_loss_f < 1 else md_loss_f
except (ValueError, TypeError):
md_loss_ui = 1.5
slot_raw = snap.get("TAIL_SLOT_MONEY") or d.get("slot_money") or "3000000"
_em = str(d.get("entry_mode") or "limit_atr").strip().lower()
return {
"entry_mode": _em,
"limit_atr_mult": d.get("limit_atr_mult", 1.5),
"limit_anchor": d.get("limit_anchor", "signal_low"),
"limit_valid_bars": int(d.get("limit_valid_bars") or 1),
"limit_fill_slip_pct": float(d.get("limit_fill_slip_pct") or 0.0),
"drop": _tail_frac_to_ui_pct(d.get("min_drop_rate")),
# 회복률은 0~1 비율(0.5=50%) — 낙폭%와 달리 _tail_ratio_to_ui_pct 사용
"rec": _tail_ratio_to_ui_pct(d.get("min_recovery_ratio"), 45.0),
"tail_ratio": d.get("tail_ratio_min"),
"tail_pct_min": _tail_frac_to_ui_pct(d.get("tail_pct_min")),
"sl_pct": _tail_frac_to_ui_pct(d.get("sl_pct")),
"tp_pct": _tail_frac_to_ui_pct(d.get("tp_pct")),
"smin": _tail_frac_to_ui_pct(d.get("shoulder_min_high")),
"scut": _tail_frac_to_ui_pct(d.get("shoulder_cut_pct")),
"cool": d.get("cooldown_min"),
"rsi": d.get("rsi_threshold"),
"rsi_period": int(d.get("rsi_period") or 14),
"time_start": int(d.get("time_start_hm") or 930),
"time_end": int(d.get("time_end_hm") or 1500),
"max_daily": int(d.get("max_daily") or 20),
"symbol_daily_loss_limit_krw": float(d.get("symbol_daily_loss_limit_krw") or 30000),
"symbol_daily_loss_limit_pct": float(d.get("symbol_daily_loss_limit_pct") or 1.5),
"reentry_min_edge_krw": float(d.get("reentry_min_edge_krw") or 0),
"reentry_require_nonneg": bool(d.get("reentry_require_nonneg", False)),
"max_rec_3m": _tail_ratio_to_ui_pct(d.get("max_rec_3m"), 90.0),
"high_chase": _tail_ratio_to_ui_pct(d.get("high_chase_thr"), 96.0),
"min_price": d.get("min_price"),
"max_daily_change": d.get("max_daily_change"),
"ma20_max_above": d.get("ma20_max_above"),
"stop_atr_mult": d.get("stop_atr_mult"),
"target_atr_mult": d.get("target_atr_mult"),
"atr_sl_min_pct": d.get("atr_sl_min_pct"),
"atr_sl_max_pct": d.get("atr_sl_max_pct"),
"atr_tp_min_pct": d.get("atr_tp_min_pct"),
"atr_tp_max_pct": d.get("atr_tp_max_pct"),
"max_loss_krw": d.get("max_loss_krw"),
"min_drop_pct_for_loss_cut": round(md_loss_ui, 2),
"slot_money": int(float(slot_raw)),
"max_stocks": int(snap.get("TAIL_MAX_STOCKS") or d.get("max_stocks") or 3),
"total_budget_krw": int(float(
snap.get("TAIL_TOTAL_BUDGET_KRW") or d.get("total_budget_krw") or 0
) or int(float(slot_raw)) * int(snap.get("TAIL_MAX_STOCKS") or d.get("max_stocks") or 3)),
"skip_hts_scan_dupes": d.get(
"skip_hts_scan_dupes",
te.resolve_tail_skip_hts_scan_dupes(snap) if _TAIL_ENGINE_AVAILABLE else False,
),
"cand_limit": int(d.get("cand_limit") or 0),
"use_intraday_drop": d.get("use_intraday_drop", False),
"use_ma20_filter": d.get("use_ma20_filter", False),
"use_rsi_filter": d.get("use_rsi_filter", True),
"use_daily_range_filter": d.get("use_daily_range_filter", True),
"use_high_chase_filter": d.get("use_high_chase_filter", True),
"bar_chg_min_pct": d.get("bar_chg_min_pct", -10.0),
"bar_chg_max_pct": d.get("bar_chg_max_pct", -1.5),
"tail_vol_mult": d.get("tail_vol_mult", 0.0),
"tail_vol_win": int(d.get("tail_vol_win") or 5),
"ratchet_tiers": str(d.get("ratchet_tiers") or "").strip(),
"max_hold_bars": int(d.get("max_hold_bars") or 0),
"trail_pct": _tail_frac_to_ui_pct(d.get("trail_pct")) or 0.0,
"trail_arm_pct": _tail_frac_to_ui_pct(d.get("trail_arm_pct")) or 0.0,
"backtest_use_tick_db": d.get("backtest_use_tick_db", True),
"backtest_use_tick_exit": d.get("backtest_use_tick_exit", True),
"backtest_tick_fallback_ohlc": d.get("backtest_tick_fallback_ohlc", False),
"pattern_hammer": d.get("pattern_hammer", True),
"pattern_pin": d.get("pattern_pin", False),
"pattern_engulfing": d.get("pattern_engulfing", False),
"pattern_piercing": d.get("pattern_piercing", False),
"pattern_harami": d.get("pattern_harami", False),
"pattern_doji": d.get("pattern_doji", False),
"pattern_morning_star": d.get("pattern_morning_star", False),
"ob_filter_enabled": _strategy_trigger_filter_enabled(
snap or {}, prefix="TAIL", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED",
),
"pg_filter_enabled": _strategy_trigger_filter_enabled(
snap or {}, prefix="TAIL", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED",
),
"max_spread_pct": float(
snap.get("TAIL_ORDERBOOK_MAX_SPREAD_PCT")
or d.get("max_spread_pct")
or 0.45
),
# 당일 누적손익 다단 트레일(SHORT 일일익절) 현재값 + 사용자 저장 프리셋 목록(세미콜론 구분)
"daily_profit_enabled": (
str(snap.get("SHORT_DAILY_PROFIT_TARGET_ENABLED") or "false").strip().lower()
in ("1", "true", "t", "y", "yes", "on")
),
"daily_risk_budget_enabled": (
str(snap.get("SHORT_DAILY_PROFIT_RISK_BUDGET_ENABLED") or "false").strip().lower()
in ("1", "true", "t", "y", "yes", "on")
),
"daily_trail_tiers": str(snap.get("SHORT_DAILY_PROFIT_TRAIL_TIERS") or "").strip(),
"daily_profit_mode": str(snap.get("SHORT_DAILY_PROFIT_MODE") or "trailing").strip().lower() or "trailing",
"daily_trail_arm_krw": int(float(snap.get("SHORT_DAILY_PROFIT_TRAIL_ARM_KRW") or 0) or 0),
"daily_trail_drop_pct": float(snap.get("SHORT_DAILY_PROFIT_TRAIL_DROP_PCT") or 0) or 0.0,
"ratchet_presets": str(snap.get("BT_RATCHET_PRESETS") or "").strip(),
"daily_trail_presets": str(snap.get("BT_DAILY_TRAIL_PRESETS") or "").strip(),
"eod_enabled": (
str(snap.get("TAIL_EOD_ENABLED") or "1").strip().lower()
in ("1", "true", "t", "y", "yes", "on")
if snap.get("TAIL_EOD_ENABLED") not in (None, "", "None")
else True
),
"eod_hm": str(snap.get("TAIL_EOD_HM") or "15:20").strip() or "15:20",
}
def _tail_ui_defaults_from_db(snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
꼬리잡기 웹 탭 초기값 — tail_engine.get_tail_defaults_from_db() 와 동일 (실매·파라서치·백테).
슬롯금액만 config_short / env 병합 snap 에서 읽음.
"""
d = _get_tail_defaults_for_backtest()
return _tail_engine_dict_to_ui(d, snap)
def _accumulate_preset(
snap: Dict[str, Any], patch: Dict[str, str], key: str, value: Any, max_keep: int = 20
) -> None:
"""
사용자가 직접 입력한 값(래칫·다단트레일)을 세미콜론 구분 프리셋 목록 키에 누적.
값 내부에 콤마(0.5:0.3,1.0:0.25)가 있으므로 목록 구분자는 세미콜론을 쓴다.
중복·빈값·'off'는 제외, 최근 max_keep 개만 유지. DB(env)에 영구 저장돼 드롭다운 복원에 쓰인다.
"""
v = str(value or "").strip()
if not v or v.lower() == "off":
return
existing = str(snap.get(key) or "").strip()
items = [x.strip() for x in existing.split(";") if x.strip()] if existing else []
if v in items:
return
items.append(v)
if len(items) > max_keep:
items = items[-max_keep:]
patch[key] = ";".join(items)
def _tail_web_save_json_to_env_patch(body: Dict[str, Any]) -> Dict[str, str]:
"""
꼬리잡기 탭 `saveTailConfig()` POST JSON → TAIL_* env 패치.
insert_env_snapshot() 이 config_short 로 자동 분리 저장.
"""
from kis_trader.engine.tail_env_keys import web_body_to_tail_env_patch
patch = web_body_to_tail_env_patch(body)
if "ob_filter" in body:
patch["TAIL_ORDERBOOK_FILTER_ENABLED"] = _env_bool_10(body.get("ob_filter"))
if "pg_filter" in body:
patch["TAIL_PROGRAM_FILTER_ENABLED"] = _env_bool_10(body.get("pg_filter"))
# 당일 누적손익 다단 트레일 — TAIL_* 가 아니라 SHORT 일일익절(daily_profit_halt) 키 (공통 헬퍼)
patch.update(_daily_trail_save_patch(body, "SHORT"))
return patch
@app.route("/api/backtest/tail/save_config", methods=["POST"])
def api_backtest_tail_save_config():
"""꼬리잡기 웹 폼 → insert_env_snapshot (config_short + env_config 분리 저장)."""
if not _TAIL_ENGINE_AVAILABLE:
return jsonify({"error": "tail_engine 미설치 또는 임포트 실패"}), 503
body = request.get_json(force=True, silent=True) or {}
try:
patch = _tail_web_save_json_to_env_patch(body)
if not patch:
return jsonify({"error": "저장할 필드 없음(JSON 비어 있음)"}), 400
db = _db()
try:
snap = db.get_merged_env_snapshot()
# 사용자가 직접 입력한 래칫·다단트레일 값을 프리셋 목록(env)에 영구 누적
_accumulate_preset(snap, patch, "BT_RATCHET_PRESETS", body.get("ratchet_tiers"))
_accumulate_preset(snap, patch, "BT_DAILY_TRAIL_PRESETS", body.get("daily_trail_tiers"))
for k, v in patch.items():
snap[k] = v
env_id = db.insert_env_snapshot(snap)
if env_id is None:
return jsonify({"error": "env 저장 실패(insert_env_snapshot)"}), 500
from config_schema import classify_config_key
saved_by_table: Dict[str, List[str]] = {}
for k in patch:
tbl = classify_config_key(k)
saved_by_table.setdefault(tbl, []).append(k)
return jsonify({
"ok": True,
"env_id": env_id,
"saved_keys": list(patch.keys()),
"saved_by_table": saved_by_table,
})
finally:
db.close()
except Exception as e:
logger.error("꼬리잡기 설정저장 오류: %s", e)
return jsonify({"error": str(e)}), 500
def _load_tail_search_json(path: Optional[str] = None) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
"""최신 search_tail_*.json 또는 지정 경로 로드."""
if path and os.path.isfile(path):
try:
with open(path, "r", encoding="utf-8") as f:
return path, json.load(f)
except (OSError, json.JSONDecodeError):
return path, None
try:
from kis_trader.backtest.tail_param_search import _latest_tail_json_path
p = _latest_tail_json_path()
except ImportError:
p = None
if not p or not os.path.isfile(p):
return None, None
try:
with open(p, "r", encoding="utf-8") as f:
return p, json.load(f)
except (OSError, json.JSONDecodeError):
return p, None
@app.route("/api/backtest/tail/search_results", methods=["GET"])
def api_backtest_tail_search_results():
"""최신 tail_param_search JSON 상위 N — 웹 폼 프리필·Apply UX."""
top_n = max(1, min(100, int(request.args.get("top", 30))))
json_path = (request.args.get("json") or "").strip() or None
path, data = _load_tail_search_json(json_path)
if not data:
return jsonify({"error": "search_tail_*.json 없음 또는 파싱 실패", "path": path}), 404
results = data.get("results") or []
rows = []
for idx, item in enumerate(results[:top_n]):
merged = None
try:
from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
merged = merge_param_search_apply_source(item, data)
except Exception:
merged = dict(item.get("apply_cfg") or {})
rows.append({
"rank": idx + 1,
"params": item.get("params") or {},
"apply_cfg": item.get("apply_cfg") or {},
"merged": merged,
"total_trades": item.get("total_trades"),
"win_rate": item.get("win_rate"),
"total_pnl": item.get("total_pnl"),
"pf": item.get("pf"),
"avg_hold_min": item.get("avg_hold_min"),
"sell_reasons": item.get("sell_reasons") or {},
})
meta = {
"path": path,
"mode": data.get("mode"),
"start": data.get("start"),
"end": data.get("end"),
"timeframe": data.get("timeframe"),
"grid_keys": data.get("grid_keys") or [],
"grid_axis_hints": data.get("grid_axis_hints") or {},
"tested_combos": data.get("tested_combos"),
"cartesian_product": data.get("cartesian_product"),
"slot_money": data.get("slot_money"),
"max_stocks": data.get("max_stocks"),
"total_budget_krw": data.get("total_budget_krw"),
}
return jsonify({"ok": True, "meta": meta, "top": rows})
@app.route("/api/backtest/tail/apply_search", methods=["POST"])
def api_backtest_tail_apply_search():
"""파라서치 N위 → 웹 폼 프리필 + 선택 시 DB 저장 (config_short TAIL_*)."""
if not _TAIL_ENGINE_AVAILABLE:
return jsonify({"error": "tail_engine 미설치 또는 임포트 실패"}), 503
body = request.get_json(force=True, silent=True) or {}
rank = max(1, int(body.get("rank") or 1))
save_db = body.get("save_db", True)
if isinstance(save_db, str):
save_db = save_db.strip().lower() in ("1", "true", "yes", "on")
json_path = (body.get("json") or "").strip() or None
path, data = _load_tail_search_json(json_path)
if not data:
return jsonify({"error": "search_tail_*.json 없음", "path": path}), 404
results = data.get("results") or []
if rank > len(results):
return jsonify({"error": f"rank 범위 초과 (1~{len(results)})"}), 400
item = results[rank - 1]
pnl = int(item.get("total_pnl") or 0)
if pnl <= 0 and not body.get("allow_non_positive_pnl"):
return jsonify({
"error": f"total_pnl={pnl} ≤ 0 — DB 미적용. force 시 allow_non_positive_pnl=true",
"rank": rank,
}), 400
try:
from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
merged = merge_param_search_apply_source(item, data)
except Exception as e:
return jsonify({"error": f"merge 실패: {e}"}), 500
ui = _tail_engine_dict_to_ui(merged)
env_id = None
if save_db:
try:
from kis_trader.backtest import tail_param_search as tps
tps.apply_params_to_db(merged)
db = _db()
try:
latest = db.get_latest_env()
env_id = (latest or {}).get("id")
finally:
db.close()
except Exception as e:
logger.error("꼬리 파라서치 DB 적용 오류: %s", e)
return jsonify({"error": str(e), "ui": ui}), 500
return jsonify({
"ok": True,
"rank": rank,
"path": path,
"env_id": env_id,
"saved": bool(save_db),
"ui": ui,
"merged": merged,
"metrics": {
"total_trades": item.get("total_trades"),
"win_rate": item.get("win_rate"),
"total_pnl": item.get("total_pnl"),
"pf": item.get("pf"),
"sell_reasons": item.get("sell_reasons") or {},
},
})
# ────────────────────────────────────────────────────────────────────────────
# API: 더블 볼린저 백테스트 (dbband_engine)
# ────────────────────────────────────────────────────────────────────────────
try:
from kis_trader.engine import dbband_engine as bbe
from kis_trader.backtest import dbband_backtest_common as dbbc
_DBBAND_ENGINE_AVAILABLE = True
except ImportError:
dbbc = None # type: ignore
_DBBAND_ENGINE_AVAILABLE = False
def _get_dbband_defaults_for_backtest():
if not _DBBAND_ENGINE_AVAILABLE:
return {}
return bbe.get_dbband_defaults_from_db()
def _dbband_engine_dict_to_ui(d: Dict[str, Any], snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
snap = snap or {}
if not d:
return {}
slot_raw = snap.get("DBBAND_SLOT_MONEY") or d.get("slot_money") or "3000000"
return {
"bb_period": int(d.get("bb_period") or 20),
"bb_inner_std": float(d.get("bb_inner_std") or 2.0),
"bb_outer_std": float(d.get("bb_outer_std") or 3.0),
"trend_ma_period": int(d.get("trend_ma_period") or 200),
"use_trend_filter": d.get("use_trend_filter", True),
"side_mode": str(d.get("side_mode") or "long_only"),
"entry_valid_bars": int(d.get("entry_valid_bars") or 3),
"entry_mode": str(d.get("entry_mode") or "break_high"),
"stop_mode": str(d.get("stop_mode") or "signal_low"),
"stop_buffer_pct": float(d.get("stop_buffer_pct") or 0.0) * 100,
"sl_pct": float(d.get("sl_pct") or 0.02) * 100,
"tp_mode": str(d.get("tp_mode") or "opposite_band"),
"tp_pct": float(d.get("tp_pct") or 0.03) * 100,
"rr_ratio": float(d.get("rr_ratio") or 2.0),
"shoulder_min_high": float(d.get("shoulder_min_high") or 0.003) * 100,
"shoulder_cut_pct": float(d.get("shoulder_cut_pct") or 0.002) * 100,
"trail_pct": float(d.get("trail_pct") or 0.0) * 100,
"trail_arm_pct": float(d.get("trail_arm_pct") or 0.0) * 100,
"cooldown_min": float(d.get("cooldown_min") or 15.0),
"time_start": int(d.get("time_start_hm") or 930),
"time_end": int(d.get("time_end_hm") or 1500),
"max_daily": int(d.get("max_daily") or 3),
"min_price": float(d.get("min_price") or 1000.0),
"slot_money": int(float(slot_raw)),
"max_stocks": int(snap.get("DBBAND_MAX_STOCKS") or d.get("max_stocks") or 3),
"total_budget_krw": int(float(
snap.get("DBBAND_TOTAL_BUDGET_KRW") or d.get("total_budget_krw") or 0
) or int(float(slot_raw)) * int(snap.get("DBBAND_MAX_STOCKS") or d.get("max_stocks") or 3)),
"max_hold_bars": int(d.get("max_hold_bars") or 0),
"timeframe": int(d.get("timeframe") or 15),
"exit_mode": str(d.get("exit_mode") or "classic"),
"force_eod_exit": d.get("force_eod_exit", False),
}
def _dbband_ui_defaults_from_db(snap: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
d = _get_dbband_defaults_for_backtest()
return _dbband_engine_dict_to_ui(d, snap)
@app.route("/api/backtest/dbband/save_config", methods=["POST"])
def api_backtest_dbband_save_config():
if not _DBBAND_ENGINE_AVAILABLE:
return jsonify({"error": "dbband_engine 미설치 또는 임포트 실패"}), 503
body = request.get_json(force=True, silent=True) or {}
try:
from kis_trader.engine.dbband_env_keys import web_body_to_dbband_env_patch
patch = web_body_to_dbband_env_patch(body)
if not patch:
return jsonify({"error": "저장할 필드 없음"}), 400
db = _db()
try:
snap = db.get_merged_env_snapshot()
for k, v in patch.items():
snap[k] = v
env_id = db.insert_env_snapshot(snap)
if env_id is None:
return jsonify({"error": "env 저장 실패"}), 500
from config_schema import classify_config_key
saved_by_table: Dict[str, List[str]] = {}
for k in patch:
tbl = classify_config_key(k)
saved_by_table.setdefault(tbl, []).append(k)
return jsonify({
"ok": True,
"env_id": env_id,
"saved_keys": list(patch.keys()),
"saved_by_table": saved_by_table,
})
finally:
db.close()
except Exception as e:
logger.error("더블BB 설정저장 오류: %s", e)
return jsonify({"error": str(e)}), 500
@app.route("/api/backtest/dbband", methods=["GET"])
def api_backtest_dbband_legacy():
"""레거시 — 종목별 ``/api/dbband/backtest?code=`` 사용."""
return api_dbband_backtest()
@app.route("/api/backtest/us_momentum", methods=["GET"])
def api_backtest_us_momentum():
"""
해외 모멘텀 전용 백테.
※ 국내 /api/backtest/momentum · api_backtest_scalping 에 market=US 분기 넣지 말 것.
"""
from kis_trader.backtest.us_momentum_web_backtest import run_us_momentum_web_backtest
args = request.args.to_dict(flat=True)
start = (args.get("start") or "").strip()
end = (args.get("end") or "").strip()
if not start or not end:
return jsonify({"error": "start/end 필요"}), 400
db = _db()
try:
ui_def = _us_momentum_ui_defaults_from_db()
out = run_us_momentum_web_backtest(
args,
db=db,
ui_def=ui_def,
enrich_trades_fn=_enrich_momentum_trades_debug,
enrich_names_fn=_enrich_trades_with_names,
trades_recent_fn=_trades_recent_first,
period_days_fn=_backtest_period_days,
)
return jsonify(out)
except Exception as e:
logger.exception("us_momentum backtest failed: %s", e)
return jsonify({"error": str(e)}), 500
finally:
try:
db.close()
except Exception:
pass
@app.route("/api/backtest/momentum", methods=["GET"])
def api_backtest_momentum():
"""모멘텀 전용 백테스트 — momentum_engine + momentum_backtest_common (SCALP reversal 분리)."""
# 해외는 전용 엔드포인트 — 국내 핸들러에 market=US if 금지
if (request.args.get("market") or "").strip().upper() == "US":
return jsonify({
"error": "해외 모멘텀은 /api/backtest/us_momentum 을 사용하세요 (국내 API 분기 금지)",
}), 400
args = request.args.to_dict(flat=True)
args["mode"] = "momentum"
with app.test_request_context(
path="/api/backtest/scalping",
query_string=args,
method="GET",
):
return api_backtest_scalping()
@app.route("/api/backtest/momentum/save_config", methods=["POST"])
def api_backtest_momentum_save_config():
"""모멘텀 탭 폼 → config_momentum + env_config INSERT."""
body = request.get_json(force=True, silent=True) or {}
try:
patch = _momentum_tab_save_patch(body)
if not patch:
return jsonify({"error": "저장할 필드 없음"}), 400
db = _db()
try:
latest = db.get_latest_env()
snap = dict(latest["snapshot"]) if latest else {}
# 사용자가 직접 입력한 다단 트레일 값을 프리셋 목록(env)에 영구 누적 (꼬리와 공유)
_accumulate_preset(snap, patch, "BT_DAILY_TRAIL_PRESETS", body.get("daily_trail_tiers"))
for k, v in patch.items():
snap[k] = v
env_id = db.insert_env_snapshot(snap)
if env_id is None:
return jsonify({"error": "env 저장 실패(insert_env_snapshot)"}), 500
from config_schema import classify_config_key
saved_by_table: Dict[str, List[str]] = {}
for k in patch:
tbl = classify_config_key(k)
saved_by_table.setdefault(tbl, []).append(k)
return jsonify({
"ok": True,
"env_id": env_id,
"saved_keys": list(patch.keys()),
"saved_by_table": saved_by_table,
})
finally:
db.close()
except Exception as e:
logger.error("모멘텀 설정저장 오류: %s", e)
return jsonify({"error": str(e)}), 500
@app.route("/api/backtest/us_momentum/save_config", methods=["POST"])
def api_backtest_us_momentum_save_config():
"""해외 모멘텀 탭 → US_MOMENTUM_* 만 (국내 MOMENTUM_/SCALP_ 미오염)."""
body = request.get_json(force=True, silent=True) or {}
try:
patch = _us_momentum_tab_save_patch(body)
if not patch:
return jsonify({"error": "저장할 필드 없음"}), 400
# 안전: US_ 접두만
bad = [k for k in patch if not str(k).startswith("US_MOMENTUM_")]
if bad:
return jsonify({"error": f"비-US 키 차단: {bad[:5]}"}), 400
db = _db()
try:
latest = db.get_latest_env()
snap = dict(latest["snapshot"]) if latest else {}
_accumulate_preset(
snap, patch, "BT_DAILY_TRAIL_PRESETS", body.get("daily_trail_tiers")
)
for k, v in patch.items():
snap[k] = v
env_id = db.insert_env_snapshot(snap)
if env_id is None:
return jsonify({"error": "env 저장 실패"}), 500
from config_schema import classify_config_key
saved_by_table: Dict[str, List[str]] = {}
for k in patch:
saved_by_table.setdefault(classify_config_key(k), []).append(k)
return jsonify({
"ok": True,
"env_id": env_id,
"saved_keys": list(patch.keys()),
"saved_by_table": saved_by_table,
})
finally:
db.close()
except Exception as e:
logger.error("해외 모멘텀 설정저장 오류: %s", e)
return jsonify({"error": str(e)}), 500
@app.route("/api/us_momentum/stocks", methods=["GET"])
def api_us_momentum_stocks():
"""영구구독 US + us_momentum_stock_config 행 목록."""
try:
db = _db()
try:
from kis_trader.strategies.us_momentum_stock_cfg import (
ensure_us_momentum_stock_config_table,
list_us_momentum_stock_rows,
seed_us_momentum_stock_from_permanent,
row_to_ui_dict,
)
from permanent_subs import codes_by_market
ensure_us_momentum_stock_config_table(db)
if request.args.get("seed") in ("1", "true", "yes"):
seed_us_momentum_stock_from_permanent(db)
cfg_rows = {str(r.get("code") or "").upper(): row_to_ui_dict(r)
for r in list_us_momentum_stock_rows(db)}
perm = codes_by_market(db, "US", enabled_only=False) or []
out = []
seen = set()
for p in perm:
code = str(p.get("code") or p.get("symbol") or "").upper()
if not code or code in seen:
continue
seen.add(code)
cfg = cfg_rows.get(code) or {}
out.append({
"code": code,
"exchange": cfg.get("exchange") or p.get("exchange") or "NASD",
"symbol": cfg.get("symbol") or p.get("symbol") or code,
"name": cfg.get("name") or p.get("note") or code,
"enabled": bool(p.get("enabled", True)),
"stock_group": cfg.get("stock_group") or "STOCK",
"has_stock_cfg": code in cfg_rows,
"cfg": cfg if code in cfg_rows else None,
})
for code, cfg in cfg_rows.items():
if code in seen:
continue
out.append({
"code": code,
"exchange": cfg.get("exchange") or "NASD",
"symbol": cfg.get("symbol") or code,
"name": cfg.get("name") or code,
"enabled": True,
"stock_group": cfg.get("stock_group") or "STOCK",
"has_stock_cfg": True,
"cfg": cfg,
})
return jsonify({"stocks": out, "count": len(out)})
finally:
db.close()
except Exception as e:
logger.error("api_us_momentum_stocks: %s", e)
return jsonify({"error": str(e)}), 500
@app.route("/api/us_momentum/stock_cfg", methods=["GET", "POST", "DELETE"])
def api_us_momentum_stock_cfg():
"""종목별 TRIGGER/청산 핀 — GET ?code= / POST body / DELETE ?code=."""
try:
from kis_trader.strategies.us_momentum_stock_cfg import (
delete_us_momentum_stock_config,
get_us_momentum_stock_row,
row_to_ui_dict,
upsert_us_momentum_stock_config,
)
db = _db()
try:
if request.method == "GET":
code = (request.args.get("code") or "").strip().upper()
if not code:
return jsonify({"error": "code 필수"}), 400
row = get_us_momentum_stock_row(db, code)
return jsonify({
"code": code,
"has_stock_cfg": bool(row),
"cfg": row_to_ui_dict(row) if row else None,
})
if request.method == "DELETE":
code = (request.args.get("code") or "").strip().upper()
if not code:
body = request.get_json(force=True, silent=True) or {}
code = str(body.get("code") or "").strip().upper()
if not code:
return jsonify({"error": "code 필수"}), 400
ok = delete_us_momentum_stock_config(db, code)
return jsonify({"ok": ok, "code": code})
body = request.get_json(force=True, silent=True) or {}
code = str(body.get("code") or "").strip().upper()
if not code:
return jsonify({"error": "code 필수"}), 400
fields = {}
for k in (
"sl_pct", "tp_pct", "tp_max_pct",
"shoulder_min_high_pct", "shoulder_cut_pct",
"trail_pct", "trail_arm_pct", "ratchet_tiers",
"max_hold_bars", "cooldown_sec", "max_daily", "slot_money",
"rsi_min", "rsi_max", "vol_mult", "vol_win",
"chase_lookback_min", "pullback_lookback_min",
"pullback_min_pct", "pullback_max_pct",
"setup_vol_max_mult", "setup_bear_bars_min",
"high_chase_thr", "max_daily_chg", "min_price",
"ema_fast_period", "ema_slow_period",
"use_defense_filters", "use_high_chase_filter",
"use_daily_range_filter", "use_ema_filter", "use_rsi_max_filter",
"pattern_breakout", "pattern_pullback",
):
if k not in body:
continue
v = body.get(k)
if v is None or v == "":
fields[k] = None
elif k.startswith("use_") or k.startswith("pattern_"):
fields[k] = 1 if str(v).lower() in ("1", "true", "yes", "on") else 0
elif k == "ratchet_tiers":
fields[k] = str(v).strip()
else:
try:
fields[k] = float(v) if "." in str(v) or k.endswith("_pct") or k in (
"slot_money", "vol_mult", "rsi_min", "rsi_max",
"high_chase_thr", "max_daily_chg", "min_price",
"pullback_min_pct", "pullback_max_pct", "setup_vol_max_mult",
) else int(float(v))
except (TypeError, ValueError):
fields[k] = v
ok = upsert_us_momentum_stock_config(
db,
code,
exchange=str(body.get("exchange") or "NASD"),
symbol=str(body.get("symbol") or code),
name=str(body.get("name") or code),
stock_group=str(body.get("stock_group") or "STOCK"),
fields=fields,
)
row = get_us_momentum_stock_row(db, code) if ok else None
return jsonify({
"ok": ok,
"code": code,
"cfg": row_to_ui_dict(row) if row else None,
})
finally:
db.close()
except Exception as e:
logger.error("api_us_momentum_stock_cfg: %s", e)
return jsonify({"error": str(e)}), 500
@app.route("/api/backtest/momentum/search_results", methods=["GET"])
def api_backtest_momentum_search_results():
"""최신 param_search_momentum JSON 상위 N — 웹 폼 프리필·Apply UX."""
top_n = max(1, min(100, int(request.args.get("top", 30))))
json_path = (request.args.get("json") or "").strip() or None
path, data = _load_momentum_search_json(json_path)
if not data:
return jsonify({"error": "search_momentum_*.json 없음 또는 파싱 실패", "path": path}), 404
results = data.get("top") or data.get("results") or []
rows = []
for idx, item in enumerate(results[:top_n]):
merged = None
try:
from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
merged = merge_param_search_apply_source(item, data)
except Exception:
merged = dict(item.get("merged_params") or item.get("params") or {})
rows.append({
"rank": item.get("rank") or (idx + 1),
"params": item.get("params") or {},
"merged_params": item.get("merged_params") or {},
"merged": merged,
"total_trades": item.get("total_trades"),
"win_rate": item.get("win_rate"),
"total_pnl": item.get("total_pnl"),
"pf": item.get("pf"),
"avg_hold_min": item.get("avg_hold") or item.get("avg_hold_min"),
"sell_reasons": item.get("sell_reasons") or {},
})
meta = {
"path": path,
"strategy": data.get("strategy") or "MOMENTUM",
"mode": data.get("mode"),
"start": data.get("start"),
"end": data.get("end"),
"grid_keys": data.get("grid_keys") or [],
"grid_axis_hints": data.get("grid_axis_hints") or {},
"tested_combos": data.get("tested_combos"),
"cartesian_product": data.get("cartesian_product"),
"slot_money": data.get("slot_money"),
"max_stocks": data.get("max_stocks"),
"total_budget_krw": data.get("total_budget_krw"),
}
return jsonify({"ok": True, "meta": meta, "top": rows})
@app.route("/api/backtest/momentum/apply_search", methods=["POST"])
def api_backtest_momentum_apply_search():
"""파라서치 N위 → 웹 폼 프리필 + 선택 시 DB 저장 (MOMENTUM_*)."""
body = request.get_json(force=True, silent=True) or {}
rank = max(1, int(body.get("rank") or 1))
save_db = body.get("save_db", True)
if isinstance(save_db, str):
save_db = save_db.strip().lower() in ("1", "true", "yes", "on")
json_path = (body.get("json") or "").strip() or None
path, data = _load_momentum_search_json(json_path)
if not data:
return jsonify({"error": "search_momentum_*.json 없음", "path": path}), 404
results = data.get("top") or data.get("results") or []
if rank > len(results):
return jsonify({"error": f"rank 범위 초과 (1~{len(results)})"}), 400
item = results[rank - 1]
pnl = int(item.get("total_pnl") or 0)
if pnl <= 0 and not body.get("allow_non_positive_pnl"):
return jsonify({
"error": f"total_pnl={pnl} ≤ 0 — DB 미적용. force 시 allow_non_positive_pnl=true",
"rank": rank,
}), 400
try:
from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
merged = merge_param_search_apply_source(item, data)
except Exception as e:
return jsonify({"error": f"merge 실패: {e}"}), 500
ui = _momentum_engine_dict_to_ui(merged)
env_id = None
if save_db:
try:
from kis_trader.backtest.param_search_momentum import apply_params_to_db
env_id = apply_params_to_db(merged)
if env_id is None:
return jsonify({"error": "DB 적용 실패", "ui": ui}), 500
except Exception as e:
logger.error("모멘텀 파라서치 DB 적용 오류: %s", e)
return jsonify({"error": str(e), "ui": ui}), 500
return jsonify({
"ok": True,
"rank": rank,
"path": path,
"env_id": env_id,
"saved": bool(save_db),
"ui": ui,
"merged": merged,
"metrics": {
"total_trades": item.get("total_trades"),
"win_rate": item.get("win_rate"),
"total_pnl": item.get("total_pnl"),
"pf": item.get("pf"),
"avg_hold_min": item.get("avg_hold") or item.get("avg_hold_min"),
"sell_reasons": item.get("sell_reasons") or {},
},
})
def _env_bool_10(v: Any) -> str:
"""env_config 불리언 컬럼용 문자열 (scalping_engine._to_bool 과 호환)."""
if isinstance(v, bool):
return "1" if v else "0"
s = str(v).strip().lower()
return "1" if s in ("1", "true", "t", "y", "yes", "on") else "0"
def _tail_bool_arg(request, key: str, def_val: Any) -> bool:
"""꼬리 백테 쿼리 불리언 → bool (미전달 시 DB 기본값). 파라서치·실매와 동일."""
raw = request.args.get(key)
if raw is None or raw == "":
if isinstance(def_val, bool):
return def_val
s = str(def_val).strip().lower()
return s in ("1", "true", "t", "y", "yes", "on")
s = str(raw).strip().lower()
return s in ("1", "true", "t", "y", "yes", "on")
def _momentum_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]:
"""모멘텀 백테 탭 → env_config (MomentumStrategy.reload_config · coarse 탐색과 동일 키 계열)."""
if not isinstance(body, dict):
return {}
patch: Dict[str, str] = {}
def gv(key: str) -> Any:
v = body.get(key)
if v is None or v == "":
return None
return v
x = gv("mom_rsi_min")
if x is not None:
patch["MOMENTUM_RSI_MIN"] = str(float(x))
x = gv("mom_rsi_max")
if x is not None:
patch["MOMENTUM_RSI_MAX"] = str(float(x))
x = gv("mom_vol_mult")
if x is not None:
patch["MOMENTUM_VOL_MULT"] = str(float(x))
x = gv("mom_vol_win")
if x is not None:
patch["MOMENTUM_VOL_WIN"] = str(int(float(x)))
x = gv("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"))
if "backtest_skip_pre_subscribe" in body:
patch["MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE"] = _env_bool_10(
body.get("backtest_skip_pre_subscribe"),
)
x = gv("eod_hm")
if x is not None:
eod_s = str(x).strip()
if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit():
eod_s = f"{eod_s[:2]}:{eod_s[2:]}"
patch["MOMENTUM_EOD_HM"] = eod_s
x = gv("trail_pct")
if x is not None:
patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(x)) / 100.0)
x = gv("trail_arm_pct")
if x is not None:
patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0)
x = gv("max_hold_bars")
if x is not None:
patch["MOMENTUM_MAX_HOLD_BARS"] = str(int(float(x)))
x = gv("ratchet_tiers")
if x is not None:
patch["MOMENTUM_RATCHET_TIERS"] = str(x).strip()
if "use_high_chase_filter" in body:
patch["MOMENTUM_USE_HIGH_CHASE_FILTER"] = _env_bool_10(body.get("use_high_chase_filter"))
if "use_daily_range_filter" in body:
patch["MOMENTUM_USE_DAILY_RANGE_FILTER"] = _env_bool_10(body.get("use_daily_range_filter"))
if "use_ema_filter" in body:
patch["MOMENTUM_USE_EMA_FILTER"] = _env_bool_10(body.get("use_ema_filter"))
if "use_rsi_max_filter" in body:
patch["MOMENTUM_USE_RSI_MAX_FILTER"] = _env_bool_10(body.get("use_rsi_max_filter"))
if "pattern_breakout" in body:
patch["MOMENTUM_PATTERN_BREAKOUT"] = _env_bool_10(body.get("pattern_breakout"))
if "pattern_pullback" in body:
patch["MOMENTUM_PATTERN_PULLBACK"] = _env_bool_10(body.get("pattern_pullback"))
x = gv("chase_lookback_min")
if x is not None:
patch["MOMENTUM_CHASE_LOOKBACK_MIN"] = str(int(float(x)))
x = gv("pullback_lookback_min")
if x is not None:
patch["MOMENTUM_PULLBACK_LOOKBACK_MIN"] = str(int(float(x)))
x = gv("pullback_min_pct")
if x is not None:
patch["MOMENTUM_PULLBACK_MIN_PCT"] = str(float(x))
x = gv("pullback_max_pct")
if x is not None:
patch["MOMENTUM_PULLBACK_MAX_PCT"] = str(float(x))
x = gv("setup_vol_max_mult")
if x is not None:
patch["MOMENTUM_SETUP_VOL_MAX_MULT"] = str(float(x))
x = gv("setup_bear_bars_min")
if x is not None:
patch["MOMENTUM_SETUP_BEAR_BARS_MIN"] = str(int(float(x)))
x = gv("ema_fast_period")
if x is not None:
patch["MOMENTUM_EMA_FAST_PERIOD"] = str(int(float(x)))
x = gv("ema_slow_period")
if x is not None:
patch["MOMENTUM_EMA_SLOW_PERIOD"] = str(int(float(x)))
# 당일 누적손익 다단 트레일 익절 (레칫식) — 꼬리와 동일 (공통 헬퍼)
patch.update(_daily_trail_save_patch(body, "MOMENTUM"))
return patch
def _us_momentum_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]:
"""해외 모멘텀 탭 → US_MOMENTUM_* 만 (국내 MOMENTUM_/SCALP_ 이중저장 금지)."""
if not isinstance(body, dict):
return {}
patch: Dict[str, str] = {}
def gv(key: str) -> Any:
v = body.get(key)
if v is None or v == "":
return None
return v
x = gv("mom_rsi_min")
if x is not None:
patch["US_MOMENTUM_RSI_MIN"] = str(float(x))
x = gv("mom_rsi_max")
if x is not None:
patch["US_MOMENTUM_RSI_MAX"] = str(float(x))
x = gv("mom_vol_mult")
if x is not None:
patch["US_MOMENTUM_VOL_MULT"] = str(float(x))
x = gv("mom_vol_win")
if x is not None:
patch["US_MOMENTUM_VOL_WIN"] = str(int(float(x)))
x = gv("time_start")
if x is not None:
patch["US_MOMENTUM_TIME_START"] = str(int(float(x)))
x = gv("time_end")
if x is not None:
patch["US_MOMENTUM_TIME_END"] = str(int(float(x)))
x = gv("sell_time_end")
if x is not None:
patch["US_MOMENTUM_SELL_TIME_END"] = str(int(float(x)))
x = gv("sl_pct")
if x is not None:
patch["US_MOMENTUM_STOP_LOSS_PCT"] = str(abs(float(x)) / 100.0)
x = gv("tp_pct")
if x is not None:
patch["US_MOMENTUM_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0)
x = gv("tp_max_pct")
if x is not None:
patch["US_MOMENTUM_TP_MAX_PCT"] = str(abs(float(x)) / 100.0)
x = gv("shoulder_min_high")
if x is not None:
patch["US_MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0)
x = gv("shoulder_cut_pct")
if x is not None:
patch["US_MOMENTUM_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0)
x = gv("trail_pct")
if x is not None:
patch["US_MOMENTUM_TRAIL_PCT"] = str(abs(float(x)) / 100.0)
x = gv("trail_arm_pct")
if x is not None:
patch["US_MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0)
x = gv("max_hold_bars")
if x is not None:
patch["US_MOMENTUM_MAX_HOLD_BARS"] = str(int(float(x)))
x = gv("ratchet_tiers")
if x is not None:
patch["US_MOMENTUM_RATCHET_TIERS"] = str(x).strip()
x = gv("cooldown_min")
if x is not None:
patch["US_MOMENTUM_COOLDOWN_SEC"] = str(int(float(x) * 60))
x = gv("max_daily")
if x is not None:
patch["US_MOMENTUM_MAX_DAILY"] = str(int(float(x)))
x = gv("slots")
if x is not None:
patch["US_MOMENTUM_MAX_STOCKS"] = str(int(float(x)))
x = gv("slot_money")
if x is not None:
sms = str(int(float(x)))
patch["US_MOMENTUM_SLOT_MONEY"] = sms
patch["US_MOMENTUM_MAX_BUY_AMOUNT"] = sms
x = gv("total_budget_krw")
if x is not None:
patch["US_MOMENTUM_TOTAL_BUDGET"] = str(int(float(x)))
x = gv("high_chase_thr")
if x is not None:
hx = float(x)
patch["US_MOMENTUM_HIGH_CHASE_THR"] = str(hx if 0 < hx <= 1 else hx / 100.0)
x = gv("max_daily_chg")
if x is not None:
patch["US_MOMENTUM_MAX_DAILY_CHG"] = str(float(x))
x = gv("min_price")
if x is not None:
patch["US_MOMENTUM_MIN_PRICE"] = str(float(x))
x = gv("max_loss_krw")
if x is not None:
patch["US_MOMENTUM_MAX_LOSS_PER_TRADE"] = str(int(float(x)))
x = gv("min_margin")
if x is not None:
patch["US_MOMENTUM_MIN_PROFIT_PCT"] = str(float(x))
x = gv("mom_max_from_open_pct")
if x is not None:
patch["US_MOMENTUM_MAX_FROM_OPEN_PCT"] = str(float(x))
x = gv("mom_min_from_open_pct")
if x is not None:
patch["US_MOMENTUM_MIN_FROM_OPEN_PCT"] = str(float(x))
if "use_defense_filters" in body:
patch["US_MOMENTUM_USE_DEFENSE_FILTERS"] = _env_bool_10(body.get("use_defense_filters"))
if "use_high_chase_filter" in body:
patch["US_MOMENTUM_USE_HIGH_CHASE_FILTER"] = _env_bool_10(body.get("use_high_chase_filter"))
if "use_daily_range_filter" in body:
patch["US_MOMENTUM_USE_DAILY_RANGE_FILTER"] = _env_bool_10(body.get("use_daily_range_filter"))
if "use_ema_filter" in body:
patch["US_MOMENTUM_USE_EMA_FILTER"] = _env_bool_10(body.get("use_ema_filter"))
if "use_rsi_max_filter" in body:
patch["US_MOMENTUM_USE_RSI_MAX_FILTER"] = _env_bool_10(body.get("use_rsi_max_filter"))
if "pattern_breakout" in body:
patch["US_MOMENTUM_PATTERN_BREAKOUT"] = _env_bool_10(body.get("pattern_breakout"))
if "pattern_pullback" in body:
patch["US_MOMENTUM_PATTERN_PULLBACK"] = _env_bool_10(body.get("pattern_pullback"))
x = gv("chase_lookback_min")
if x is not None:
patch["US_MOMENTUM_CHASE_LOOKBACK_MIN"] = str(int(float(x)))
x = gv("pullback_lookback_min")
if x is not None:
patch["US_MOMENTUM_PULLBACK_LOOKBACK_MIN"] = str(int(float(x)))
x = gv("pullback_min_pct")
if x is not None:
patch["US_MOMENTUM_PULLBACK_MIN_PCT"] = str(float(x))
x = gv("pullback_max_pct")
if x is not None:
patch["US_MOMENTUM_PULLBACK_MAX_PCT"] = str(float(x))
x = gv("setup_vol_max_mult")
if x is not None:
patch["US_MOMENTUM_SETUP_VOL_MAX_MULT"] = str(float(x))
x = gv("setup_bear_bars_min")
if x is not None:
patch["US_MOMENTUM_SETUP_BEAR_BARS_MIN"] = str(int(float(x)))
x = gv("ema_fast_period")
if x is not None:
patch["US_MOMENTUM_EMA_FAST_PERIOD"] = str(int(float(x)))
x = gv("ema_slow_period")
if x is not None:
patch["US_MOMENTUM_EMA_SLOW_PERIOD"] = str(int(float(x)))
if "eod_enabled" in body:
patch["US_MOMENTUM_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled"))
x = gv("eod_hm")
if x is not None:
eod_s = str(x).strip()
if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit():
eod_s = f"{eod_s[:2]}:{eod_s[2:]}"
patch["US_MOMENTUM_EOD_HM"] = eod_s
patch.update(_daily_trail_save_patch(body, "US_MOMENTUM"))
return patch
def _scalp_reversal_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]:
"""스캘핑 reversal 탭 saveScalpConfig JSON → SCALP_* env."""
if not isinstance(body, dict):
return {}
patch: Dict[str, str] = {}
def gv(key: str) -> Any:
v = body.get(key)
if v is None or v == "":
return None
return v
x = gv("rsi_oversold")
if x is not None:
patch["SCALP_RSI_OVERSOLD"] = str(float(x))
x = gv("rsi_overbought")
if x is not None:
patch["SCALP_RSI_OVERBOUGHT"] = str(float(x))
x = gv("rsi_period")
if x is not None:
patch["SCALP_RSI_PERIOD"] = str(int(float(x)))
x = gv("sl_pct")
if x is not None:
r = abs(float(x)) / 100.0
patch["SCALP_STOP_LOSS_PCT"] = str(r)
x = gv("tp_pct")
if x is not None:
patch["SCALP_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0)
x = gv("tp_max_pct")
if x is not None:
patch["SCALP_TP_MAX_PCT"] = str(abs(float(x)) / 100.0)
x = gv("drop_rate")
if x is not None:
patch["SCALP_MIN_DROP_RATE"] = str(abs(float(x)) / 100.0)
x = gv("vol_mult")
if x is not None:
# 거래량 배수 — 0=OFF. 공용 VOL_MULTIPLIER (실매 reload_config / 엔진 동일 키)
patch["VOL_MULTIPLIER"] = str(float(x))
x = gv("shoulder_min_high")
if x is not None:
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"))
x = gv("time_start_hm")
if x is not None:
patch["SCALP_TIME_START"] = str(int(float(x)))
x = gv("time_end_hm")
if x is not None:
te = str(int(float(x)))
patch["SCALP_TIME_END"] = te
patch["TIME_END"] = te
x = gv("max_daily")
if x is not None:
patch["SCALP_MAX_DAILY"] = str(int(float(x)))
x = gv("slot_money")
if x is not None:
sm = str(int(float(x)))
patch["SLOT_MONEY_DEFAULT"] = sm
patch["SCALP_MAX_BUY_AMOUNT"] = sm
patch["MAX_BUY_AMOUNT_PER_STOCK"] = sm
x = gv("max_stocks")
if x is not None:
patch["SCALP_MAX_STOCKS"] = str(int(float(x)))
x = gv("total_budget_krw")
if x is not None:
patch["SCALP_TOTAL_BUDGET_KRW"] = str(int(float(x)))
if "skip_hts_scan_dupes" in body:
patch["SCALP_SKIP_HTS_SCAN_DUPES"] = _env_bool_10(body.get("skip_hts_scan_dupes"))
if "require_reversal_candle" in body:
patch["SCALP_REQUIRE_REVERSAL_CANDLE"] = _env_bool_10(body.get("require_reversal_candle"))
if "eod_enabled" in body:
patch["SCALP_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled"))
x = gv("eod_hm")
if x is not None:
eod_s = str(x).strip()
if eod_s:
patch["SCALP_EOD_HM"] = eod_s
return patch
def _breakout_tab_save_patch(body: Dict[str, Any]) -> Dict[str, str]:
"""돌파 백테 탭 폼 → BREAKOUT_* env (BreakoutStrategy 가 읽는 음수 손절 비율 포함)."""
if not isinstance(body, dict):
return {}
patch: Dict[str, str] = {}
def gv(key: str) -> Any:
v = body.get(key)
if v is None or v == "":
return None
return v
x = gv("lookback_min")
if x is not None:
patch["BREAKOUT_LOOKBACK_MIN"] = str(int(float(x)))
x = gv("vol_window")
if x is not None:
patch["BREAKOUT_VOL_WIN"] = str(int(float(x)))
x = gv("vol_mult")
if x is not None:
patch["BREAKOUT_VOL_MULT"] = str(float(x))
x = gv("min_turnover_1m_pct")
if x is not None:
patch["BREAKOUT_MIN_TURNOVER_1M_PCT"] = str(float(x))
x = gv("prev_chg_min")
if x is not None:
patch["BREAKOUT_PREV_CHG_MIN"] = str(float(x))
x = gv("prev_chg_max")
if x is not None:
patch["BREAKOUT_PREV_CHG_MAX"] = str(float(x))
x = gv("sl_pct")
if x is not None:
patch["BREAKOUT_STOP_LOSS_PCT"] = str(-abs(float(x)) / 100.0)
x = gv("sl_mode")
if x is not None:
patch["BREAKOUT_SL_MODE"] = str(x).strip().lower()
x = gv("atr_period")
if x is not None:
patch["BREAKOUT_ATR_PERIOD"] = str(int(float(x)))
x = gv("atr_sl_mult")
if x is not None:
patch["BREAKOUT_ATR_SL_MULT"] = str(float(x))
x = gv("atr_sl_min_pct")
if x is not None:
patch["BREAKOUT_ATR_SL_MIN_PCT"] = str(float(x))
x = gv("atr_sl_max_pct")
if x is not None:
patch["BREAKOUT_ATR_SL_MAX_PCT"] = str(float(x))
x = gv("tp_pct")
if x is not None:
patch["BREAKOUT_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0)
x = gv("trail_pct")
if x is not None:
patch["BREAKOUT_TRAIL_PCT"] = str(abs(float(x)) / 100.0)
x = gv("trail_arm_pct")
if x is not None:
patch["BREAKOUT_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0)
x = gv("shoulder_min_high_pct")
if x is not None:
patch["BREAKOUT_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0)
x = gv("shoulder_cut_pct")
if x is not None:
patch["BREAKOUT_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0)
# 래칫 — 빈 문자열도 OFF 로 명시 저장
if "ratchet_tiers" in body:
patch["BREAKOUT_RATCHET_TIERS"] = str(body.get("ratchet_tiers") or "").strip()
if "eod_enabled" in body:
patch["BREAKOUT_EOD_ENABLED"] = _env_bool_10(body.get("eod_enabled"))
x = gv("eod_hm")
if x is not None:
eod_s = str(x).strip()
if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit():
eod_s = f"{eod_s[:2]}:{eod_s[2:]}"
patch["BREAKOUT_EOD_HM"] = eod_s
x = gv("max_hold_bars")
if x is not None:
patch["BREAKOUT_MAX_HOLD_BARS"] = str(int(float(x)))
x = gv("time_start_hm")
if x is not None:
patch["BREAKOUT_TIME_START"] = str(int(float(x)))
x = gv("time_end_hm")
if x is not None:
te = str(int(float(x)))
patch["BREAKOUT_TIME_END"] = te
patch["BREAKOUT_GOLDEN_END_HM"] = te
x = gv("max_daily")
if x is not None:
patch["BREAKOUT_MAX_DAILY"] = str(int(float(x)))
x = gv("cooldown_min")
if x is not None:
patch["BREAKOUT_COOLDOWN_SEC"] = str(int(float(x) * 60))
x = gv("max_daily_chg")
if x is not None:
patch["BREAKOUT_MAX_DAILY_CHG"] = str(float(x))
x = gv("min_price")
if x is not None:
patch["BREAKOUT_MIN_PRICE"] = str(int(float(x)))
# 가짜돌파(휩쏘) 필터 — 0=OFF
x = gv("confirm_margin_pct")
if x is not None:
patch["BREAKOUT_CONFIRM_MARGIN_PCT"] = str(float(x))
x = gv("body_min_pct")
if x is not None:
patch["BREAKOUT_BODY_MIN_PCT"] = str(float(x))
x = gv("max_loss_krw")
if x is not None:
patch["BREAKOUT_MAX_LOSS_PER_TRADE_KRW"] = str(int(float(x)))
x = gv("entry_mode")
if x is not None:
patch["BREAKOUT_ENTRY_MODE"] = str(x).strip().lower()
x = gv("intrabar_slippage_pct")
if x is not None:
patch["BREAKOUT_INTRABAR_SLIPPAGE_PCT"] = str(float(x))
x = gv("slot_money")
if x is not None:
patch["BREAKOUT_SLOT_MONEY"] = str(int(float(x)))
x = gv("max_stocks")
if x is not None:
patch["BREAKOUT_MAX_STOCKS"] = str(int(float(x)))
x = gv("total_budget_krw")
if x is not None:
patch["BREAKOUT_TOTAL_BUDGET_KRW"] = str(int(float(x)))
if "use_ema_filter" in body:
patch["BREAKOUT_USE_EMA_FILTER"] = _env_bool_10(body.get("use_ema_filter"))
if "ob_filter" in body:
patch["BREAKOUT_ORDERBOOK_FILTER_ENABLED"] = _env_bool_10(body.get("ob_filter"))
if "pg_filter" in body:
patch["BREAKOUT_PROGRAM_FILTER_ENABLED"] = _env_bool_10(body.get("pg_filter"))
x = gv("ema_fast_period")
if x is not None:
patch["BREAKOUT_EMA_FAST_PERIOD"] = str(int(float(x)))
x = gv("ema_slow_period")
if x is not None:
patch["BREAKOUT_EMA_SLOW_PERIOD"] = str(int(float(x)))
if "skip_hts_scan_dupes" in body:
patch["BREAKOUT_SKIP_HTS_SCAN_DUPES"] = _env_bool_10(body.get("skip_hts_scan_dupes"))
# 당일 누적손익 다단 트레일 익절 (레칫식) — 꼬리와 동일 (공통 헬퍼)
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", 20)))
symbol_daily_loss_limit_krw = float(
request.args.get("symbol_daily_loss_limit_krw", _def.get("symbol_daily_loss_limit_krw", 30000))
)
symbol_daily_loss_limit_pct = float(
request.args.get("symbol_daily_loss_limit_pct", _def.get("symbol_daily_loss_limit_pct", 1.5))
)
reentry_min_edge_krw = float(
request.args.get("reentry_min_edge_krw", _def.get("reentry_min_edge_krw", 0))
)
eod_patch = _eod_params_from_request(request, _def, default_hm="15:20")
_legacy_force_eod = request.args.get("force_eod_exit")
if _legacy_force_eod not in (None, ""):
eod_patch["eod_enabled"] = str(_legacy_force_eod).strip().lower() in (
"1", "true", "y", "yes", "on",
)
# V3 방어 파라미터 연동
min_price = float(request.args.get("min_price", _def.get("min_price", 1000.0)))
max_daily_change = float(request.args.get("max_daily_change", _def.get("max_daily_change", 20.0)))
ma20_max_above = float(request.args.get("ma20_max_above", _def.get("ma20_max_above", 3.0)))
stop_atr_mult = float(request.args.get("stop_atr_mult", _def.get("stop_atr_mult", 1.5)))
target_atr_mult = float(request.args.get("target_atr_mult", _def.get("target_atr_mult", 2.0)))
max_loss_krw = int(request.args.get("max_loss_krw", _def.get("max_loss_krw", 200000)))
_min_drop_loss_arg = request.args.get("min_drop_pct_for_loss_cut")
min_drop_pct_for_loss_cut = _def.get("min_drop_pct_for_loss_cut", 0.015)
if _min_drop_loss_arg not in (None, ""):
v = float(_min_drop_loss_arg)
min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v
risk_pct = float(request.args.get("risk_pct", _def.get("risk_pct", 0.01) * 100)) / 100
kelly_mult = float(request.args.get("kelly_mult", _def.get("kelly_mult", 0.25)))
min_hold_sec = float(request.args.get("min_hold_sec", _def.get("min_hold_sec", 30.0)))
capital = float(request.args.get("capital", _def.get("capital", 100000000.0)))
try:
tail_tf = int(request.args.get("timeframe", request.args.get("tf", 3)))
except (TypeError, ValueError):
tail_tf = 3
if tail_tf not in (3, 5, 15, 60):
return jsonify({
"error": f"timeframe(tf)는 ws_candles 저장 단위 3·5·15·60 중 하나여야 합니다 (요청: {tail_tf})",
}), 400
db = _db()
try:
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=%s "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[tail_tf, start_key, end_key]
).fetchall()
codes = [r["code"] for r in codes_raw]
use_saved_history_tail, tail_univ_mode, _ = _parse_backtest_universe_arg(
request, default="history", sim_kind=None,
)
use_engine = _TAIL_ENGINE_AVAILABLE and request.args.get("use_engine", "1") == "1"
all_trades: List[Dict] = []
universe_source = "all"
universe_history_slots = 0
candles_by_code: Dict[str, List[Dict]] = {}
bt_meta: Dict[str, Any] = {}
tail_trigger_flags: Dict[str, Any] = {
"skip_hts_scan_dupes": _tail_bool_arg(
request, "skip_hts_scan_dupes", _def.get(
"skip_hts_scan_dupes",
te.resolve_tail_skip_hts_scan_dupes() if _TAIL_ENGINE_AVAILABLE else False,
),
),
"use_intraday_drop": _tail_bool_arg(
request, "use_intraday_drop", _def.get("use_intraday_drop", False),
),
"use_ma20_filter": _tail_bool_arg(
request, "use_ma20_filter", _def.get("use_ma20_filter", False),
),
"use_rsi_filter": _tail_bool_arg(
request, "use_rsi_filter", _def.get("use_rsi_filter", True),
),
"use_daily_range_filter": _tail_bool_arg(
request, "use_daily_range_filter", _def.get("use_daily_range_filter", True),
),
"use_high_chase_filter": _tail_bool_arg(
request, "use_high_chase_filter", _def.get("use_high_chase_filter", True),
),
"bar_chg_min_pct": float(request.args.get("bar_chg_min_pct", _def.get("bar_chg_min_pct", -10.0))),
"bar_chg_max_pct": float(request.args.get("bar_chg_max_pct", _def.get("bar_chg_max_pct", -1.5))),
"pattern_hammer": _tail_bool_arg(request, "pattern_hammer", _def.get("pattern_hammer", True)),
"pattern_pin": _tail_bool_arg(request, "pattern_pin", _def.get("pattern_pin", False)),
"pattern_engulfing": _tail_bool_arg(request, "pattern_engulfing", _def.get("pattern_engulfing", False)),
"pattern_piercing": _tail_bool_arg(request, "pattern_piercing", _def.get("pattern_piercing", False)),
"pattern_harami": _tail_bool_arg(request, "pattern_harami", _def.get("pattern_harami", False)),
"pattern_doji": _tail_bool_arg(request, "pattern_doji", _def.get("pattern_doji", False)),
"pattern_morning_star": _tail_bool_arg(
request, "pattern_morning_star", _def.get("pattern_morning_star", False),
),
}
try:
from kis_trader.engine.limit_entry_common import short_entry_mode, tail_limit_params
except ImportError:
short_entry_mode = lambda p=None: "limit_atr" # type: ignore
tail_limit_params = lambda p=None: {} # type: ignore
_entry_mode = str(
request.args.get("entry_mode") or _def.get("entry_mode") or short_entry_mode()
).strip().lower()
_limit_probe = dict(_def)
_limit_probe["entry_mode"] = _entry_mode
for _lk in ("limit_atr_mult", "limit_anchor", "limit_valid_bars", "limit_fill_slip_pct"):
_lv = request.args.get(_lk)
if _lv is not None and str(_lv).strip() != "":
_limit_probe[_lk] = _lv
_lp_tail = tail_limit_params(_limit_probe)
if use_engine:
params = {
"entry_mode": _entry_mode,
"limit_atr_mult": _lp_tail["mult"],
"limit_anchor": _lp_tail["anchor"],
"limit_valid_bars": _lp_tail["valid_bars"],
"limit_fill_slip_pct": _lp_tail["fill_slip_pct"],
"min_drop_rate": min_drop_rate, "min_recovery_ratio": min_recovery_ratio,
"max_rec_3m": max_rec_3m, "tail_ratio_min": tail_ratio_min, "tail_pct_min": tail_pct_min,
"sl_pct": sl_pct, "tp_pct": tp_pct,
"shoulder_min_high": shoulder_min_high, "shoulder_cut_pct": shoulder_cut_pct,
"rsi_period": rsi_period, "rsi_threshold": rsi_threshold, "high_chase_thr": high_chase_thr,
"time_start_hm": time_start_hm, "time_end_hm": time_end_hm,
"cooldown_min": cooldown_min, "max_daily": max_daily,
"symbol_daily_loss_limit_krw": symbol_daily_loss_limit_krw,
"symbol_daily_loss_limit_pct": symbol_daily_loss_limit_pct,
"reentry_min_edge_krw": reentry_min_edge_krw,
"min_price": min_price, "max_daily_change": max_daily_change, "ma20_max_above": ma20_max_above,
"stop_atr_mult": stop_atr_mult, "target_atr_mult": target_atr_mult, "max_loss_krw": max_loss_krw,
"atr_sl_min_pct": float(request.args.get("atr_sl_min_pct", _def.get("atr_sl_min_pct", 0.5))),
"atr_sl_max_pct": float(request.args.get("atr_sl_max_pct", _def.get("atr_sl_max_pct", 1.0))),
"atr_tp_min_pct": float(request.args.get("atr_tp_min_pct", _def.get("atr_tp_min_pct", 0.3))),
"atr_tp_max_pct": float(request.args.get("atr_tp_max_pct", _def.get("atr_tp_max_pct", 1.0))),
"tail_vol_mult": float(request.args.get("tail_vol_mult", _def.get("tail_vol_mult", 0.0))),
"tail_vol_win": int(float(request.args.get("tail_vol_win", _def.get("tail_vol_win", 5)))),
"backtest_vol_fill_cap_pct": float(request.args.get(
"backtest_vol_fill_cap_pct", _def.get("backtest_vol_fill_cap_pct", 0.0),
)),
"min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut,
"risk_pct": risk_pct, "kelly_mult": kelly_mult, "min_hold_sec": min_hold_sec, "capital": capital,
**eod_patch,
"max_stocks": max_stocks,
"total_budget_krw": total_budget_krw,
# 실매 SHORT_CAND_LIMIT 동일 적용 — 그 시각 유니버스 상위 N개만 검사(0=무제한).
"cand_limit": int(float(
request.args.get("cand_limit", _def.get("cand_limit", 0)) or 0
)),
"portfolio_mode": True,
"ratchet_tiers": str(
request.args.get("ratchet_tiers", _def.get("ratchet_tiers", "")) or ""
).strip(),
"max_hold_bars": int(float(
request.args.get("max_hold_bars", _def.get("max_hold_bars", 0)) or 0
)),
"backtest_use_tick_db": _tail_bool_arg(
request, "backtest_use_tick_db", _def.get("backtest_use_tick_db", True),
),
"backtest_use_tick_exit": _tail_bool_arg(
request, "backtest_use_tick_exit", _def.get("backtest_use_tick_exit", True),
),
"backtest_tick_fallback_ohlc": _tail_bool_arg(
request, "backtest_tick_fallback_ohlc", _def.get("backtest_tick_fallback_ohlc", False),
),
"trail_pct": trail_pct,
"trail_arm_pct": trail_arm_pct,
# 당일 누적손익 트레일 익절 — 마스터 스위치(daily_profit_enabled) 기준.
# apply_daily_profit_halt_sim 이 engine_params 로 읽어 신규진입 차단.
**_daily_trail_params_from_request(request, prefix="SHORT"),
**tail_trigger_flags,
}
start_ymd = start_key[:8]
end_ymd = end_key[:8]
_univ_hs = _parse_universe_history_source_arg(request)
universe_by_slot, universe_source, universe_history_slots, _scan_iv = (
tbc.resolve_tail_universe(
start_ymd, end_ymd,
use_saved_history=use_saved_history_tail,
history_source=_univ_hs,
)
)
params["scan_interval_min"] = _scan_iv
params["timeframe"] = tail_tf
params["_universe_history_source"] = _univ_hs
# 백테 전용 필터 토글 (이 1회 백테에만 적용. 비우면 DB=실매값)
_ob_tg = _backtest_filter_toggle(request.args.get("ob_filter"))
if _ob_tg is not None:
params["_orderbook_filter_enabled"] = _ob_tg
_pg_tg = _backtest_filter_toggle(request.args.get("pg_filter"))
if _pg_tg is not None:
params["_program_filter_enabled"] = _pg_tg
_spread_req = request.args.get("max_spread_pct")
if _spread_req not in (None, ""):
params["_ob_max_spread_pct"] = float(_spread_req)
# kiwoom_0d 본체는 ob_body=1 일 때만. spread 값만으로 본체 강제하면
# log_backfill 판정 재생(파람서치·CLI --orderbook-filter on 기본)과 어긋남.
_ob_body = str(request.args.get("ob_body", "0")).strip().lower() in (
"1", "true", "y", "yes", "on",
)
if _ob_body:
params["backtest_use_kiwoom_body_snapshot"] = True
params["_backtest_use_kiwoom_body"] = True
candles_by_code, _total_candles, has_holding_peak = tbc.load_tail_candles_by_code(
db, start_key, end_key, tail_tf, rsi_period=rsi_period,
)
bt_meta = {
"db": db,
"start_key": start_key,
"end_key": end_key,
"timeframe": tail_tf,
"backtest_env_timeline": _backtest_env_timeline_from_request(request),
}
all_trades = tbc.run_tail_backtest_web_aligned(
candles_by_code, params, universe_by_slot,
slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax,
max_stocks=max_stocks, total_budget_krw=total_budget_krw,
meta_out=bt_meta,
)
else:
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=%s AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[tail_tf, code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
candles = [dict(r) for r in rows]
closes = [float(c["close"]) for c in candles]
rsis = _compute_rsi_series(closes, rsi_period)
position = None
last_exit_dt: Dict[str, datetime] = {}
daily_cnt: Dict[str, int] = {}
# look-ahead 없는 당일 누적 OHLC
cur_day = None
running_open = 0.0
running_high = 0.0
running_low = 0.0
for i in range(rsi_period + 1, len(candles)):
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
op = float(c["open"])
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
# ── 당일 누적 OHLC 갱신 (선행 편향 없음) ─────────────────
if day != cur_day:
cur_day = day
running_open = op
running_high = hi
running_low = lo if lo > 0 else hi
else:
running_high = max(running_high, hi)
if lo > 0:
running_low = min(running_low, lo)
# ── 마지막 봉 여부 ────────────────────────────────────────
is_eod = is_strategy_eod_bar(c["candle_time"], params, "TAIL")
# ─────────────────────────────────────────────────────────
# 포지션 보유 중: 청산 체크
# ─────────────────────────────────────────────────────────
if position is not None:
max_p = max(position["max_price"], hi)
position["max_price"] = max_p
cur_c_info = {"high": hi, "low": lo, "close": cl, "candle_time": c["candle_time"]}
_pos = dict(position)
_pos["max_price"] = max_p
res = te.check_sell_signal_live(_pos, cur_c_info, params, is_eod=is_eod)
if res:
reason, exit_price = res
else:
reason, exit_price = None, cl
if reason:
ep = position["entry_price"]
qty = max(1, int(slot_money / ep))
fee = (ep + exit_price) * qty * fee_rate
tax = exit_price * qty * sell_tax
pnl = (exit_price - ep) * qty - fee - tax
hold = round((
_t2dt(c["candle_time"]) -
_t2dt(position["entry_time"])
).total_seconds() / 60, 1)
all_trades.append({
"code": code,
"entry_time": position["entry_time"],
"exit_time": c["candle_time"],
"entry": round(ep),
"exit": round(exit_price),
"pnl": round(pnl),
"reason": reason,
"hold_min": hold,
})
last_exit_dt[day] = _t2dt(c["candle_time"])
daily_cnt[day] = daily_cnt.get(day, 0) + 1
position = None
continue # 다음 봉으로
# ─────────────────────────────────────────────────────────
# 포지션 없음: 매수 조건 체크
# ─────────────────────────────────────────────────────────
if cl <= 0 or running_open <= 0:
continue
if hm < time_start_hm or hm > time_end_hm:
continue
if daily_cnt.get(day, 0) >= max_daily:
continue
# 쿨다운: 마지막 청산 후 N분 이내 재진입 금지
if day in last_exit_dt:
elapsed = (_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
if elapsed < cooldown_min:
continue
# ── 1. 당일 낙폭 ─────────────────────────────────────────
drop = (running_open - running_low) / running_open
if drop < min_drop_rate:
continue
# ── 2. 당일 회복률 ─────────────────────────────────────
day_range = running_high - running_low
rec_day = (cl - running_low) / day_range if day_range > 0 else 0
if rec_day < min_recovery_ratio:
continue
# ── 3. 망치봉 꼬리 비율 계산 ────────────────────────────
body_top = max(op, cl)
body_bot = min(op, cl)
body_len = body_top - body_bot if body_top > body_bot else 1.0
tail_len = body_bot - lo if lo > 0 else 0.0
# 꼬리 없는 봉이면 이전 봉에서 재탐색 (최대 3봉 전)
if tail_len <= 0:
for j in range(i - 1, max(i - 4, rsi_period), -1):
prev = candles[j]
o2, h2, l2, c2 = float(prev["open"]), float(prev["high"]), float(prev["low"]), float(prev["close"])
if l2 <= 0:
continue
bt2, bb2 = max(o2, c2), min(o2, c2)
bl2 = bt2 - bb2 if bt2 > bb2 else 1.0
tl2 = bb2 - l2
if tl2 > 0:
tail_len = tl2
body_len = bl2
lo = l2
break
tail_ratio = tail_len / body_len
tail_pct = tail_len / lo if lo > 0 and tail_len > 0 else 0.0
if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
continue
# ── 4. 3분봉 내 회복 위치 (무릎~어깨) ──────────────────
c_range = float(c["high"]) - float(c["low"])
rec_3m = (cl - float(c["low"])) / c_range if c_range > 0 else 0
if not (min_recovery_ratio <= rec_3m <= max_rec_3m):
continue
# ── 5. RSI 과열 방지 ────────────────────────────────────
rsi_val = rsis[i]
if rsi_val is None or rsi_val >= rsi_threshold:
continue
# ── 6. 피뢰침 방지: 고점 근접 추격 금지 ────────────────
if cl >= running_high * high_chase_thr:
continue
# ── 매수 실행: 다음 봉 시가 진입 ───────────────────────
if i + 1 >= len(candles):
continue
next_c = candles[i + 1]
if next_c["candle_time"][:8] != day:
continue # 장 마감 직전 봉이면 다음날 시가 = 갭위험 → skip
entry_price = float(next_c["open"])
if entry_price <= 0:
entry_price = cl
position = {
"entry_price": entry_price,
"entry_time": next_c["candle_time"],
"stop": entry_price * (1 - sl_pct),
"target": entry_price * (1 + tp_pct),
"max_price": entry_price,
}
# 진입 봉을 이미 처리했으므로 다음 인덱스로 이동
i += 1
# ── 통계 집계 ──────────────────────────────────────────────────
total = len(all_trades)
wins = [t for t in all_trades if t["pnl"] > 0]
losses = [t for t in all_trades if t["pnl"] < 0]
total_pnl = sum(t["pnl"] for t in all_trades)
avg_hold = (sum(t["hold_min"] for t in all_trades) / total) if total else 0
win_pnl = sum(t["pnl"] for t in wins)
loss_pnl = sum(t["pnl"] for t in losses)
pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
# MDD
peak, mdd, cum = 0.0, 0.0, 0.0
peak_cum_at = "" # 장중 누적손익 최고점에 도달한 시각(모멘텀 동일 표기)
equity, daily_map = [], {}
for t in sorted(all_trades, key=lambda x: x["exit_time"]):
cum += t["pnl"]
if cum > peak:
peak = cum
peak_cum_at = str(t.get("exit_time") or "")
dd = peak - cum
if dd > mdd:
mdd = dd
day = t["exit_time"][:8]
equity.append({"date": f"{day[:4]}-{day[4:6]}-{day[6:]}", "cum_pnl": round(cum)})
daily_map[day] = daily_map.get(day, 0) + t["pnl"]
daily_list = [{"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v)}
for d, v in sorted(daily_map.items())]
bot_pct = round(total_pnl / total_budget_krw * 100, 2) if total_budget_krw > 0 else 0.0
period_days = _backtest_period_days(start, end, fallback=len(daily_list) or 1)
daily_avg_pct = round(bot_pct / period_days, 3) if period_days > 0 else 0.0
reasons: Dict[str, int] = {}
for t in all_trades:
reasons[t["reason"]] = reasons.get(t["reason"], 0) + 1
universe_warning = None
budget_warning = None
if total_budget_krw < max_stocks * slot_money * 0.95:
budget_warning = (
f"총한도 {total_budget_krw:,.0f}원 < 동시{max_stocks}×1회투자 "
f"{max_stocks * slot_money:,.0f}원 — 잔여금 소액매수·과다 회전 위험. "
"실매 정렬: 총한도↑ 또는 동시보유↓"
)
skip_stats = {}
if use_engine:
skip_stats = (bt_meta.get("skip_stats") or params.get("_portfolio_skip_stats") or {})
skipped_micro = int(skip_stats.get("skipped_micro_buys") or 0)
min_inv_r = te._tail_min_invest_ratio_of_slot(params) if use_engine else 0.9
if skipped_micro > 0:
micro_note = f"소액매수 스킵 {skipped_micro}건 (slot {min_inv_r * 100:.0f}% 미만)"
budget_warning = f"{budget_warning} | {micro_note}" if budget_warning else micro_note
if use_saved_history_tail and universe_source == "all":
universe_warning = (
"저장 후보 이력을 요청했으나 해당 기간 이력이 없어 전종목(ws_candles)으로 실행되었습니다."
)
elif not use_saved_history_tail and universe_source == "all":
universe_warning = (
"전종목 모드(저장 이력 OFF). 거래 수가 많습니다. "
"파라서치 기본(저장 이력)과 비교하려면 체크박스를 켜세요."
)
elif universe_source == "history" and total > 25:
universe_warning = (
f"저장 이력 {universe_history_slots}슬롯 사용 중 거래 {total}건 — "
"매수시간·기간·파라미터가 파라서치와 다른지 확인하세요."
)
tail_trades_all = list(all_trades)
_enrich_trades_with_names(db, tail_trades_all)
# 전체 거래 기준 누적손익 부착 후 최신순 슬라이스 (실매·모멘텀과 동일)
_enrich_momentum_trades_debug(
tail_trades_all, total_budget_krw=total_budget_krw, with_tick_debug=True,
)
tail_trades_out = _trades_recent_first(tail_trades_all, 200)
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"],
"cand_limit": params.get("cand_limit", 0) if use_engine else 0,
**tail_trigger_flags,
"skip_hts_scan_dupes_effective": bt_meta.get("skip_hts_scan_dupes_effective"),
"skip_hts_scan_dupes_requested": bt_meta.get("skip_hts_scan_dupes_requested"),
},
"summary": {
"total_trades": total,
"win_trades": len(wins),
"loss_trades": len(losses),
"win_rate": round(len(wins) / total * 100, 1) if total else 0,
"total_pnl": round(total_pnl),
"avg_hold_min": round(avg_hold, 1),
"profit_factor": round(pf, 2),
"max_drawdown": round(mdd),
"peak_cum_pnl": round(peak),
"peak_cum_at": peak_cum_at[:19] if peak_cum_at else "",
"bot_pct": bot_pct,
"daily_avg_pct": daily_avg_pct,
"backtest_days": period_days,
"universe_warning": universe_warning,
"budget_warning": budget_warning,
"tick_backtest": bt_meta.get("tick_backtest") if use_engine else None,
"backtest_buy_source": bt_meta.get("backtest_buy_source") if use_engine else None,
"tick_entry_sources": skip_stats.get("tick_entry_sources") if use_engine else None,
"skip_stats": skip_stats if use_engine else None,
},
"equity": equity,
"daily": daily_list,
"reasons": reasons,
"trades": tail_trades_out,
})
finally:
db.close()
@app.route("/api/backtest/breakout", methods=["GET"])
def api_backtest_breakout():
"""돌파매매 백테스트 — ``run_breakout_backtest`` (라이브 BreakoutStrategy 와 동일 함수)."""
_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,
),
"skip_hts_scan_dupes": _tail_bool_arg(
request, "skip_hts_scan_dupes", _def.get(
"skip_hts_scan_dupes",
resolve_breakout_skip_hts_scan_dupes(),
),
),
}
_uef = request.args.get("use_ema_filter")
if _uef not in (None, ""):
ui["use_ema_filter"] = str(_uef).strip().lower() in ("1", "true", "y", "yes", "on")
else:
ui["use_ema_filter"] = bool(_def.get("use_ema_filter", False))
ui["ema_fast_period"] = _arg("ema_fast_period", _def.get("ema_fast_period", 9), lambda v: int(float(v)))
ui["ema_slow_period"] = _arg("ema_slow_period", _def.get("ema_slow_period", 21), lambda v: int(float(v)))
ui.update(_eod_params_from_request(request, _def, default_hm="15:15"))
engine = _bo_ui_to_engine_params(ui)
# 백테 전용 필터 토글 (이 1회 백테에만 적용. 비우면 DB=실매값)
_ob_tg = _backtest_filter_toggle(request.args.get("ob_filter"))
if _ob_tg is not None:
engine["_orderbook_filter_enabled"] = _ob_tg
_pg_tg = _backtest_filter_toggle(request.args.get("pg_filter"))
if _pg_tg is not None:
engine["_program_filter_enabled"] = _pg_tg
_spread_req = request.args.get("max_spread_pct")
if _spread_req not in (None, ""):
engine["_ob_max_spread_pct"] = float(_spread_req)
engine["backtest_use_kiwoom_body_snapshot"] = True
engine["_backtest_use_kiwoom_body"] = True
db = _db()
try:
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
_univ_hs = _parse_universe_history_source_arg(request)
from kis_trader.backtest.breakout_backtest_common import load_breakout_candles_by_code
codes_candles, _ = load_breakout_candles_by_code(
db, start_key, end_key,
lookback_min=int(engine.get("lookback_min") or 1),
vol_window=int(engine.get("vol_window") or 7),
history_source=_univ_hs,
)
use_saved_history, bo_univ_mode, _ = _parse_backtest_universe_arg(
request, default="history", sim_kind=None,
)
universe_by_slot, universe_source, universe_history_slots, _scan_iv = (
_resolve_backtest_universe(
db,
start_key,
end_key,
use_saved_history,
codes_candles,
strategy_id="BREAKOUT",
history_source=_univ_hs,
)
)
# scan_at 타임라인 ↔ 슬롯 dict 동일 이력소스
# (engine_params 는 아래 ui/engine 병합 시 복사되므로 engine에도 넣음)
engine["_universe_history_source"] = _univ_hs
latest_env = db.get_latest_env()
env_row = dict(latest_env["snapshot"]) if latest_env else {}
fee_rate, sell_tax, slot_from_env = bbc.fee_and_slot_from_env(env_row)
slot_money_v = float(ui.get("slot_money") or slot_from_env)
max_stocks_req = _arg(
"max_stocks", _def.get("max_stocks", 3), lambda v: int(float(v)),
)
total_budget_req = _arg(
"total_budget_krw", _def.get("total_budget_krw", 0), float,
)
portfolio = bbc.resolve_breakout_portfolio_params(
env_row,
None,
slot_money=slot_money_v,
max_stocks=max_stocks_req if max_stocks_req > 0 else None,
total_budget_krw=total_budget_req if total_budget_req > 0 else None,
)
total_budget_v = float(portfolio["total_budget_krw"])
max_stocks_v = int(portfolio["max_stocks"])
engine["slot_money"] = slot_money_v
engine["max_stocks"] = max_stocks_v
engine["total_budget_krw"] = total_budget_v
engine["portfolio_mode"] = True
bt_meta: Dict[str, Any] = {
"db": db,
"start_key": start_key,
"end_key": end_key,
"backtest_env_timeline": _backtest_env_timeline_from_request(request),
}
all_trades = bbc.run_breakout_backtest_web_aligned(
codes_candles,
engine,
universe_by_slot=universe_by_slot,
slot_money=slot_money_v,
fee_rate=fee_rate,
sell_tax=sell_tax,
max_stocks=max_stocks_v,
total_budget_krw=total_budget_v,
meta_out=bt_meta,
)
# 당일 누적손익 트레일 익절 시뮬 — 마스터 스위치(daily_profit_enabled) 기준.
_trail_p = _daily_trail_params_from_request(request, prefix="BREAKOUT")
if _trail_p.get("_backtest_daily_profit_trail"):
from kis_trader.backtest.backtest_portfolio_common import apply_daily_profit_halt_sim
all_trades = apply_daily_profit_halt_sim(
all_trades, _trail_p, budget_krw=float(total_budget_v or 0),
)
period_days = _backtest_period_days(start, end, fallback=1)
stats = bbc.summarize_breakout_trades(
all_trades,
total_budget_krw=total_budget_v,
period_days=period_days,
)
total = int(stats["total_trades"])
total_pnl = int(stats["total_pnl"])
wins_n = int(stats["wins"])
losses_n = int(stats["losses"])
avg_hold = float(stats["avg_hold_min"])
pf = float(stats["pf"])
bot_pct = float(stats["bot_pct"])
daily_avg_pct = float(stats["daily_avg_pct"])
wins = [t for t in all_trades if t.get("pnl", 0) > 0]
losses = [t for t in all_trades if t.get("pnl", 0) < 0]
peak, mdd, cum = 0.0, 0.0, 0.0
peak_cum_at = ""
equity: List[Dict[str, Any]] = []
daily_map: Dict[str, int] = {}
for t in sorted(all_trades, key=_trade_exit_sort_key):
cum += float(t.get("pnl") or 0)
if cum > peak:
peak = cum
peak_cum_at = str(t.get("sell_time") or t.get("exit_time") or "")
dd = peak - cum
if dd > mdd:
mdd = dd
day = str(t.get("sell_time", "") or t.get("exit_time", ""))[:8]
if day:
equity.append({
"date": f"{day[:4]}-{day[4:6]}-{day[6:]}",
"cum_pnl": round(cum),
})
daily_map[day] = daily_map.get(day, 0) + int(t.get("pnl") or 0)
daily_list = [
{"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v)}
for d, v in sorted(daily_map.items())
]
reasons: Dict[str, int] = {}
for t in all_trades:
rk = str(t.get("sell_reason") or "unknown")
reasons[rk] = reasons.get(rk, 0) + 1
_enrich_momentum_trades_debug(
all_trades, total_budget_krw=total_budget_v, with_tick_debug=True,
)
trades_out = _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"),
"peak_cum_pnl": round(peak),
"peak_cum_at": peak_cum_at[:19] if peak_cum_at else "",
},
"equity": equity,
"daily": daily_list,
"reasons": reasons,
"trades": trades_out,
})
except Exception as e:
logger.exception("breakout backtest failed")
return jsonify({"error": str(e)}), 500
finally:
db.close()
@app.route("/api/backtest/range_break", methods=["GET"])
def api_backtest_range_break():
"""박스권 돌파 백테스트 — ``run_range_break_backtest`` (라이브 RangeBreakStrategy 와 동일)."""
_def = _rb_defaults_from_db()
start = request.args.get("start", "")
end = request.args.get("end", "")
def _arg(key: str, default: Any, cast=float):
raw = request.args.get(key)
if raw in (None, ""):
return default
try:
return cast(raw)
except (ValueError, TypeError):
return default
ui = {
"box_lookback_min": _arg("box_lookback_min", _def["box_lookback_min"], lambda v: int(float(v))),
"box_max_width_pct": _arg("box_max_width_pct", _def["box_max_width_pct"], float),
"box_min_width_pct": _arg("box_min_width_pct", _def["box_min_width_pct"], float),
"setup_vol_max_mult": _arg("setup_vol_max_mult", _def["setup_vol_max_mult"], float),
"setup_bear_bars_min": _arg("setup_bear_bars_min", _def["setup_bear_bars_min"], lambda v: int(float(v))),
"vol_mult": _arg("vol_mult", _def["vol_mult"], float),
"vol_window": _arg("vol_window", _def["vol_window"], lambda v: int(float(v))),
"vol_baseline_win": _arg("vol_baseline_win", _def.get("vol_baseline_win", 30), lambda v: int(float(v))),
"break_margin_pct": _arg("break_margin_pct", _def.get("break_margin_pct", 0.0), float),
"body_min_pct": _arg("body_min_pct", _def.get("body_min_pct", 0.0), float),
"sl_pct": _arg("sl_pct", _def["sl_pct"], float),
"tp_pct": _arg("tp_pct", _def["tp_pct"], float),
"trail_pct": _arg("trail_pct", _def["trail_pct"], float),
"trail_arm_pct": _arg("trail_arm_pct", _def.get("trail_arm_pct", 1.5), float),
"shoulder_min_high_pct": _arg("shoulder_min_high_pct", _def.get("shoulder_min_high_pct", 3.0), float),
"shoulder_cut_pct": _arg("shoulder_cut_pct", _def.get("shoulder_cut_pct", 0.5), float),
"time_start_hm": _arg("time_start_hm", _def["time_start_hm"], lambda v: int(float(v))),
"time_end_hm": _arg("time_end_hm", _def["time_end_hm"], lambda v: int(float(v))),
"max_daily": _arg("max_daily", _def["max_daily"], lambda v: int(float(v))),
"cooldown_min": _arg("cooldown_min", _def["cooldown_min"], float),
"max_daily_chg": _arg("max_daily_chg", _def["max_daily_chg"], float),
"min_price": _arg("min_price", _def["min_price"], float),
"high_chase_thr": _arg("high_chase_thr", _def.get("high_chase_thr", 0.96), float),
"max_loss_krw": _arg("max_loss_krw", _def["max_loss_krw"], lambda v: int(float(v))),
"slot_money": _arg("slot_money", _def["slot_money"], lambda v: int(float(v))),
"max_hold_bars": _arg("max_hold_bars", _def.get("max_hold_bars", 0), lambda v: int(float(v))),
"fee_rate_pct": _def.get("fee_rate_pct", 0.015),
"sell_tax_pct": _def.get("sell_tax_pct", 0.18),
}
_uhf = request.args.get("use_high_chase_filter")
if _uhf not in (None, ""):
ui["use_high_chase_filter"] = str(_uhf).strip().lower() in ("1", "true", "y", "yes", "on")
else:
ui["use_high_chase_filter"] = bool(_def.get("use_high_chase_filter", True))
engine = _rb_ui_to_engine_params(ui)
db = _db()
try:
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key],
).fetchall()
codes = [r["code"] for r in codes_raw]
codes_candles: Dict[str, List[Dict]] = {}
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=1 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key],
).fetchall()
if len(rows) < 5:
continue
codes_candles[code] = [dict(r) for r in rows]
use_saved_history, rb_univ_mode, _ = _parse_backtest_universe_arg(
request, default="history", sim_kind=None,
)
universe_by_slot, universe_source, universe_history_slots, _scan_iv = (
_resolve_backtest_universe(
db,
start_key,
end_key,
use_saved_history,
codes_candles,
strategy_id="RANGE_BREAK",
history_source=_parse_universe_history_source_arg(request),
)
)
latest_env = db.get_latest_env()
env_row = dict(latest_env["snapshot"]) if latest_env else {}
fee_rate, sell_tax, slot_from_env = rbc.fee_and_slot_from_env(env_row)
slot_money_v = float(ui.get("slot_money") or slot_from_env)
max_stocks_req = _arg(
"max_stocks", _def.get("max_stocks", 3), lambda v: int(float(v)),
)
total_budget_req = _arg(
"total_budget_krw", _def.get("total_budget_krw", 0), float,
)
portfolio = rbc.resolve_range_break_portfolio_params(
env_row,
None,
slot_money=slot_money_v,
max_stocks=max_stocks_req if max_stocks_req > 0 else None,
total_budget_krw=total_budget_req if total_budget_req > 0 else None,
)
total_budget_v = float(portfolio["total_budget_krw"])
max_stocks_v = int(portfolio["max_stocks"])
engine["slot_money"] = slot_money_v
engine["max_stocks"] = max_stocks_v
engine["total_budget_krw"] = total_budget_v
engine["portfolio_mode"] = True
_env_tl = _backtest_env_timeline_from_request(request)
bt_meta: Dict[str, Any] = {
"db": db, "start_key": start_key, "end_key": end_key,
"backtest_env_timeline": _env_tl,
}
all_trades = rbc.run_range_break_backtest_web_aligned(
codes_candles,
engine,
universe_by_slot=universe_by_slot,
slot_money=slot_money_v,
fee_rate=fee_rate,
sell_tax=sell_tax,
max_stocks=max_stocks_v,
total_budget_krw=total_budget_v,
meta_out=bt_meta,
)
period_days = _backtest_period_days(start, end, fallback=1)
stats = rbc.summarize_range_break_trades(
all_trades,
total_budget_krw=total_budget_v,
period_days=period_days,
)
total = int(stats["total_trades"])
total_pnl = int(stats["total_pnl"])
wins_n = int(stats["wins"])
losses_n = int(stats["losses"])
avg_hold = float(stats["avg_hold_min"])
pf = float(stats["pf"])
bot_pct = float(stats["bot_pct"])
daily_avg_pct = float(stats["daily_avg_pct"])
peak, mdd, cum = 0.0, 0.0, 0.0
peak_cum_at = ""
equity: List[Dict[str, Any]] = []
daily_map: Dict[str, int] = {}
for t in sorted(all_trades, key=_trade_exit_sort_key):
cum += float(t.get("pnl") or 0)
if cum > peak:
peak = cum
peak_cum_at = str(t.get("sell_time") or t.get("exit_time") or "")
dd = peak - cum
if dd > mdd:
mdd = dd
day = str(t.get("sell_time", "") or t.get("exit_time", ""))[:8]
if day:
equity.append({
"date": f"{day[:4]}-{day[4:6]}-{day[6:]}",
"cum_pnl": round(cum),
})
daily_map[day] = daily_map.get(day, 0) + int(t.get("pnl") or 0)
daily_list = [
{"date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "pnl": round(v)}
for d, v in sorted(daily_map.items())
]
reasons: Dict[str, int] = {}
for t in all_trades:
rk = str(t.get("sell_reason") or "unknown")
reasons[rk] = reasons.get(rk, 0) + 1
_enrich_momentum_trades_debug(
all_trades, total_budget_krw=total_budget_v, with_tick_debug=False,
)
trades_out = _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"),
"peak_cum_pnl": round(peak),
"peak_cum_at": peak_cum_at[:19] if peak_cum_at else "",
},
"equity": equity,
"daily": daily_list,
"reasons": reasons,
"trades": trades_out,
})
except Exception as e:
logger.exception("range_break backtest failed")
return jsonify({"error": str(e)}), 500
finally:
db.close()
# ────────────────────────────────────────────────────────────────────────────
# API: 홀딩 전략 — 관심종목 + 종목별 파라미터 + 캔들 수집 + 백테스트 + 파라미터 탐색
# ────────────────────────────────────────────────────────────────────────────
def _holding_db() -> TradeDB:
db = _db()
hb.ensure_holding_tables(db)
return db
def _updow_db() -> TradeDB:
"""UPDOW 탭·API: 분봉(holding_min_candles) + updow_stock_config."""
db = _db()
uhc.ensure_updow_backtest_tables(db)
return db
@app.route("/api/holding/stocks", methods=["GET"])
def api_holding_stocks():
"""관심종목 목록 + 종목별 현재 파라미터 + 보유 봉수 반환"""
db = _holding_db()
try:
items = hb.load_watchlist()
result = []
for item in items:
code = item["code"]
cfg = hb.get_stock_config(db, code)
cfg["name"] = item.get("name", cfg.get("name", ""))
# 보유 봉수
row = db.conn.execute(
"SELECT COUNT(*) as cnt, MIN(candle_date) as mn, MAX(candle_date) as mx "
"FROM holding_candles WHERE code=%s", [code]
).fetchone()
cfg["candle_count"] = int(row["cnt"]) if row else 0
cfg["candle_min"] = str(row["mn"]) if row and row["mn"] else ""
cfg["candle_max"] = str(row["mx"]) if row and row["mx"] else ""
# 60분봉 현황 추가
ms = hb.get_min_candle_stats(db, code, tf_min=60)
cfg["min60_count"] = ms["count"]
cfg["min60_min"] = ms["min"]
cfg["min60_max"] = ms["max"]
result.append(cfg)
return jsonify(result)
finally:
db.close()
@app.route("/api/holding/config/<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", _default_trading_day_ymd())
if not code:
return jsonify({"error": "code 필수"}), 400
db = _holding_db()
try:
app_key, app_secret, base_url, mock = hb._get_kis_token(db)
rows = hb.fetch_daily_ohlcv(code, start_date, end_date, app_key, app_secret, base_url, mock=mock)
saved = hb.store_candles(db, code, rows)
return jsonify({"ok": True, "fetched": len(rows), "saved": saved})
except Exception as e:
logger.error(f"캔들 수집 오류 ({code}): {e}")
return jsonify({"error": str(e)}), 500
finally:
db.close()
@app.route("/api/holding/backtest", methods=["GET"])
def api_holding_backtest():
"""홀딩 전략 백테스트 (종목별 파라미터 사용 or 요청 파라미터 오버라이드)"""
code = request.args.get("code", "")
start_date = request.args.get("start", "2023-01-01")
end_date = request.args.get("end", _default_trading_day_ymd())
if not code:
return jsonify({"error": "code 필수"}), 400
db = _holding_db()
try:
candles = hb.get_stored_candles(db, code, start_date, end_date)
if len(candles) < 20:
# 친절한 에러: DB 전체 보유 봉 수와 기간도 함께 안내
all_candles = hb.get_stored_candles(db, code)
if not all_candles:
hint = "📥 먼저 [캔들 수집] 버튼으로 데이터를 수집하세요."
else:
first = str(all_candles[0]["candle_date"])[:10]
last = str(all_candles[-1]["candle_date"])[:10]
hint = (
f"DB에 {len(all_candles)}봉 있음 ({first} ~ {last})\n"
f"👉 백테스트 시작일을 '{first}' 이후로 설정하세요."
)
return jsonify({
"error": f"봉 부족: {len(candles)}개 (최소 20개 필요)\n{hint}"
}), 400
# 요청 파라미터로 DB 설정 오버라이드 가능
cfg = hb.get_stock_config(db, code)
for ck in hb.DEFAULT_STOCK_CONFIG:
v = request.args.get(ck)
if v is not None:
cfg[ck] = float(v)
result = hb.run_backtest(candles, cfg)
result["code"] = code
result["candle_count"] = len(candles)
result["params"] = {k: cfg[k] for k in hb.DEFAULT_STOCK_CONFIG}
return jsonify(result)
finally:
db.close()
@app.route("/api/holding/v1/backtest", methods=["GET"])
def api_holding_v1_backtest():
"""홀딩 V1 (RSI 분할매수) 백테스트"""
code = request.args.get("code", "")
start_date = request.args.get("start", "")
end_date = request.args.get("end", _default_trading_day_ymd())
if not code:
return jsonify({"error": "code 필수"}), 400
# 카드 UI 파라미터 수집 (DEFAULT_V1_CONFIG 키 기준)
cfg = {}
for key in hv1.DEFAULT_V1_CONFIG.keys():
val = request.args.get(key)
if val is not None:
try:
cfg[key] = float(val)
except (ValueError, TypeError):
pass
db = _holding_db()
try:
candles = hb.get_stored_candles(db, code, start_date, end_date)
if len(candles) < 10:
return jsonify({"error": f"봉 부족: {len(candles)}"}), 400
res = hv1.run_backtest_v1(candles, cfg)
if "error" not in res:
res["candle_rows"] = hb.build_daily_candle_display_rows(candles)
return jsonify(res)
finally:
db.close()
@app.route("/api/holding/v1/param_search", methods=["GET"])
def api_holding_v1_param_search():
"""홀딩 V1 (RSI 분할매수) 파라미터 Grid Search"""
code = request.args.get("code", "")
start_date = request.args.get("start", "")
end_date = request.args.get("end", _default_trading_day_ymd())
min_trades = max(0, min(50, int(request.args.get("min_trades", 1))))
if not code:
return jsonify({"error": "code 필수"}), 400
# 카드 UI 파라미터를 base_cfg로 (그리드 외 파라미터 고정)
base_cfg = {}
for key in hv1.DEFAULT_V1_CONFIG.keys():
val = request.args.get(key)
if val is not None:
try:
base_cfg[key] = float(val)
except (ValueError, TypeError):
pass
db = _holding_db()
try:
candles = hb.get_stored_candles(db, code, start_date, end_date)
if len(candles) < 20:
return jsonify({"error": f"봉 부족: {len(candles)}"}), 400
results = hv1.run_param_search_v1(candles, min_trades=min_trades,
base_cfg=base_cfg if base_cfg else None)
return jsonify({"code": code, "top": results[:30]})
finally:
db.close()
@app.route("/api/holding/param_search", methods=["GET"])
def api_holding_param_search():
"""홀딩 전략 파라미터 Grid Search (단일 종목)"""
code = request.args.get("code", "")
start_date = request.args.get("start", "2023-01-01")
end_date = request.args.get("end", _default_trading_day_ymd())
min_trades = max(0, min(50, int(request.args.get("min_trades", 1))))
if not code:
return jsonify({"error": "code 필수"}), 400
db = _holding_db()
try:
candles = hb.get_stored_candles(db, code, start_date, end_date)
if len(candles) < 20:
return jsonify({"error": f"봉 부족: {len(candles)}"}), 400
# 추세BT와 동일 축: DB 최신 저장값 전체를 base로 쓰고, URL(카드 입력)만 덮어씀.
# (예: buy1_ratio 가 URL에 없으면 예전엔 DEFAULT 50%로만 탐색되어 수량·손익이 추세BT의 2배로 나오는 문제)
stored = hb.get_stock_config(db, code)
base_cfg: Dict[str, float] = {}
for key in hb.DEFAULT_STOCK_CONFIG.keys():
raw = stored.get(key, hb.DEFAULT_STOCK_CONFIG[key])
try:
base_cfg[key] = float(raw)
except (TypeError, ValueError):
base_cfg[key] = float(hb.DEFAULT_STOCK_CONFIG[key])
for key in hb.DEFAULT_STOCK_CONFIG.keys():
val = request.args.get(key)
if val is not None:
try:
base_cfg[key] = float(val)
except (ValueError, TypeError):
pass
results, meta = hb.run_param_search(
candles, min_trades=min_trades, base_cfg=base_cfg
)
return jsonify({"code": code, "top": results[:30], "meta": meta})
finally:
db.close()
# ────────────────────────────────────────────────────────────────────────────
# 메인 페이지
# ────────────────────────────────────────────────────────────────────────────
@app.route("/api/holding/min_candles/fetch", methods=["POST"])
def api_holding_min_candles_fetch():
"""60분봉 수집 API: 백그라운드 스레드로 실행, 즉시 job_id 반환"""
body = request.get_json(force=True, silent=True) or {}
code = body.get("code", "")
start = body.get("start", "")
end = body.get("end", _default_trading_day_ymd())
tf = int(body.get("tf", 60))
if not code or not start:
return jsonify({"error": "code, start 필수"}), 400
job_id = uuid.uuid4().hex[:8]
_min_fetch_jobs[job_id] = {
"status": "running",
"code": code,
"fetched": 0, # 수집한 1분봉 수
"saved": 0, # DB에 저장된 60분봉 수
"current_date": "", # 현재 처리 중인 날짜
"error": None,
}
def _run():
db = _holding_db()
try:
app_key, app_secret, base_url, mock = hb._get_kis_token(db)
hb.fetch_and_store_min_candles(
db, code, start, end,
app_key, app_secret, base_url,
tf_min=tf, mock=mock,
progress=_min_fetch_jobs[job_id],
)
_min_fetch_jobs[job_id]["status"] = "done"
except Exception as e:
logger.error(f"60분봉 수집 오류 ({code}): {e}")
_min_fetch_jobs[job_id]["status"] = "error"
_min_fetch_jobs[job_id]["error"] = str(e)
finally:
db.close()
threading.Thread(target=_run, daemon=True).start()
return jsonify({"job_id": job_id, "status": "started"})
@app.route("/api/holding/min_candles/status/<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", _default_trading_day_ymd())
try:
tf = int(body.get("tf", 60))
except (TypeError, ValueError):
tf = 60
if tf not in hb.KIWOOM_MINUTE_TICS:
return jsonify({
"error": f"tf는 키움 ka10080 허용값만 가능: {list(hb.KIWOOM_MINUTE_TICS)} (요청: {tf})",
}), 400
if not code or not start:
return jsonify({"error": "code, start 필수"}), 400
job_id = uuid.uuid4().hex[:8]
_min_fetch_jobs[job_id] = {
"status": "running",
"code": code,
"market_type": market_type,
"exchange": exchange,
"symbol": symbol,
"tf": tf,
"source": "kis_overseas" if market_type != "KR" else "kiwoom",
"fetched": 0,
"saved": 0,
"current_date": "",
"error": None,
}
def _run():
db = _holding_db()
try:
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", _default_trading_day_ymd())
tf = int(request.args.get("tf", 60))
if not code:
return jsonify({"error": "code 필수"}), 400
db = _holding_db()
try:
candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf)
if not candles:
stats = hb.get_min_candle_stats(db, code, tf_min=tf)
if stats["count"] == 0:
hint = f"📥 먼저 [60분봉 수집] 버튼으로 데이터를 수집하세요."
else:
hint = (f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n"
f"👉 백테스트 시작일을 '{stats['min'][:10]}' 이후로 설정하세요.")
return jsonify({"error": f"60분봉 없음\n{hint}"}), 400
if len(candles) < 20:
stats = hb.get_min_candle_stats(db, code, tf_min=tf)
hint = (f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n"
f"👉 백테스트 날짜 범위를 넓혀 최소 20봉 이상 포함하세요.")
return jsonify({
"error": f"봉 부족: {len(candles)}개 (최소 20개 필요)\n{hint}"
}), 400
# 파라미터 오버라이드 (일봉 백테스트와 동일 방식)
cfg = hb.get_stock_config(db, code)
overrides = ["rsi_period","rsi_buy1","rsi_buy2","rsi_buy3","rsi_sell",
"take_profit_pct","stop_loss_pct","buy1_ratio","buy2_ratio",
"buy3_ratio","slot_money"]
for k in overrides:
v = request.args.get(k)
if v is not None:
cfg[k] = float(v)
# tf_min=60 전달 → 52주 윈도우를 날짜 기반으로 정확히 계산
result = hb.run_backtest(candles, cfg, tf_min=tf)
result["code"] = code
result["candle_count"] = len(candles)
result["tf"] = tf
result["params"] = {k: cfg[k] for k in hb.DEFAULT_STOCK_CONFIG}
return jsonify(result)
finally:
db.close()
@app.route("/api/holding/min_stats", methods=["GET"])
def api_holding_min_stats():
"""60분봉 보유 현황 (종목 카드에서 표시용)"""
code = request.args.get("code", "")
tf = int(request.args.get("tf", 60))
if not code:
return jsonify({"error": "code 필수"}), 400
db = _holding_db()
try:
return jsonify(hb.get_min_candle_stats(db, code, tf_min=tf))
finally:
db.close()
# ═════════════════════════════════════════════════════════════════════════════
# UPDOWN 박스권 엔진 (신규) — kis_trader.engine.updown_box
# 판별 evaluate_box · 진입 box_entry_signal · 청산 eval_box_exit_at_price
# 백테 run_backtest_box · 파라서치 run_param_search_box_multi (실매 단일 소스)
# 유니버스: 조건검색 → 박스필터(SCAN) → updown_watchlist(sticky, 최대 30)
# ═════════════════════════════════════════════════════════════════════════════
def _box_cfg_with_overrides(args) -> Dict[str, Any]:
"""글로벌 박스 cfg(env UPDOWN_BOX_*) + 요청 쿼리 오버라이드."""
from kis_trader.engine.updown_box import get_box_cfg_from_env
cfg = dict(get_box_cfg_from_env())
for k in list(cfg.keys()):
v = args.get(k)
if v is not None and str(v).strip() != "":
if k == "ratchet_tiers":
cfg[k] = str(v) # 다단 래칫은 "수익%:컷%,…" 문자열 그대로
continue
try:
cfg[k] = float(v)
except (ValueError, TypeError):
pass
return cfg
@app.route("/api/updown_box/config", methods=["GET"])
def api_updown_box_config():
"""박스 글로벌 파라미터(env UPDOWN_BOX_*) + watchlist 통계."""
from kis_trader.engine.updown_box import get_box_cfg_from_env
from kis_trader.strategies.updown_watchlist import (
active_count,
ensure_updown_watchlist_table,
watch_max,
)
from kis_trader.utils.env import get_env_int
db = _db()
try:
cfg = get_box_cfg_from_env()
ensure_updown_watchlist_table(db)
return jsonify({
"ok": True,
"cfg": cfg,
"watch_active": active_count(db),
"watch_max": watch_max(db),
"scan_tf_min": int(get_env_int("UPDOWN_SCAN_TF_MIN", 15)),
})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
@app.route("/api/updown_box/watchlist", methods=["GET"])
def api_updown_box_watchlist():
"""updown_watchlist 전체(또는 active) 목록."""
from kis_trader.strategies.updown_watchlist import (
ensure_updown_watchlist_table,
list_active_watchlist,
list_all_watchlist,
)
only_active = str(request.args.get("active", "0")).strip() in ("1", "true", "on")
db = _db()
try:
ensure_updown_watchlist_table(db)
rows = list_active_watchlist(db) if only_active else list_all_watchlist(db)
return jsonify({"ok": True, "rows": rows})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
@app.route("/api/updown_box/watchlist/add", methods=["POST"])
def api_updown_box_watchlist_add():
"""watchlist 수동 추가 (HTS 안 켜고 박스 후보 직접 등록)."""
from kis_trader.strategies.updown_watchlist import (
add_manual,
ensure_updown_watchlist_table,
)
body = request.get_json(force=True, silent=True) or {}
code = str(body.get("code", "")).strip()
if len(code) != 6 or not code.isdigit():
return jsonify({"ok": False, "error": "6자리 종목코드 필요"}), 400
name = str(body.get("name", "")).strip()
try:
box_low = float(body.get("box_low") or 0)
box_high = float(body.get("box_high") or 0)
except (ValueError, TypeError):
box_low = box_high = 0.0
db = _db()
try:
ensure_updown_watchlist_table(db)
ok = add_manual(db, code, name, box_low=box_low, box_high=box_high)
return jsonify({"ok": bool(ok)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
@app.route("/api/updown_box/watchlist/remove", methods=["POST"])
def api_updown_box_watchlist_remove():
"""watchlist 1건 삭제 (수동 제거)."""
from kis_trader.strategies.updown_watchlist import (
ensure_updown_watchlist_table,
remove_manual,
)
body = request.get_json(force=True, silent=True) or {}
code = str(body.get("code", "")).strip()
if not code:
return jsonify({"ok": False, "error": "code 필수"}), 400
db = _db()
try:
ensure_updown_watchlist_table(db)
ok = remove_manual(db, code)
return jsonify({"ok": bool(ok)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
@app.route("/api/updown_box/backtest", methods=["GET"])
def api_updown_box_backtest():
"""단일 종목 박스권 백테 (run_backtest_box) — 실매와 동일 엔진."""
from kis_trader.engine.updown_box import evaluate_box, run_backtest_box
from kis_trader.utils.env import get_env_int
code = request.args.get("code", "")
start_date = request.args.get("start", "")
end_date = request.args.get("end", _default_trading_day_ymd())
if not code:
return jsonify({"error": "code 필수"}), 400
tf_raw = request.args.get("tf")
try:
tf = int(float(tf_raw)) if tf_raw not in (None, "") else get_env_int("UPDOWN_SCAN_TF_MIN", 15)
except (TypeError, ValueError):
tf = get_env_int("UPDOWN_SCAN_TF_MIN", 15)
if tf not in hb.KIWOOM_MINUTE_TICS:
return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나 (요청: {tf})"}), 400
db = _updow_db()
try:
candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf)
if not candles:
stats = hb.get_min_candle_stats(db, code, tf_min=tf)
if stats["count"] == 0:
hint = "📥 먼저 [키움 분봉 수집]으로 해당 tf 데이터를 수집하세요."
else:
hint = f"DB에 {stats['count']}봉 있음 ({stats['min']} ~ {stats['max']})\n👉 시작일을 '{stats['min'][:10]}' 이후로 설정하세요."
return jsonify({"error": f"{tf}분봉 없음\n{hint}"}), 400
cfg = _box_cfg_with_overrides(request.args)
min_need = int(cfg.get("min_bars", 20)) + 2
if len(candles) < min_need:
return jsonify({"error": f"봉 부족: {len(candles)}개 (최소 {min_need})"}), 400
result = run_backtest_box(candles, cfg)
# 현재(최근 봉 기준) 박스 판별 상태도 함께 (직관용)
box_now = evaluate_box(candles, cfg)
result["code"] = code
result["tf"] = tf
result["candle_count"] = len(candles)
result["box_now"] = {
"is_box": box_now.get("is_box"),
"box_low": box_now.get("box_low"),
"box_high": box_now.get("box_high"),
"range_pct": box_now.get("range_pct"),
"bb_bw": box_now.get("bb_bw"),
"ma_slope": box_now.get("ma_slope"),
"box_score": box_now.get("box_score"),
"reason": box_now.get("reason"),
}
result["params"] = {k: cfg.get(k) for k in cfg}
result["candle_rows"] = hb.build_daily_candle_display_rows(candles)
return jsonify(result)
finally:
db.close()
@app.route("/api/updown_box/param_search", methods=["GET"])
def api_updown_box_param_search():
"""박스권 글로벌 파라미터 그리드 탐색 — 다종목 합산(run_param_search_box_multi).
종목 소스: ?codes=005930,000660 또는 ?from_watchlist=1 (active) 또는 ?code=단일.
"""
from kis_trader.engine.updown_box import (
default_box_param_grid,
get_box_cfg_from_env,
run_backtest_box,
run_param_search_box_multi,
)
from kis_trader.utils.env import get_env_int
start_date = request.args.get("start", "")
end_date = request.args.get("end", _default_trading_day_ymd())
min_trades = max(0, min(100, int(request.args.get("min_trades", 3))))
mode = str(request.args.get("mode", "fast") or "fast").strip().lower()
rank_by = str(request.args.get("rank_by", "alpha") or "alpha").strip().lower()
keep_neg = str(request.args.get("keep_negative_alpha", "0")).strip() in ("1", "true", "on")
tf_raw = request.args.get("tf")
try:
tf = int(float(tf_raw)) if tf_raw not in (None, "") else get_env_int("UPDOWN_SCAN_TF_MIN", 15)
except (TypeError, ValueError):
tf = get_env_int("UPDOWN_SCAN_TF_MIN", 15)
if tf not in hb.KIWOOM_MINUTE_TICS:
return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나 (요청: {tf})"}), 400
db = _updow_db()
try:
codes: List[str] = []
if str(request.args.get("from_watchlist", "0")).strip() in ("1", "true", "on"):
from kis_trader.strategies.updown_watchlist import (
ensure_updown_watchlist_table,
list_active_watchlist,
)
ensure_updown_watchlist_table(db)
codes = [str(r.get("code") or "").strip() for r in list_active_watchlist(db)]
else:
raw = request.args.get("codes") or request.args.get("code") or ""
codes = [c for c in str(raw).replace(" ", "").split(",") if c]
codes = [c for c in codes if len(c) == 6 and c.isdigit()]
if not codes:
return jsonify({"error": "종목 없음 — codes=005930,000660 또는 from_watchlist=1"}), 400
base_cfg = get_box_cfg_from_env()
min_bars = int(base_cfg.get("min_bars", 20)) + 2
candles_by_code: Dict[str, List[Dict[str, Any]]] = {}
skipped: List[str] = []
for code in codes:
cs = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf)
if cs and len(cs) >= min_bars:
candles_by_code[code] = cs
else:
skipped.append(code)
if not candles_by_code:
return jsonify({"error": f"{tf}분봉 데이터 가진 종목 없음 (수집 필요)"}), 400
# === 추세종목(α≤0) 자동 제외 — base_cfg 기준 봇손익 ≤ B&H 이면 박스 대상 아님 ===
dropped_trend: List[Dict[str, Any]] = []
if not keep_neg:
kept: Dict[str, List[Dict[str, Any]]] = {}
for code, cs in candles_by_code.items():
bt = run_backtest_box(cs, base_cfg)
a = float(bt.get("alpha_pct", bt.get("total_pnl", 0)) or 0)
if a > 0:
kept[code] = cs
else:
dropped_trend.append({
"code": code,
"bot_pnl": float(bt.get("total_pnl", 0) or 0),
"bh_pct": float(bt.get("buy_hold_pct", 0) or 0),
"alpha": round(a, 3),
})
if not kept:
return jsonify({
"error": "α>0 종목 0 — 박스전략 적합 종목이 없습니다 (모두 추세주). "
"keep_negative_alpha=1 로 강제 가능",
"dropped_trend": dropped_trend,
}), 400
candles_by_code = kept
grid = default_box_param_grid("full" if mode == "full" else "fast")
try:
combo_cap = int(float(get_env_int("UPDOWN_BOX_WEB_MAX_COMBOS", 500)))
except (TypeError, ValueError):
combo_cap = 500
results, meta = run_param_search_box_multi(
candles_by_code,
grid=grid,
base_cfg=base_cfg,
min_trades_total=min_trades,
max_combos=max(0, combo_cap),
rank_by=rank_by,
)
meta["tf"] = tf
meta["search_mode"] = mode
meta["skipped_codes"] = skipped
meta["dropped_trend"] = dropped_trend
top = [{**r, "tf": tf} for r in results[:30]]
return jsonify({"codes": list(candles_by_code.keys()), "top": top, "meta": meta})
finally:
db.close()
@app.route("/api/updown_box/apply", methods=["POST"])
def api_updown_box_apply():
"""파라서치 결과(apply_cfg) → env_config UPDOWN_BOX_* 적용."""
from kis_trader.engine.updown_box import box_cfg_to_env_patch
body = request.get_json(force=True, silent=True) or {}
apply_cfg = body.get("apply_cfg") or {}
if not isinstance(apply_cfg, dict) or not apply_cfg:
return jsonify({"ok": False, "error": "apply_cfg 필요"}), 400
clean: Dict[str, Any] = {}
for k, v in apply_cfg.items():
if k == "ratchet_tiers":
clean[k] = str(v) # 다단 래칫 문자열 그대로
elif isinstance(v, (int, float)) or str(v).replace(".", "", 1).isdigit():
clean[k] = float(v)
patch = box_cfg_to_env_patch(clean)
if not patch:
return jsonify({"ok": False, "error": "적용할 키 없음"}), 400
db = _db()
try:
snap = db.get_merged_env_snapshot() or {}
for k, v in patch.items():
snap[k] = str(v)
eid = db.insert_env_snapshot(snap)
if eid is None:
return jsonify({"ok": False, "error": "insert_env_snapshot 실패"}), 500
return jsonify({"ok": True, "env_id": eid, "applied": patch})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
@app.route("/api/updown_box/stock_list", methods=["GET"])
def api_updown_box_stock_list():
"""종목 셀렉트용 목록 — watchlist(active+inactive) + 종목별 오버라이드 등록 종목 합집합."""
from kis_trader.strategies.updown_box_stock_cfg import list_box_stock_cfg
from kis_trader.strategies.updown_watchlist import (
ensure_updown_watchlist_table,
list_all_watchlist,
)
db = _updow_db()
try:
ensure_updown_watchlist_table(db)
merged: Dict[str, Dict[str, Any]] = {}
for r in list_all_watchlist(db):
c = str(r.get("code") or "").strip()
if not c:
continue
merged[c] = {"code": c, "name": str(r.get("name") or c), "n_overrides": 0,
"status": str(r.get("status") or "")}
for it in list_box_stock_cfg(db):
c = str(it.get("code") or "").strip()
if not c:
continue
if c in merged:
merged[c]["n_overrides"] = int(it.get("n_overrides") or 0)
else:
merged[c] = {"code": c, "name": str(it.get("name") or c),
"n_overrides": int(it.get("n_overrides") or 0), "status": ""}
items = sorted(merged.values(), key=lambda x: x["code"])
return jsonify({"items": items})
finally:
db.close()
@app.route("/api/updown_box/stock_cfg", methods=["GET"])
def api_updown_box_stock_cfg_get():
"""종목별 박스 오버라이드 + 현재 글로벌값 — 종목 선택 시 인풋 채우기용.
응답: {code, name, overrides:{설정된 키만}, global:{박스 전 키 기본/글로벌}, effective:{병합 결과}}
"""
from kis_trader.engine.updown_box import get_box_cfg_from_env
from kis_trader.strategies.updown_box_stock_cfg import (
BOX_ALL_KEYS,
get_box_overrides,
)
code = str(request.args.get("code") or "").strip()
if not code:
return jsonify({"error": "code 필수"}), 400
db = _updow_db()
try:
glob = get_box_cfg_from_env()
ov = get_box_overrides(db, code)
eff: Dict[str, Any] = {}
for k in BOX_ALL_KEYS:
eff[k] = ov[k] if k in ov else glob.get(k)
glob_out = {k: glob.get(k) for k in BOX_ALL_KEYS}
return jsonify({"code": code, "overrides": ov, "global": glob_out, "effective": eff})
finally:
db.close()
@app.route("/api/updown_box/stock_cfg", methods=["POST"])
def api_updown_box_stock_cfg_save():
"""종목별 박스 오버라이드 저장(upsert). body: {code, name, overrides:{키:값}}.
값이 빈칸/None 이면 해당 키는 글로벌 상속(NULL)로 클리어. ratchet_tiers='' 는 래칫 OFF.
"""
from kis_trader.strategies.updown_box_stock_cfg import (
BOX_ALL_KEYS,
delete_box_overrides,
set_box_overrides,
)
body = request.get_json(force=True, silent=True) or {}
code = str(body.get("code") or "").strip()
if not code:
return jsonify({"ok": False, "error": "code 필수"}), 400
name = str(body.get("name") or code)
raw_ov = body.get("overrides") or {}
if not isinstance(raw_ov, dict):
return jsonify({"ok": False, "error": "overrides dict 필요"}), 400
db = _updow_db()
try:
if str(body.get("clear") or "").strip() in ("1", "true", "on"):
ok = delete_box_overrides(db, code)
return jsonify({"ok": ok, "cleared": True})
# 허용 키만 통과
ov = {k: raw_ov[k] for k in BOX_ALL_KEYS if k in raw_ov}
ok = set_box_overrides(db, code, name, ov)
if not ok:
return jsonify({"ok": False, "error": "저장 실패"}), 500
return jsonify({"ok": True, "code": code, "saved_keys": list(ov.keys())})
finally:
db.close()
# ── US 모멘텀: 해외 1분봉 → ws_candles.market=US ──────────────────────────
_us_candle_fetch_jobs: Dict[str, Dict[str, Any]] = {}
@app.route("/api/ws_candles/fetch_us", methods=["POST"])
def api_ws_candles_fetch_us():
"""
영구구독 US 전 종목(또는 body.codes) KIS 해외 분봉 → ws_candles (market=US).
기본 tf=1. rollup_3m=true 면 1m→3m 롤업 후 저장 (국내 갭보정과 동일 철학).
"""
import threading
import uuid as _uuid
body = request.get_json(force=True, silent=True) or {}
start = str(body.get("start") or "").strip()
end = str(body.get("end") or "").strip() or start
try:
tf = int(body.get("tf", 1))
except (TypeError, ValueError):
tf = 1
if tf not in (1, 3, 5, 15, 30, 60):
return jsonify({"error": f"tf 허용: 1/3/5/15/30/60 (요청 {tf})"}), 400
rollup_3m = str(body.get("rollup_3m", True)).lower() in ("1", "true", "yes", "on")
if not start:
return jsonify({"error": "start 필수"}), 400
codes_req = body.get("codes")
job_id = _uuid.uuid4().hex[:8]
_us_candle_fetch_jobs[job_id] = {
"status": "running",
"saved": 0,
"codes": 0,
"rollup_3m": 0,
"current": "",
"error": None,
}
def _run():
import holding_bot as hb
from permanent_subs import codes_by_market
from kis_trader.engine.candle_rollup import rollup_1m_bars_to_tf
db = _db()
try:
if codes_req:
rows = [
{
"code": str(c).upper(),
"exchange": "NASD",
"symbol": str(c).upper(),
}
for c in codes_req
]
else:
rows = codes_by_market(db, "US", enabled_only=True)
if not rows:
raise RuntimeError("영구구독 US 종목 없음")
job = _us_candle_fetch_jobs[job_id]
job["codes"] = len(rows)
total_saved = 0
total_rollup = 0
for r in rows:
code = str(r.get("code") or "").upper()
ex = str(r.get("exchange") or "NASD").upper()
sym = str(r.get("symbol") or code).upper()
job["current"] = f"{code}/{sym}"
bars = hb.fetch_60min_via_kiwoom(
code=code,
start_date=start,
end_date=end,
kiwoom_key="",
kiwoom_secret="",
is_mock=False,
tf_min=tf,
market_type="US",
exchange=ex,
symbol=sym,
)
norm_1m = []
for b in bars or []:
cd = str(b.get("candle_date") or "")
digits = "".join(ch for ch in cd if ch.isdigit())
ctime = (digits + "0000")[:12]
if len(ctime) < 12:
continue
row_bar = {
"candle_time": ctime,
"open": float(b.get("open") or 0),
"high": float(b.get("high") or 0),
"low": float(b.get("low") or 0),
"close": float(b.get("close") or 0),
"volume": int(b.get("volume") or 0),
}
if row_bar["close"] <= 0:
continue
norm_1m.append(row_bar)
db.upsert_ws_candle(
code=code,
timeframe=tf,
candle_time=ctime,
open_=row_bar["open"],
high=row_bar["high"],
low=row_bar["low"],
close=row_bar["close"],
volume=row_bar["volume"],
is_confirmed=1,
source="kis_us_rest",
market="US",
)
total_saved += 1
if rollup_3m and tf == 1 and norm_1m:
rolled = rollup_1m_bars_to_tf(norm_1m, 3) or []
for rb in rolled:
ctime = str(rb.get("candle_time") or "")[:12]
if len(ctime) < 12:
continue
db.upsert_ws_candle(
code=code,
timeframe=3,
candle_time=ctime,
open_=float(rb.get("open") or 0),
high=float(rb.get("high") or 0),
low=float(rb.get("low") or 0),
close=float(rb.get("close") or 0),
volume=int(rb.get("volume") or 0),
is_confirmed=1,
source="rollup_1m",
market="US",
)
total_rollup += 1
job["saved"] = total_saved
job["rollup_3m"] = total_rollup
job["status"] = "done"
job["current"] = ""
except Exception as e:
logger.exception("US ws_candles fetch 실패: %s", e)
_us_candle_fetch_jobs[job_id]["status"] = "error"
_us_candle_fetch_jobs[job_id]["error"] = str(e)
finally:
try:
db.close()
except Exception:
pass
threading.Thread(target=_run, daemon=True, name=f"us-candle-{job_id}").start()
return jsonify({"ok": True, "job_id": job_id})
@app.route("/api/ws_candles/fetch_us/status/<job_id>", methods=["GET"])
def api_ws_candles_fetch_us_status(job_id: str):
job = _us_candle_fetch_jobs.get(job_id)
if not job:
return jsonify({"error": "없는 job_id"}), 404
return jsonify(job)
@app.route("/api/permanent_subs", methods=["GET"])
def api_permanent_subs_list():
"""영구구독(국내 WS + 해외 WS) 목록. 해외(US)는 ws_tr_key(D+거래소+심볼) 포함."""
import permanent_subs as ps
from kis_trader.utils.env import get_env_int
from kis_trader.utils.stock_name import resolve_stock_display_name
from kis_trader.utils.usd_krw_fx import get_display_usd_krw_rate
# light=1: 자동새로고침 — 종목명 REST 스킵 (느림 방지)
light = str(request.args.get("light") or "").strip().lower() in (
"1", "true", "yes", "y", "on",
)
db = _db()
try:
ps.ensure_permanent_subs_table(db)
# 기존 60/15 혼재 → 기준봉(기본 1분)으로 정리 (1분이면 상위봉 롤업)
try:
nfix = ps.normalize_all_tf_to_base(db)
if nfix:
logger.info("영구구독 tf_min 기준봉 통일 %d", nfix)
except Exception as e:
logger.debug("영구구독 tf 정규화 스킵: %s", e)
base_tf = int(get_env_int("PERM_SUB_BASE_TF_MIN", 1))
if base_tf < 1:
base_tf = 1
rows = ps.list_permanent_subs(db, enabled_only=False)
# 현재가: ws_candles 만 (틱 전수스캔·외부 FX HTTP 금지)
try:
quotes = ps.last_quotes_for_codes(
db,
[str(r.get("code") or "") for r in rows],
tf_min=base_tf,
)
except Exception as e:
logger.debug("영구구독 현재가 스킵: %s", e)
quotes = {}
try:
refresh_sec = int(get_env_int("PERM_SUB_UI_REFRESH_SEC", 15))
except (TypeError, ValueError):
refresh_sec = 15
if refresh_sec < 5:
refresh_sec = 5
fx_rate = float(get_display_usd_krw_rate())
fx_ymd = datetime.now().strftime("%Y-%m-%d")
# KR 종목명 일괄 보강용 클라이언트 (루프마다 새로 만들지 않음)
name_client = None
out = []
for r in rows:
mt = str(r.get("market_type") or "KR").strip().upper()
ex = str(r.get("exchange") or "").strip().upper()
code = str(r.get("code") or "").strip().upper()
sym = str(r.get("symbol") or code).strip().upper()
ws_tr_key = ps.us_ws_tr_key(ex, sym) if mt == "US" else code
name = ""
if mt == "KR" and code:
try:
name = resolve_stock_display_name(db, code, fallback="")
if name == code:
name = ""
except Exception:
name = ""
if (not name) and (not light):
try:
if name_client is None:
from kis_trader.execution.kis_client import KISClient
# 시세/종목정보 = 실전 도메인 (모의 search-stock-info 500)
name_client = KISClient(mock=False)
fetched = name_client.inquire_stock_name(code)
if fetched and fetched != code:
name = fetched
if hasattr(db, "upsert_stock_meta"):
try:
db.upsert_stock_meta(code, name=name)
except Exception:
pass
except Exception as e:
logger.debug("영구구독 종목명 REST 스킵 %s: %s", code, e)
elif mt == "US":
name = sym
q = quotes.get(code) or {}
try:
px = float(q.get("price") or 0)
except (TypeError, ValueError):
px = 0.0
price_krw = None
if mt == "US" and px > 0 and fx_rate > 0:
price_krw = int(round(px * fx_rate))
out.append({
"code": code,
"name": name or "",
"market_type": mt,
"exchange": ex,
"symbol": sym,
"tf_min": base_tf,
"enabled": int(r.get("enabled", 1)),
"note": r.get("note") or "",
"ws_tr_key": ws_tr_key,
"price": px,
"price_krw": price_krw,
"price_src": q.get("price_src") or "",
"candle_time": q.get("candle_time") or "",
"tick_time": q.get("tick_time") or "",
"chg_pct": q.get("chg_pct"),
"volume": int(q.get("volume") or 0),
"quote_updated_at": q.get("updated_at") or "",
})
return jsonify({
"ok": True,
"rows": out,
"base_tf_min": base_tf,
"refresh_sec": refresh_sec,
"fx_rate": round(fx_rate, 4),
"fx_ymd": fx_ymd,
"fx_pair": "USD/KRW",
"fx_note": "표시용(캐시/폴백) · 체결환율 아님",
})
except Exception as e:
return jsonify({"ok": False, "error": str(e), "rows": []}), 500
finally:
db.close()
@app.route("/api/permanent_subs/save", methods=["POST"])
def api_permanent_subs_save():
"""영구구독 1건 등록/수정 (code UNIQUE upsert)."""
import permanent_subs as ps
from kis_trader.utils.env import get_env_int
body = request.get_json(force=True, silent=True) or {}
code = str(body.get("code") or "").strip().upper()
if not code:
return jsonify({"ok": False, "error": "code 필수"}), 400
# 기준봉 고정 (요청 tf 무시) — 1분 저장 + 롤업
try:
tf_min = int(get_env_int("PERM_SUB_BASE_TF_MIN", 1))
except (TypeError, ValueError):
tf_min = 1
if tf_min < 1:
tf_min = 1
db = _db()
try:
ps.upsert_permanent_sub(
db,
code,
market_type=body.get("market_type"),
exchange=body.get("exchange"),
symbol=body.get("symbol"),
tf_min=tf_min,
enabled=bool(body.get("enabled", True)),
note=str(body.get("note") or ""),
)
return jsonify({"ok": True, "code": code, "tf_min": tf_min})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
@app.route("/api/permanent_subs/delete", methods=["POST"])
def api_permanent_subs_delete():
"""영구구독 1건 삭제."""
import permanent_subs as ps
body = request.get_json(force=True, silent=True) or {}
code = str(body.get("code") or "").strip().upper()
if not code:
return jsonify({"ok": False, "error": "code 필수"}), 400
db = _db()
try:
ok = ps.remove_permanent_sub(db, code)
return jsonify({"ok": bool(ok), "code": code})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
# ─────────────────────────────────────────────────────────────────────────────
# DART 수주 공시 SCAN / 워치 / 백테
# ─────────────────────────────────────────────────────────────────────────────
@app.route("/api/dart/disclosures", methods=["GET"])
def api_dart_disclosures():
"""최근 공시 목록 (웹 탭 실시간 폴링)."""
limit = int(request.args.get("limit", 50) or 50)
limit = max(1, min(200, limit))
db = _db()
try:
try:
from kis_trader.scan.dart_order_tracker import ensure_dart_disclosure_columns
ensure_dart_disclosure_columns(db)
except Exception:
pass
rows = db.conn.execute(
"""
SELECT rcept_no, corp_code, corp_name, stock_code, report_nm,
rcept_dt, url, first_seen_at, mm_sent,
filter_ok, sales_pct, filter_reason
FROM dart_disclosures
ORDER BY first_seen_at DESC
LIMIT %s
""",
(limit,),
).fetchall()
watch = []
try:
from kis_trader.scan.dart_watchlist import list_active_watch
watch = list_active_watch(db)
except Exception:
pass
snap = db.get_merged_env_snapshot() or {}
return jsonify({
"ok": True,
"rows": [dict(r) for r in rows],
"watch": watch,
"flags": {
"scan": str(snap.get("DART_SCAN_ENABLED") or snap.get("DART_ENABLED") or "true").lower()
in ("1", "true", "yes", "on"),
"subscribe": str(snap.get("DART_SUBSCRIBE_ENABLED") or "false").lower()
in ("1", "true", "yes", "on"),
"trade": str(snap.get("DART_TRADE_ENABLED") or "false").lower()
in ("1", "true", "yes", "on"),
"strategy": str(snap.get("STRATEGY_DART_ENABLED") or "false").lower()
in ("1", "true", "yes", "on"),
"watch_max": int(float(snap.get("DART_WATCH_MAX") or 15)),
"watch_ttl_hours": int(float(snap.get("DART_WATCH_TTL_HOURS") or 24)),
"quality_filter": str(snap.get("DART_QUALITY_FILTER_ENABLED") or "true").lower()
in ("1", "true", "yes", "on"),
"min_sales_pct": float(snap.get("DART_MIN_SALES_PCT") or 5.0),
"require_theme": str(snap.get("DART_REQUIRE_THEME") or "true").lower()
in ("1", "true", "yes", "on"),
},
})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
@app.route("/api/dart/config", methods=["POST"])
def api_dart_config():
"""SCAN/구독/매매 스위치·워치 한도 저장 → config_dart."""
body = request.get_json(force=True, silent=True) or {}
patch = {}
for k in (
"DART_SCAN_ENABLED", "DART_SUBSCRIBE_ENABLED", "DART_TRADE_ENABLED",
"STRATEGY_DART_ENABLED", "DART_WATCH_MAX", "DART_WATCH_TTL_HOURS",
"DART_POLL_SEC", "DART_LOOKBACK_DAYS",
"DART_QUALITY_FILTER_ENABLED", "DART_MIN_SALES_PCT", "DART_REQUIRE_THEME",
):
if k in body:
patch[k] = str(body[k]).strip()
if not patch:
return jsonify({"ok": False, "error": "no keys"}), 400
db = _db()
try:
db.insert_env_snapshot(patch)
return jsonify({"ok": True, "saved": list(patch.keys())})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
@app.route("/api/backtest/dart", methods=["GET"])
def api_backtest_dart():
from kis_trader.backtest.dart_backtest_common import run_dart_backtest_web_aligned
from kis_trader.engine import dart_engine as de
start = (request.args.get("start") or "").strip()
end = (request.args.get("end") or "").strip()
if not start or not end:
return jsonify({"ok": False, "error": "start/end 필수"}), 400
db = _db()
try:
env_row = db.get_merged_env_snapshot() or {}
params = de.get_dart_defaults_from_db(env_row=env_row)
# 쿼리 오버라이드
for qk, pk in (
("rsi_oversold", "rsi_oversold"),
("rsi_reclaim", "rsi_reclaim"),
("sl_pct", "sl_pct"),
("tp_pct", "tp_pct"),
("vol_mult", "vol_mult"),
("event_window_bars", "event_window_bars"),
("slot_money", "slot_money"),
):
if request.args.get(qk) not in (None, ""):
params[pk] = float(request.args.get(qk)) if qk != "event_window_bars" else int(float(request.args.get(qk)))
result = run_dart_backtest_web_aligned(
start=start, end=end, params=params, env_row=env_row,
)
return jsonify(result)
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
@app.route("/api/backtest/dart/defaults", methods=["GET"])
def api_backtest_dart_defaults():
from kis_trader.engine import dart_engine as de
db = _db()
try:
env_row = db.get_merged_env_snapshot() or {}
return jsonify({"ok": True, "params": de.get_dart_defaults_from_db(env_row=env_row)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
finally:
db.close()
# ─────────────────────────────────────────────────────────────────────────────
# 웹 파라미터 탐색 (SCALP / MOMENTUM / BREAKOUT / SHORT)
# ─────────────────────────────────────────────────────────────────────────────
# 브라우저·프록시 타임아웃 방지: 대형 그리드 탐색은 CLI 만 사용.
# kis_trader/backtest/param_search_scalping.py
# kis_trader/backtest/param_search_momentum.py
# kis_trader/backtest/param_search_breakout.py
# kis_trader/backtest/tail_param_search.py
# 웹: /api/env/params → 입력란에 DB 최신값 → 백테스트 API 만 호출.
# UPDOWN 박스: /api/updown_box/param_search (글로벌 그리드, 다종목 합산). 홀딩: /api/holding/*/param_search.
# DBBAND: /api/dbband/param_search (종목별 dbband_stock_config + env 그리드)
def _dbband_db() -> TradeDB:
from kis_trader.strategies import dbband_stock_cfg as dsc
db = _db()
dsc.ensure_dbband_backtest_tables(db)
return db
def _dbband_merged_engine_tf(db_hold: TradeDB, code: str) -> Tuple[Dict[str, Any], int, bool]:
from kis_trader.strategies import dbband_stock_cfg as dsc
dsc.ensure_dbband_stock_config_table(db_hold)
base = bbe.get_dbband_defaults_from_db(db_hold) if _DBBAND_ENGINE_AVAILABLE else {}
snap = db_hold.get_merged_env_snapshot()
env_tf = int(base.get("timeframe") or snap.get("DBBAND_TIMEFRAME") or 15)
has_row = dsc.get_dbband_stock_config_row(db_hold, code) is not None
merged = dsc.load_dbband_engine_cfg(db_hold, code, base)
tf = dsc.effective_dbband_tf_for_code(db_hold, code, env_tf)
return merged, tf, has_row
@app.route("/api/dbband/stocks", methods=["GET"])
def api_dbband_stocks():
from kis_trader.strategies import dbband_stock_cfg as dsc
db = _dbband_db()
try:
return jsonify(dsc.list_dbband_stock_codes(db))
finally:
db.close()
@app.route("/api/dbband/config", methods=["GET"])
def api_dbband_config():
from kis_trader.strategies import dbband_stock_cfg as dsc
code = (request.args.get("code") or "").strip()
db = _dbband_db()
try:
base = bbe.get_dbband_defaults_from_db(db) if _DBBAND_ENGINE_AVAILABLE else {}
if not code:
return jsonify({
"env_fallback": dsc.engine_cfg_to_ui(base),
"dbband_tf_min": int(base.get("timeframe") or 15),
})
merged, tf_min, has_row = _dbband_merged_engine_tf(db, code)
meta = dsc.get_dbband_stock_meta(db, code) or {}
return jsonify({
"code": code,
"name": meta.get("name") or code,
"dbband_tf_min": tf_min,
"dbband_stock_saved": has_row,
"param_source": "dbband_stock_config" if has_row else "env-fallback",
"engine": dsc.engine_cfg_to_ui(merged),
"market_type": meta.get("market_type"),
"exchange": meta.get("exchange"),
"symbol": meta.get("symbol") or code,
})
finally:
db.close()
@app.route("/api/dbband/save_holding", methods=["POST"])
def api_dbband_save_holding():
"""선택 종목 ``dbband_stock_config`` INSERT — 웹·백테·실매 단일 소스."""
from kis_trader.strategies import dbband_stock_cfg as dsc
body = request.get_json(force=True, silent=True) or {}
code = str(body.get("code", "")).strip()
if not code:
return jsonify({"error": "code 필수"}), 400
market_type = str(body.get("market_type", "KR")).strip().upper() or "KR"
exchange = str(body.get("exchange", "KRX")).strip().upper() or ("KRX" if market_type == "KR" else "NASD")
symbol = str(body.get("symbol", code)).strip().upper() or code
name = str(body.get("name", "")).strip()
src = body.get("apply_cfg") if isinstance(body.get("apply_cfg"), dict) else body
tf_raw = body.get("tf_min", body.get("tf"))
try:
tf_min = int(float(tf_raw)) if tf_raw is not None and str(tf_raw).strip() != "" else None
except (TypeError, ValueError):
tf_min = None
db = _dbband_db()
try:
_, hold_tf, _ = _dbband_merged_engine_tf(db, code)
if tf_min is None:
tf_min = int(hold_tf)
if tf_min not in hb.KIWOOM_MINUTE_TICS:
return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나 (요청: {tf_min})"}), 400
nm = name or code
meta = dsc.get_dbband_stock_meta(db, code)
if not name and meta and meta.get("name"):
nm = str(meta["name"]).strip()
dsc.set_dbband_stock_config(
db, code, nm, src, tf_min=tf_min,
market_type=market_type, exchange=exchange, symbol=symbol,
)
return jsonify({"ok": True, "code": code, "symbol": symbol, "dbband_tf_min": int(tf_min)})
except Exception as e:
logger.error("DBBAND 종목 저장 오류: %s", e)
return jsonify({"error": str(e)}), 500
finally:
db.close()
@app.route("/api/dbband/backtest", methods=["GET"])
def api_dbband_backtest():
"""종목 1개 더블 BB 백테 — holding_min_candles + dbband_stock_config."""
if not _DBBAND_ENGINE_AVAILABLE:
return jsonify({"error": "dbband_engine 미설치"}), 503
code = request.args.get("code", "").strip()
start_date = request.args.get("start", "")
end_date = request.args.get("end", _default_trading_day_ymd())
if not code:
return jsonify({"error": "code 필수 (QQQM·069500 등 종목별 파라미터)"}), 400
db = _dbband_db()
try:
merged, hold_tf, holding_saved = _dbband_merged_engine_tf(db, code)
tf_raw = request.args.get("tf")
tf = int(float(tf_raw)) if tf_raw not in (None, "") else int(hold_tf)
if tf not in hb.KIWOOM_MINUTE_TICS:
return jsonify({"error": f"tf는 {list(hb.KIWOOM_MINUTE_TICS)} 중 하나"}), 400
cfg = dict(merged)
for ck in bbe.CFG_ENGINE_KEYS:
v = request.args.get(ck)
if v is None:
continue
if ck in ("side_mode", "entry_mode", "stop_mode", "tp_mode", "exit_mode"):
cfg[ck] = str(v).strip().lower()
elif ck == "use_trend_filter":
cfg[ck] = str(v).lower() in ("1", "true", "y", "yes", "on")
else:
try:
cfg[ck] = float(v)
except (TypeError, ValueError):
pass
if request.args.get("sl_pct") not in (None, ""):
cfg["sl_pct"] = abs(float(request.args.get("sl_pct"))) / 100.0
if request.args.get("tp_pct") not in (None, ""):
cfg["tp_pct"] = abs(float(request.args.get("tp_pct"))) / 100.0
if request.args.get("shoulder_min_high") not in (None, ""):
cfg["shoulder_min_high"] = abs(float(request.args.get("shoulder_min_high"))) / 100.0
if request.args.get("shoulder_cut_pct") not in (None, ""):
cfg["shoulder_cut_pct"] = abs(float(request.args.get("shoulder_cut_pct"))) / 100.0
raw_candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=tf)
candles = dbbc.normalize_stored_min_candles(raw_candles)
if not candles:
stats = hb.get_min_candle_stats(db, code, tf_min=tf)
hint = "📥 [키움 분봉 수집] 또는 영구구독 WS로 분봉을 먼저 수집하세요."
if stats.get("count"):
hint = f"DB {stats['count']}봉 ({stats['min']}~{stats['max']}) — 시작일 조정"
return jsonify({"error": f"{tf}분봉 없음\n{hint}"}), 400
min_need = max(int(cfg.get("trend_ma_period") or 200) + 10, 50)
if len(candles) < min_need:
return jsonify({"error": f"봉 부족: {len(candles)} < {min_need} (추세MA+워밍업)"}), 400
sk = start_date.replace("-", "") + "0000"
ek = end_date.replace("-", "") + "2359"
_env_tl = _backtest_env_timeline_from_request(request)
cfg["backtest_env_timeline"] = _env_tl
from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params
attach_backtest_env_timeline_to_params(
cfg,
{"db": db, "start_key": sk, "end_key": ek, "backtest_env_timeline": _env_tl},
"DBBAND",
)
trades = bbe.run_dbband_backtest_single(candles, cfg)
snap = db.get_merged_env_snapshot()
portfolio = dbbc.resolve_dbband_portfolio_params(snap, cfg)
slot_money = float(cfg.get("slot_money") or portfolio.get("slot_money") or 3_000_000)
fee_rate, sell_tax, _ = dbbc.fee_and_slot_from_env_row(snap)
from kis_trader.backtest.backtest_portfolio_common import backtest_slip_pct
dbbc.attach_dbband_trade_pnl(
trades, slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax,
slip_pct=backtest_slip_pct(cfg),
)
from kis_trader.strategies import dbband_stock_cfg as dsc
meta = dsc.get_dbband_stock_meta(db, code) or {}
nm = str(meta.get("name") or code).strip() or code
ps = "dbband_stock_config" if holding_saved else "env-fallback"
report = dbbc.build_dbband_backtest_report(
trades,
candles,
cfg,
code=code,
name=nm,
start_date=start_date,
end_date=end_date,
tf=tf,
candle_count=len(candles),
param_source=ps,
portfolio=portfolio,
)
return jsonify({
"ok": True,
"code": code,
"tf": tf,
"candle_count": len(candles),
"param_source": ps,
"params": report["params"],
"summary": report["summary"],
"equity": report["equity"],
"daily": report["daily"],
"reasons": report["reasons"],
"trades": trades,
})
except Exception as e:
logger.error("DBBAND 백테 오류: %s", e)
return jsonify({"error": str(e)}), 500
finally:
db.close()
@app.route("/api/dbband/param_search", methods=["GET"])
def api_dbband_param_search():
"""종목별 그리드 탐색 — ``dbband_stock_config`` 베이스 + env ``DBBAND_GRID_*``."""
if not _DBBAND_ENGINE_AVAILABLE:
return jsonify({"error": "dbband_engine 미설치"}), 503
code = request.args.get("code", "").strip()
if not code:
return jsonify({"error": "code 필수"}), 400
start_date = request.args.get("start", "")
end_date = request.args.get("end", _default_trading_day_ymd())
db = _dbband_db()
try:
from kis_trader.backtest.dbband_param_search import run_search_for_code
merged, hold_tf, holding_saved = _dbband_merged_engine_tf(db, code)
tf = int(request.args.get("tf") or hold_tf)
top_n = max(1, min(100, int(request.args.get("top", 30))))
out = run_search_for_code(
db, code, start_date, end_date, tf, base_cfg=merged, top_n=top_n,
)
out["param_source"] = "dbband_stock_config" if holding_saved else "env-fallback"
return jsonify(out)
except Exception as e:
logger.error("DBBAND 파라서치 오류: %s", e)
return jsonify({"error": str(e)}), 500
finally:
db.close()
def _pf_parse_amt(v: Any) -> float:
"""KIS 금액 문자열 → float (쉼표 제거)."""
try:
return abs(float(str(v or 0).replace(",", "")))
except (TypeError, ValueError):
return 0.0
def _pf_int_price(v: Any) -> int:
"""주식 가격·평가금 — 원 단위 정수 (소수점 제거)."""
try:
return int(round(float(v or 0)))
except (TypeError, ValueError):
return 0
def _portfolio_account_summary(order_mgr) -> 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",
})
def _portfolio_live_quote_enabled() -> bool:
from kis_trader.utils.env import get_env_bool
return bool(get_env_bool("PORTFOLIO_LIVE_QUOTE_ENABLED", True))
def _portfolio_live_quote_ms() -> int:
from kis_trader.utils.env import get_env_int
return max(200, int(get_env_int("PORTFOLIO_LIVE_QUOTE_MS", 400) or 400))
def _portfolio_live_quote_max_age_sec() -> float:
from kis_trader.utils.env import get_env_float
return max(1.0, float(get_env_float("PORTFOLIO_LIVE_QUOTE_MAX_AGE_SEC", 120.0) or 120.0))
def _parse_live_quote_codes(raw: str) -> List[str]:
out: List[str] = []
seen = set()
for part in str(raw or "").replace(";", ",").split(","):
c = part.strip()
if not c or c in seen:
continue
seen.add(c)
out.append(c)
if len(out) >= 80:
break
return out
def _recv_ts_age_ms(recv_ts: str) -> Optional[int]:
s = str(recv_ts or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"):
try:
dt = datetime.strptime(s[:26], fmt)
return max(0, int((datetime.now() - dt).total_seconds() * 1000))
except ValueError:
continue
return None
def _build_portfolio_live_quotes(codes: List[str]) -> Dict[str, Any]:
"""
ws_ticks / ws_ticks_us 최신 SELECT — 증권사 REST/WS 추가 호출 없음.
국내 6자리 → KR, 그 외 → US.
day_chg_pct = 전일(직전세션) 종가 대비 당일 등락% (HTS 전일대비와 동일 취지).
"""
kr = [c for c in codes if c.isdigit() and len(c) == 6]
us = [c for c in codes if c not in set(kr)]
db = _db()
merged: Dict[str, Dict[str, Any]] = {}
prev_map: Dict[str, float] = {}
if kr:
merged.update(db.get_latest_ws_tick_quotes(kr, market="KR"))
prev_map.update(db.get_prev_close_map(kr, market="KR"))
if us:
merged.update(db.get_latest_ws_tick_quotes(us, market="US"))
prev_map.update(db.get_prev_close_map(us, market="US"))
max_age = _portfolio_live_quote_max_age_sec()
quotes: Dict[str, Dict[str, Any]] = {}
for code, q in merged.items():
age_ms = _recv_ts_age_ms(str(q.get("recv_ts") or ""))
stale = age_ms is not None and (age_ms / 1000.0) > max_age
px = float(q.get("price") or 0)
prev = float(prev_map.get(code) or 0)
day_chg = None
if px > 0 and prev > 0:
day_chg = round((px - prev) / prev * 100.0, 4)
quotes[code] = {
"price": px,
"ts": q.get("recv_ts") or "",
"tick_time": q.get("tick_time") or "",
"source": q.get("source") or "",
"currency": q.get("currency") or "KRW",
"market": q.get("market") or "KR",
"age_ms": age_ms,
"stale": bool(stale),
"prev_close": prev if prev > 0 else None,
"day_chg_pct": day_chg,
}
return {
"ok": True,
"enabled": True,
"quotes": quotes,
"max_age_sec": max_age,
"interval_ms": _portfolio_live_quote_ms(),
}
@app.route("/api/portfolio/live_quotes", methods=["GET"])
def api_portfolio_live_quotes():
"""보유·실거래「보유중」표시용 — DB 틱 스냅샷만 (매매 경로 비접촉)."""
if not _portfolio_live_quote_enabled():
return jsonify({
"ok": True,
"enabled": False,
"quotes": {},
"interval_ms": _portfolio_live_quote_ms(),
})
codes = _parse_live_quote_codes(request.args.get("codes") or "")
if not codes:
return jsonify({
"ok": True,
"enabled": True,
"quotes": {},
"interval_ms": _portfolio_live_quote_ms(),
})
return jsonify(_build_portfolio_live_quotes(codes))
@app.route("/api/portfolio/live_quotes_stream", methods=["GET"])
def api_portfolio_live_quotes_stream():
"""SSE: 주기적으로 live_quotes 푸시. 브라우저 탭 닫으면 루프 종료."""
if not _portfolio_live_quote_enabled():
def _off() -> Generator[str, None, None]:
yield "data: " + json.dumps({
"ok": True, "enabled": False, "quotes": {},
}, ensure_ascii=False) + "\n\n"
return Response(
stream_with_context(_off()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
codes = _parse_live_quote_codes(request.args.get("codes") or "")
interval_s = _portfolio_live_quote_ms() / 1000.0
def _gen() -> Generator[str, None, None]:
while True:
try:
payload = _build_portfolio_live_quotes(codes) if codes else {
"ok": True, "enabled": True, "quotes": {},
"interval_ms": _portfolio_live_quote_ms(),
}
yield "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n"
except GeneratorExit:
break
except Exception as e:
logger.debug("live_quotes_stream: %s", e)
yield "data: " + json.dumps({
"ok": False, "error": str(e)[:200],
}, ensure_ascii=False) + "\n\n"
time.sleep(interval_s)
return Response(
stream_with_context(_gen()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.route("/api/portfolio/sell", methods=["POST"])
def api_portfolio_sell():
"""
시장가 전량 매도 — kis_trader OrderManager._place_sell 과 동일 경로.
body: { "code", "strategy", "reason"?(optional) }
"""
from kis_trader.execution.order_manager import OrderRequest
from kis_trader.utils.env import get_env_from_db
data = request.get_json(silent=True) or {}
code = str(data.get("code") or "").strip()
strategy = str(data.get("strategy") or "").strip()
if not code or not strategy:
return jsonify({"ok": False, "error": "code 와 strategy 가 필요합니다."}), 400
db = _db()
cur = db.conn.execute(
"SELECT * FROM active_trades WHERE code=%s AND strategy=%s",
(code, strategy),
)
row = cur.fetchone()
if not row:
return jsonify({
"ok": False,
"error": f"active_trades 에 없음: {code} [{strategy}]",
}), 404
qty = int(row.get("current_qty") or 0)
if qty <= 0:
return jsonify({"ok": False, "error": "매도 수량(current_qty)이 0 입니다."}), 400
order_mgr, market_client = _portfolio_infra()
buy_px = float(row.get("avg_buy_price") or 0)
cur_px = _portfolio_live_price(code, row, market_client)
profit_pct = ((cur_px - buy_px) / buy_px) if buy_px > 0 and cur_px > 0 else 0.0
default_reason = get_env_from_db("WEB_MANUAL_SELL_REASON", "웹동기화(HTS대체)")
reason = str(data.get("reason") or default_reason or "웹동기화(HTS대체)")
# active_trades PK(strategy) 는 행 값 그대로 — close_trade 가 trade_history 에 canonical 저장.
req = OrderRequest(
strategy_id=strategy,
code=code,
name=str(row.get("name") or code),
side="SELL",
qty=qty,
price_ref=cur_px,
reason=reason,
buy_price=buy_px,
profit_pct=profit_pct,
)
try:
result = order_mgr.place(req)
except Exception as e:
logger.exception("portfolio sell %s %s", code, strategy)
return jsonify({"ok": False, "error": str(e)}), 500
if result.success:
return jsonify({
"ok": True,
"ord_no": result.ord_no,
"filled_qty": result.filled_qty,
"filled_avg_price": result.filled_avg_price,
"reason": reason,
})
return jsonify({
"ok": False,
"error": result.reason or "매도 실패",
"detail": result.reason,
}), 400
@app.route("/api/portfolio/sell_untracked_all", methods=["POST"])
def api_portfolio_sell_untracked_all():
"""전략 미등록(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
from kis_trader.utils.kr_trading_day import trading_dates_payload
_mom_ui = _momentum_ui_defaults_from_db(_mom_def_db())
_us_mom_ui = _us_momentum_ui_defaults_from_db(snap)
_scalp_ui = _scalp_ui_defaults_from_db()
_bo_ui = _bo_defaults_from_db()
_rb_ui = _rb_defaults_from_db()
# 당일 누적손익 다단 트레일 현재값 + 프리셋 목록(꼬리와 공유) — 모멘텀·돌파
_dt_presets = str(snap.get("BT_DAILY_TRAIL_PRESETS") or "").strip()
for _ui, _pfx in ((_mom_ui, "MOMENTUM"), (_bo_ui, "BREAKOUT"), (_us_mom_ui, "US_MOMENTUM")):
if isinstance(_ui, dict):
_ui["daily_profit_enabled"] = (
str(snap.get(f"{_pfx}_DAILY_PROFIT_TARGET_ENABLED") or "false").strip().lower()
in ("1", "true", "t", "y", "yes", "on")
)
_ui["daily_risk_budget_enabled"] = (
str(snap.get(f"{_pfx}_DAILY_PROFIT_RISK_BUDGET_ENABLED") or "false").strip().lower()
in ("1", "true", "t", "y", "yes", "on")
)
_ui["daily_trail_tiers"] = str(snap.get(f"{_pfx}_DAILY_PROFIT_TRAIL_TIERS") or "").strip()
_ui["daily_profit_mode"] = (
str(snap.get(f"{_pfx}_DAILY_PROFIT_MODE") or "trailing").strip().lower() or "trailing"
)
_ui["daily_trail_presets"] = _dt_presets
try:
_lb = int(float(str(snap.get("PARAM_SEARCH_DEFAULT_LOOKBACK_DAYS") or "7")))
except (ValueError, TypeError):
_lb = 7
return jsonify({
"scalp": _scalp_ui,
"tail": _tail_ui_defaults_from_db(snap),
"momentum": _mom_ui,
"us_momentum": _us_mom_ui,
"breakout": _bo_ui,
"range_break": _rb_ui,
"dbband": _dbband_ui_defaults_from_db(snap) if _DBBAND_ENGINE_AVAILABLE else {},
# 주말·휴장 → 이전 장운영일 (웹 날짜 인풋 기본값)
"dates": trading_dates_payload(max(1, _lb)),
})
finally:
db.close()
@app.route("/api/live_config", methods=["GET"])
def api_live_config_get():
"""실매 운영 설정 탭 — 스키마 + 현재값 + 당일 익절·손익 상태."""
from kis_trader.web.live_config_schema import (
LIVE_STRATEGY_IDS,
build_live_config_groups,
read_snap_value,
snap_value_to_ui,
)
day = (request.args.get("date") or "").strip()[:10]
if not day:
day = _default_trading_day_ymd()
else:
try:
from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day
day = clamp_to_prev_kr_trading_day(day)
except ValueError:
day = _default_trading_day_ymd()
db = _db()
try:
snap = db.get_merged_env_snapshot() or {}
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": "신규 매수만 중단 — 보유 종목 손절·익절 유지",
},
}
# ── Optuna 웹 잡 (타임아웃 회피: start 즉시 반환 + status 폴링) ─────────────
@app.route("/api/optuna/start", methods=["POST"])
def api_optuna_start():
"""Optuna subprocess 시작. apply-best 없음.
body: strategies[] 또는 strategy, start, end, trials, mode[, symbol].
symbol 있으면 us_momentum 종목 cfg Optuna (1종목).
"""
from kis_trader.backtest import optuna_web_jobs as owj
body = request.get_json(silent=True) or {}
# form 도 허용
strategy = (body.get("strategy") or request.form.get("strategy") or "").strip()
strategies = body.get("strategies")
if strategies is None and request.form.get("strategies"):
strategies = request.form.get("strategies")
start = (body.get("start") or request.form.get("start") or "").strip()
end = (body.get("end") or request.form.get("end") or "").strip()
mode = (body.get("mode") or request.form.get("mode") or "tpe").strip()
symbol = (body.get("symbol") or request.form.get("symbol") or "").strip()
try:
trials = int(body.get("trials") or request.form.get("trials") or 200)
except (TypeError, ValueError):
trials = 200
try:
meta = owj.start_optuna_job(
strategy=strategy or None,
strategies=strategies,
start=start,
end=end,
trials=trials,
mode=mode,
symbol=symbol or None,
)
return jsonify({"ok": True, "job": meta})
except Exception as e:
logger.warning("optuna start failed: %s", e)
return jsonify({"ok": False, "error": str(e)}), 400
@app.route("/api/optuna/status/<job_id>", methods=["GET"])
def api_optuna_status(job_id: str):
from kis_trader.backtest import optuna_web_jobs as owj
meta = owj.load_job(job_id)
if not meta:
return jsonify({"ok": False, "error": "없는 job_id"}), 404
return jsonify({"ok": True, "job": owj.refresh_job_status(meta)})
@app.route("/api/optuna/jobs", methods=["GET"])
def api_optuna_jobs():
from kis_trader.backtest import optuna_web_jobs as owj
limit = request.args.get("limit", 20, type=int)
sort = (request.args.get("sort") or "started").strip().lower()
if sort not in ("started", "finished"):
sort = "started"
# CLI/순차 스크립트 JSON 도 웹 목록에 보이게 (기본 ON)
import_cli = request.args.get("import_cli", "1")
imported = []
if str(import_cli).strip() not in ("0", "false", "False", "no"):
try:
imported = owj.import_recent_cli_results(limit_per_strategy=3)
except Exception as e:
logger.warning("optuna import_cli: %s", e)
jobs = []
for m in owj.list_jobs(limit, sort=sort):
try:
jobs.append(owj.refresh_job_status(m))
except Exception:
jobs.append(m)
# refresh 가 save 해도 정렬 키는 started/finished — 응답 직전에 한 번 더 정렬
jobs.sort(key=lambda x: owj._job_sort_ts(x, sort), reverse=True)
return jsonify({"ok": True, "jobs": jobs, "imported_n": len(imported), "sort": sort})
@app.route("/api/optuna/import-cli", methods=["POST"])
def api_optuna_import_cli():
"""results/optuna_*_tpe_*.json → 웹 잡 목록 등록."""
from kis_trader.backtest import optuna_web_jobs as owj
body = request.get_json(silent=True) or {}
try:
lim = int(body.get("limit_per_strategy") or 3)
except (TypeError, ValueError):
lim = 3
try:
imported = owj.import_recent_cli_results(limit_per_strategy=lim)
return jsonify({
"ok": True,
"imported_n": len(imported),
"jobs": imported,
})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 400
@app.route("/api/optuna/active", methods=["GET"])
def api_optuna_active():
"""상단 바용: 실행 중 잡 + (참고) CLI 외부 프로세스."""
from kis_trader.backtest import optuna_web_jobs as owj
running = owj.find_running_jobs()
external = owj.any_optuna_python_running()
latest = None
try:
lp = Path(__file__).resolve().parent / "logs" / "optuna_web_latest_job.txt"
if lp.is_file():
jid = lp.read_text(encoding="utf-8").strip()
if jid:
meta = owj.load_job(jid)
if meta:
latest = owj.refresh_job_status(meta)
except Exception:
latest = None
return jsonify({
"ok": True,
"running": running,
"latest": latest,
"external": external,
})
@app.route("/api/optuna/stop/<job_id>", methods=["POST"])
def api_optuna_stop(job_id: str):
from kis_trader.backtest import optuna_web_jobs as owj
try:
meta = owj.stop_optuna_job(job_id)
return jsonify({"ok": True, "job": meta})
except FileNotFoundError:
return jsonify({"ok": False, "error": "없는 job_id"}), 404
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 400
@app.route("/api/optuna/defaults", methods=["GET"])
def api_optuna_defaults():
"""날짜 기본값(거래일)."""
from kis_trader.utils.kr_trading_day import (
default_kr_trading_end,
default_kr_trading_start,
)
end = default_kr_trading_end()
start = default_kr_trading_start(1, end=end) # 최근 2거래일 느낌: end-1일 보정
return jsonify({
"ok": True,
"start": start,
"end": end,
"trials": 200,
"mode": "tpe",
"strategies": ["momentum", "tail", "breakout", "scalp", "all"],
"note": "apply-best 없음. 탐색 게이트 WR/PF=0, 사후 results_gated.",
})
@app.route("/api/optuna/candidate", methods=["GET"])
def api_optuna_candidate():
"""보기: gated/learn/mode 후보 파라미터 미리보기."""
from kis_trader.backtest import optuna_web_jobs as owj
job_id = (request.args.get("job_id") or "").strip() or None
result_json = (request.args.get("result_json") or "").strip() or None
source = (request.args.get("source") or "gated").strip()
try:
rank = int(request.args.get("rank") or 1)
except (TypeError, ValueError):
rank = 1
try:
out = owj.get_candidate_detail(
job_id=job_id, result_json=result_json, source=source, rank=rank,
)
return jsonify(out)
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 400
@app.route("/api/optuna/apply", methods=["POST"])
def api_optuna_apply():
"""완료 잡의 gated/mode/learn 후보 → DB 적용 (confirm 후 호출).
body.symbol 있으면 us_momentum → stock_config 행만.
"""
from kis_trader.backtest import optuna_web_jobs as owj
body = request.get_json(silent=True) or {}
job_id = (body.get("job_id") or "").strip() or None
result_json = (body.get("result_json") or "").strip() or None
source = (body.get("source") or "gated").strip()
symbol = (body.get("symbol") or "").strip() or None
exchange = (body.get("exchange") or "").strip() or None
stock_group = (body.get("stock_group") or "").strip() or None
try:
rank = int(body.get("rank") or 1)
except (TypeError, ValueError):
rank = 1
force = bool(body.get("allow_non_positive_pnl"))
try:
out = owj.apply_optuna_result(
job_id=job_id,
result_json=result_json,
source=source,
rank=rank,
allow_non_positive_pnl=force,
symbol=symbol,
exchange=exchange,
stock_group=stock_group,
)
return jsonify(out)
except Exception as e:
logger.warning("optuna apply failed: %s", e)
return jsonify({"ok": False, "error": str(e)}), 400
# ── 전략 백테 CLI 잡 (꼬리·스캘핑·돌파·모멘텀 공용) ─────────────────────────
@app.route("/api/backtest/job/start", methods=["POST"])
def api_bt_job_start():
"""백그라운드 백테 시작. strategy=tail|scalp|breakout|momentum."""
from kis_trader.backtest import bt_web_jobs as bj
body = request.get_json(silent=True) or {}
strategy = (body.get("strategy") or "tail").strip().lower()
start = (body.get("start") or "").strip()
end = (body.get("end") or "").strip()
try:
tf_raw = body.get("timeframe") or body.get("tf")
tf = int(tf_raw) if tf_raw not in (None, "") else None
except (TypeError, ValueError):
tf = None
univ = (body.get("universe") or "history").strip().lower()
hist_src = (body.get("universe_history_source") or "").strip().lower() or None
tick_db = body.get("backtest_use_tick_db")
tick_exit = body.get("backtest_use_tick_exit")
if tick_db is not None:
tick_db = str(tick_db).strip().lower() in ("1", "true", "t", "y", "yes", "on")
if tick_exit is not None:
tick_exit = str(tick_exit).strip().lower() in ("1", "true", "t", "y", "yes", "on")
ob = (body.get("orderbook_filter") or "off").strip().lower() or "off"
env_tl = str(body.get("env_timeline") or "").strip().lower() in (
"1", "true", "t", "y", "yes", "on",
)
try:
job = bj.start_bt_job(
strategy=strategy,
start=start,
end=end,
timeframe=tf,
universe=univ,
universe_history_source=hist_src,
tick_db=tick_db,
tick_exit=tick_exit,
orderbook_filter=ob,
env_timeline=env_tl,
)
return jsonify({"ok": True, "job": job})
except Exception as e:
logger.warning("bt job start failed: %s", e)
return jsonify({"ok": False, "error": str(e)}), 400
@app.route("/api/backtest/job/status", methods=["GET"])
def api_bt_job_status():
from kis_trader.backtest import bt_web_jobs as bj
job_id = (request.args.get("job_id") or "").strip()
if not job_id:
return jsonify({"ok": False, "error": "job_id 필요"}), 400
job = bj.refresh_job(job_id)
if not job:
return jsonify({"ok": False, "error": "job 없음"}), 404
return jsonify({"ok": True, "job": job})
@app.route("/api/backtest/job/stop", methods=["POST"])
def api_bt_job_stop():
from kis_trader.backtest import bt_web_jobs as bj
body = request.get_json(silent=True) or {}
job_id = (body.get("job_id") or request.args.get("job_id") or "").strip()
if not job_id:
return jsonify({"ok": False, "error": "job_id 필요"}), 400
try:
job = bj.stop_bt_job(job_id)
return jsonify({"ok": True, "job": job})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 400
@app.route("/api/backtest/job/result", methods=["GET"])
def api_bt_job_result():
from kis_trader.backtest import bt_web_jobs as bj
job_id = (request.args.get("job_id") or "").strip()
if not job_id:
return jsonify({"ok": False, "error": "job_id 필요"}), 400
job = bj.refresh_job(job_id)
if not job:
return jsonify({"ok": False, "error": "job 없음"}), 404
rj = job.get("result_json")
if not rj or not Path(str(rj)).is_file():
return jsonify({"ok": False, "error": "결과 파일 없음", "job": job}), 404
try:
data = json.loads(Path(str(rj)).read_text(encoding="utf-8"))
except Exception as e:
return jsonify({"ok": False, "error": f"결과 파싱 실패: {e}"}), 400
web_shaped = bool(
data.get("summary")
and (data.get("equity") is not None or data.get("trades") is not None)
)
out = {
"ok": True,
"job_id": job_id,
"strategy": job.get("strategy") or data.get("strategy") or "tail",
"result_json": str(rj),
"summary": data.get("summary") or {},
"trades": data.get("trades") or [],
"meta": data.get("meta") or {},
"params": data.get("params") or {},
"params_used": data.get("params_used") or {},
"equity": data.get("equity") or [],
"daily": data.get("daily") or [],
"reasons": data.get("reasons") or {},
"timeframe": data.get("timeframe") or job.get("timeframe"),
"universe_source": data.get("universe_source")
or ((data.get("params") or {}).get("universe_source"))
or job.get("universe"),
"data": data if web_shaped else data.get("data"),
"raw": data,
}
return jsonify(out)
# 하위호환: 꼬리 전용 URL
@app.route("/api/backtest/tail/job/start", methods=["POST"])
def api_tail_bt_job_start():
body = dict(request.get_json(silent=True) or {})
body["strategy"] = "tail"
# 기존 클라이언트가 timeframe 기본을 기대
if body.get("timeframe") in (None, ""):
body["timeframe"] = 3
with app.test_request_context(
"/api/backtest/job/start", method="POST", json=body,
):
return api_bt_job_start()
@app.route("/api/backtest/tail/job/status", methods=["GET"])
def api_tail_bt_job_status():
return api_bt_job_status()
@app.route("/api/backtest/tail/job/stop", methods=["POST"])
def api_tail_bt_job_stop():
return api_bt_job_stop()
@app.route("/api/backtest/tail/job/result", methods=["GET"])
def api_tail_bt_job_result():
return api_bt_job_result()
# ── 운영/모의(KIS_MOCK) 토글 — 매매 클라이언트만. 시세 실키는 유지. ─────────
_KIS_TRADER_UNIT = "kis_trader_main.service"
def _systemctl_kis_trader(verb: str, *, use_sudo: bool = True, timeout: int = 30) -> Tuple[int, str]:
"""kis_trader_main.service 전용 systemctl (mm_butler 와 동일 화이트리스트)."""
cmd: List[str] = []
if use_sudo:
cmd.append("sudo")
cmd.extend(["/bin/systemctl", verb, _KIS_TRADER_UNIT])
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
out = ((r.stdout or "") + (r.stderr or "")).strip()
return int(r.returncode), out
except subprocess.TimeoutExpired:
return 124, "systemctl 시간 초과"
except FileNotFoundError as e:
return 127, f"systemctl 불가: {e}"
except Exception as e:
return 1, str(e)
def _kis_mock_from_db() -> bool:
from kis_trader.utils.env import get_env_bool, invalidate_merged_env_cache
invalidate_merged_env_cache()
return bool(get_env_bool("KIS_MOCK", True))
def _kis_mock_status_payload() -> Dict[str, Any]:
kis_mock = _kis_mock_from_db()
rc, status = _systemctl_kis_trader("is-active", use_sudo=False, timeout=10)
bot_active = (status or "").strip() == "active"
return {
"ok": True,
"kis_mock": kis_mock,
"label": "모의" if kis_mock else "실전",
"bot_active": bot_active,
"bot_status": (status or "").strip() or "unknown",
"unit": _KIS_TRADER_UNIT,
"is_active_rc": rc,
}
@app.route("/api/ops/kis_mock", methods=["GET"])
def api_ops_kis_mock_get():
"""매매 모의/실전(KIS_MOCK) + 봇 active 상태."""
try:
return jsonify(_kis_mock_status_payload())
except Exception as e:
logger.exception("ops kis_mock GET 실패")
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/ops/kis_mock", methods=["POST"])
def api_ops_kis_mock_set():
"""KIS_MOCK DB 저장 후 kis_trader_main 재시작 (매매 client 재생성).
시세 market_client(실키) 정책은 봇 기동 로직 그대로. 웹 프로세스는 재시작하지 않음.
"""
body = request.get_json(silent=True) or {}
raw = body.get("kis_mock", None)
if raw is None:
return jsonify({"ok": False, "error": "kis_mock 필수"}), 400
if isinstance(raw, bool):
want_mock = raw
else:
want_mock = str(raw).strip().lower() in ("1", "true", "t", "yes", "y", "on")
db_ok = False
env_id = None
try:
from kis_trader.utils.env import invalidate_merged_env_cache
db = TradeDB()
try:
env_id = db.insert_env_snapshot({
"KIS_MOCK": "true" if want_mock else "false",
})
db_ok = bool(env_id)
finally:
db.close()
invalidate_merged_env_cache()
except Exception as e:
logger.exception("ops kis_mock DB 저장 실패")
return jsonify({
"ok": False,
"db_ok": False,
"restart_ok": False,
"error": f"DB 저장 실패: {e}",
}), 500
if not db_ok:
return jsonify({
"ok": False,
"db_ok": False,
"restart_ok": False,
"error": "insert_env_snapshot 실패",
}), 500
rc, out = _systemctl_kis_trader("restart", use_sudo=True, timeout=45)
# 기동 여유
import time as _time
_time.sleep(2.0)
rc2, status = _systemctl_kis_trader("is-active", use_sudo=False, timeout=10)
bot_active = (status or "").strip() == "active"
restart_ok = (rc == 0 and bot_active)
payload = {
"ok": bool(db_ok and restart_ok),
"db_ok": db_ok,
"env_id": env_id,
"kis_mock": want_mock,
"label": "모의" if want_mock else "실전",
"restart_ok": restart_ok,
"restart_rc": rc,
"restart_out": out,
"bot_active": bot_active,
"bot_status": (status or "").strip() or "unknown",
"is_active_rc": rc2,
"unit": _KIS_TRADER_UNIT,
"note": "시세는 실키 유지 · 매매/계좌만 KIS_MOCK",
}
if not restart_ok:
payload["error"] = (
f"DB는 저장됨(KIS_MOCK={'true' if want_mock else 'false'}) "
f"하지만 봇 재시작 실패(rc={rc}, status={status}). "
f"수동: sudo systemctl restart {_KIS_TRADER_UNIT}"
)
return jsonify(payload), 500
return jsonify(payload)
@app.route("/")
def index():
return render_template("backtest.html")
if __name__ == "__main__":
def _warm_portfolio():
try:
_portfolio_infra()
logger.info("보유·매도 API 워밍업 완료")
except Exception as e:
logger.warning("보유·매도 워밍업 스킵: %s", e)
threading.Thread(target=_warm_portfolio, daemon=True).start()
app.run(host="0.0.0.0", port=5050, debug=False, threaded=True)