9220 lines
387 KiB
Python
9220 lines
387 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
backtest_web.py — 매매 성과 분석 & 백테스트 웹 대시보드
|
||
==========================================================
|
||
실행: python3 backtest_web.py
|
||
접속: http://localhost:5050
|
||
|
||
탭1. 실거래 분석 → trade_history 기반 (SCALP / SHORT / MOMENTUM / UPDOW / BREAKOUT / HOLDING 등)
|
||
탭2. 오늘 운영 → 당일 전략별·합계 거래대금·뽀찌(운용한도) 사용량·총수익률 (봇 재시작 불필요)
|
||
탭2b. 운영 설정 → 일일익절·전략ON/OFF·운용한도·TRIGGER 필터 등 env 실시간 조회·저장
|
||
탭3. 보유·매도 → active_trades + 실계좌 대조, OrderManager 시장가 매도 (HTS 수동매도 DB 꼬임 방지)
|
||
탭3. 스캘핑 백테스트 → ws_candles 1분봉 가격 재현(Price-Replay) 백테스트
|
||
탭4. 꼬리잡기 백테스트 → ws_candles 3분봉 기반, tail_engine 연동 (V3 방어 파라미터 지원)
|
||
"""
|
||
|
||
import sys, os, math, json, logging, threading, uuid, 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)
|