- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
463 lines
33 KiB
Python
463 lines
33 KiB
Python
"""
|
||
전략 파라미터 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()
|