레디스 및 구조 수정중

This commit is contained in:
2026-02-25 16:56:34 +09:00
parent 89f54bae55
commit ec02f27200
10 changed files with 428 additions and 131 deletions

View File

@@ -1,45 +1,248 @@
# 한국투자증권 API # ═══════════════════════════════════════════════════════════════════════════════
# 자동 주식 매매 시스템 설정 파일 (.env)
# ═══════════════════════════════════════════════════════════════════════════════
#
# 모든 설정이 .env 파일 변수로 제어 가능합니다.
# 기본값은 config.py에 정의되어 있고, 여기서 오버라이드할 수 있습니다.
#
# 참조: CLAUDE.md - 섹션 1.2 환경 변수 참고
# ═══════════════════════════════════════════════════════════════════════════════
# 기본 애플리케이션 설정
# ═══════════════════════════════════════════════════════════════════════════════
ENVIRONMENT=development
DEBUG=false
LOG_LEVEL=INFO
# 백엔드 서버
BACKEND_HOST=0.0.0.0
BACKEND_PORT=8000
SECRET_KEY=your-secret-key-here
# ═══════════════════════════════════════════════════════════════════════════════
# 데이터베이스 및 캐시
# ═══════════════════════════════════════════════════════════════════════════════
DATABASE_URL=postgresql://autostock:autostock_password@localhost:5432/autostock_db
REDIS_URL=redis://localhost:6379/0
RABBITMQ_URL=amqp://autostock:autostock_password@localhost:5672/
# PostgreSQL (도커 환경)
POSTGRES_USER=autostock
POSTGRES_PASSWORD=autostock_password
POSTGRES_DB=autostock
# ═══════════════════════════════════════════════════════════════════════════════
# 한국투자증권 (KIS) API 설정
# ═══════════════════════════════════════════════════════════════════════════════
# 모드 선택: "virtual" (모의투자) 또는 "real" (실전투자)
KIS_MODE=virtual
# 모의투자 (Paper Trading) 설정
KIS_VIRTUAL_APP_KEY=your_virtual_app_key
KIS_VIRTUAL_APP_SECRET=your_virtual_app_secret
KIS_VIRTUAL_ACCOUNT_NO=00000000-01
KIS_VIRTUAL_URL=https://openapivts.koreainvestment.com:29443
# 실전투자 설정
KIS_REAL_APP_KEY=your_real_app_key
KIS_REAL_APP_SECRET=your_real_app_secret
KIS_REAL_ACCOUNT_NO=00000000-01
KIS_REAL_URL=https://openapi.koreainvestment.com:9443
# WebSocket 접속키 (수동 지정 시 우선, 비어있으면 자동 발급)
KIS_WS_APPROVAL_KEY=
# WebSocket URL (커스토마이징 필요 시만 수정)
KIS_WS_REAL_URL=wss://openapi.koreainvestment.com:21000/websocket/realtime
KIS_WS_VIRTUAL_URL=wss://openapiwss.koreainvestment.com:29443/websocket/realtime
# KIS 실시간 스트림 타이밍 (초 단위)
KIS_SYNC_INTERVAL=10 # 구독 목록 동기화 주기
KIS_RECONNECT_WAIT=5 # 재연결 대기 시간
KIS_APPROVAL_KEY_TTL=82800 # approval_key 캐시 유효시간 (초, 23시간)
# 레거시 호환 (직접 지정 시 우선)
KOREA_INVESTMENT_APP_KEY=your_app_key_here KOREA_INVESTMENT_APP_KEY=your_app_key_here
KOREA_INVESTMENT_APP_SECRET=your_app_secret_here KOREA_INVESTMENT_APP_SECRET=your_app_secret_here
KOREA_INVESTMENT_ACCOUNT_NUMBER=your_account_number KOREA_INVESTMENT_ACCOUNT_NUMBER=your_account_number
KOREA_INVESTMENT_API_BASE_URL=https://openapi.koreainvestment.com:9443 KOREA_INVESTMENT_API_BASE_URL=https://openapi.koreainvestment.com:9443
# Claude AI # ═══════════════════════════════════════════════════════════════════════════════
ANTHROPIC_API_KEY=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 # AI 프로바이더 설정
# ═══════════════════════════════════════════════════════════════════════════════
AI_PROVIDER=claude # "claude" 또는 "gemini"
ANTHROPIC_API_KEY=your_anthropic_api_key
# Database # MCP 서버 설정
POSTGRES_USER=autostock MCP_SERVER_URL=http://localhost:8001
POSTGRES_PASSWORD=autostock_password MCP_VALIDATION_ENABLED=true # AI 검증 활성화
POSTGRES_DB=autostock_db MCP_CONFIDENCE_THRESHOLD=0.5 # AI 신뢰도 최소값 (0.0~1.0)
DATABASE_URL=postgresql://autostock:autostock_password@postgres:5432/autostock_db
# Redis # ═══════════════════════════════════════════════════════════════════════════════
REDIS_URL=redis://redis:6379/0 # 거래 설정 (CLAUDE.md 섹션 5-8 기반)
# ═══════════════════════════════════════════════════════════════════════════════
# RabbitMQ # 기본 거래 파라미터
RABBITMQ_DEFAULT_USER=autostock AUTO_TRADE_ENABLED=true # 자동매매 활성화
RABBITMQ_DEFAULT_PASS=autostock_password ANALYSIS_STOCK_COUNT=150 # 배치 분석 종목 수
RABBITMQ_URL=amqp://autostock:autostock_password@rabbitmq:5672/ AUTO_TRADE_TOP_N_PER_RULE=2 # 규칙당 자동매매 최대 종목 수
MAX_DAILY_TRADES=5
# Backend # 포지션 규칙 (섹션 5.1)
BACKEND_HOST=0.0.0.0 MAX_POSITION_SIZE=0.04 # 1종목당 최대 3-4% 자본
BACKEND_PORT=8000 MAX_HOLDINGS_COUNT=20 # 동시 보유 최대 20종목
SECRET_KEY=your_secret_key_change_this_in_production LEVERAGE_ETF_MAX_RATIO=0.20 # 레버리지/인버스 ETF 20% 이내
# Frontend # 손절/익절 설정 (섹션 5.2)
STOP_LOSS_PERCENT=-6.0 # 저변동 종목 손절 -6%
STOP_LOSS_HIGH_VOL=-10.0 # 고변동 종목 손절 -10%
TAKE_PROFIT_PERCENT=5.0 # 익절 기본값
# ATR 기반 손절/트레일링 (섹션 5.2)
ATR_STOP_MULTIPLIER=2.5 # 2.5 × ATR 손절
ATR_TRAILING_MULTIPLIER=1.5 # 1.5 × ATR 트레일링
# MDD (최대 낙폭) 관리 (섹션 5.3)
MDD_PAUSE_THRESHOLD=-10.0 # -10% MDD: 신규진입 중단 + 30% 축소
MDD_FORCE_THRESHOLD=-20.0 # -20% MDD: 50% 강제 축소
# 가격 필터 (섹션 5.4)
MIN_PRICE_KRW=5000 # 국내 5,000원 이하 매수 금지
MIN_PRICE_USD=4.0 # 미국 $4 이하 매수 금지
# 연속 손실 관리 (섹션 7.4)
CONSECUTIVE_LOSS_BAN_COUNT=3 # 3회 연속 손실
CONSECUTIVE_LOSS_BAN_DAYS=30 # 30거래일 진입 금지
# ═══════════════════════════════════════════════════════════════════════════════
# 전략 A: RSI + 볼린저 밴드 평균회귀 (섹션 6)
# ═══════════════════════════════════════════════════════════════════════════════
MEAN_REVERSION_MAX_CAPITAL=0.30 # 전체 자본의 30% 이내 (섹션 7.5)
MEAN_REVERSION_RSI_THRESHOLD=30.0 # RSI 과매도 기준 (≤30)
MEAN_REVERSION_MULTIPLIER_STRONG=1.50 # RSI ≤ 20: 강한 신호 배수 (+50%)
MEAN_REVERSION_MULTIPLIER_NORMAL=1.25 # RSI ≤ 25: 중간 신호 배수 (+25%)
MEAN_REVERSION_MULTIPLIER_WEAK=1.00 # 기본 신호 배수 (4%)
# 시장 국면 필터 (7.5)
BEAR_MARKET_FILTER_ENABLED=true
BEAR_MARKET_MIN_SCORE=5
BEAR_MARKET_MAX_TRADES=3
BEAR_MARKET_POSITION_RATIO=0.5
# 급등 과열 필터 (섹션 5.4)
OVERHEAT_FILTER_ENABLED=true
OVERHEAT_3DAY_MAX=15.0 # 3일 최대 상승률 15%
OVERHEAT_5DAY_MAX=20.0 # 5일 최대 상승률 20%
OVERHEAT_10DAY_MAX=30.0 # 10일 최대 상승률 30%
OVERHEAT_DISPARITY_MAX=108.0 # 괴리율 최대 108%
# 트렌드 필터 (7.1)
TREND_FILTER_ENABLED=false
TREND_MA_SHORT=20 # 단기 MA 기간
TREND_MA_LONG=60 # 장기 MA 기간
TREND_TOLERANCE_PCT=0.005 # 허용 오차 0.5%
# ═══════════════════════════════════════════════════════════════════════════════
# 전략 B: 데이트레이딩 (섹션 8.2)
# ═══════════════════════════════════════════════════════════════════════════════
DAY_TRADE_ENABLED=true # 데이트레이딩 활성화
DAY_TRADE_MAX_POSITIONS=3 # 동시 최대 3종목
DAY_TRADE_CAPITAL_RATIO=0.25 # 전체 자본 대비 최대 25%
DAY_TRADE_MAX_RATIO_DT=0.25 # 전체 데이트레이딩 최대 25%
DAY_TRADE_PER_STOCK_RATIO=0.10 # 종목당 최대 10%
DAY_TRADE_MAX_CAPITAL_RATIO=0.10 # 종목당 최대 10%
# 데이트레이딩 손절/익절
DAY_TRADE_ORB_STOP_LOSS_PCT=0.02 # ORB 손절 -2%
DAY_TRADE_REVERSION_STOP_LOSS_PCT=0.025 # VWAP/RSI_BB 손절 -2.5%
DAY_TRADE_TRAILING_TRIGGER=0.015 # 트레일링 발동 +1.5%
DAY_TRADE_TRAILING_ATR_MULT=1.0 # 트레일링 ATR 배수
DAY_TRADE_ORB_BREAKOUT_MARGIN=0.002 # ORB 돌파 최소 여유 +0.2%
DAY_TRADE_COST_PCT=0.005 # 수수료+슬리피지 0.5%
# 데이트레이딩 일일/시간 제한
DAY_TRADE_DAILY_LOSS_LIMIT=-0.03 # 일일 손실 한도 -3%
DAY_TRADE_ENTRY_CUTOFF_HOUR=15 # 신규 진입 마감 시간 (15시)
DAY_TRADE_ENTRY_CUTOFF_MIN=0 # 신규 진입 마감 분
DAY_TRADE_EOD_HOUR=15 # EOD 강제 청산 시작 시간
DAY_TRADE_EOD_MIN=20 # EOD 강제 청산 시작 분
DAY_TRADE_RSI_BREAKEVEN_THRESHOLD=40.0 # RSI_BB 진입 기준 (5분봉 RSI ≤ 40)
# ═══════════════════════════════════════════════════════════════════════════════
# 단기 전략 (15분봉, 섹션 8.2)
# ═══════════════════════════════════════════════════════════════════════════════
SHORT_TERM_MIN_ATR_RATIO=0.008 # ATR/close ≥ 0.8% (거래비용 커버)
SHORT_TERM_RSI_LOOKBACK=3 # RSI 반등 확인 과거 봉 수
SHORT_TERM_VOLUME_MULTIPLIER=1.5 # 거래량 필터 배수 (1.5배)
# ═══════════════════════════════════════════════════════════════════════════════
# 감시 목록 관리 (watchlist.py)
# ═══════════════════════════════════════════════════════════════════════════════
WATCHLIST_INIT_SIZE=50 # 아침 배치 초기화 종목 수
WATCHLIST_MAX_SIZE=100 # 최대 관리 종목 수
WATCHLIST_ENTRY_SCORE=60 # 장중 신규 진입 기준 점수
WATCHLIST_EXIT_SCORE=20 # 장중 제거 기준 점수
# ═══════════════════════════════════════════════════════════════════════════════
# 기술적 지표 설정 (technical_indicators.py)
# ═══════════════════════════════════════════════════════════════════════════════
# RSI (Relative Strength Index)
RSI_PERIOD=14 # RSI 계산 기간 (14일)
RSI_OVERSOLD_LEVEL=30.0 # RSI 과매도 수준
RSI_OVERBOUGHT_LEVEL=70.0 # RSI 과매수 수준
RSI_RECOVERY_RANGE_MIN=30.0 # RSI 회복 범위 최소값 (30~45)
RSI_RECOVERY_RANGE_MAX=45.0 # RSI 회복 범위 최대값
RSI_STABLE_RANGE_MIN=45.0 # RSI 안정 범위 최소값 (45~60)
RSI_STABLE_RANGE_MAX=60.0 # RSI 안정 범위 최대값
# 볼린저 밴드
BOLLINGER_PERIOD=20 # 볼린저 밴드 기간 (20일)
BOLLINGER_STDDEV=2.0 # 표준편차 배수
# 이동평균 (Moving Average)
MA_SHORT_PERIOD=5 # 단기 MA (5일)
MA_MEDIUM_PERIOD=20 # 중기 MA (20일)
MA_LONG_PERIOD=60 # 장기 MA (60일)
# ATR (Average True Range)
ATR_PERIOD=14 # ATR 계산 기간 (14일)
# ═══════════════════════════════════════════════════════════════════════════════
# API 레이트 제한 및 안전 운영
# ═══════════════════════════════════════════════════════════════════════════════
API_MAX_RETRIES=3 # 최대 재시도 횟수
API_RETRY_DELAY=1.5 # 초기 재시도 대기 시간 (초)
API_RETRY_BACKOFF=2.0 # 지수 백오프 배율
API_MIN_REQUEST_INTERVAL=0.5 # 최소 요청 간격 (초)
API_MAX_CONCURRENT_REQUESTS=4 # 동시 최대 요청 수
API_TIMEOUT=30.0 # API 타임아웃 (초)
# API 모니터링
API_ERROR_THRESHOLD=0.3 # 오류율 임계값 (30%)
API_MONITORING_WINDOW=100 # 모니터링 윈도우 (최근 100개 요청)
# ═══════════════════════════════════════════════════════════════════════════════
# ML (머신러닝) 설정
# ═══════════════════════════════════════════════════════════════════════════════
ML_TF_ENABLED=true # ML 모델 기반 보조지표 사용
ML_ENSEMBLE_ENABLED=true # ML 앙상블 활성화
ML_ENSEMBLE_MODEL_DIR=models/ensemble
ML_ENSEMBLE_RULE_WEIGHT=0.3 # 룰베이스 스코어 비중 (30%)
ML_ENSEMBLE_ML_WEIGHT=0.7 # ML 스코어 비중 (70%)
ML_ENSEMBLE_BUY_THRESHOLD=60 # 매수 최소 앙상블 스코어 (0~100)
ML_ENSEMBLE_CONFIDENCE_MIN=0.5 # 최소 신뢰도
# ═══════════════════════════════════════════════════════════════════════════════
# 프론트엔드 (선택사항)
# ═══════════════════════════════════════════════════════════════════════════════
VITE_API_URL=http://localhost:8000 VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000/ws VITE_WS_URL=ws://localhost:8000/ws
# MCP Server # ═══════════════════════════════════════════════════════════════════════════════
MCP_SERVER_HOST=0.0.0.0 # 기타 설정
MCP_SERVER_PORT=8001 # ═══════════════════════════════════════════════════════════════════════════════
TOKEN_SAVE_MODE=true # JSON 압축, 차트 데이터 30일 제한
NEWS_API_KEY=your_news_api_key
# News API (선택)
NEWS_API_KEY=your_news_api_key_here
# 환경
ENVIRONMENT=development
DEBUG=true
# 로그 레벨
LOG_LEVEL=INFO

View File

@@ -206,6 +206,70 @@ class Settings(BaseSettings):
# Monitoring & Alerting # Monitoring & Alerting
API_ERROR_THRESHOLD: float = 0.3 # 오류율 임계값 (30%) API_ERROR_THRESHOLD: float = 0.3 # 오류율 임계값 (30%)
API_MONITORING_WINDOW: int = 100 # 모니터링 윈도우 (최근 N개 요청) API_MONITORING_WINDOW: int = 100 # 모니터링 윈도우 (최근 N개 요청)
# ═════════════════════════════════════════════════════════════════════════════
# 데이트레이딩 세부 파라미터 (day_trading_strategy.py)
# ═════════════════════════════════════════════════════════════════════════════
DAY_TRADE_MAX_CAPITAL_RATIO: float = 0.10 # 종목당 최대 10% 자본
DAY_TRADE_MAX_RATIO_DT: float = 0.25 # 전체 데이트레이딩 최대 25%
DAY_TRADE_RSI_BREAKEVEN_THRESHOLD: float = 40.0 # RSI_BB 진입 기준 (5분봉 RSI ≤ 40)
DAY_TRADE_ORB_STOP_LOSS_PCT: float = 0.02 # ORB 손절 -2%
DAY_TRADE_REVERSION_STOP_LOSS_PCT: float = 0.025 # VWAP/RSI_BB 손절 -2.5%
DAY_TRADE_TRAILING_TRIGGER: float = 0.015 # 트레일링 발동 +1.5%
DAY_TRADE_TRAILING_ATR_MULT: float = 1.0 # 트레일링 ATR 배수
DAY_TRADE_ORB_BREAKOUT_MARGIN: float = 0.002 # ORB 돌파 최소 여유 +0.2%
DAY_TRADE_COST_PCT: float = 0.005 # 수수료+슬리피지 합산 (매수+매도)
DAY_TRADE_ENTRY_CUTOFF_HOUR: int = 15 # 신규 진입 마감 시간 (15시)
DAY_TRADE_ENTRY_CUTOFF_MIN: int = 0 # 신규 진입 마감 분
DAY_TRADE_EOD_HOUR: int = 15 # EOD 강제 청산 시작 시간
DAY_TRADE_EOD_MIN: int = 20 # EOD 강제 청산 시작 분
# ═════════════════════════════════════════════════════════════════════════════
# 단기 전략 (short_term_strategy.py)
# ═════════════════════════════════════════════════════════════════════════════
SHORT_TERM_MIN_ATR_RATIO: float = 0.008 # ATR/close ≥ 0.8% : 거래비용 커버
SHORT_TERM_RSI_LOOKBACK: int = 3 # RSI 반등 확인용 과거 봉 수
SHORT_TERM_VOLUME_MULTIPLIER: float = 1.5 # 거래량 필터 배수 (1.5배 이상)
# ═════════════════════════════════════════════════════════════════════════════
# 평균회귀 전략 (mean_reversion_strategy.py)
# ═════════════════════════════════════════════════════════════════════════════
MEAN_REVERSION_RSI_THRESHOLD: float = 30.0 # RSI 과매도 기준 (≤30)
MEAN_REVERSION_MULTIPLIER_STRONG: float = 1.50 # 강한 신호 배수 (+50%)
MEAN_REVERSION_MULTIPLIER_NORMAL: float = 1.25 # 중간 신호 배수 (+25%)
MEAN_REVERSION_MULTIPLIER_WEAK: float = 1.00 # 약한 신호 배수 (기본값)
# ═════════════════════════════════════════════════════════════════════════════
# KIS WebSocket & 실시간 스트림 (kis_realtime_stream.py)
# ═════════════════════════════════════════════════════════════════════════════
KIS_SYNC_INTERVAL: int = 10 # 구독 목록 동기화 주기 (초)
KIS_RECONNECT_WAIT: int = 5 # 재연결 대기 시간 (초)
KIS_APPROVAL_KEY_TTL: int = 82800 # approval_key 캐시 유효시간 (초, 23시간)
# ═════════════════════════════════════════════════════════════════════════════
# 감시 목록 (watchlist.py)
# ═════════════════════════════════════════════════════════════════════════════
WATCHLIST_INIT_SIZE: int = 50 # 아침 배치 초기화 종목 수
WATCHLIST_MAX_SIZE: int = 100 # 최대 관리 종목 수
WATCHLIST_ENTRY_SCORE: int = 60 # 장중 신규 진입 기준 점수
WATCHLIST_EXIT_SCORE: int = 20 # 장중 제거 기준 점수
# ═════════════════════════════════════════════════════════════════════════════
# 기술적 지표 임계값 (technical_indicators.py)
# ═════════════════════════════════════════════════════════════════════════════
RSI_OVERSOLD_LEVEL: float = 30.0 # RSI 과매도 수준
RSI_OVERBOUGHT_LEVEL: float = 70.0 # RSI 과매수 수준
RSI_RECOVERY_RANGE_MIN: float = 30.0 # RSI 회복 범위 최소값
RSI_RECOVERY_RANGE_MAX: float = 45.0 # RSI 회복 범위 최대값
RSI_STABLE_RANGE_MIN: float = 45.0 # RSI 안정 범위 최소값
RSI_STABLE_RANGE_MAX: float = 60.0 # RSI 안정 범위 최대값
BOLLINGER_PERIOD: int = 20 # 볼린저 밴드 기간 (20일)
BOLLINGER_STDDEV: float = 2.0 # 볼린저 밴드 표준편차 배수
MA_SHORT_PERIOD: int = 5 # 단기 이동평균 기간 (5일)
MA_MEDIUM_PERIOD: int = 20 # 중기 이동평균 기간 (20일)
MA_LONG_PERIOD: int = 60 # 장기 이동평균 기간 (60일)
RSI_PERIOD: int = 14 # RSI 계산 기간
ATR_PERIOD: int = 14 # ATR 계산 기간
class Config: class Config:
env_file = str(BACKEND_ENV_FILE) if BACKEND_ENV_FILE.exists() else ".env" env_file = str(BACKEND_ENV_FILE) if BACKEND_ENV_FILE.exists() else ".env"

View File

@@ -59,13 +59,25 @@ def _normalize_recommendations(rec: dict) -> dict:
""" """
if not rec: if not rec:
return rec return rec
# 이미 recommendations_by_rule 키가 있으면 그대로 사용
if rec.get("recommendations_by_rule"): # Case 1: Already normalized (dict)
if isinstance(rec.get("recommendations_by_rule"), dict):
return rec return rec
# recommendations 리스트를 rule 기준으로 그룹화
raw_list = rec.get("recommendations", []) # Determine the source of the recommendation list
raw_list = []
if "recommendations" in rec and isinstance(rec["recommendations"], list):
raw_list = rec["recommendations"]
elif "recommendations_by_rule" in rec and isinstance(rec["recommendations_by_rule"], list):
# This is the case that causes the bug
raw_list = rec["recommendations_by_rule"]
if not raw_list: if not raw_list:
# if there is no list to process, make sure the output has a dict
rec["recommendations_by_rule"] = {}
return rec return rec
# Group the list by rule
by_rule: dict = {} by_rule: dict = {}
for item in raw_list: for item in raw_list:
rule = ( rule = (
@@ -75,6 +87,8 @@ def _normalize_recommendations(rec: dict) -> dict:
or "general" or "general"
) )
by_rule.setdefault(rule, []).append(item) by_rule.setdefault(rule, []).append(item)
# Return the original dict with the new/replaced 'recommendations_by_rule'
return {**rec, "recommendations_by_rule": by_rule} return {**rec, "recommendations_by_rule": by_rule}

View File

@@ -11,34 +11,43 @@
- 일일 손실 한도: 전체 자본 -3% 초과 시 신규 진입 중단 - 일일 손실 한도: 전체 자본 -3% 초과 시 신규 진입 중단
- 동시 최대 3종목, 종목당 최대 10% 자본 - 동시 최대 3종목, 종목당 최대 10% 자본
- 전체 데이트레이딩 자본: 총자산의 최대 25% (CLAUDE.md 8.2) - 전체 데이트레이딩 자본: 총자산의 최대 25% (CLAUDE.md 8.2)
- 수수료+슬리피지 포함 기준: 0.5% (매수 0.015% + 매도 0.3% + 슬리피지 0.2%)
[.env 설정]
DAY_TRADE_MAX_CAPITAL_RATIO
DAY_TRADE_MAX_RATIO_DT
DAY_TRADE_MAX_POSITIONS (== MAX_DAY_TRADE_POSITIONS)
DAY_TRADE_DAILY_LOSS_LIMIT
DAY_TRADE_ORB_STOP_LOSS_PCT
DAY_TRADE_REVERSION_STOP_LOSS_PCT
DAY_TRADE_TRAILING_TRIGGER
DAY_TRADE_TRAILING_ATR_MULT
DAY_TRADE_ORB_BREAKOUT_MARGIN
DAY_TRADE_COST_PCT
DAY_TRADE_ENTRY_CUTOFF_HOUR, DAY_TRADE_ENTRY_CUTOFF_MIN
DAY_TRADE_EOD_HOUR, DAY_TRADE_EOD_MIN
""" """
import logging import logging
from typing import Optional, Dict, List, Tuple from typing import Optional, Dict, List, Tuple
from dataclasses import dataclass from dataclasses import dataclass
from datetime import time from datetime import time
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 장 시간 상수 (KST) ────────────────────────────────────────────────────── # ── 장 시간 상수 (KST) ──────────────────────────────────────────────────────
MARKET_OPEN = time(9, 0) MARKET_OPEN = time(9, 0)
ORB_END = time(9, 30) # Opening Range 집계 종료 ORB_END = time(9, 30) # Opening Range 집계 종료
ENTRY_CUTOFF = time(15, 0) # 신규 진입 마감 (이후는 청산만)
EOD_FLAT = time(15, 20) # EOD 강제 청산 시작
MARKET_CLOSE = time(15, 30)
# ── 리스크 파라미터 ─────────────────────────────────────────────────────────── # 동적 시간 설정 (settings에서 읽음)
MAX_CAPITAL_RATIO = 0.10 # 종목당 최대 10% def _get_entry_cutoff() -> time:
MAX_DAY_TRADE_RATIO = 0.25 # 전체 데이트레이딩 최대 25% """신규 진입 마감 시간 (.env: DAY_TRADE_ENTRY_CUTOFF_HOUR, DAY_TRADE_ENTRY_CUTOFF_MIN)"""
MAX_POSITIONS = 3 # 동시 최대 3종목 return time(settings.DAY_TRADE_ENTRY_CUTOFF_HOUR, settings.DAY_TRADE_ENTRY_CUTOFF_MIN)
DAILY_LOSS_LIMIT = -0.03 # 일일 손실 한도 -3%
STOP_LOSS_PCT = 0.02 # ORB 손절 -2% def _get_eod_flat() -> time:
STOP_LOSS_PCT_REVERSION = 0.025 # VWAP/RSI_BB 평균회귀 전략 손절 -2.5% """EOD 강제 청산 시작 시간 (.env: DAY_TRADE_EOD_HOUR, DAY_TRADE_EOD_MIN)"""
# ↑ ORB(-2%)보다 약간 넓게 잡되, 일일 손실 한도(-3%) 이내에서 단일 포지션이 소화 가능하도록 설정 return time(settings.DAY_TRADE_EOD_HOUR, settings.DAY_TRADE_EOD_MIN)
TRAILING_TRIGGER = 0.015 # 트레일링 발동 +1.5%
TRAILING_ATR_MULT = 1.0 # 트레일링 ATR 배수 MARKET_CLOSE = time(15, 30)
ORB_BREAKOUT_MARGIN = 0.002 # ORB 돌파 최소 여유 +0.2%
COST_PCT = 0.005 # 수수료+슬리피지 합산 (매수+매도)
# ── 데이터 클래스 ───────────────────────────────────────────────────────────── # ── 데이터 클래스 ─────────────────────────────────────────────────────────────
@@ -226,7 +235,7 @@ def check_orb_signal(
if not orb or current_price <= 0: if not orb or current_price <= 0:
return None return None
if current_price < orb.high * (1 + ORB_BREAKOUT_MARGIN): if current_price < orb.high * (1 + settings.DAY_TRADE_ORB_BREAKOUT_MARGIN):
return None return None
# 최근 1분봉 거래량 체크 # 최근 1분봉 거래량 체크
@@ -243,7 +252,7 @@ def check_orb_signal(
if candles_5m[-2].close < candles_5m[-2].open: if candles_5m[-2].close < candles_5m[-2].open:
return None # 직전 5분봉 음봉 → 스킵 return None # 직전 5분봉 음봉 → 스킵
stop_loss = max(orb.low, current_price * (1 - STOP_LOSS_PCT)) stop_loss = max(orb.low, current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT))
risk = current_price - stop_loss risk = current_price - stop_loss
# 수수료 고려: 목표 = 진입가 + 2R + 수수료 보전 # 수수료 고려: 목표 = 진입가 + 2R + 수수료 보전
target = current_price + max(risk * 2.0, current_price * 0.03) target = current_price + max(risk * 2.0, current_price * 0.03)
@@ -302,8 +311,8 @@ def check_vwap_bounce_signal(
return None return None
atr = _atr(candles_5min, 14) atr = _atr(candles_5min, 14)
raw_stop = (current_price - 1.5 * atr) if atr else current_price * (1 - STOP_LOSS_PCT) raw_stop = (current_price - 1.5 * atr) if atr else current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT)
stop_loss = max(raw_stop, current_price * (1 - STOP_LOSS_PCT_REVERSION), 1.0) # 하드 스탑 -2.5% (일일 손실 한도 -3% 이내) stop_loss = max(raw_stop, current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT), 1.0) # 하드 스탑 -2.5% (일일 손실 한도 -3% 이내)
target = (current_price + 2.0 * atr) if atr else vwap * 1.03 target = (current_price + 2.0 * atr) if atr else vwap * 1.03
return DayTradeSignal( return DayTradeSignal(
@@ -360,8 +369,8 @@ def check_rsi_bb_signal(
return None return None
atr = _atr(candles_5min, 14) atr = _atr(candles_5min, 14)
raw_stop = (current_price - 1.5 * atr) if atr else current_price * (1 - STOP_LOSS_PCT) raw_stop = (current_price - 1.5 * atr) if atr else current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT)
stop_loss = max(raw_stop, current_price * (1 - STOP_LOSS_PCT_REVERSION), 1.0) # 하드 스탑 -2.5% (일일 손실 한도 -3% 이내) stop_loss = max(raw_stop, current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT), 1.0) # 하드 스탑 -2.5% (일일 손실 한도 -3% 이내)
# 최소 1.5R 보장: bb_middle이 너무 가까우면 R:R 확보를 위해 목표 상향 # 최소 1.5R 보장: bb_middle이 너무 가까우면 R:R 확보를 위해 목표 상향
risk = current_price - stop_loss risk = current_price - stop_loss
target = max(bb_middle, current_price + risk * 1.5) target = max(bb_middle, current_price + risk * 1.5)
@@ -403,10 +412,10 @@ def check_day_trade_exit(
5. 5분봉 RSI ≥ 70 (과매수 청산) 5. 5분봉 RSI ≥ 70 (과매수 청산)
""" """
# 1. EOD 강제 청산 # 1. EOD 강제 청산
if current_time >= EOD_FLAT: if current_time >= _get_eod_flat():
return DayTradeExit( return DayTradeExit(
action="SELL_ALL", action="SELL_ALL",
reason=f"EOD 강제 청산 ({current_time.strftime('%H:%M')} ≥ 15:20)", reason=f"EOD 강제 청산 ({current_time.strftime('%H:%M')} ≥ {_get_eod_flat().strftime('%H:%M')})",
) )
# 2. 손절 # 2. 손절
@@ -427,10 +436,10 @@ def check_day_trade_exit(
# 4. 트레일링 스탑 (1.5% 이상 수익 시 발동) # 4. 트레일링 스탑 (1.5% 이상 수익 시 발동)
profit_pct = (highest_since_entry - entry_price) / entry_price profit_pct = (highest_since_entry - entry_price) / entry_price
if profit_pct >= TRAILING_TRIGGER: if profit_pct >= settings.DAY_TRADE_TRAILING_TRIGGER:
atr = _atr(candles_5min, 14) atr = _atr(candles_5min, 14)
if atr: if atr:
trailing_stop = highest_since_entry - TRAILING_ATR_MULT * atr trailing_stop = highest_since_entry - settings.DAY_TRADE_TRAILING_ATR_MULT * atr
else: else:
trailing_stop = highest_since_entry * (1 - 0.02) # fallback: 최고가 -2% (5분봉 데이터 부족 시) trailing_stop = highest_since_entry * (1 - 0.02) # fallback: 최고가 -2% (5분봉 데이터 부족 시)
if current_price <= trailing_stop: if current_price <= trailing_stop:
@@ -446,7 +455,7 @@ def check_day_trade_exit(
rsi = _rsi(candles_5min, 14) rsi = _rsi(candles_5min, 14)
if rsi is not None and rsi >= 70: if rsi is not None and rsi >= 70:
gain_pct = (current_price - entry_price) / entry_price * 100 gain_pct = (current_price - entry_price) / entry_price * 100
if gain_pct > COST_PCT * 100: # 수수료 이상 수익인 경우만 if gain_pct > settings.DAY_TRADE_COST_PCT * 100: # 수수료 이상 수익인 경우만
return DayTradeExit( return DayTradeExit(
action="SELL_ALL", action="SELL_ALL",
reason=f"과매수 청산: RSI={rsi:.1f}≥70, 수익={gain_pct:.1f}%", reason=f"과매수 청산: RSI={rsi:.1f}≥70, 수익={gain_pct:.1f}%",

View File

@@ -46,9 +46,10 @@ TR_TICK = "H0STCNT0" # 주식 현재가 (체결 틱)
TR_QUOTE = "H0STASP0" # 주식 호가 TR_QUOTE = "H0STASP0" # 주식 호가
# ── 타이밍 상수 ────────────────────────────────────────────────────────────── # ── 타이밍 상수 ──────────────────────────────────────────────────────────────
_SYNC_INTERVAL = 10 # 구독 목록 동기화 주기 (초) # (.env 설정: KIS_SYNC_INTERVAL, KIS_RECONNECT_WAIT, KIS_APPROVAL_KEY_TTL)
_RECONNECT_WAIT = 5 # 재연결 대기 (초) _SYNC_INTERVAL = settings.KIS_SYNC_INTERVAL # 구독 목록 동기화 주기 (초)
_APPROVAL_KEY_TTL = 82800 # approval_key 캐시 유효시간 (23시간, 초) _RECONNECT_WAIT = settings.KIS_RECONNECT_WAIT # 재연결 대기 (초)
_APPROVAL_KEY_TTL = settings.KIS_APPROVAL_KEY_TTL # approval_key 캐시 유효시간 (초)
# ── 모듈 레벨 approval_key 캐시 ────────────────────────────────────────────── # ── 모듈 레벨 approval_key 캐시 ──────────────────────────────────────────────

View File

@@ -7,12 +7,20 @@ RSI + 볼린저 밴드 평균회귀 전략 (CLAUDE.md 섹션 7.1-7.5)
① R:R 개선: 손절 -8%→-6%, 1차 익절 RSI 45~50→55~65, 트레일링 스탑 도입 ① R:R 개선: 손절 -8%→-6%, 1차 익절 RSI 45~50→55~65, 트레일링 스탑 도입
② 진입 기회 확대: BULL RSI ≤25 → ≤30 (2배 증가) ② 진입 기회 확대: BULL RSI ≤25 → ≤30 (2배 증가)
③ 동적 포지션 사이징: RSI/ADX 기반 4~8% 다이나믉 비중 ③ 동적 포지션 사이징: RSI/ADX 기반 4~8% 다이나믉 비중
[.env 설정]
MEAN_REVERSION_RSI_THRESHOLD
MEAN_REVERSION_MULTIPLIER_STRONG
MEAN_REVERSION_MULTIPLIER_NORMAL
MEAN_REVERSION_MULTIPLIER_WEAK
""" """
import logging import logging
from typing import Optional, Dict, List, Tuple from typing import Optional, Dict, List, Tuple
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -208,7 +216,7 @@ def check_buy_signal(
return None return None
if regime == MarketRegime.BULL: if regime == MarketRegime.BULL:
rsi_ok = rsi <= 30 # ← 25 → 30으로 완화: 진입 기회 ~2배 증가 rsi_ok = rsi <= settings.MEAN_REVERSION_RSI_THRESHOLD # ← 30 (또는 .env 설정값)
vol_ok = current_volume >= avg_volume_20d * 2 # 거래량 20일평균 200% 이상 vol_ok = current_volume >= avg_volume_20d * 2 # 거래량 20일평균 200% 이상
if rsi_ok or (vol_ok and rsi <= 50): # vol_ok 단독 시 RSI 50 상한 (과열 진입 차단) if rsi_ok or (vol_ok and rsi <= 50): # vol_ok 단독 시 RSI 50 상한 (과열 진입 차단)
bb_low_minus = bb_lower * 0.97 # -3% bb_low_minus = bb_lower * 0.97 # -3%
@@ -357,13 +365,13 @@ def calculate_position_size(
# RSI 기반 동적 변수 스케일링 # RSI 기반 동적 변수 스케일링
if rsi is not None: if rsi is not None:
if rsi <= 20: if rsi <= 20:
multiplier = 1.50 # 군도 과매도 → 6% multiplier = settings.MEAN_REVERSION_MULTIPLIER_STRONG # 강한 과매도 신호
elif rsi <= 25: elif rsi <= 25:
multiplier = 1.25 # 강한 신호 → 5% multiplier = settings.MEAN_REVERSION_MULTIPLIER_NORMAL # 강한 신호
else: else:
multiplier = 1.00 # 표준 → 4% multiplier = settings.MEAN_REVERSION_MULTIPLIER_WEAK # 표준 신호
else: else:
multiplier = 1.00 multiplier = settings.MEAN_REVERSION_MULTIPLIER_WEAK
# ADX < 15: 특돉한 회황장 → +0.25× 비중 보너스 (최대 2.0× = 8%) # ADX < 15: 특돉한 회황장 → +0.25× 비중 보너스 (최대 2.0× = 8%)
if adx is not None and adx < 15: if adx is not None and adx < 15:

View File

@@ -4,16 +4,11 @@
전체 자본의 최대 25% 이내에서 운용. 전체 자본의 최대 25% 이내에서 운용.
일봉 RSI ≤ 40 관심 종목에 한해 15분봉 신호를 감지한다. 일봉 RSI ≤ 40 관심 종목에 한해 15분봉 신호를 감지한다.
[승률 개선 항목] [.env 설정]
1. 가격 하한 필터 → ATR 비율 필터 (ATR/close ≥ 0.8%) SHORT_TERM_MIN_ATR_RATIO (거래비용 커버용 ATR 비율 ≥ 0.8%)
— 거래비용을 커버할 수 있는 변동성 종목만 선별 SHORT_TERM_RSI_LOOKBACK (RSI 반등 확인 과거 봉 수)
2. 거래량 배수 1.3 → 1.5 (허위 신호 감소) SHORT_TERM_VOLUME_MULTIPLIER (거래량 배수)
3. RSI 방향 확인 — RSI가 바닥에서 반등 중인 경우만 진입 DAY_TRADE_ENABLED (단기 전략 활성화 여부)
4. 반전 캔들 확인 — 양봉 or 긴 하단 꼬리 (optional)
5. 손절 조건 추가 — entry - 1.0×ATR (CRITICAL: 기존 코드에 누락되어 있던 것)
6. 장 초반 30분 / 마감 15분 진입 금지 (노이즈 구간)
⚠️ 슬리피지 주의: 수수료 0.3% + 슬리피지 0.2% 포함 백테스트 필수
""" """
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -21,22 +16,17 @@ from datetime import time as dtime
from enum import Enum from enum import Enum
from typing import List, Optional from typing import List, Optional
from app.core.config import settings
from .mean_reversion_strategy import calculate_rsi, calculate_bollinger from .mean_reversion_strategy import calculate_rsi, calculate_bollinger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 자본 비율 상한
MAX_CAPITAL_RATIO = 0.25
# 비용 가정 — 백테스트 시 왕복으로 적용 # 비용 가정 — 백테스트 시 왕복으로 적용
COMMISSION_RATE = 0.003 COMMISSION_RATE = 0.003
SLIPPAGE_RATE = 0.002 SLIPPAGE_RATE = 0.002
TOTAL_COST_RATE = COMMISSION_RATE + SLIPPAGE_RATE TOTAL_COST_RATE = COMMISSION_RATE + SLIPPAGE_RATE
# 승률 개선 파라미터
MIN_ATR_RATIO = 0.008 # ATR/close ≥ 0.8% : 거래비용 커버 보장
RSI_DIRECTION_LOOKBACK = 3 # RSI 반등 확인용 과거 봉 수
# 장 운영 시간 필터 # 장 운영 시간 필터
_SKIP_OPEN_UNTIL = dtime(9, 30) # 장 초반 30분 제외 (개장 직후 변동성 과다) _SKIP_OPEN_UNTIL = dtime(9, 30) # 장 초반 30분 제외 (개장 직후 변동성 과다)
_SKIP_CLOSE_FROM = dtime(15, 15) # 마감 15분 전 제외 (종가 청산 압력) _SKIP_CLOSE_FROM = dtime(15, 15) # 마감 15분 전 제외 (종가 청산 압력)
@@ -89,11 +79,10 @@ class ShortTermStrategy:
DAILY_RSI_THRESHOLD = 40.0 DAILY_RSI_THRESHOLD = 40.0
M15_RSI_ENTRY = 30.0 M15_RSI_ENTRY = 30.0
M15_RSI_EXIT = 55.0 M15_RSI_EXIT = 55.0
VOLUME_MULTIPLIER = 1.5 # 1.3 → 1.5 강화
MIN_ATR_RATIO = MIN_ATR_RATIO
def __init__(self, max_capital_ratio: float = MAX_CAPITAL_RATIO): def __init__(self, max_capital_ratio: float = None):
self.max_capital_ratio = max_capital_ratio # .env에서 DAY_TRADE_CAPITAL_RATIO 읽기 (없으면 기본값 0.25)
self.max_capital_ratio = max_capital_ratio or settings.DAY_TRADE_CAPITAL_RATIO
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public API # Public API
@@ -137,7 +126,7 @@ class ShortTermStrategy:
if not (_SKIP_OPEN_UNTIL <= trade_time < _SKIP_CLOSE_FROM): if not (_SKIP_OPEN_UNTIL <= trade_time < _SKIP_CLOSE_FROM):
return None return None
min_len = 25 + RSI_DIRECTION_LOOKBACK min_len = 25 + settings.SHORT_TERM_RSI_LOOKBACK
if len(m15_closes) < min_len or len(m15_volumes) < 20: if len(m15_closes) < min_len or len(m15_volumes) < 20:
return None return None
@@ -171,14 +160,14 @@ class ShortTermStrategy:
# 조건 6: 거래량 급증 (1.5배 강화) # 조건 6: 거래량 급증 (1.5배 강화)
vol_ma20 = sum(m15_volumes[-20:]) / 20 vol_ma20 = sum(m15_volumes[-20:]) / 20
if vol_ma20 == 0 or current_volume <= vol_ma20 * self.VOLUME_MULTIPLIER: if vol_ma20 == 0 or current_volume <= vol_ma20 * settings.SHORT_TERM_VOLUME_MULTIPLIER:
return None return None
# 조건 7: ATR 비율 필터 — 5,000원 필터 대체 # 조건 7: ATR 비율 필터 — 5,000원 필터 대체
atr_ratio = 0.0 atr_ratio = 0.0
if atr_15m is not None: if atr_15m is not None:
atr_ratio = atr_15m / close if close > 0 else 0.0 atr_ratio = atr_15m / close if close > 0 else 0.0
if atr_ratio < self.MIN_ATR_RATIO: if atr_ratio < settings.SHORT_TERM_MIN_ATR_RATIO:
# 변동성 부족 → 1.5×ATR 익절 목표가 왕복 비용에 도달안함 # 변동성 부족 → 1.5×ATR 익절 목표가 왕복 비용에 도달안함
return None return None

View File

@@ -158,10 +158,11 @@ def analyze_macd(chart_data: list) -> dict:
def analyze_rsi(chart_data: list) -> dict: def analyze_rsi(chart_data: list) -> dict:
if len(chart_data) < 15: rsi_period = settings.RSI_PERIOD # 기본값 14
if len(chart_data) < rsi_period + 1:
return {"signal": None, "description": "데이터 부족"} return {"signal": None, "description": "데이터 부족"}
prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-15:]] prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-(rsi_period+1):]]
gains = [] gains = []
losses = [] losses = []
for i in range(1, len(prices)): for i in range(1, len(prices)):
@@ -173,8 +174,8 @@ def analyze_rsi(chart_data: list) -> dict:
gains.append(0) gains.append(0)
losses.append(abs(change)) losses.append(abs(change))
avg_gain = sum(gains[-14:]) / 14 avg_gain = sum(gains[-rsi_period:]) / rsi_period
avg_loss = sum(losses[-14:]) / 14 avg_loss = sum(losses[-rsi_period:]) / rsi_period
if avg_loss == 0: if avg_loss == 0:
rsi = 100 rsi = 100
@@ -182,8 +183,8 @@ def analyze_rsi(chart_data: list) -> dict:
rs = avg_gain / avg_loss rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs)) rsi = 100 - (100 / (1 + rs))
if len(chart_data) >= 16: if len(chart_data) >= rsi_period + 2:
prev_prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-16:-1]] prev_prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-(rsi_period+2):-(1)]]
prev_gains = [] prev_gains = []
prev_losses = [] prev_losses = []
for i in range(1, len(prev_prices)): for i in range(1, len(prev_prices)):
@@ -195,8 +196,8 @@ def analyze_rsi(chart_data: list) -> dict:
prev_gains.append(0) prev_gains.append(0)
prev_losses.append(abs(change)) prev_losses.append(abs(change))
prev_avg_gain = sum(prev_gains[-14:]) / 14 prev_avg_gain = sum(prev_gains[-rsi_period:]) / rsi_period
prev_avg_loss = sum(prev_losses[-14:]) / 14 prev_avg_loss = sum(prev_losses[-rsi_period:]) / rsi_period
if prev_avg_loss == 0: if prev_avg_loss == 0:
rsi_prev = 100 rsi_prev = 100
else: else:
@@ -218,16 +219,18 @@ def analyze_rsi(chart_data: list) -> dict:
def analyze_bollinger(chart_data: list) -> dict: def analyze_bollinger(chart_data: list) -> dict:
if len(chart_data) < 20: bb_period = settings.BOLLINGER_PERIOD # 기본값 20
bb_stddev = settings.BOLLINGER_STDDEV # 기본값 2.0
if len(chart_data) < bb_period:
return {"signal": None, "description": "데이터 부족"} return {"signal": None, "description": "데이터 부족"}
prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-20:]] prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-bb_period:]]
ma20 = sum(prices) / len(prices) ma_bb = sum(prices) / len(prices)
variance = sum((p - ma20) ** 2 for p in prices) / len(prices) variance = sum((p - ma_bb) ** 2 for p in prices) / len(prices)
std_dev = variance ** 0.5 std_dev = variance ** 0.5
upper_band = ma20 + (2 * std_dev) upper_band = ma_bb + (bb_stddev * std_dev)
lower_band = ma20 - (2 * std_dev) lower_band = ma_bb - (bb_stddev * std_dev)
current_price = prices[-1] current_price = prices[-1]
if current_price < lower_band: if current_price < lower_band:
@@ -239,13 +242,17 @@ def analyze_bollinger(chart_data: list) -> dict:
def analyze_moving_average(chart_data: list) -> dict: def analyze_moving_average(chart_data: list) -> dict:
if len(chart_data) < 60: ma_long = settings.MA_LONG_PERIOD # 기본값 60
ma_short = settings.MA_MEDIUM_PERIOD # 기본값 20
ma_quick = settings.MA_SHORT_PERIOD # 기본값 5
if len(chart_data) < ma_long:
return {"signal": None, "description": "데이터 부족"} return {"signal": None, "description": "데이터 부족"}
prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-60:]] prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-ma_long:]]
ma5 = sum(prices[-5:]) / 5 ma5 = sum(prices[-ma_quick:]) / ma_quick
ma20 = sum(prices[-20:]) / 20 ma20 = sum(prices[-ma_short:]) / ma_short
ma60 = sum(prices[-60:]) / 60 ma60 = sum(prices[-ma_long:]) / ma_long
current_price = prices[-1] current_price = prices[-1]
if ma5 > ma20 and current_price > ma5: if ma5 > ma20 and current_price > ma5:

View File

@@ -4,10 +4,11 @@
아침 배치(batch_daily_analysis)와 장중 배치(batch_intraday_analysis)가 공유하는 아침 배치(batch_daily_analysis)와 장중 배치(batch_intraday_analysis)가 공유하는
초기화 함수. 각 배치 스크립트가 자신의 AsyncSession을 전달해 사용한다. 초기화 함수. 각 배치 스크립트가 자신의 AsyncSession을 전달해 사용한다.
[승률 개선] [.env 설정]
- scanner_tickers가 제공되면 해당 종목을 워치리스트 앞쪽에 우선 배치 WATCHLIST_INIT_SIZE (아침 배치 초기화 종목 수)
- 나머지 슬롯은 score 상위 순으로 채움 WATCHLIST_MAX_SIZE (최대 관리 종목 수)
- added_reason: "scanner_signal" | "morning_batch" 로 구분되어 UI 에서도 식별 가능 WATCHLIST_ENTRY_SCORE (장중 신규 진입 기준 점수)
WATCHLIST_EXIT_SCORE (장중 제거 기준 점수)
""" """
from datetime import datetime, date from datetime import datetime, date
from typing import Optional from typing import Optional
@@ -17,17 +18,12 @@ import pytz
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.models import WatchlistStock, StockAnalysis from app.models.models import WatchlistStock, StockAnalysis
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 분석종목 관련 상수 # 스캐너 신호 종목에 배정하는 최대 슬롯 수 (settings.WATCHLIST_INIT_SIZE 중 이 수만큼 우선)
WATCHLIST_INIT_SIZE: int = 50 # 아침 배치 초기화 종목 수
WATCHLIST_MAX_SIZE: int = 100 # 최대 관리 종목 수
WATCHLIST_ENTRY_SCORE: int = 60 # 장중 신규 진입 기준 점수
WATCHLIST_EXIT_SCORE: int = 20 # 장중 제거 기준 점수
# 스캐너 신호 종목에 배정하는 최대 슬롯 수 (WATCHLIST_INIT_SIZE 중 이 수만큼 우선)
SCANNER_PRIORITY_SLOTS: int = 20 SCANNER_PRIORITY_SLOTS: int = 20
@@ -68,7 +64,7 @@ async def initialize_watchlist(
select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score) select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score)
.where(StockAnalysis.analysis_date == analysis_date) .where(StockAnalysis.analysis_date == analysis_date)
.order_by(StockAnalysis.score.desc()) .order_by(StockAnalysis.score.desc())
.limit(WATCHLIST_INIT_SIZE) .limit(settings.WATCHLIST_INIT_SIZE)
) )
top_stocks = result.all() top_stocks = result.all()
top_codes = {row.stock_code for row in top_stocks} top_codes = {row.stock_code for row in top_stocks}
@@ -119,7 +115,7 @@ async def initialize_watchlist(
scanner_added += 1 scanner_added += 1
# --- 3순위: 나머지 슬롯 — score 상위 순 --- # --- 3순위: 나머지 슬롯 — score 상위 순 ---
remaining = WATCHLIST_INIT_SIZE - added remaining = settings.WATCHLIST_INIT_SIZE - added
for row in top_stocks: for row in top_stocks:
if remaining <= 0: if remaining <= 0:
break break

View File

@@ -13,8 +13,11 @@ class GeminiClient:
"""Gemini AI 클라이언트 (ClaudeClient와 동일한 인터페이스)""" """Gemini AI 클라이언트 (ClaudeClient와 동일한 인터페이스)"""
def __init__(self): def __init__(self):
self.client = genai.Client(api_key=settings.GEMINI_API_KEY) if not settings.GEMINI_API_KEY:
self.model_name = settings.GEMINI_MODEL raise ValueError("GEMINI_API_KEY is not set")
genai.configure(api_key=settings.GEMINI_API_KEY)
self.model = genai.GenerativeModel(settings.GEMINI_MODEL)
self.generate_config = types.GenerateContentConfig( self.generate_config = types.GenerateContentConfig(
max_output_tokens=settings.MAX_TOKENS, max_output_tokens=settings.MAX_TOKENS,
temperature=settings.TEMPERATURE, temperature=settings.TEMPERATURE,
@@ -28,10 +31,13 @@ class GeminiClient:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Gemini를 통한 분석 실행""" """Gemini를 통한 분석 실행"""
try: try:
response = await self.client.aio.models.generate_content( model_with_system_prompt = genai.GenerativeModel(
model=self.model_name, model_name=self.model.model_name,
contents=f"{system_prompt}\n\n{user_message}", system_instruction=system_prompt
config=self.generate_config, )
response = await model_with_system_prompt.generate_content_async(
user_message,
generation_config=self.generate_config,
) )
text = response.text if response.text else "" text = response.text if response.text else ""