feat: 전략 파라미터 DB 관리 시스템 구현

[추가 파일]
1. models/models.py — StrategyConfig 테이블 추가
2. services/strategy_config_service.py — DB 읽기 + TTL 캐시 서비스
3. api/endpoints/strategy_config.py — CRUD REST API
4. api/__init__.py — strategy_config 라우터 등록

[동작 방식]
- DB에 파라미터 있으면 DB 우선, 없으면 config.py 기본값 fallback
- TTL 30초 인메모리 캐시 → 재시작 없이 실시간 파라미터 변경
- 전략 코드에서 strategy_cfg.get("KEY", default) 패턴으로 사용
- /api/strategy-config 엔드포인트로 UI에서 관리 가능

[관리 대상 파라미터 그룹]
- INDICATOR: RSI, 볼린저, MA, ATR, ADX
- RISK: 손절/익절, MDD, 포지션 크기, 연속손실
- FILTER: 과열필터, 하락장필터, 트렌드필터
- DAY_TRADE: 데이트레이딩 세부 파라미터
- MEAN_REVERSION: 평균회귀 전략 파라미터
This commit is contained in:
2026-03-03 10:40:02 +09:00
parent 14e6d87025
commit f3323bb742
4 changed files with 556 additions and 61 deletions

View File

@@ -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"])

View File

@@ -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

View File

@@ -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)

View File

@@ -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()