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:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1,590 @@
"""
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