diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 775eba9..1ead057 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -4,7 +4,11 @@ API 라우터 통합 모듈 - 각 엔드포인트 라우터를 prefix/tag와 함께 등록 """ from fastapi import APIRouter -from app.api.endpoints import trading_rules, trades, portfolio, market_data, mcp, recommendations, monitoring, tensorflow_predictions, debug, risk_management, ml_ensemble, journals, day_trading +from app.api.endpoints import ( + trading_rules, trades, portfolio, market_data, mcp, recommendations, + monitoring, tensorflow_predictions, debug, risk_management, ml_ensemble, + journals, day_trading, strategy_config, +) api_router = APIRouter() @@ -21,3 +25,4 @@ api_router.include_router(risk_management.router, prefix="/risk", tags=["risk_ma api_router.include_router(ml_ensemble.router, prefix="/ml-ensemble", tags=["ml_ensemble"]) api_router.include_router(journals.router, prefix="/journals", tags=["journals"]) api_router.include_router(day_trading.router, prefix="/daytrading", tags=["day_trading"]) +api_router.include_router(strategy_config.router, prefix="/strategy-config", tags=["strategy_config"]) diff --git a/backend/app/api/endpoints/strategy_config.py b/backend/app/api/endpoints/strategy_config.py new file mode 100644 index 0000000..3762b91 --- /dev/null +++ b/backend/app/api/endpoints/strategy_config.py @@ -0,0 +1,165 @@ +""" +전략 파라미터 관리 API + +GET /api/strategy-config — 전체 파라미터 목록 +GET /api/strategy-config/groups — 그룹 목록 +GET /api/strategy-config/{key} — 단일 파라미터 조회 +PUT /api/strategy-config/{key} — 파라미터 값 수정 +POST /api/strategy-config/{key}/reset — 단일 파라미터 기본값 리셋 +POST /api/strategy-config/reset-all — 전체 기본값 리셋 +PATCH /api/strategy-config/{key}/toggle — is_active 토글 +""" +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import select, update + +from app.core.database import AsyncSessionLocal +from app.models.models import StrategyConfig +from app.services.strategy_config_service import strategy_cfg + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ── Pydantic 스키마 ─────────────────────────────────────────────────────────── + +class StrategyConfigResponse(BaseModel): + id: int + key: str + group: str + label: str + description: Optional[str] + value_type: str + value: str + default_value: str + min_value: Optional[str] + max_value: Optional[str] + is_active: bool + updated_by: Optional[str] + + class Config: + from_attributes = True + + +class UpdateValueRequest(BaseModel): + value: str + updated_by: Optional[str] = "api" + + +class BulkUpdateItem(BaseModel): + key: str + value: str + + +class BulkUpdateRequest(BaseModel): + items: List[BulkUpdateItem] + updated_by: Optional[str] = "api" + + +# ── 엔드포인트 ──────────────────────────────────────────────────────────────── + +@router.get("", response_model=List[StrategyConfigResponse]) +async def list_configs( + group: Optional[str] = Query(None, description="그룹 필터 (INDICATOR/RISK/FILTER/DAY_TRADE/MEAN_REVERSION/GENERAL)"), + active_only: bool = Query(False, description="활성 파라미터만 조회") +): + """전체 전략 파라미터 목록 조회.""" + async with AsyncSessionLocal() as session: + stmt = select(StrategyConfig).order_by(StrategyConfig.group, StrategyConfig.key) + if group: + stmt = stmt.where(StrategyConfig.group == group.upper()) + if active_only: + stmt = stmt.where(StrategyConfig.is_active == True) + result = await session.execute(stmt) + return result.scalars().all() + + +@router.get("/groups", response_model=List[str]) +async def list_groups(): + """사용 중인 그룹 목록 반환.""" + return ["INDICATOR", "RISK", "FILTER", "DAY_TRADE", "MEAN_REVERSION", "GENERAL"] + + +@router.get("/cache-snapshot", response_model=Dict[str, Any]) +async def get_cache_snapshot(): + """현재 인메모리 캐시 상태 조회 (디버그용).""" + return strategy_cfg._cache + + +@router.get("/{key}", response_model=StrategyConfigResponse) +async def get_config(key: str): + """단일 파라미터 조회.""" + async with AsyncSessionLocal() as session: + result = await session.execute( + select(StrategyConfig).where(StrategyConfig.key == key) + ) + cfg = result.scalar_one_or_none() + if not cfg: + raise HTTPException(status_code=404, detail=f"파라미터 '{key}'를 찾을 수 없습니다.") + return cfg + + +@router.put("/{key}", response_model=StrategyConfigResponse) +async def update_config(key: str, body: UpdateValueRequest): + """파라미터 값 수정. 수정 즉시 캐시 무효화 → 30초 이내 전략 코드 반영.""" + try: + cfg = await strategy_cfg.upsert(key, body.value, body.updated_by or "api") + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + if not cfg: + raise HTTPException(status_code=404, detail=f"파라미터 '{key}'를 찾을 수 없습니다.") + return cfg + + +@router.put("", response_model=Dict[str, Any]) +async def bulk_update(body: BulkUpdateRequest): + """여러 파라미터 일괄 수정.""" + results = {"updated": [], "failed": []} + for item in body.items: + try: + cfg = await strategy_cfg.upsert(item.key, item.value, body.updated_by or "api") + if cfg: + results["updated"].append(item.key) + else: + results["failed"].append({"key": item.key, "reason": "not_found"}) + except ValueError as e: + results["failed"].append({"key": item.key, "reason": str(e)}) + return results + + +@router.post("/{key}/reset", response_model=StrategyConfigResponse) +async def reset_config(key: str): + """단일 파라미터를 기본값으로 리셋.""" + cfg = await strategy_cfg.reset_to_default(key) + if not cfg: + raise HTTPException(status_code=404, detail=f"파라미터 '{key}'를 찾을 수 없습니다.") + return cfg + + +@router.post("/reset-all", response_model=Dict[str, str]) +async def reset_all_configs(): + """모든 파라미터를 기본값으로 리셋.""" + await strategy_cfg.reset_all_to_default() + return {"status": "ok", "message": "전체 파라미터가 기본값으로 초기화되었습니다."} + + +@router.patch("/{key}/toggle", response_model=StrategyConfigResponse) +async def toggle_active(key: str): + """is_active 토글 (비활성화 시 해당 파라미터는 config.py 기본값 사용).""" + async with AsyncSessionLocal() as session: + result = await session.execute( + select(StrategyConfig).where(StrategyConfig.key == key) + ) + cfg = result.scalar_one_or_none() + if not cfg: + raise HTTPException(status_code=404, detail=f"파라미터 '{key}'를 찾을 수 없습니다.") + cfg.is_active = not cfg.is_active + await session.commit() + await session.refresh(cfg) + + await strategy_cfg.invalidate() + return cfg diff --git a/backend/app/models/models.py b/backend/app/models/models.py index e76674c..eac4642 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -5,6 +5,7 @@ SQLAlchemy ORM 모델 정의 - Portfolio: 포트폴리오 보유 종목 - TradeHistory: 주문/체결 이력 - TradingRule: 매매 규칙 설정 +- StrategyConfig: 전략 파라미터 DB 관리 (하드코딩 대체) """ from datetime import datetime, date from typing import Optional, List @@ -40,23 +41,15 @@ class TradingSession(Base): id = Column(Integer, primary_key=True, index=True) session_date = Column(Date, default=date.today, unique=True, index=True) - # 연속 손실 추적 consecutive_losses = Column(Integer, default=0) consecutive_wins = Column(Integer, default=0) - - # 일일 손익 daily_profit_loss = Column(Numeric(15, 2), default=0) daily_trades_count = Column(Integer, default=0) - - # 리스크 제한 상태 - is_paused = Column(Boolean, default=False) # 3연패 또는 일일 손실 한도 도달 시 True + is_paused = Column(Boolean, default=False) pause_reason = Column(String(200), nullable=True) - - # 계좌 정보 starting_balance = Column(Numeric(15, 2)) ending_balance = Column(Numeric(15, 2)) - peak_balance = Column(Numeric(15, 2)) # 당일 최고 잔고 - + peak_balance = Column(Numeric(15, 2)) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -76,6 +69,52 @@ class TradingRule(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +class StrategyConfig(Base): + """전략 파라미터 DB 관리 테이블 + + config.py의 하드코딩된 전략 파라미터를 DB에서 관리. + - 재시작 없이 파라미터 실시간 변경 가능 + - DB 값 없으면 config.py 기본값 자동 fallback + - strategy_config_service.get() 으로 접근 + + [그룹 분류] + - INDICATOR : RSI, 볼린저, MA, ATR, ADX 등 기술적 지표 파라미터 + - RISK : 손절/익절, MDD, 포지션 크기, 연속손실 등 리스크 파라미터 + - FILTER : 과열필터, 하락장필터, 트렌드필터 등 매매 필터 + - DAY_TRADE : 데이트레이딩 세부 파라미터 + - MEAN_REVERSION : 평균회귀 전략 파라미터 + - GENERAL : 기타 일반 파라미터 + """ + __tablename__ = "strategy_configs" + __table_args__ = ( + UniqueConstraint('key', name='uq_strategy_config_key'), + ) + + id = Column(Integer, primary_key=True, index=True) + + # 파라미터 식별 + key = Column(String(100), nullable=False, unique=True, index=True) # ex: RSI_PERIOD + group = Column(String(50), nullable=False, index=True) # INDICATOR / RISK / FILTER / DAY_TRADE / MEAN_REVERSION / GENERAL + label = Column(String(200), nullable=False) # UI 표시 이름 (한글 가능) + description = Column(Text, nullable=True) # 파라미터 설명 + + # 값 관리 + value_type = Column(String(20), nullable=False) # int / float / bool / str + value = Column(String(500), nullable=False) # 실제 값 (문자열로 저장, 타입 캐스팅은 서비스 레이어) + default_value = Column(String(500), nullable=False) # config.py 기본값 (참조용) + + # 유효성 검증 + min_value = Column(String(100), nullable=True) # 최솟값 (선택) + max_value = Column(String(100), nullable=True) # 최댓값 (선택) + + # 상태 + is_active = Column(Boolean, default=True, nullable=False) # False면 기본값 사용 + updated_by = Column(String(100), nullable=True) # 마지막 수정자 + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class TradeHistory(Base): __tablename__ = "trade_history" @@ -88,21 +127,18 @@ class TradeHistory(Base): order_quantity = Column(Integer, nullable=False) executed_price = Column(Numeric(15, 2)) executed_quantity = Column(Integer) - total_amount = Column(Numeric(15, 2)) # 총 거래 금액 + total_amount = Column(Numeric(15, 2)) status = Column(String(50), nullable=False) order_id = Column(String(100)) message = Column(Text) - entry_reason = Column(Text) # 진입 이유 (매수 신호 상세) - exit_reason = Column(Text) # 매도 이유 (매도 신호 상세) - signals = Column(JSONB) # 기술적 신호 목록 - - # 🔥 리스크 관리 필드 추가 - entry_trade_id = Column(Integer, nullable=True) # 매도 시 얰결된 매수 거래 ID - slippage = Column(Numeric(15, 2), nullable=True) # 슬리피지 (체결가 - 주문가) - commission = Column(Numeric(15, 2), nullable=True) # 수수료 - net_profit_loss = Column(Numeric(15, 2), nullable=True) # 수수료 차감 후 순손익 - profit_loss_rate = Column(Numeric(10, 4), nullable=True) # 수익률 (%) - + entry_reason = Column(Text) + exit_reason = Column(Text) + signals = Column(JSONB) + entry_trade_id = Column(Integer, nullable=True) + slippage = Column(Numeric(15, 2), nullable=True) + commission = Column(Numeric(15, 2), nullable=True) + net_profit_loss = Column(Numeric(15, 2), nullable=True) + profit_loss_rate = Column(Numeric(10, 4), nullable=True) created_at = Column(DateTime, default=datetime.utcnow, index=True) executed_at = Column(DateTime) @@ -118,7 +154,7 @@ class Portfolio(Base): current_price = Column(Numeric(15, 2)) profit_loss = Column(Numeric(15, 2)) profit_loss_rate = Column(Numeric(10, 4)) - market = Column(String(10), nullable=True, default="KRX") # KRX, NASD, NYSE, AMEX + market = Column(String(10), nullable=True, default="KRX") updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -129,7 +165,6 @@ class MarketData(Base): stock_code = Column(String(20), nullable=False, index=True) price = Column(Numeric(15, 2), nullable=False) change_rate = Column(Numeric(10, 4)) - # real‑time volume may also exceed int32; switch to bigint for safety volume = Column(BigInteger) high_price = Column(Numeric(15, 2)) low_price = Column(Numeric(15, 2)) @@ -188,14 +223,9 @@ class StockAnalysis(Base): stock_code = Column(String(20), nullable=False, index=True) stock_name = Column(String(100), nullable=False) analysis_date = Column(Date, nullable=False, index=True) - - # 가격 정보 current_price = Column(Integer, nullable=False) change_rate = Column(Numeric(10, 4)) - # volumes can exceed 32‑bit limits (e.g. 3 billion+), use bigint to avoid overflow volume = Column(BigInteger) - - # 기술적 지표 rsi = Column(Numeric(10, 4)) ma5 = Column(Numeric(15, 2)) ma20 = Column(Numeric(15, 2)) @@ -205,23 +235,13 @@ class StockAnalysis(Base): bollinger_upper = Column(Numeric(15, 2)) bollinger_lower = Column(Numeric(15, 2)) bollinger_middle = Column(Numeric(15, 2)) - - # 분석 결과 - signals = Column(JSONB) # ["RSI 과매도", "골든크로스" 등] + signals = Column(JSONB) score = Column(Integer, default=0) - - # ATR (섹션 6.2 ATR 기반 손절) atr_14 = Column(Numeric(15, 2), nullable=True) - - # ADX (추세 강도 — 평균회귀 필터용) adx_14 = Column(Numeric(10, 2), nullable=True) - - # ML 앙상블 스코어 - ml_score = Column(Integer, nullable=True) # 앙상블 스코어 (0~100) - ml_confidence = Column(Numeric(5, 4), nullable=True) # 신뢰도 (0~1) - ml_signal = Column(String(10), nullable=True) # BUY/HOLD/SELL - - # 메타데이터 + ml_score = Column(Integer, nullable=True) + ml_confidence = Column(Numeric(5, 4), nullable=True) + ml_signal = Column(String(10), nullable=True) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -242,11 +262,10 @@ class ActiveTrade(Base): take_profit = Column(Numeric(15, 2)) order_id = Column(String(100)) entry_reason = Column(Text) - status = Column(String(20), default="active") # active, closed - # 분할 청산 지원 (섹션 6.2, 7.3) - remaining_pct = Column(Numeric(5, 2), default=1.0) # 잔여 비율 (1.0=전량, 0.5=절반 청산 후) - highest_since_entry = Column(Numeric(15, 2), nullable=True) # 진입 후 최고가 (트레일링 스탑용) - strategy_name = Column(String(100), nullable=True) # 전략명 (MEAN_REVERSION 등) + status = Column(String(20), default="active") + remaining_pct = Column(Numeric(5, 2), default=1.0) + highest_since_entry = Column(Numeric(15, 2), nullable=True) + strategy_name = Column(String(100), nullable=True) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -255,7 +274,7 @@ class TradeJournal(Base): __tablename__ = "trade_journals" id = Column(Integer, primary_key=True, index=True) - trade_id = Column(Integer) # ForeignKey("trade_history.id") - models.py 내 순환 참조 방지 위해 정수로 정의 + trade_id = Column(Integer) stock_code = Column(String(20), nullable=False, index=True) stock_name = Column(String(100)) journal_date = Column(DateTime, default=datetime.utcnow) @@ -269,24 +288,15 @@ class TradeJournal(Base): class StockBan(Base): - """종목별 연속 손실 금지 기록 (CLAUDE.md 섹션 7.4) - - 재시작 후에도 ban 상태 유지를 위해 DB에 영속화. - """ __tablename__ = "stock_bans" stock_code = Column(String(20), primary_key=True) consecutive_losses = Column(Integer, default=0, nullable=False) - ban_until = Column(DateTime, nullable=True) # None이면 금지 아님 + ban_until = Column(DateTime, nullable=True) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) class WatchlistStock(Base): - """분석종목 — 장중 미니배치가 관리하는 활성 관심 종목. - - 아침 배치(batch_daily_analysis)가 score 상위 50개로 초기화하고, - 장중 미니배치(batch_intraday_analysis)가 10분마다 진입/제거를 관리한다. - """ __tablename__ = "watchlist_stocks" id = Column(Integer, primary_key=True, index=True) @@ -301,7 +311,6 @@ class WatchlistStock(Base): class WatchlistHistory(Base): - """분석종목 스코어 히스토리 — 10분마다 적재되는 시계열 데이터.""" __tablename__ = "watchlist_history" id = Column(Integer, primary_key=True, index=True) diff --git a/backend/app/services/strategy_config_service.py b/backend/app/services/strategy_config_service.py new file mode 100644 index 0000000..df9e8cb --- /dev/null +++ b/backend/app/services/strategy_config_service.py @@ -0,0 +1,316 @@ +""" +전략 파라미터 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 + +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"), + "STOP_LOSS_PERCENT": ("RISK", "기본 손절 비율", "저변동 종목 손절선 (%)", "float", "-6.0", "-20", "-1"), + "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", "연속 손실 금지 기간(일)", "진입 금지 유지 거래일 수", "int", "30", "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 ──────────────────────────────────────────────────────── + "MEAN_REVERSION_RSI_THRESHOLD": ("MEAN_REVERSION", "평균회귀 RSI 과매도 기준", "RSI 이 값 이하면 과매도로 진입 검토", "float", "30.0", "10", "50"), + "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), + "DAY_TRADE_MAX_POSITIONS": ("DAY_TRADE", "동시 최대 종목 수", "데이트레이딩 동시 보유 최대 종목 수", "int", "3", "1", "20"), + "DAY_TRADE_CAPITAL_RATIO": ("DAY_TRADE", "전체 자본 비율 상한", "데이트레이딩에 투입 가능한 총자본 비율", "float", "0.25", "0.05","1.0"), + "DAY_TRADE_PER_STOCK_RATIO": ("DAY_TRADE", "종목당 자본 비율", "1종목당 최대 투입 자본 비율", "float", "0.10", "0.01","0.5"), + "DAY_TRADE_STOP_LOSS_PCT": ("DAY_TRADE", "데이트레이딩 손절 비율", "종목 손절선 (%)", "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"), + "DAY_TRADE_TRAILING_TRIGGER": ("DAY_TRADE", "트레일링 발동 수익률", "이 수익률 초과 시 트레일링 스탑 발동", "float", "0.015", "0.005","0.1"), + "DAY_TRADE_RSI_BREAKEVEN_THRESHOLD": ("DAY_TRADE", "RSI_BB 진입 기준", "5분봉 RSI 이 값 이하이면 RSI_BB 진입", "float", "40.0", "20", "60"), + "DAY_TRADE_ORB_STOP_LOSS_PCT": ("DAY_TRADE", "ORB 손절 비율", "ORB 전략 손절선", "float", "0.02", "0.005","0.1"), + "DAY_TRADE_REVERSION_STOP_LOSS_PCT": ("DAY_TRADE", "VWAP/RSI_BB 손절 비율", "VWAP/RSI_BB 전략 손절선", "float", "0.025", "0.005","0.1"), + "DAY_TRADE_TRAILING_ATR_MULT": ("DAY_TRADE", "트레일링 ATR 배수", "트레일링 스탑 계산 ATR 배수", "float", "1.0", "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 ─────────────────────────────────────────────────────────────── + "ANALYSIS_STOCK_COUNT": ("GENERAL", "배치 분석 종목 수", "아침 배치에서 분석할 최대 종목 수", "int", "150", "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"), + "SHORT_TERM_VOLUME_MULTIPLIER": ("GENERAL", "단기 전략 거래량 배수", "단기 전략 진입 조건: 거래량 배수", "float", "1.5", "1.0", "5.0"), +} + + +class StrategyConfigService: + """전략 파라미터 DB 캐시 서비스.""" + + CACHE_TTL = 30 # seconds + + def __init__(self): + self._cache: Dict[str, Any] = {} + self._cache_ts: float = 0.0 + self._initialized = False + + # ── 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(): + asyncio.ensure_future(self._refresh()) + else: + loop.run_until_complete(self._refresh()) + except Exception: + pass + + 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(): + 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]: + """파라미터 값을 업데이트하고 캐시를 무효화.""" + 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 + cfg.updated_at = __import__("datetime").datetime.utcnow() + 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]: + """파라미터를 기본값으로 리셋.""" + 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 + + async def _refresh(self): + """DB에서 전체 파라미터를 읽어 캐시 갱신.""" + try: + 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: + 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() + except Exception as e: + logger.warning(f"[StrategyConfig] 캐시 갱신 실패 (기존 캐시 유지): {e}") + + @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()