""" 전략 파라미터 DB 관리 서비스 [역할] - StrategyConfig 테이블에서 전략 파라미터를 읽어 캐시에 보관 - TTL 30초 인메모리 캐시 → 재시작 없이 실시간 파라미터 변경 가능 - DB 값 없거나 is_active=False면 config.py 기본값으로 자동 fallback [전략 코드에서 사용법] from app.services.strategy_config_service import strategy_cfg rsi_period = strategy_cfg.get("RSI_PERIOD", 14) # int stop_loss = strategy_cfg.get("STOP_LOSS_PERCENT", -6.0) # float enabled = strategy_cfg.get("TREND_FILTER_ENABLED", False) # bool [초기 데이터 투입] 서버 시작 시 auto_init()이 호출되어 config.py의 기본값을 DB에 아직 없는 키에 한해 자동으로 seed 합니다. 이후 /api/strategy-config 엔드포인트로 값을 수정하면 30초 이내에 전략 코드에 반영됩니다. """ import asyncio import logging import time from typing import Any, Dict, Optional from sqlalchemy import select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from app.core.database import AsyncSessionLocal from app.models.models import StrategyConfig from app.core.timeutil import now_kst from app.core.log_throttle import warn_throttled # noqa: E402 logger = logging.getLogger(__name__) # ───────────────────────────────────────────────────────────────────────────── # 기본값 정의 (config.py와 동기화) # key: (group, label, description, value_type, default, min, max) # ───────────────────────────────────────────────────────────────────────────── DEFAULT_PARAMS: Dict[str, tuple] = { # ── INDICATOR ───────────────────────────────────────────────────────────── "RSI_PERIOD": ("INDICATOR", "RSI 계산 기간", "RSI 계산에 사용할 봉 수", "int", "14", "5", "50"), "RSI_OVERSOLD_LEVEL": ("INDICATOR", "RSI 과매도 기준", "이 값 이하면 과매도로 판단", "float", "30.0", "10", "50"), "RSI_OVERBOUGHT_LEVEL": ("INDICATOR", "RSI 과매수 기준", "이 값 이상이면 과매수로 판단", "float", "70.0", "50", "90"), "RSI_RECOVERY_RANGE_MIN": ("INDICATOR", "RSI 반등 구간 하한", "바닥 탈출 반등 신호 RSI 최솟값", "float", "30.0", "20", "50"), "RSI_RECOVERY_RANGE_MAX": ("INDICATOR", "RSI 반등 구간 상한", "바닥 탈출 반등 신호 RSI 최댓값", "float", "45.0", "30", "60"), "RSI_STABLE_RANGE_MIN": ("INDICATOR", "RSI 안정 구간 하한", "안정 상승 신호 RSI 최솟값", "float", "45.0", "30", "70"), "RSI_STABLE_RANGE_MAX": ("INDICATOR", "RSI 안정 구간 상한", "안정 상승 신호 RSI 최댓값", "float", "60.0", "40", "80"), "BOLLINGER_PERIOD": ("INDICATOR", "볼린저 밴드 기간", "볼린저 밴드 계산 기간 (봉 수)", "int", "20", "10", "50"), "BOLLINGER_STDDEV": ("INDICATOR", "볼린저 표준편차 배수", "상/하한선 계산 표준편차 배수", "float", "2.0", "1.0", "3.0"), "MA_SHORT_PERIOD": ("INDICATOR", "단기 이동평균 기간", "단기 MA 계산 기간 (일)", "int", "5", "3", "20"), "MA_MEDIUM_PERIOD": ("INDICATOR", "중기 이동평균 기간", "중기 MA 계산 기간 (일)", "int", "20", "10", "60"), "MA_LONG_PERIOD": ("INDICATOR", "장기 이동평균 기간", "장기 MA 계산 기간 (일)", "int", "60", "20", "200"), "ATR_PERIOD": ("INDICATOR", "ATR 계산 기간", "Average True Range 계산 기간", "int", "14", "5", "30"), # ── RISK ────────────────────────────────────────────────────────────────── "MAX_POSITION_SIZE": ("RISK", "종목당 최대 비중", "1종목당 총자산 대비 최대 비율 (0.04 = 4%)", "float", "0.04", "0.01", "0.20"), "MAX_DAILY_TRADES": ("RISK", "일일 최대 매매 횟수", "하루 최대 신규 매수 횟수", "int", "5", "1", "50"), "MAX_HOLDINGS_COUNT": ("RISK", "최대 동시 보유 종목", "동시에 보유 가능한 최대 종목 수", "int", "20", "1", "100"), # [2026-09-27] config.py 정합: 기본값 -6.0 → -7.0 (config.py STOP_LOSS_PERCENT=7.0, _META는 음수 규약 유지) "STOP_LOSS_PERCENT": ("RISK", "기본 손절 비율", "저변동 종목 손절선 (%)", "float", "-7.0", "-20", "-1"), # [2026-09-27] config.py 정합 검토: config.py STOP_LOSS_HIGH_VOL=10.0(양수 저장) vs 여기 -10.0 — 크기 동일, 단순 부호 규약 차이이므로 변경하지 않음 "STOP_LOSS_HIGH_VOL": ("RISK", "고변동 손절 비율", "고변동성 종목 손절선 (%)", "float", "-10.0", "-30", "-1"), "TAKE_PROFIT_PERCENT": ("RISK", "기본 익절 비율", "익절 기본값 (%)", "float", "5.0", "1", "50"), "ATR_STOP_MULTIPLIER": ("RISK", "ATR 손절 배수", "ATR × 배수 = 손절선", "float", "2.5", "1.0", "5.0"), "ATR_TRAILING_MULTIPLIER": ("RISK", "ATR 트레일링 배수", "ATR × 배수 = 트레일링 스탑", "float", "1.5", "0.5", "4.0"), "MDD_PAUSE_THRESHOLD": ("RISK", "MDD 신규진입 중단 기준", "이 MDD 이하이면 신규 포지션 중단 (%)", "float", "-10.0", "-30", "-1"), "MDD_FORCE_THRESHOLD": ("RISK", "MDD 강제 축소 기준", "이 MDD 이하이면 50% 강제 축소 (%)", "float", "-20.0", "-50", "-1"), "MIN_PRICE_KRW": ("RISK", "국내 최소 주가 (원)", "이 가격 미만 종목 매수 금지", "int", "5000", "100", "50000"), "MIN_PRICE_USD": ("RISK", "해외 최소 주가 (달러)", "이 가격 미만 종목 매수 금지", "float", "4.0", "0.1", "100"), "CONSECUTIVE_LOSS_BAN_COUNT":("RISK", "연속 손실 금지 횟수", "N회 연속 손실 시 해당 종목 진입 금지", "int", "3", "1", "10"), "CONSECUTIVE_LOSS_BAN_DAYS": ("RISK", "연속 손실 금지 기간(일)", "진입 금지 유지 캘린더일 수 (30거래일 ≈ 42)", "int", "42", "1", "180"), "MEAN_REVERSION_MAX_CAPITAL":("RISK", "평균회귀 최대 자본 비율", "평균회귀 전략에 투입 가능한 총자본 비율", "float", "0.30", "0.05", "1.0"), # ── FILTER ──────────────────────────────────────────────────────────────── "TREND_FILTER_ENABLED": ("FILTER", "추세 필터 활성화", "MA 기반 상승 추세 종목만 매수", "bool", "False", None, None), "TREND_MA_SHORT": ("FILTER", "추세 단기 MA", "추세 판별 단기 이동평균 기간", "int", "20", "5", "60"), "TREND_MA_LONG": ("FILTER", "추세 장기 MA", "추세 판별 장기 이동평균 기간", "int", "60", "20", "200"), "TREND_TOLERANCE_PCT": ("FILTER", "추세 허용 오차", "MA 이탈 허용 비율 (0.005 = 0.5%)", "float", "0.005", "0", "0.05"), "OVERHEAT_FILTER_ENABLED": ("FILTER", "급등 과열 필터 활성화", "단기 급등 종목 추격 매수 차단", "bool", "True", None, None), "OVERHEAT_3DAY_MAX": ("FILTER", "3일 급등 상한 (%)", "3일 이내 이 비율 이상 상승 시 매수 차단", "float", "15.0", "5", "50"), "OVERHEAT_5DAY_MAX": ("FILTER", "5일 급등 상한 (%)", "5일 이내 이 비율 이상 상승 시 매수 차단", "float", "20.0", "5", "60"), "OVERHEAT_10DAY_MAX": ("FILTER", "10일 급등 상한 (%)", "10일 이내 이 비율 이상 상승 시 매수 차단", "float", "30.0", "10", "80"), "OVERHEAT_DISPARITY_MAX": ("FILTER", "이격도 상한", "MA20 대비 이격도 상한 (108 = 8% 이상 이격 차단)", "float", "108.0", "100", "130"), "BEAR_MARKET_FILTER_ENABLED":("FILTER", "하락장 필터 활성화", "코스피 MA 기반 하락장 자동 감지 및 매매 축소", "bool", "True", None, None), "BEAR_MARKET_MIN_SCORE": ("FILTER", "하락장 최소 진입 점수", "하락장 시 이 점수 이상 종목만 매수", "int", "5", "1", "20"), "BEAR_MARKET_MAX_TRADES": ("FILTER", "하락장 최대 매매 횟수", "하락장 시 일일 최대 신규 매수 횟수", "int", "3", "0", "10"), "BEAR_MARKET_POSITION_RATIO":("FILTER", "하락장 포지션 비율", "하락장 시 포지션 크기 축소 비율 (0.5 = 50%)", "float", "0.5", "0.1", "1.0"), "WATCHLIST_INIT_SIZE": ("FILTER", "관심종목 초기화 크기", "아침 배치 관심종목 초기화 종목 수", "int", "150", "10", "500"), "WATCHLIST_MAX_SIZE": ("FILTER", "관심종목 최대 크기", "관리 가능한 최대 관심종목 수", "int", "150", "10", "500"), "WATCHLIST_ENTRY_SCORE": ("FILTER", "관심종목 신규 진입 점수", "이 점수 이상이면 관심종목에 추가", "int", "60", "1", "100"), "WATCHLIST_EXIT_SCORE": ("FILTER", "관심종목 제거 점수", "이 점수 이하이면 관심종목에서 제거", "int", "20", "0", "60"), # ── MEAN_REVERSION ──────────────────────────────────────────────────────── # [2026-09-27] config.py 정합: 기본값 30.0 → 46.0 (CLAUDE.md §8.2 BULL 진입 RSI<46), max 50 → 60 "MEAN_REVERSION_RSI_THRESHOLD": ("MEAN_REVERSION", "평균회귀 RSI 과매도 기준", "RSI 이 값 이하면 과매도로 진입 검토", "float", "46.0", "10", "60"), "MEAN_REVERSION_MULTIPLIER_STRONG": ("MEAN_REVERSION", "강한 신호 포지션 배수", "강한 신호 시 기본 포지션 대비 배수", "float", "1.50", "1.0", "3.0"), "MEAN_REVERSION_MULTIPLIER_NORMAL": ("MEAN_REVERSION", "중간 신호 포지션 배수", "중간 신호 시 기본 포지션 대비 배수", "float", "1.25", "1.0", "2.0"), "MEAN_REVERSION_MULTIPLIER_WEAK": ("MEAN_REVERSION", "약한 신호 포지션 배수", "약한 신호 시 기본 포지션 대비 배수", "float", "1.00", "0.5", "1.5"), # ── DAY_TRADE ───────────────────────────────────────────────────────────── "DAY_TRADE_ENABLED": ("DAY_TRADE", "데이트레이딩 활성화", "데이트레이딩 전략 On/Off", "bool", "True", None, None), # [2026-09-27] config.py 정합: 기본값 3 → 5 "DAY_TRADE_MAX_POSITIONS": ("DAY_TRADE", "동시 최대 종목 수", "데이트레이딩 동시 보유 최대 종목 수", "int", "5", "1", "20"), # [2026-09-27] config.py 정합: 기본값 0.25 → 0.30 (스윙:DT = 70:30) "DAY_TRADE_CAPITAL_RATIO": ("DAY_TRADE", "전체 자본 비율 상한", "데이트레이딩에 투입 가능한 총자본 비율", "float", "0.30", "0.05","1.0"), # [2026-09-27] config.py 정합: 기본값 0.10 → 0.06 (min 0.01 이므로 범위 조정 불필요) "DAY_TRADE_PER_STOCK_RATIO": ("DAY_TRADE", "종목당 자본 비율", "1종목당 최대 투입 자본 비율", "float", "0.06", "0.01","0.5"), "DAY_TRADE_STOP_LOSS_PCT": ("DAY_TRADE", "복원 폴백 손절", "재시작 복원 폴백 손절선 (실제 진입 손절은 전략별 ORB/REVERSION 손절)", "float", "0.02", "0.005","0.1"), "DAY_TRADE_DAILY_LOSS_LIMIT": ("DAY_TRADE", "일일 손실 한도", "일일 손실 한도 초과 시 데이트레이딩 중단 (%)", "float", "-0.03", "-0.2", "-0.005"), "DAY_TRADE_EOD_FLAT_HOUR": ("DAY_TRADE", "EOD 강제 청산 시 (시)", "장 마감 전 강제 청산 시작 시각 (시)", "int", "15", "13", "15"), "DAY_TRADE_EOD_FLAT_MIN": ("DAY_TRADE", "EOD 강제 청산 시 (분)", "장 마감 전 강제 청산 시작 시각 (분)", "int", "20", "0", "59"), "DAY_TRADE_ORB_VOL_MULT": ("DAY_TRADE", "ORB 거래량 배수", "ORB 돌파 확인용 최소 거래량 배수", "float", "1.5", "1.0", "5.0"), # [2026-09-27] config.py 정합: 기본값 0.015 → 0.01 (min 0.005 이므로 범위 조정 불필요) "DAY_TRADE_TRAILING_TRIGGER": ("DAY_TRADE", "트레일링 발동 수익률", "이 수익률 초과 시 트레일링 스탑 발동", "float", "0.01", "0.005","0.1"), "DAY_TRADE_RSI_BREAKEVEN_THRESHOLD": ("DAY_TRADE", "RSI_BB 진입 기준", "5분봉 RSI 이 값 이하이면 RSI_BB 진입", "float", "40.0", "20", "60"), # [2026-09-27] config.py 정합: 기본값 0.02 → 0.03 (노이즈 손절 방지) "DAY_TRADE_ORB_STOP_LOSS_PCT": ("DAY_TRADE", "ORB 손절 비율", "ORB 전략 손절선", "float", "0.03", "0.005","0.1"), # [2026-09-27] config.py 정합: 기본값 0.025 → 0.035 (반등 여유 확보) "DAY_TRADE_REVERSION_STOP_LOSS_PCT": ("DAY_TRADE", "VWAP/RSI_BB 손절 비율", "VWAP/RSI_BB 전략 손절선", "float", "0.035", "0.005","0.1"), "DAY_TRADE_TRAILING_ATR_MULT": ("DAY_TRADE", "트레일링 ATR 배수", "트레일링 스탑 계산 ATR 배수 (whipsaw 방지 1.5)", "float", "1.5", "0.5", "3.0"), "DAY_TRADE_ORB_BREAKOUT_MARGIN": ("DAY_TRADE", "ORB 돌파 여유 비율", "ORB 돌파 판정 최소 여유율", "float", "0.002", "0", "0.02"), "DAY_TRADE_ENTRY_CUTOFF_HOUR": ("DAY_TRADE", "신규 진입 마감 시 (시)", "이 시각 이후 신규 진입 불가 (시)", "int", "15", "9", "15"), "DAY_TRADE_ENTRY_CUTOFF_MIN": ("DAY_TRADE", "신규 진입 마감 시 (분)", "이 시각 이후 신규 진입 불가 (분)", "int", "0", "0", "59"), "DAY_TRADE_EOD_HOUR": ("DAY_TRADE", "EOD 청산 시각 (시)", "EOD 강제 청산 시작 시각 (시)", "int", "15", "13", "15"), "DAY_TRADE_EOD_MIN": ("DAY_TRADE", "EOD 청산 시각 (분)", "EOD 강제 청산 시작 시각 (분)", "int", "20", "0", "59"), # ── GENERAL ─────────────────────────────────────────────────────────────── # [2026-09-27] config.py 정합: 기본값 150 → 300 "ANALYSIS_STOCK_COUNT": ("GENERAL", "배치 분석 종목 수", "아침 배치에서 분석할 최대 종목 수", "int", "300", "10", "1000"), "AUTO_TRADE_TOP_N_PER_RULE": ("GENERAL", "규칙당 자동매매 종목 수", "매매 규칙 1개당 자동 실행 최대 종목 수", "int", "2", "1", "10"), "ML_ENSEMBLE_ENABLED": ("GENERAL", "ML 앙상블 활성화", "LightGBM + LSTM + RF 앙상블 모델 사용", "bool", "True", None, None), "ML_ENSEMBLE_RULE_WEIGHT": ("GENERAL", "룰베이스 스코어 비중", "앙상블 스코어에서 룰베이스 비중", "float", "0.3", "0", "1.0"), "ML_ENSEMBLE_ML_WEIGHT": ("GENERAL", "ML 스코어 비중", "앙상블 스코어에서 ML 비중", "float", "0.7", "0", "1.0"), "ML_ENSEMBLE_BUY_THRESHOLD": ("GENERAL", "ML 앙상블 매수 최소 점수", "이 점수 이상이면 ML BUY 신호 (0~100)", "int", "60", "0", "100"), "ML_ENSEMBLE_CONFIDENCE_MIN": ("GENERAL", "ML 최소 신뢰도", "이 신뢰도 이상이면 ML 신호 인정 (0~1)", "float", "0.5", "0", "1.0"), "MCP_VALIDATION_ENABLED": ("GENERAL", "MCP AI 검증 활성화", "자동매매 전 AI 최종 판단 활성화", "bool", "True", None, None), "MCP_CONFIDENCE_THRESHOLD": ("GENERAL", "MCP AI 최소 신뢰도", "MCP AI 신뢰도 최솟값 (0~1)", "float", "0.5", "0", "1.0"), "SHORT_TERM_MIN_ATR_RATIO": ("GENERAL", "단기 전략 최소 ATR 비율", "단기 전략 진입 조건: ATR/close 최솟값", "float", "0.008", "0", "0.1"), "SHORT_TERM_RSI_LOOKBACK": ("GENERAL", "단기 전략 RSI 반등 확인 봉", "RSI 반등 확인용 과거 봉 수", "int", "3", "1", "10"), # [2026-09-27] config.py 정합: 기본값 1.5 → 1.3 "SHORT_TERM_VOLUME_MULTIPLIER": ("GENERAL", "단기 전략 거래량 배수", "단기 전략 진입 조건: 거래량 배수", "float", "1.3", "1.0", "5.0"), } # [2026-10-09] DAY_TRADE 그룹은 **settings(config.py) 단일 소스**다. DT 엔진 # (market_schedule._dt_eod_flat, day_trading_strategy, daytrading/*)은 strategy_cfg 를 읽지 않으므로 # UI 에서 값을 바꿔도 아무 효과가 없었다(예: RSI_BREAKEVEN 40 표시 vs 실제 RSI_BB 게이트 35). # 효과 없는 편집이 "적용된 것처럼" 보이는 것을 막기 위해 쓰기·캐시 노출·시드를 차단한다. # 배선하려면 §0 우선순위 결정과 백테스트 근거가 필요하다. READ_ONLY_GROUPS = frozenset({"DAY_TRADE"}) # [2026-10-09 R5] RISK 그룹 중 실매매 경로가 strategy_cfg 를 읽지 않는 키(settings 단일 소스). # UI 에서 낮춰도(예: MAX_POSITION_SIZE 0.04→0.02) 실매매는 settings 값을 쓰므로 "위험을 줄였다"는 거짓 믿음을 만든다. # 실매매에 배선된 RISK 키(STOP_LOSS_*, ATR_*_MULTIPLIER)와 MDD_*(swing/scheduler 가 불일치 경보)는 제외한다. # 배선하려면 §0 우선순위 결정이 필요하며 MAX_POSITION_SIZE 는 min(strategy_cfg, settings) 로만 허용해야 한다. # 백테스트/최적화(strategy_cfg.get)는 이 키들에 대해 settings 폴백을 받는다(실전과 동일 값). READ_ONLY_RISK_KEYS = frozenset({ "MAX_POSITION_SIZE", "MAX_DAILY_TRADES", "MAX_HOLDINGS_COUNT", "MIN_PRICE_KRW", "MIN_PRICE_USD", "CONSECUTIVE_LOSS_BAN_COUNT", "CONSECUTIVE_LOSS_BAN_DAYS", "MEAN_REVERSION_MAX_CAPITAL", "TAKE_PROFIT_PERCENT", }) READ_ONLY_KEYS = frozenset( {k for k, v in DEFAULT_PARAMS.items() if v[0] in READ_ONLY_GROUPS} | READ_ONLY_RISK_KEYS ) # [2026-10-09] 표시 키 -> 엔진이 실제로 읽는 settings 키. 별칭 키는 소비자가 없어 # settings 값을 그대로 보여주면 엔진 실효값과 어긋난다(RSI_BB 40 표시 vs 게이트 35). DT_EFFECTIVE_KEY_ALIASES: Dict[str, str] = { "DAY_TRADE_RSI_BREAKEVEN_THRESHOLD": "DAY_TRADE_RSI_BB_RSI_THRESHOLD", "DAY_TRADE_EOD_FLAT_HOUR": "DAY_TRADE_EOD_HOUR", "DAY_TRADE_EOD_FLAT_MIN": "DAY_TRADE_EOD_MIN", "DAY_TRADE_PER_STOCK_RATIO": "DAY_TRADE_MAX_CAPITAL_RATIO", "DAY_TRADE_CAPITAL_RATIO": "DAY_TRADE_MAX_RATIO_DT", "DAY_TRADE_ORB_VOL_MULT": "DAY_TRADE_ORB_BREAKOUT_VOL_MULT", } # DB 행 라벨은 시드 후 갱신되지 않으므로 직렬화 시 덮어쓴다. DT_LABEL_OVERRIDES: Dict[str, str] = { "DAY_TRADE_STOP_LOSS_PCT": "복원 폴백 손절", } def _effective_settings_key_v2(key: str) -> str: return DT_EFFECTIVE_KEY_ALIASES.get(key, key) def is_read_only_key(key: str, group: Optional[str] = None) -> bool: return key in READ_ONLY_KEYS or (group in READ_ONLY_GROUPS) or key.startswith("DAY_TRADE_") # READ_ONLY_KEYS 에 RISK 미배선 키 포함 def get_effective_dt_params_v2() -> Dict[str, Any]: """DT 엔진이 실제로 쓰는 값(settings). UI 표시용 — strategy_configs 값이 아니다.""" from app.core.config import settings out: Dict[str, Any] = {} for k in sorted(READ_ONLY_KEYS): ek = _effective_settings_key_v2(k) if hasattr(settings, ek): out[k] = getattr(settings, ek) return out def serialize_config_v2(cfg: Any) -> Dict[str, Any]: """API 응답용 직렬화. read_only 행은 DB 값 대신 settings 실효값을 표시한다(거짓 표시 방지).""" from app.core.config import settings d = { "id": cfg.id, "key": cfg.key, "group": cfg.group, "label": cfg.label, "description": cfg.description, "value_type": cfg.value_type, "value": cfg.value, "default_value": cfg.default_value, "min_value": cfg.min_value, "max_value": cfg.max_value, "is_active": cfg.is_active, "updated_by": cfg.updated_by, "read_only": False, } if is_read_only_key(cfg.key, cfg.group): d["read_only"] = True ek = _effective_settings_key_v2(cfg.key) if cfg.key in DT_LABEL_OVERRIDES: d["label"] = DT_LABEL_OVERRIDES[cfg.key] if hasattr(settings, ek): eff = str(getattr(settings, ek)) d["value"] = eff d["default_value"] = eff d["updated_by"] = "settings" return d class StrategyConfigService: """전략 파라미터 DB 캐시 서비스.""" CACHE_TTL = 30 # seconds STALE_ERROR_SEC = 300 # 갱신 실패 지속 시 error 로그/health 경보 기준 def __init__(self): self._cache: Dict[str, Any] = {} self._cache_ts: float = 0.0 self._initialized = False # 동시 갱신 1건만 허용(in-flight 가드) + 태스크 참조 보관(GC 방지) self._refresh_inflight: bool = False self._refresh_task: Optional["asyncio.Future"] = None self._last_success_ts: float = 0.0 # monotonic, 마지막 성공 시각 self._consecutive_failures: int = 0 # ── public API ──────────────────────────────────────────────────────────── def get(self, key: str, default: Any = None) -> Any: """파라미터 값을 반환. 캐시 만료 시 동기적으로 갱신 시도.""" if self._is_cache_stale(): # 비동기 컨텍스트 외부(동기 코드)에서 호출 시 캐시를 즉시 갱신하지 않고 # 마지막으로 알려진 값을 반환 (다음 비동기 갱신 때 반영). try: loop = asyncio.get_event_loop() if loop.is_running(): if not self._refresh_inflight: self._refresh_inflight = True try: self._refresh_task = asyncio.ensure_future(self._refresh()) except Exception: self._refresh_inflight = False raise else: loop.run_until_complete(self._refresh()) except Exception as _swallowed: warn_throttled(logger, "strategy_config_service:169", "[StrategyConfig] 동기 get 중 캐시 갱신 실패: %s", _swallowed) return self._cache.get(key, default) async def aget(self, key: str, default: Any = None) -> Any: """비동기 버전. 캐시 만료 시 즉시 DB에서 갱신.""" if self._is_cache_stale(): await self._refresh() return self._cache.get(key, default) async def invalidate(self): """캐시를 즉시 무효화하고 DB에서 새로 로드.""" self._cache_ts = 0.0 await self._refresh() async def auto_init(self): """서버 시작 시 호출 — DEFAULT_PARAMS 중 DB에 없는 키를 seed.""" try: async with AsyncSessionLocal() as session: for key, (group, label, desc, vtype, default, vmin, vmax) in DEFAULT_PARAMS.items(): if is_read_only_key(key, group): continue # DT 는 settings 단일 소스 — 무효 행을 새로 시드하지 않는다 stmt = pg_insert(StrategyConfig).values( key=key, group=group, label=label, description=desc, value_type=vtype, value=default, default_value=default, min_value=vmin, max_value=vmax, is_active=True, ).on_conflict_do_nothing(index_elements=["key"]) # 이미 있으면 skip await session.execute(stmt) await session.commit() await self._refresh() self._initialized = True logger.info(f"[StrategyConfig] auto_init 완료 — {len(DEFAULT_PARAMS)}개 파라미터 seed") except Exception as e: logger.error(f"[StrategyConfig] auto_init 실패: {e}") async def get_all(self) -> list: """전체 파라미터 목록 반환 (API용).""" async with AsyncSessionLocal() as session: result = await session.execute( select(StrategyConfig).order_by(StrategyConfig.group, StrategyConfig.key) ) return result.scalars().all() async def upsert(self, key: str, value: str, updated_by: str = "api") -> Optional[StrategyConfig]: """파라미터 값을 업데이트하고 캐시를 무효화.""" if is_read_only_key(key): raise ValueError(f"{key}: DAY_TRADE 파라미터는 config.py(settings) 단일 소스이며 UI 로 변경할 수 없습니다") try: async with AsyncSessionLocal() as session: result = await session.execute( select(StrategyConfig).where(StrategyConfig.key == key) ) cfg = result.scalar_one_or_none() if cfg is None: return None # 유효성 검증 self._validate(cfg, value) cfg.value = value cfg.updated_by = updated_by # [2026-09-29] `__import__("datetime").now_kst()` 였다 — # `datetime` 모듈에 `now_kst` 가 없어 **AttributeError** 로 항상 터졌고, # 아래 `except Exception: raise` 가 그대로 올려서 전략 파라미터 저장이 # 전 경로(UI `PUT /strategy-config/{key}`, 일괄 저장, optimizer)에서 # 500 이었다. 모듈 최상단(:32)에 이미 `now_kst` 가 import 되어 있다. cfg.updated_at = now_kst() await session.commit() await session.refresh(cfg) await self.invalidate() return cfg except ValueError: raise except Exception as e: logger.error(f"[StrategyConfig] upsert 실패 ({key}): {e}") raise async def reset_to_default(self, key: str) -> Optional[StrategyConfig]: """파라미터를 기본값으로 리셋.""" if is_read_only_key(key): raise ValueError(f"{key}: DAY_TRADE 파라미터는 settings 단일 소스라 리셋 대상이 아닙니다") async with AsyncSessionLocal() as session: result = await session.execute( select(StrategyConfig).where(StrategyConfig.key == key) ) cfg = result.scalar_one_or_none() if cfg is None: return None cfg.value = cfg.default_value cfg.updated_by = "reset" await session.commit() await session.refresh(cfg) await self.invalidate() return cfg async def reset_all_to_default(self): """모든 파라미터를 기본값으로 리셋.""" async with AsyncSessionLocal() as session: await session.execute( update(StrategyConfig).values(value=StrategyConfig.default_value, updated_by="reset_all") ) await session.commit() await self.invalidate() # ── internal ────────────────────────────────────────────────────────────── def _is_cache_stale(self) -> bool: return (time.monotonic() - self._cache_ts) > self.CACHE_TTL def get_cache_health_v2(self) -> Dict[str, Any]: """/health 노출용 캐시 상태. 마지막 성공 후 STALE_ERROR_SEC 초과면 stale_alarm=True.""" age = (time.monotonic() - self._last_success_ts) if self._last_success_ts else None return { "age_sec": None if age is None else round(age, 1), "consecutive_failures": self._consecutive_failures, "stale_alarm": age is None or age > self.STALE_ERROR_SEC, "keys": len(self._cache), } async def _refresh(self): """DB에서 전체 파라미터를 읽어 캐시 갱신. 실패 시 _cache_ts 를 밀어 TTL 만큼 백오프.""" self._refresh_inflight = True try: await self._refresh_impl() finally: self._refresh_inflight = False async def _refresh_impl(self): try: # DB-READ-EXCEPTION: SELECT 전용(쓰기 없음), in-flight 1건 + TTL 백오프로 호출 폭주 차단 async with AsyncSessionLocal() as session: result = await session.execute( select(StrategyConfig).where(StrategyConfig.is_active == True) ) rows = result.scalars().all() new_cache: Dict[str, Any] = {} for row in rows: if is_read_only_key(row.key, row.group): continue # settings 단일 소스 — strategy_cfg.get 으로 새어 나가지 않게 한다 try: new_cache[row.key] = self._cast(row.value, row.value_type) except Exception: # 캐스팅 실패 시 기본값 사용 new_cache[row.key] = self._cast(row.default_value, row.value_type) self._cache = new_cache self._cache_ts = time.monotonic() self._last_success_ts = self._cache_ts self._consecutive_failures = 0 except Exception as e: self._consecutive_failures += 1 # 실패해도 _cache_ts 를 현재로 밀어 다음 시도를 CACHE_TTL 뒤로 미룬다(백오프) self._cache_ts = time.monotonic() logger.warning(f"[StrategyConfig] 캐시 갱신 실패 (기존 캐시 유지, 연속 {self._consecutive_failures}회): {e}") if self._last_success_ts: age = time.monotonic() - self._last_success_ts if age > self.STALE_ERROR_SEC: logger.error(f"[StrategyConfig] 전략 파라미터 캐시가 {age:.0f}초째 낡음 — 옛 값으로 매매 중") @staticmethod def _cast(value: str, value_type: str) -> Any: if value_type == "int": return int(float(value)) if value_type == "float": return float(value) if value_type == "bool": return value.lower() in ("true", "1", "yes") return value # str @staticmethod def _validate(cfg: StrategyConfig, value: str): """min/max 범위 검증.""" try: v = StrategyConfigService._cast(value, cfg.value_type) if cfg.min_value is not None: mn = StrategyConfigService._cast(cfg.min_value, cfg.value_type) if v < mn: raise ValueError(f"{cfg.key}: {value} < min({cfg.min_value})") if cfg.max_value is not None: mx = StrategyConfigService._cast(cfg.max_value, cfg.value_type) if v > mx: raise ValueError(f"{cfg.key}: {value} > max({cfg.max_value})") except ValueError: raise except Exception: pass # bool 등 비교 불가 타입은 skip # 전역 싱글톤 strategy_cfg = StrategyConfigService()