변경 사항 ---- - _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>
591 lines
22 KiB
Python
591 lines
22 KiB
Python
"""
|
|
kis_trader/strategies/updow_holding_cfg.py
|
|
==========================================
|
|
UPDOW(하락매수) 종목별 파라미터 — **``updow_stock_config`` 단일 테이블** (웹·백테·실매·JSON 적용 동일).
|
|
|
|
- 종목 행이 있으면 그 값만 사용 (``env_config`` 의 ``UPDOW_*`` 로 덮지 않음).
|
|
- 행이 없을 때만 env ``UPDOW_*`` 폴백 (신규 종목·그리드 끝값 등 전역 설정용).
|
|
- ``holding_stock_config`` 의 ``updow_*`` 는 **최초 1회 마이그레이션** 용도만 (이후 미사용).
|
|
|
|
분봉(tf): ``updow_stock_config.tf_min`` 양수 → 해당 분봉, 아니면 env ``UPDOW_TF_MIN``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Dict, Optional
|
|
|
|
from ..engine.limit_entry_common import ENTRY_ALIGN, ENTRY_LIMIT_ATR
|
|
from ..utils.env import get_env_int
|
|
from .updow_buy import CFG_ENGINE_KEYS, DEFAULT_UPDOW_CONFIG
|
|
|
|
logger = logging.getLogger("kis_trader.updow_holding_cfg")
|
|
|
|
_UPDOW_STOCK_DDL = """
|
|
CREATE TABLE IF NOT EXISTS updow_stock_config (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
code VARCHAR(10) NOT NULL,
|
|
market_type VARCHAR(8) NOT NULL DEFAULT 'KR',
|
|
exchange VARCHAR(16) NOT NULL DEFAULT 'KRX',
|
|
symbol VARCHAR(32) NOT NULL DEFAULT '',
|
|
name VARCHAR(50) DEFAULT '',
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
body_drop_min_pct DOUBLE NOT NULL,
|
|
body_drop_max_pct DOUBLE NOT NULL DEFAULT 0,
|
|
tp_pct DOUBLE NOT NULL,
|
|
stop_loss_pct DOUBLE NOT NULL,
|
|
max_hold_bars INT NOT NULL,
|
|
exit_on_green DOUBLE NOT NULL DEFAULT 0,
|
|
tf_min INT NOT NULL DEFAULT 60,
|
|
slot_money DOUBLE NOT NULL DEFAULT 3000000,
|
|
regime_ma_bars INT NOT NULL DEFAULT 0,
|
|
regime_ma_ease_pct DOUBLE NOT NULL DEFAULT 0,
|
|
atr_use_dynamic DOUBLE NOT NULL DEFAULT 0,
|
|
atr_period INT NOT NULL DEFAULT 14,
|
|
atr_sl_mult DOUBLE NOT NULL DEFAULT 2,
|
|
atr_tp_mult DOUBLE NOT NULL DEFAULT 4,
|
|
atr_sl_min_pct DOUBLE NOT NULL DEFAULT 0.8,
|
|
atr_sl_max_pct DOUBLE NOT NULL DEFAULT 6,
|
|
atr_tp_min_pct DOUBLE NOT NULL DEFAULT 1.5,
|
|
atr_tp_max_pct DOUBLE NOT NULL DEFAULT 12,
|
|
shoulder_min_high_pct DOUBLE NOT NULL DEFAULT 0.3,
|
|
shoulder_cut_pct DOUBLE NOT NULL DEFAULT 0.2,
|
|
limit_atr_mult DOUBLE NOT NULL DEFAULT 1.5,
|
|
entry_mode VARCHAR(16) NOT NULL DEFAULT '',
|
|
KEY idx_updow_stock_code (code)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='UPDOW 하락매수 종목별 파라미터'
|
|
"""
|
|
|
|
|
|
def _trade_db_core(db: Any):
|
|
"""TradeDBExt 이면 ``.raw``, 아니면 그대로."""
|
|
return getattr(db, "raw", db)
|
|
|
|
|
|
def ensure_updow_stock_config_table(db: Any) -> None:
|
|
"""``updow_stock_config`` 테이블 생성."""
|
|
raw = _trade_db_core(db)
|
|
raw.conn.execute(_UPDOW_STOCK_DDL.strip())
|
|
# 구버전 테이블(시장 컬럼 없음) 호환 마이그레이션
|
|
alters = (
|
|
"ADD COLUMN market_type VARCHAR(8) NOT NULL DEFAULT 'KR' AFTER code",
|
|
"ADD COLUMN exchange VARCHAR(16) NOT NULL DEFAULT 'KRX' AFTER market_type",
|
|
"ADD COLUMN symbol VARCHAR(32) NOT NULL DEFAULT '' AFTER exchange",
|
|
"ADD COLUMN shoulder_min_high_pct DOUBLE NOT NULL DEFAULT 0.3 AFTER atr_tp_max_pct",
|
|
"ADD COLUMN shoulder_cut_pct DOUBLE NOT NULL DEFAULT 0.2 AFTER shoulder_min_high_pct",
|
|
"ADD COLUMN body_drop_max_pct DOUBLE NOT NULL DEFAULT 0 AFTER body_drop_min_pct",
|
|
"ADD COLUMN limit_atr_mult DOUBLE NOT NULL DEFAULT 1.5 AFTER shoulder_cut_pct",
|
|
# 종목별 진입모드(align/limit_atr) — '' 이면 글로벌 env UPDOW_ENTRY_MODE 사용
|
|
"ADD COLUMN entry_mode VARCHAR(16) NOT NULL DEFAULT '' AFTER limit_atr_mult",
|
|
)
|
|
for frag in alters:
|
|
try:
|
|
raw.conn.execute(f"ALTER TABLE updow_stock_config {frag}")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
raw.conn.execute(
|
|
"UPDATE updow_stock_config "
|
|
"SET symbol = code "
|
|
"WHERE (symbol IS NULL OR symbol = '') AND code IS NOT NULL AND code != ''"
|
|
)
|
|
except Exception:
|
|
pass
|
|
# 영문 티커(QQQM 등)가 KR/KRX 로 저장된 구버전 행 보정
|
|
try:
|
|
raw.conn.execute(
|
|
"UPDATE updow_stock_config "
|
|
"SET market_type='US', exchange='NASD', symbol=UPPER(code) "
|
|
"WHERE code REGEXP '^[A-Za-z]{1,8}$' "
|
|
"AND (market_type='KR' OR exchange='KRX' OR exchange='')"
|
|
)
|
|
except Exception:
|
|
pass
|
|
raw.conn.commit()
|
|
|
|
|
|
def ensure_updow_backtest_tables(db: Any) -> None:
|
|
"""
|
|
UPDOW 백테·탐색·웹용 DB 준비.
|
|
|
|
- ``holding_min_candles`` 등 분봉 저장 테이블 (``holding_bot.ensure_holding_tables``)
|
|
- ``updow_stock_config`` 종목별 파라미터 (하락매수 전용, ``holding_stock_config`` 와 분리)
|
|
"""
|
|
try:
|
|
import holding_bot as hb
|
|
|
|
hb.ensure_holding_tables(db)
|
|
except Exception as e:
|
|
logger.warning("holding 분봉 테이블 확인 경고: %s", e)
|
|
ensure_updow_stock_config_table(db)
|
|
logger.info("✅ UPDOW DB 확인: holding_min_candles(분봉) + updow_stock_config(종목 파라미터)")
|
|
|
|
|
|
def _engine_cfg_from_row(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
out: Dict[str, Any] = {}
|
|
for k in CFG_ENGINE_KEYS:
|
|
if k in row and row[k] is not None:
|
|
out[k] = float(row[k])
|
|
else:
|
|
# 구버전 행·신규 추가 키 누락 시 엔진 기본값으로 보강 (KeyError 방지)
|
|
out[k] = float(DEFAULT_UPDOW_CONFIG.get(k, 0.0))
|
|
# 종목별 진입모드(문자열) — 유효값일 때만 주입.
|
|
# 빈 문자열을 out["entry_mode"]에 넣으면 updow_entry_mode()가 align 으로 오인하므로
|
|
# 반드시 valid 일 때만 키를 세팅하고, 아니면 키 자체를 넣지 않아 글로벌 env 로 폴백한다.
|
|
em = str(row.get("entry_mode") or "").strip().lower()
|
|
if em in (ENTRY_ALIGN, ENTRY_LIMIT_ATR):
|
|
out["entry_mode"] = em
|
|
return out
|
|
|
|
|
|
def _normalize_market_meta(
|
|
market_type: Optional[str],
|
|
exchange: Optional[str],
|
|
symbol: Optional[str],
|
|
code: str,
|
|
) -> tuple[str, str, str]:
|
|
mt = str(market_type or "KR").strip().upper()
|
|
if mt not in ("KR", "US"):
|
|
mt = "KR"
|
|
ex = str(exchange or ("KRX" if mt == "KR" else "NASD")).strip().upper()
|
|
sym = str(symbol or code or "").strip().upper()
|
|
if not sym:
|
|
sym = str(code or "").strip().upper()
|
|
if not ex:
|
|
ex = "KRX" if mt == "KR" else "NASD"
|
|
return mt, ex, sym
|
|
|
|
|
|
def resolve_market_meta(
|
|
code: str,
|
|
market_type: Optional[str] = None,
|
|
exchange: Optional[str] = None,
|
|
symbol: Optional[str] = None,
|
|
) -> tuple[str, str, str]:
|
|
"""
|
|
시장 메타 정규화 + 티커 형태 보정.
|
|
|
|
DB에 QQQM 등이 market_type=KR 로 잘못 들어가도,
|
|
6자리 숫자가 아닌 영문 티커는 해외(US)로 간주한다.
|
|
"""
|
|
c = str(code or symbol or "").strip().upper()
|
|
mt, ex, sym = _normalize_market_meta(market_type, exchange, symbol, c)
|
|
if not c:
|
|
return mt, ex, sym
|
|
if c.isdigit() and len(c) == 6:
|
|
return "KR", ex if ex not in ("NASD", "NAS", "NYSE", "NYS", "AMEX", "AMS") else "KRX", sym or c
|
|
if c.isalpha() and 1 <= len(c) <= 8:
|
|
if mt == "KR" or ex in ("", "KRX"):
|
|
return "US", "NASD", sym or c
|
|
return mt, ex, sym
|
|
|
|
|
|
def get_updow_stock_config_row(db: Any, code: str) -> Optional[Dict[str, Any]]:
|
|
"""``updow_stock_config`` 최신 1행 → 엔진 cfg dict (없으면 None)."""
|
|
ensure_updow_stock_config_table(db)
|
|
code = str(code or "").strip()
|
|
if not code:
|
|
return None
|
|
raw = _trade_db_core(db)
|
|
row = raw.conn.execute(
|
|
"SELECT * FROM updow_stock_config "
|
|
"WHERE code=%s OR symbol=%s "
|
|
"ORDER BY id DESC LIMIT 1",
|
|
[code, code],
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
d = dict(row) if not isinstance(row, dict) else dict(row)
|
|
return _engine_cfg_from_row(d)
|
|
|
|
|
|
def get_updow_stock_meta(db: Any, code: str) -> Optional[Dict[str, Any]]:
|
|
"""종목명·tf 포함 메타 (최신 행)."""
|
|
ensure_updow_stock_config_table(db)
|
|
code = str(code or "").strip()
|
|
if not code:
|
|
return None
|
|
raw = _trade_db_core(db)
|
|
row = raw.conn.execute(
|
|
"SELECT code, market_type, exchange, symbol, name, tf_min, created_at "
|
|
"FROM updow_stock_config "
|
|
"WHERE code=%s OR symbol=%s ORDER BY id DESC LIMIT 1",
|
|
[code, code],
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
return dict(row) if not isinstance(row, dict) else dict(row)
|
|
|
|
|
|
def set_updow_stock_config(
|
|
db: Any,
|
|
code: str,
|
|
name: str,
|
|
engine_cfg: Dict[str, Any],
|
|
tf_min: Optional[int] = None,
|
|
market_type: Optional[str] = None,
|
|
exchange: Optional[str] = None,
|
|
symbol: Optional[str] = None,
|
|
) -> None:
|
|
"""UPDOW 종목 파라미터 INSERT (최신 row 방식)."""
|
|
ensure_updow_stock_config_table(db)
|
|
code = str(code or symbol or "").strip().upper()
|
|
if not code:
|
|
raise ValueError("code 필수")
|
|
mt, ex, sym = _normalize_market_meta(market_type, exchange, symbol, code)
|
|
base = dict(DEFAULT_UPDOW_CONFIG)
|
|
for k in CFG_ENGINE_KEYS:
|
|
if k in engine_cfg and engine_cfg[k] is not None:
|
|
base[k] = float(engine_cfg[k])
|
|
try:
|
|
tfv = int(float(tf_min if tf_min is not None else engine_cfg.get("tf_min", base.get("tf_min", 60))))
|
|
except (TypeError, ValueError):
|
|
tfv = int(get_env_int("UPDOW_TF_MIN", 60))
|
|
if tfv < 1:
|
|
tfv = int(get_env_int("UPDOW_TF_MIN", 60))
|
|
# 진입모드(문자열) — align/limit_atr 만 허용, 그 외는 '' (글로벌 env 폴백)
|
|
em = str(engine_cfg.get("entry_mode") or "").strip().lower()
|
|
if em not in (ENTRY_ALIGN, ENTRY_LIMIT_ATR):
|
|
em = ""
|
|
cols = ["code", "market_type", "exchange", "symbol", "name", "tf_min"] + list(CFG_ENGINE_KEYS) + ["entry_mode"]
|
|
vals = [code, mt, ex, sym, str(name or "").strip() or code, tfv]
|
|
for k in CFG_ENGINE_KEYS:
|
|
vals.append(base[k])
|
|
vals.append(em)
|
|
placeholders = ", ".join(["%s"] * len(cols))
|
|
col_sql = ", ".join(cols)
|
|
raw = _trade_db_core(db)
|
|
raw.conn.execute(
|
|
f"INSERT INTO updow_stock_config ({col_sql}) VALUES ({placeholders})",
|
|
vals,
|
|
)
|
|
raw.conn.commit()
|
|
|
|
|
|
def load_updow_engine_cfg(db: Any, code: str, env_fallback: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
UPDOW 종목 cfg — ``updow_stock_config`` 우선, 없으면 ``env_fallback`` 만.
|
|
(holding_stock_config·env 이중 병합 없음)
|
|
"""
|
|
row_cfg = get_updow_stock_config_row(db, code)
|
|
if row_cfg:
|
|
return row_cfg
|
|
return dict(env_fallback)
|
|
|
|
|
|
def fetch_latest_updow_stock_config_by_code(db: Any) -> Dict[str, Dict[str, Any]]:
|
|
"""종목코드별 ``updow_stock_config`` 최신 1행 (실매 유니버스·웹 목록)."""
|
|
ensure_updow_stock_config_table(db)
|
|
raw = _trade_db_core(db)
|
|
sql = """
|
|
SELECT u.* FROM updow_stock_config u
|
|
INNER JOIN (
|
|
SELECT code, MAX(id) AS mx FROM updow_stock_config GROUP BY code
|
|
) t ON u.code = t.code AND u.id = t.mx
|
|
ORDER BY u.code
|
|
"""
|
|
cur = raw.conn.execute(sql)
|
|
rows = cur.fetchall() if cur else []
|
|
out: Dict[str, Dict[str, Any]] = {}
|
|
for row in rows:
|
|
d = dict(row) if not isinstance(row, dict) else dict(row)
|
|
code = str(d.get("code", "")).strip()
|
|
if not code:
|
|
continue
|
|
eng = _engine_cfg_from_row(d)
|
|
eng["name"] = str(d.get("name") or code).strip() or code
|
|
eng["tf_min"] = int(float(d.get("tf_min") or get_env_int("UPDOW_TF_MIN", 60)))
|
|
eng["market_type"] = str(d.get("market_type") or "KR").strip().upper() or "KR"
|
|
eng["exchange"] = str(d.get("exchange") or ("KRX" if eng["market_type"] == "KR" else "NASD")).strip().upper()
|
|
eng["symbol"] = str(d.get("symbol") or code).strip().upper() or code
|
|
out[code] = eng
|
|
return out
|
|
|
|
|
|
def list_updow_stock_codes(db: Any) -> list:
|
|
"""웹 종목 셀렉트용 [{code, name}, ...]."""
|
|
by_code = fetch_latest_updow_stock_config_by_code(db)
|
|
out = []
|
|
for c in sorted(by_code.keys()):
|
|
mt, ex, sym = resolve_market_meta(
|
|
c,
|
|
by_code[c].get("market_type"),
|
|
by_code[c].get("exchange"),
|
|
by_code[c].get("symbol"),
|
|
)
|
|
out.append({
|
|
"code": c,
|
|
"name": by_code[c].get("name", c),
|
|
"market_type": mt,
|
|
"exchange": ex,
|
|
"symbol": sym,
|
|
})
|
|
return out
|
|
|
|
|
|
def migrate_updow_from_holding(db: Any, code: str, env_fallback: Dict[str, Any]) -> bool:
|
|
"""
|
|
``holding_stock_config`` → ``updow_stock_config`` 1회 복사 (이미 있으면 False).
|
|
구버전 holding ``updow_*`` + 익절/손절 병합값을 그대로 옮김.
|
|
"""
|
|
if get_updow_stock_config_row(db, code):
|
|
return False
|
|
try:
|
|
import holding_bot as hb
|
|
except ImportError:
|
|
return False
|
|
hold_row = hb.get_stock_config_row(db, code)
|
|
if not hold_row:
|
|
return False
|
|
merged = merge_holding_row_into_updow_cfg(hold_row, env_fallback)
|
|
tf = effective_updow_tf_min(hold_row, int(env_fallback.get("tf_min", get_env_int("UPDOW_TF_MIN", 60))))
|
|
name = str(hold_row.get("name") or code).strip() or code
|
|
set_updow_stock_config(db, code, name, merged, tf_min=tf)
|
|
logger.info("updow_stock_config 마이그레이션: %s ← holding_stock_config", code)
|
|
return True
|
|
|
|
|
|
def migrate_all_updow_from_holding(db: Any, env_fallback: Dict[str, Any]) -> int:
|
|
"""holding 에 등록된 모든 코드를 updow_stock_config 로 (없는 것만)."""
|
|
try:
|
|
import holding_bot as hb
|
|
except ImportError:
|
|
return 0
|
|
n = 0
|
|
try:
|
|
items = hb.load_watchlist()
|
|
except Exception:
|
|
items = []
|
|
codes = {str(it.get("code", "")).strip() for it in items if it.get("code")}
|
|
raw = _trade_db_core(db)
|
|
cur = raw.conn.execute("SELECT DISTINCT code FROM holding_stock_config")
|
|
for row in (cur.fetchall() if cur else []):
|
|
c = str(dict(row).get("code", "")).strip()
|
|
if c:
|
|
codes.add(c)
|
|
for code in sorted(codes):
|
|
if migrate_updow_from_holding(db, code, env_fallback):
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def ensure_holding_updow_columns(db: Any) -> None:
|
|
"""holding_stock_config 에 UPDOW 전용 선택 컬럼 추가(이미 있으면 무시)."""
|
|
raw = _trade_db_core(db)
|
|
alters = (
|
|
"ADD COLUMN updow_body_drop_min_pct DOUBLE NULL "
|
|
"COMMENT 'UPDOW 직전봉 몸통하락%% 하한, NULL=env UPDOW_BODY_DROP_MIN_PCT'",
|
|
"ADD COLUMN updow_max_hold_bars INT NULL "
|
|
"COMMENT 'UPDOW 최대 보유 봉 수, NULL=env UPDOW_MAX_HOLD_BARS'",
|
|
"ADD COLUMN updow_exit_on_green DOUBLE NULL "
|
|
"COMMENT 'UPDOW 양봉청산 1=ON 0=OFF, NULL=env UPDOW_EXIT_ON_GREEN'",
|
|
"ADD COLUMN updow_tf_min INT NULL "
|
|
"COMMENT 'UPDOW 분봉 tf, NULL 또는 1미만=env UPDOW_TF_MIN'",
|
|
"ADD COLUMN updow_regime_ma_bars INT NULL "
|
|
"COMMENT 'UPDOW KOSPI프록시 1분 MA봉수 0=OFF NULL=env UPDOW_REGIME_MA_BARS'",
|
|
"ADD COLUMN updow_atr_use_dynamic DOUBLE NULL "
|
|
"COMMENT 'UPDOW ATR 동적 청산 1=ON 0=OFF NULL=env UPDOW_ATR_USE_DYNAMIC'",
|
|
"ADD COLUMN updow_atr_period INT NULL "
|
|
"COMMENT 'UPDOW ATR 기간(봉), NULL=env UPDOW_ATR_PERIOD'",
|
|
"ADD COLUMN updow_atr_sl_mult DOUBLE NULL "
|
|
"COMMENT 'UPDOW ATR 손절 배수, NULL=env UPDOW_ATR_SL_MULT'",
|
|
"ADD COLUMN updow_atr_tp_mult DOUBLE NULL "
|
|
"COMMENT 'UPDOW ATR 익절 배수, NULL=env UPDOW_ATR_TP_MULT'",
|
|
"ADD COLUMN updow_atr_sl_min_pct DOUBLE NULL "
|
|
"COMMENT 'UPDOW ATR 손절 하한(%) NULL=env UPDOW_ATR_SL_MIN_PCT'",
|
|
"ADD COLUMN updow_atr_sl_max_pct DOUBLE NULL "
|
|
"COMMENT 'UPDOW ATR 손절 상한(%) NULL=env UPDOW_ATR_SL_MAX_PCT'",
|
|
"ADD COLUMN updow_atr_tp_min_pct DOUBLE NULL "
|
|
"COMMENT 'UPDOW ATR 익절 하한(%) NULL=env UPDOW_ATR_TP_MIN_PCT'",
|
|
"ADD COLUMN updow_atr_tp_max_pct DOUBLE NULL "
|
|
"COMMENT 'UPDOW ATR 익절 상한(%) NULL=env UPDOW_ATR_TP_MAX_PCT'",
|
|
"ADD COLUMN updow_shoulder_min_high_pct DOUBLE NULL "
|
|
"COMMENT 'UPDOW 어깨 발동(%%) NULL=env UPDOW_SHOULDER_MIN_HIGH_PCT'",
|
|
"ADD COLUMN updow_shoulder_cut_pct DOUBLE NULL "
|
|
"COMMENT 'UPDOW 어깨 폭(%%) NULL=env UPDOW_SHOULDER_CUT_PCT'",
|
|
)
|
|
for frag in alters:
|
|
try:
|
|
raw.conn.execute(f"ALTER TABLE holding_stock_config {frag}")
|
|
raw.conn.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def fetch_latest_holding_stock_config_by_code(db: Any) -> Dict[str, Dict[str, Any]]:
|
|
"""종목코드별 ``holding_stock_config`` 최신 1행 (id DESC)."""
|
|
raw = _trade_db_core(db)
|
|
sql = """
|
|
SELECT h.* FROM holding_stock_config h
|
|
INNER JOIN (
|
|
SELECT code, MAX(id) AS mx FROM holding_stock_config GROUP BY code
|
|
) t ON h.code = t.code AND h.id = t.mx
|
|
ORDER BY h.code
|
|
"""
|
|
cur = raw.conn.execute(sql)
|
|
rows = cur.fetchall() if cur else []
|
|
out: Dict[str, Dict[str, Any]] = {}
|
|
for row in rows:
|
|
d = dict(row) if not isinstance(row, dict) else dict(row)
|
|
code = str(d.get("code", "")).strip()
|
|
if not code:
|
|
continue
|
|
d.pop("id", None)
|
|
d.pop("created_at", None)
|
|
out[code] = d
|
|
return out
|
|
|
|
|
|
def merge_holding_row_into_updow_cfg(
|
|
row: Optional[Dict[str, Any]],
|
|
env_fallback: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
``holding_stock_config`` 한 행을 ``updow_buy`` 엔진 cfg 형식으로 병합.
|
|
|
|
- ``take_profit_pct`` → ``tp_pct``
|
|
- ``stop_loss_pct`` → ``stop_loss_pct`` (%% 양수, 백테와 동일)
|
|
- ``slot_money`` → ``slot_money``
|
|
- ``updow_body_drop_min_pct`` / ``updow_max_hold_bars`` / ``updow_exit_on_green`` /
|
|
``updow_regime_ma_bars``
|
|
가 NULL 이 아니고 유효하면 env 를 덮어씀.
|
|
"""
|
|
out: Dict[str, Any] = dict(env_fallback)
|
|
if not row:
|
|
return out
|
|
|
|
def _f(key: str) -> Optional[float]:
|
|
v = row.get(key)
|
|
if v is None or (isinstance(v, str) and not str(v).strip()):
|
|
return None
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
try:
|
|
tp = _f("take_profit_pct")
|
|
if tp is not None and tp > 0:
|
|
out["tp_pct"] = tp
|
|
sl = _f("stop_loss_pct")
|
|
if sl is not None and sl > 0:
|
|
out["stop_loss_pct"] = sl
|
|
sm = _f("slot_money")
|
|
if sm is not None and sm > 0:
|
|
out["slot_money"] = sm
|
|
|
|
bd = _f("updow_body_drop_min_pct")
|
|
if bd is not None and bd >= 0.0:
|
|
out["body_drop_min_pct"] = bd
|
|
|
|
mh = _f("updow_max_hold_bars")
|
|
if mh is not None and mh >= 1.0:
|
|
out["max_hold_bars"] = float(int(mh))
|
|
|
|
eg = _f("updow_exit_on_green")
|
|
# -1 = 미설정(장기홀딩 기본값) → env 유지. 0~1 은 UPDOW 양봉청산 on/off.
|
|
if eg is not None and eg >= 0.0:
|
|
out["exit_on_green"] = 1.0 if eg >= 0.5 else 0.0
|
|
|
|
rm = _f("updow_regime_ma_bars")
|
|
if rm is not None and rm >= 0.0:
|
|
out["regime_ma_bars"] = float(int(rm))
|
|
|
|
aud = _f("updow_atr_use_dynamic")
|
|
if aud is not None and aud >= 0.0:
|
|
out["atr_use_dynamic"] = 1.0 if aud >= 0.5 else 0.0
|
|
ap = _f("updow_atr_period")
|
|
if ap is not None and ap >= 1.0:
|
|
out["atr_period"] = float(int(ap))
|
|
asm = _f("updow_atr_sl_mult")
|
|
if asm is not None and asm > 0.0:
|
|
out["atr_sl_mult"] = asm
|
|
atm = _f("updow_atr_tp_mult")
|
|
if atm is not None and atm > 0.0:
|
|
out["atr_tp_mult"] = atm
|
|
asmin = _f("updow_atr_sl_min_pct")
|
|
if asmin is not None and asmin > 0.0:
|
|
out["atr_sl_min_pct"] = asmin
|
|
asmax = _f("updow_atr_sl_max_pct")
|
|
if asmax is not None and asmax > 0.0:
|
|
out["atr_sl_max_pct"] = asmax
|
|
atmin = _f("updow_atr_tp_min_pct")
|
|
if atmin is not None and atmin > 0.0:
|
|
out["atr_tp_min_pct"] = atmin
|
|
atmax = _f("updow_atr_tp_max_pct")
|
|
if atmax is not None and atmax > 0.0:
|
|
out["atr_tp_max_pct"] = atmax
|
|
usmh = _f("updow_shoulder_min_high_pct")
|
|
if usmh is not None and usmh > 0.0:
|
|
out["shoulder_min_high_pct"] = usmh
|
|
usc = _f("updow_shoulder_cut_pct")
|
|
if usc is not None and usc > 0.0:
|
|
out["shoulder_cut_pct"] = usc
|
|
except Exception as e:
|
|
logger.debug("merge_holding_row_into_updow_cfg 경고: %s", e)
|
|
|
|
return out
|
|
|
|
|
|
def updow_param_sources_for_code(
|
|
db: Any,
|
|
code: str,
|
|
env_fallback: Dict[str, Any],
|
|
) -> Dict[str, Dict[str, Any]]:
|
|
"""웹 표시: ``updow_stock_config`` 행 있으면 전부 종목, 없으면 env 폴백."""
|
|
stock_cfg = get_updow_stock_config_row(db, code)
|
|
applied = load_updow_engine_cfg(db, code, env_fallback)
|
|
src = "updow_stock" if stock_cfg else "env"
|
|
out: Dict[str, Dict[str, Any]] = {}
|
|
for k in CFG_ENGINE_KEYS:
|
|
out[k] = {
|
|
"from": src,
|
|
"applied": applied.get(k),
|
|
"updow_stock": stock_cfg.get(k) if stock_cfg else None,
|
|
"env": env_fallback.get(k),
|
|
}
|
|
return out
|
|
|
|
|
|
def effective_updow_tf_for_code(db: Any, code: str, env_tf: int) -> int:
|
|
"""``updow_stock_config.tf_min`` 우선."""
|
|
meta = get_updow_stock_meta(db, code)
|
|
if meta:
|
|
try:
|
|
iv = int(float(meta.get("tf_min", 0)))
|
|
if iv >= 1:
|
|
return iv
|
|
except (TypeError, ValueError):
|
|
pass
|
|
try:
|
|
et = int(env_tf)
|
|
except (TypeError, ValueError):
|
|
et = 0
|
|
if et < 1:
|
|
et = get_env_int("UPDOW_TF_MIN", 60)
|
|
return et
|
|
|
|
|
|
def effective_updow_tf_min(row: Optional[Dict[str, Any]], env_tf: int) -> int:
|
|
"""
|
|
종목별 ``holding_stock_config.updow_tf_min`` — 양의 정수면 해당 분봉,
|
|
NULL·1미만·변환 실패 시 ``env_tf`` (보통 env ``UPDOW_TF_MIN`` 스냅샷 값).
|
|
"""
|
|
try:
|
|
et = int(env_tf)
|
|
except (TypeError, ValueError):
|
|
et = 0
|
|
if et < 1:
|
|
et = get_env_int("UPDOW_TF_MIN", 60)
|
|
if not row:
|
|
return et
|
|
raw = row.get("updow_tf_min")
|
|
if raw is None:
|
|
return et
|
|
try:
|
|
iv = int(float(raw))
|
|
except (TypeError, ValueError):
|
|
return et
|
|
if iv < 1:
|
|
return et
|
|
return iv
|