feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
455
holding_bot.py
455
holding_bot.py
@@ -74,6 +74,22 @@ DEFAULT_STOCK_CONFIG = {
|
||||
"trail_stop_pct": 8.0,
|
||||
# 추세 필터 사용 여부 (1=사용, 0=미사용 → 기존 역추세 방식으로 고정)
|
||||
"trend_filter": 1.0,
|
||||
# ── UPDOW(kis_trader) 병합용: -1 이면 env_config 의 UPDOW_* 사용 (컬럼 NULL 과 동일 취급)
|
||||
"updow_body_drop_min_pct": -1.0,
|
||||
"updow_max_hold_bars": -1.0,
|
||||
"updow_exit_on_green": -1.0,
|
||||
# UPDOW 분봉(tf): -1 또는 0 미만이면 env UPDOW_TF_MIN (holding_min_candles.tf 와 일치 권장)
|
||||
"updow_tf_min": -1.0,
|
||||
"updow_regime_ma_bars": -1.0,
|
||||
# ATR 동적 청산(UPDOW): -1 이면 env_config UPDOW_ATR_* 사용
|
||||
"updow_atr_use_dynamic": -1.0,
|
||||
"updow_atr_period": -1.0,
|
||||
"updow_atr_sl_mult": -1.0,
|
||||
"updow_atr_tp_mult": -1.0,
|
||||
"updow_atr_sl_min_pct": -1.0,
|
||||
"updow_atr_sl_max_pct": -1.0,
|
||||
"updow_atr_tp_min_pct": -1.0,
|
||||
"updow_atr_tp_max_pct": -1.0,
|
||||
# ── 컨텍스트 낙폭 필터 (0=비활성화) ──────────────────────────
|
||||
"ath_drop_min_pct": 0.0,
|
||||
"year_drop_min_pct": 0.0,
|
||||
@@ -144,6 +160,7 @@ HOLDING_DDL = [
|
||||
buy3_ratio FLOAT DEFAULT 40,
|
||||
slot_money FLOAT DEFAULT 3000000,
|
||||
atr_period FLOAT DEFAULT 14,
|
||||
atr_mult FLOAT DEFAULT 3 COMMENT '샹들리에 엑시트 ATR 배수',
|
||||
ath_drop_min_pct FLOAT DEFAULT 0 COMMENT 'ATH 대비 최소 낙폭%% (0=비활성)',
|
||||
year_drop_min_pct FLOAT DEFAULT 0 COMMENT '연도고점 대비 최소 낙폭%% (0=비활성)',
|
||||
w52_drop_min_pct FLOAT DEFAULT 0 COMMENT '52주고점 대비 최소 낙폭%% (0=비활성)',
|
||||
@@ -151,6 +168,11 @@ HOLDING_DDL = [
|
||||
ma_slow FLOAT DEFAULT 60 COMMENT '장기 이동평균 기간',
|
||||
trail_stop_pct FLOAT DEFAULT 8 COMMENT '트레일링 스탑 %% (0=비활성)',
|
||||
trend_filter FLOAT DEFAULT 1 COMMENT '추세 필터 (1=사용/0=미사용)',
|
||||
updow_body_drop_min_pct DOUBLE NULL COMMENT 'UPDOW 몸통하락%% 하한 NULL=env',
|
||||
updow_max_hold_bars INT NULL COMMENT 'UPDOW 최대보유봉 NULL=env',
|
||||
updow_exit_on_green DOUBLE NULL COMMENT 'UPDOW 양봉청산 1/0 NULL=env',
|
||||
updow_tf_min INT NULL COMMENT 'UPDOW 분봉 tf NULL·<1=env',
|
||||
updow_regime_ma_bars INT NULL COMMENT 'UPDOW KOSPI프록시 MA봉수 0=OFF NULL=env',
|
||||
KEY idx_holding_config_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
@@ -175,6 +197,20 @@ def ensure_holding_tables(db: TradeDB):
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN ma_slow FLOAT DEFAULT 60 COMMENT '장기 이동평균 기간'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN trail_stop_pct FLOAT DEFAULT 8 COMMENT '트레일링 스탑 %% (0=비활성)'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN trend_filter FLOAT DEFAULT 1 COMMENT '추세 필터 (1=사용/0=미사용)'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN atr_mult FLOAT DEFAULT 3 COMMENT '샹들리에 엑시트 ATR 배수'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_body_drop_min_pct DOUBLE NULL COMMENT 'UPDOW 몸통하락%% NULL=env'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_max_hold_bars INT NULL COMMENT 'UPDOW 최대보유봉 NULL=env'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_exit_on_green DOUBLE NULL COMMENT 'UPDOW 양봉청산 NULL=env'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_tf_min INT NULL COMMENT 'UPDOW 분봉 tf NULL·<1=env UPDOW_TF_MIN'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_regime_ma_bars INT NULL COMMENT 'UPDOW KOSPI프록시 MA봉수 NULL=env'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_use_dynamic DOUBLE NULL COMMENT 'UPDOW ATR 동적청산 NULL=env'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_period INT NULL COMMENT 'UPDOW ATR 기간 NULL=env'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_sl_mult DOUBLE NULL COMMENT 'UPDOW ATR 손절배수 NULL=env'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_tp_mult DOUBLE NULL COMMENT 'UPDOW ATR 익절배수 NULL=env'",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_sl_min_pct DOUBLE NULL",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_sl_max_pct DOUBLE NULL",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_tp_min_pct DOUBLE NULL",
|
||||
"ALTER TABLE holding_stock_config ADD COLUMN updow_atr_tp_max_pct DOUBLE NULL",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
@@ -188,18 +224,25 @@ def ensure_holding_tables(db: TradeDB):
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 종목별 파라미터 관리
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def get_stock_config(db: TradeDB, code: str) -> Dict:
|
||||
"""종목별 최신 파라미터 조회 (없으면 DEFAULT 반환)"""
|
||||
def get_stock_config_row(db: TradeDB, code: str) -> Optional[Dict]:
|
||||
"""holding_stock_config 에 해당 코드가 있으면 최신 1행만 dict 로 반환, 없으면 None."""
|
||||
row = db.conn.execute(
|
||||
"SELECT * FROM holding_stock_config WHERE code=%s ORDER BY id DESC LIMIT 1",
|
||||
[code]
|
||||
[code],
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
cfg = dict(row)
|
||||
cfg.pop("id", None)
|
||||
cfg.pop("created_at", None)
|
||||
return cfg
|
||||
|
||||
|
||||
def get_stock_config(db: TradeDB, code: str) -> Dict:
|
||||
"""종목별 최신 파라미터 조회 (없으면 DEFAULT 반환)"""
|
||||
row = get_stock_config_row(db, code)
|
||||
if row:
|
||||
cfg = dict(row)
|
||||
cfg.pop("id", None)
|
||||
cfg.pop("created_at", None)
|
||||
return cfg
|
||||
# 기본값 반환 (DB에 없으면)
|
||||
return row
|
||||
cfg = dict(DEFAULT_STOCK_CONFIG)
|
||||
cfg["code"] = code
|
||||
cfg["name"] = ""
|
||||
@@ -500,7 +543,7 @@ class _KiwoomTokenManager:
|
||||
(동일 appkey로 kis_ws 에서 이미 발급받은 토큰은 재사용)
|
||||
"""
|
||||
try:
|
||||
from kis_ws import _get_kiwoom_token_cached
|
||||
from kis_trader.ws.kis_ws import _get_kiwoom_token_cached
|
||||
is_mock = "mockapi" in self._domain
|
||||
token = _get_kiwoom_token_cached(self._app_key, self._app_secret, is_mock)
|
||||
if token:
|
||||
@@ -514,6 +557,193 @@ class _KiwoomTokenManager:
|
||||
return self._token
|
||||
|
||||
|
||||
# 키움 ka10080 tic_scope 에 허용되는 분 단위 (API 스펙과 동일)
|
||||
KIWOOM_MINUTE_TICS: Tuple[int, ...] = (1, 3, 5, 10, 15, 30, 45, 60)
|
||||
|
||||
# KIS 해외 시세 API(EXCD) — 주문용 NASD/NYSE 와 분봉 시세용 NAS/NYS 코드가 다름
|
||||
_KIS_OVERSEAS_PRICE_EXCD_MAP: Dict[str, str] = {
|
||||
"NASD": "NAS",
|
||||
"NAS": "NAS",
|
||||
"NYSE": "NYS",
|
||||
"NYS": "NYS",
|
||||
"AMEX": "AMS",
|
||||
"AMS": "AMS",
|
||||
"SEHK": "HKS",
|
||||
"HKS": "HKS",
|
||||
"SHAA": "SHS",
|
||||
"SHS": "SHS",
|
||||
"SZAA": "SZS",
|
||||
"SZS": "SZS",
|
||||
}
|
||||
|
||||
|
||||
def _kis_overseas_price_excd(exchange: str) -> str:
|
||||
"""해외주식 분봉/시세 API용 거래소 코드로 정규화 (예: NASD → NAS)."""
|
||||
ex = str(exchange or "NAS").strip().upper()
|
||||
return _KIS_OVERSEAS_PRICE_EXCD_MAP.get(ex, ex)
|
||||
|
||||
|
||||
def _fetch_overseas_min_via_kis(
|
||||
symbol: str,
|
||||
exchange: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
tf_min: int = 60,
|
||||
max_pages: int = 200,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
KIS 해외주식 분봉조회로 1분봉 수집 후 tf_min 단위로 집계한다.
|
||||
- URL: /uapi/overseas-price/v1/quotations/inquire-time-itemchartprice
|
||||
- TR : HHDFS76950200
|
||||
"""
|
||||
app_key, app_secret, base_url, is_mock = _get_kis_token(TradeDB())
|
||||
token = _ensure_token(is_mock)
|
||||
url = f"{base_url}/uapi/overseas-price/v1/quotations/inquire-time-itemchartprice"
|
||||
|
||||
sd = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
ed = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
|
||||
symb = str(symbol or "").strip().upper()
|
||||
excd = _kis_overseas_price_excd(exchange)
|
||||
nmin = str(int(tf_min))
|
||||
next_yn = ""
|
||||
keyb = ""
|
||||
rows: List[Dict] = []
|
||||
reached_start = False
|
||||
|
||||
def _first_non_empty(d: Dict, keys: List[str], default: str = "") -> str:
|
||||
for k in keys:
|
||||
v = d.get(k)
|
||||
if v is not None and str(v).strip() != "":
|
||||
return str(v).strip()
|
||||
return default
|
||||
|
||||
for page in range(max_pages):
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"authorization": f"Bearer {token}",
|
||||
"appkey": app_key,
|
||||
"appsecret": app_secret,
|
||||
"tr_id": "HHDFS76950200",
|
||||
}
|
||||
params = {
|
||||
"AUTH": "",
|
||||
"EXCD": excd,
|
||||
"SYMB": symb,
|
||||
"NMIN": nmin,
|
||||
"PINC": "1",
|
||||
"NEXT": next_yn,
|
||||
"KEYB": keyb,
|
||||
"NREC": "120",
|
||||
"FILL": "Y",
|
||||
}
|
||||
data = None
|
||||
for retry in range(3):
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
if resp.status_code == 429:
|
||||
time.sleep((retry + 1) * 1.5)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if str(data.get("rt_cd", "0")) != "0":
|
||||
msg = str(data.get("msg1") or data.get("msg_cd") or "")
|
||||
if "초과" in msg and retry < 2:
|
||||
time.sleep((retry + 1) * 1.0)
|
||||
continue
|
||||
logger.error(f"❌ KIS 해외 분봉 API 오류 ({symb}): {msg}")
|
||||
data = None
|
||||
break
|
||||
except Exception as e:
|
||||
if retry >= 2:
|
||||
logger.error(f"❌ KIS 해외 분봉 조회 실패 ({symb}, page {page}): {e}")
|
||||
else:
|
||||
time.sleep(1.0)
|
||||
if data is None:
|
||||
break
|
||||
|
||||
recs = data.get("output2")
|
||||
if not isinstance(recs, list):
|
||||
recs = []
|
||||
if not recs:
|
||||
break
|
||||
|
||||
meta = data.get("output1") if isinstance(data.get("output1"), dict) else {}
|
||||
|
||||
for rec in recs:
|
||||
if not isinstance(rec, dict):
|
||||
continue
|
||||
date_s = _first_non_empty(rec, ["xymd", "stck_bsop_date", "date", "base_date"])
|
||||
time_s = _first_non_empty(rec, ["xhms", "stck_cntg_hour", "time", "hour"])
|
||||
if not date_s:
|
||||
continue
|
||||
if len(time_s) == 4:
|
||||
time_s = f"{time_s}00"
|
||||
if len(time_s) < 6:
|
||||
time_s = "000000"
|
||||
try:
|
||||
candle_dt = datetime.strptime(date_s[:8] + time_s[:6], "%Y%m%d%H%M%S")
|
||||
except Exception:
|
||||
continue
|
||||
if candle_dt.date() < sd.date():
|
||||
reached_start = True
|
||||
break
|
||||
if candle_dt.date() > ed.date():
|
||||
continue
|
||||
|
||||
open_v = _first_non_empty(rec, ["open", "oprc", "stck_oprc"], "0")
|
||||
high_v = _first_non_empty(rec, ["high", "hgpr", "stck_hgpr"], "0")
|
||||
low_v = _first_non_empty(rec, ["low", "lwpr", "stck_lwpr"], "0")
|
||||
close_v = _first_non_empty(rec, ["last", "clos", "stck_prpr", "close"], "0")
|
||||
vol_v = _first_non_empty(rec, ["evol", "tvol", "cntg_vol", "volume"], "0")
|
||||
|
||||
try:
|
||||
o = abs(float(open_v or 0))
|
||||
h = abs(float(high_v or 0))
|
||||
l = abs(float(low_v or 0))
|
||||
c = abs(float(close_v or 0))
|
||||
v = abs(int(float(vol_v or 0)))
|
||||
except Exception:
|
||||
continue
|
||||
if c <= 0:
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"candle_date": candle_dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": v,
|
||||
}
|
||||
)
|
||||
|
||||
if reached_start:
|
||||
break
|
||||
|
||||
next_yn = str(meta.get("next") or meta.get("NEXT") or "").strip()
|
||||
if not next_yn or next_yn in ("", "0", "N"):
|
||||
break
|
||||
last_rec = recs[-1] if recs else {}
|
||||
if isinstance(last_rec, dict):
|
||||
keyb = (
|
||||
str(last_rec.get("xymd") or "")[:8]
|
||||
+ str(last_rec.get("xhms") or "")[:6]
|
||||
)
|
||||
if not keyb:
|
||||
break
|
||||
time.sleep(0.35)
|
||||
|
||||
rows.sort(key=lambda x: x.get("candle_date", ""))
|
||||
dedup: Dict[str, Dict] = {}
|
||||
for r in rows:
|
||||
dedup[r["candle_date"]] = r
|
||||
result = [dedup[k] for k in sorted(dedup.keys())]
|
||||
logger.info(f"✅ KIS 해외 {tf_min}분봉 수집 완료: {symb}/{excd} {len(result)}봉 ({start_date}~{end_date})")
|
||||
return result
|
||||
|
||||
|
||||
def fetch_60min_via_kiwoom(
|
||||
code: str,
|
||||
start_date: str, # "YYYY-MM-DD"
|
||||
@@ -522,26 +752,49 @@ def fetch_60min_via_kiwoom(
|
||||
kiwoom_secret: str, # DB env_config의 KIWOOM_APP_SECRET_REAL 또는 KIWOOM_APP_SECRET_MOCK
|
||||
is_mock: bool = False, # True → mockapi.kiwoom.com (모의)
|
||||
max_pages: int = 50, # 연속조회 최대 횟수 (1페이지 ≈ 900봉)
|
||||
tf_min: int = 60, # 1/3/5/10/15/30/45/60 — ka10080 tic_scope
|
||||
market_type: str = "KR", # KR | US (현재 ka10080은 KR 전용)
|
||||
exchange: str = "KRX", # KRX | NASD | NYSE ...
|
||||
symbol: str = "", # US 심볼(예: QQQM)
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
키움증권 REST API (ka10080) 로 60분봉 수집 → holding_min_candles 저장용.
|
||||
키움증권 REST API (ka10080) 로 분봉 수집 → holding_min_candles 저장용.
|
||||
|
||||
kiwoom_universe_scanner.py / kiwoom_trader_dual.py 와 동일한
|
||||
TokenManager + Chart 패턴 사용.
|
||||
|
||||
▶ KIS 한계: inquire-time-itemchartprice 는 당일(2일치) 데이터만 제공
|
||||
▶ 키움 장점: ka10080 1회 호출 = 900봉 (60분봉 기준 약 128영업일)
|
||||
cont-yn: Y + next-key 연속조회로 2년치 이상 수집 가능
|
||||
▶ 키움 장점: ka10080 1회 호출 = 900봉 (틱 주기별 상이), cont-yn 연속조회로 긴 구간 수집
|
||||
|
||||
반환: [{"candle_date": "YYYY-MM-DD HH:MM:SS", "open": float, "high": float,
|
||||
"low": float, "close": float, "volume": int}, ...]
|
||||
최신 → 과거 순, start_date~end_date 범위 필터 적용
|
||||
"""
|
||||
mt = str(market_type or "KR").strip().upper()
|
||||
ex = str(exchange or ("KRX" if mt == "KR" else "NASD")).strip().upper()
|
||||
sym = str(symbol or code or "").strip().upper()
|
||||
if mt != "KR":
|
||||
# 해외는 키움 ka10080 대신 KIS 해외 분봉 endpoint로 라우팅한다.
|
||||
return _fetch_overseas_min_via_kis(
|
||||
symbol=sym,
|
||||
exchange=ex,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
tf_min=tf_min,
|
||||
max_pages=max_pages,
|
||||
)
|
||||
|
||||
from kiwoom_rest_api.koreanstock.chart import Chart
|
||||
|
||||
if tf_min not in KIWOOM_MINUTE_TICS:
|
||||
logger.error(f"❌ 키움 ka10080 미지원 tf_min={tf_min} (허용: {KIWOOM_MINUTE_TICS})")
|
||||
return []
|
||||
|
||||
tic_scope = str(int(tf_min))
|
||||
mode_str = "모의" if is_mock else "실전"
|
||||
base_url = "https://mockapi.kiwoom.com" if is_mock else "https://api.kiwoom.com"
|
||||
logger.info(f"키움 60분봉 수집 시작: {code} [{mode_str}] {start_date}~{end_date}")
|
||||
target_code = str(code or "").strip().upper()
|
||||
logger.info(f"키움 {tf_min}분봉 수집 시작: {target_code} [{mode_str}/{mt}] {start_date}~{end_date}")
|
||||
|
||||
# ── 1. TokenManager + Chart 초기화 (kiwoom_universe_scanner 와 동일 패턴) ──
|
||||
try:
|
||||
@@ -564,19 +817,19 @@ def fetch_60min_via_kiwoom(
|
||||
for page in range(max_pages):
|
||||
try:
|
||||
resp = chart.stock_minute_chart_request_ka10080(
|
||||
stk_cd = code,
|
||||
tic_scope = "60", # 60분봉 (1/3/5/10/15/30/45/60 지원)
|
||||
stk_cd = target_code,
|
||||
tic_scope = tic_scope, # 1/3/5/10/15/30/45/60 (키움 API)
|
||||
upd_stkpc_tp = "1", # 수정주가 반영
|
||||
cont_yn = cont_yn,
|
||||
next_key = next_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 키움 ka10080 조회 실패 (page {page}): {e}")
|
||||
logger.error(f"❌ 키움 ka10080 조회 실패 ({target_code}, page {page}): {e}")
|
||||
break
|
||||
|
||||
records = resp.get("stk_min_pole_chart_qry") or []
|
||||
if not records:
|
||||
logger.info(f"키움 60분봉: 더 이상 데이터 없음 (page {page})")
|
||||
logger.info(f"키움 {tf_min}분봉: 더 이상 데이터 없음 (page {page})")
|
||||
break
|
||||
|
||||
reached_start = False
|
||||
@@ -624,7 +877,7 @@ def fetch_60min_via_kiwoom(
|
||||
next_key = new_nkey
|
||||
time.sleep(0.3) # API 레이트리밋 준수 (초당 3회 이하)
|
||||
|
||||
logger.info(f"✅ 키움 60분봉 수집 완료: {code} {len(rows)}봉 ({start_date}~{end_date})")
|
||||
logger.info(f"✅ 키움 {tf_min}분봉 수집 완료: {target_code} {len(rows)}봉 ({start_date}~{end_date})")
|
||||
return rows
|
||||
|
||||
def fetch_min_ohlcv(
|
||||
@@ -1131,6 +1384,56 @@ def _calc_context_signals(candles: List[Dict], current_price: float) -> Dict:
|
||||
"w52_drop_pct": drop_pct(w52_high),
|
||||
}
|
||||
|
||||
|
||||
def build_daily_candle_display_rows(candles: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
백테스트 구간 일봉(또는 분봉) 표시용 — 최신이 앞.
|
||||
전일(직전 봉) 종가 대비 등락(원·%). 첫 봉은 전일 종가가 없으면 DB change_rate 로 근사.
|
||||
dir: 1=상승(빨강), -1=하락(파랑), 0=보합
|
||||
"""
|
||||
rows_chrono: List[Dict] = []
|
||||
prev_close: Optional[float] = None
|
||||
for c in candles:
|
||||
ds = str(c.get("candle_date", ""))
|
||||
date_short = ds[:10] if len(ds) >= 10 else ds
|
||||
o = float(c.get("open") or 0)
|
||||
hi = float(c.get("high") or 0)
|
||||
lo = float(c.get("low") or 0)
|
||||
cl = float(c.get("close") or 0)
|
||||
chg_won: Optional[float] = None
|
||||
chg_pct: Optional[float] = None
|
||||
direction = 0
|
||||
if prev_close is not None and prev_close > 0:
|
||||
chg_won = round(cl - prev_close)
|
||||
chg_pct = round((cl - prev_close) / prev_close * 100, 2)
|
||||
if chg_won > 0:
|
||||
direction = 1
|
||||
elif chg_won < 0:
|
||||
direction = -1
|
||||
else:
|
||||
cr = float(c.get("change_rate") or 0)
|
||||
if cr != 0 and cl > 0:
|
||||
chg_pct = round(cr, 2)
|
||||
denom = 1.0 + cr / 100.0
|
||||
prev_est = cl / denom if abs(denom) > 1e-9 else cl
|
||||
chg_won = round(cl - prev_est)
|
||||
direction = 1 if chg_won > 0 else (-1 if chg_won < 0 else 0)
|
||||
rows_chrono.append({
|
||||
"date": date_short,
|
||||
"dt": ds[:16] if len(ds) > 10 else date_short,
|
||||
"open": round(o) if o else 0,
|
||||
"high": round(hi) if hi else 0,
|
||||
"low": round(lo) if lo else 0,
|
||||
"close": round(cl) if cl else 0,
|
||||
"chg_won": chg_won,
|
||||
"chg_pct": chg_pct,
|
||||
"dir": direction,
|
||||
})
|
||||
if cl > 0:
|
||||
prev_close = cl
|
||||
return list(reversed(rows_chrono))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 백테스트 엔진
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1157,7 +1460,11 @@ def run_backtest(
|
||||
진입가: 신호봉 다음 봉 시가 (1봉 지연)
|
||||
"""
|
||||
if len(candles) < 20:
|
||||
return {"error": "봉 부족", "total_trades": 0}
|
||||
return {
|
||||
"error": "봉 부족",
|
||||
"total_trades": 0,
|
||||
"candle_rows": build_daily_candle_display_rows(candles),
|
||||
}
|
||||
|
||||
# ── 봉 유형 자동 감지 ─────────────────────────────────────────────
|
||||
_sample_dt = str(candles[0].get("candle_date", ""))
|
||||
@@ -1233,6 +1540,47 @@ def run_backtest(
|
||||
cum_pnl = 0.0
|
||||
|
||||
start_i = max(rsi_period + 1, ma_slow_p) # MA_SLOW가 준비된 이후부터 진입
|
||||
# 구간 일봉이 MA장기보다 짧으면 for 루프가 한 번도 돌지 않아 거래 0건·봇수익률 0%만 나옴
|
||||
if start_i >= len(candles) - 1:
|
||||
bnh_pct = (
|
||||
round((closes[-1] - closes[0]) / closes[0] * 100, 2) if closes[0] > 0 else 0.0
|
||||
)
|
||||
bnh_pnl = round(slot_money * bnh_pct / 100)
|
||||
need = start_i + 2
|
||||
c0_al = closes[start_i] if start_i < len(closes) else closes[0]
|
||||
bnh_aligned_pct = (
|
||||
round((closes[-1] - c0_al) / c0_al * 100, 2) if c0_al > 0 else 0.0
|
||||
)
|
||||
bnh_aligned_pnl = round(slot_money * bnh_aligned_pct / 100)
|
||||
return {
|
||||
"error": (
|
||||
f"백테스트 일봉 {len(candles)}개로는 MA장기({ma_slow_p}일) 준비가 안 됩니다 "
|
||||
f"(시뮬 시작 인덱스≥{start_i}, 마지막 진입 가능 인덱스<{len(candles) - 1}). "
|
||||
f"시작일을 앞당기거나 MA장기를 {max(5, len(candles) - 15)} 이하로 낮추세요 "
|
||||
f"(권장: 구간에 거래일 기준 최소 {need}봉 이상)."
|
||||
),
|
||||
"summary": {
|
||||
"total_trades": 0,
|
||||
"win_trades": 0,
|
||||
"loss_trades": 0,
|
||||
"win_rate": 0.0,
|
||||
"total_pnl": 0.0,
|
||||
"avg_hold_days": 0.0,
|
||||
"profit_factor": 9999.0,
|
||||
"max_drawdown": 0.0,
|
||||
"bnh_pct": bnh_pct,
|
||||
"bnh_pnl": bnh_pnl,
|
||||
"bot_pct": 0.0,
|
||||
"alpha_pct": round(0.0 - bnh_pct, 2),
|
||||
"bnh_aligned_pct": bnh_aligned_pct,
|
||||
"bnh_aligned_pnl": bnh_aligned_pnl,
|
||||
"alpha_aligned_pct": round(0.0 - bnh_aligned_pct, 2),
|
||||
},
|
||||
"equity": [],
|
||||
"reasons": {},
|
||||
"trades": [],
|
||||
"candle_rows": build_daily_candle_display_rows(candles),
|
||||
}
|
||||
|
||||
for i in range(start_i, len(candles) - 1):
|
||||
rsi = rsis[i]
|
||||
@@ -1434,13 +1782,20 @@ def run_backtest(
|
||||
reasons[prefix] = reasons.get(prefix, 0) + 1
|
||||
|
||||
# ── Buy & Hold 벤치마크 ──────────────────────────────────────────────
|
||||
# 첫 봉 종가 매수 → 마지막 봉 종가 매도 (수수료·세금 제외 단순 계산)
|
||||
# 전체 구간: 첫 봉 종가 → 마지막 봉 종가 (수수료·세금 제외)
|
||||
bnh_pct = round((closes[-1] - closes[0]) / closes[0] * 100, 2) if closes[0] > 0 else 0.0
|
||||
bnh_pnl = round(slot_money * bnh_pct / 100)
|
||||
# 시뮬과 동일 시작점: MA·RSI 워밍업이 끝난 봉(start_i) 종가 → 마지막 종가
|
||||
c0_al = closes[start_i] if start_i < len(closes) else closes[0]
|
||||
bnh_aligned_pct = (
|
||||
round((closes[-1] - c0_al) / c0_al * 100, 2) if c0_al > 0 else 0.0
|
||||
)
|
||||
bnh_aligned_pnl = round(slot_money * bnh_aligned_pct / 100)
|
||||
# 봇 수익률: 투자 원금(slot_money) 대비 실현 손익
|
||||
bot_pct = round(total_pnl / slot_money * 100, 2) if slot_money > 0 else 0.0
|
||||
# 알파 = 봇 수익률 - Buy & Hold 수익률 (양수면 봇이 시장 이김)
|
||||
alpha_pct = round(bot_pct - bnh_pct, 2)
|
||||
alpha_aligned_pct = round(bot_pct - bnh_aligned_pct, 2)
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
@@ -1453,14 +1808,18 @@ def run_backtest(
|
||||
"profit_factor": round(min(pf, 9999.0), 2),
|
||||
"max_drawdown": round(mdd),
|
||||
# Buy & Hold 비교
|
||||
"bnh_pct": bnh_pct, # 기간 동안 그냥 들고 있었을 때 수익률(%)
|
||||
"bnh_pnl": bnh_pnl, # 그냥 들고 있었을 때 손익(원)
|
||||
"bot_pct": bot_pct, # 봇 수익률(투자금 대비 %)
|
||||
"alpha_pct": alpha_pct, # 초과수익(봇 - Buy&Hold, %p)
|
||||
"bnh_pct": bnh_pct, # 전 구간 첫 종가 → 마지막 종가
|
||||
"bnh_pnl": bnh_pnl,
|
||||
"bot_pct": bot_pct,
|
||||
"alpha_pct": alpha_pct, # 봇 - 전구간 B&H
|
||||
"bnh_aligned_pct": bnh_aligned_pct, # 시뮬 시작 봉 종가 기준 B&H
|
||||
"bnh_aligned_pnl": bnh_aligned_pnl,
|
||||
"alpha_aligned_pct": alpha_aligned_pct, # 봇 - 동일구간 B&H
|
||||
},
|
||||
"equity": equity,
|
||||
"reasons": reasons,
|
||||
"trades": trades[-200:],
|
||||
"candle_rows": build_daily_candle_display_rows(candles),
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1469,17 +1828,20 @@ def run_backtest(
|
||||
def run_param_search(
|
||||
candles: List[Dict],
|
||||
grid: Optional[Dict] = None,
|
||||
min_trades: int = 2,
|
||||
base_cfg: Optional[Dict] = None, # 사용자가 설정한 현재 파라미터 (없으면 DEFAULT 사용)
|
||||
) -> List[Dict]:
|
||||
min_trades: int = 1,
|
||||
base_cfg: Optional[Dict] = None, # 사용자가 설정한 현재 파라미터 (그리드 외 키는 이 값으로 고정)
|
||||
) -> Tuple[List[Dict], Dict]:
|
||||
"""
|
||||
종목 하나에 대해 파라미터 Grid Search 실행
|
||||
반환: 수익 기준 내림차순 정렬된 결과 리스트
|
||||
반환: (수익 기준 내림차순 결과 리스트, 탐색 메타 dict)
|
||||
|
||||
base_cfg: 탐색 그리드에 없는 파라미터들의 기준값.
|
||||
웹 카드에서 사용자가 직접 설정한 값을 전달하면
|
||||
rsi_sell, rsi_period, rsi_buy3 등이 그 값으로 고정됨.
|
||||
None 이면 DEFAULT_STOCK_CONFIG 사용.
|
||||
|
||||
min_trades: 이 값보다 거래 수가 적은 조합은 TOP에서 제외.
|
||||
0이면 거래 수 필터 없음(느려질 수 있음).
|
||||
"""
|
||||
from itertools import product as iproduct
|
||||
|
||||
@@ -1506,27 +1868,42 @@ def run_param_search(
|
||||
keys = list(grid.keys())
|
||||
combos = list(iproduct(*[grid[k] for k in keys]))
|
||||
|
||||
results = []
|
||||
results: List[Dict] = []
|
||||
# base_cfg가 주어지면 사용자 설정값 우선, 없으면 DEFAULT 사용
|
||||
_base = dict(DEFAULT_STOCK_CONFIG)
|
||||
if base_cfg:
|
||||
_base.update(base_cfg)
|
||||
|
||||
skip_ma = 0
|
||||
skip_rsi = 0
|
||||
skip_err = 0
|
||||
skip_low = 0
|
||||
ran_bt = 0
|
||||
|
||||
for vals in combos:
|
||||
cfg = dict(_base)
|
||||
cfg.update(dict(zip(keys, vals)))
|
||||
# MA 단기 < 장기 조건 보정
|
||||
if cfg["ma_fast"] >= cfg["ma_slow"]:
|
||||
skip_ma += 1
|
||||
continue
|
||||
# RSI buy1 > buy2 조건 보정
|
||||
if cfg["rsi_buy1"] <= cfg["rsi_buy2"]:
|
||||
skip_rsi += 1
|
||||
continue
|
||||
ran_bt += 1
|
||||
res = run_backtest(candles, cfg)
|
||||
s = res.get("summary", {})
|
||||
if s.get("total_trades", 0) < min_trades:
|
||||
if res.get("error"):
|
||||
skip_err += 1
|
||||
continue
|
||||
s = res.get("summary", {})
|
||||
if min_trades > 0 and s.get("total_trades", 0) < min_trades:
|
||||
skip_low += 1
|
||||
continue
|
||||
results.append({
|
||||
"params": {k: cfg[k] for k in keys},
|
||||
# 웹 [적용] 버튼: 그리드 외 rsi_sell·rsi_period·낙폭 등 탐색에 실제 사용된 전체 cfg 반영
|
||||
"apply_cfg": dict(cfg),
|
||||
"total_pnl": s["total_pnl"],
|
||||
"win_rate": s["win_rate"],
|
||||
"total_trades": s["total_trades"],
|
||||
@@ -1536,7 +1913,21 @@ def run_param_search(
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x["total_pnl"], reverse=True)
|
||||
return results
|
||||
|
||||
fixed_keys = sorted(set(DEFAULT_STOCK_CONFIG.keys()) - set(keys))
|
||||
meta = {
|
||||
"grid_keys": keys,
|
||||
"fixed_param_keys": fixed_keys,
|
||||
"cartesian_product": len(combos),
|
||||
"skipped_invalid_ma": skip_ma,
|
||||
"skipped_invalid_rsi_order": skip_rsi,
|
||||
"backtests_run": ran_bt,
|
||||
"skipped_backtest_error": skip_err,
|
||||
"skipped_below_min_trades": skip_low,
|
||||
"min_trades": min_trades,
|
||||
"passed": len(results),
|
||||
}
|
||||
return results, meta
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user