레디스 및 구조 수정중

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

@@ -206,6 +206,70 @@ class Settings(BaseSettings):
# Monitoring & Alerting
API_ERROR_THRESHOLD: float = 0.3 # 오류율 임계값 (30%)
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:
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:
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
# 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 there is no list to process, make sure the output has a dict
rec["recommendations_by_rule"] = {}
return rec
# Group the list by rule
by_rule: dict = {}
for item in raw_list:
rule = (
@@ -75,6 +87,8 @@ def _normalize_recommendations(rec: dict) -> dict:
or "general"
)
by_rule.setdefault(rule, []).append(item)
# Return the original dict with the new/replaced 'recommendations_by_rule'
return {**rec, "recommendations_by_rule": by_rule}

View File

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

View File

@@ -46,9 +46,10 @@ TR_TICK = "H0STCNT0" # 주식 현재가 (체결 틱)
TR_QUOTE = "H0STASP0" # 주식 호가
# ── 타이밍 상수 ──────────────────────────────────────────────────────────────
_SYNC_INTERVAL = 10 # 구독 목록 동기화 주기 (초)
_RECONNECT_WAIT = 5 # 재연결 대기 (초)
_APPROVAL_KEY_TTL = 82800 # approval_key 캐시 유효시간 (23시간, 초)
# (.env 설정: KIS_SYNC_INTERVAL, KIS_RECONNECT_WAIT, KIS_APPROVAL_KEY_TTL)
_SYNC_INTERVAL = settings.KIS_SYNC_INTERVAL # 구독 목록 동기화 주기 (초)
_RECONNECT_WAIT = settings.KIS_RECONNECT_WAIT # 재연결 대기 (초)
_APPROVAL_KEY_TTL = settings.KIS_APPROVAL_KEY_TTL # 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, 트레일링 스탑 도입
② 진입 기회 확대: BULL RSI ≤25 → ≤30 (2배 증가)
③ 동적 포지션 사이징: RSI/ADX 기반 4~8% 다이나믉 비중
[.env 설정]
MEAN_REVERSION_RSI_THRESHOLD
MEAN_REVERSION_MULTIPLIER_STRONG
MEAN_REVERSION_MULTIPLIER_NORMAL
MEAN_REVERSION_MULTIPLIER_WEAK
"""
import logging
from typing import Optional, Dict, List, Tuple
from dataclasses import dataclass
from enum import Enum
from app.core.config import settings
logger = logging.getLogger(__name__)
@@ -208,7 +216,7 @@ def check_buy_signal(
return None
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% 이상
if rsi_ok or (vol_ok and rsi <= 50): # vol_ok 단독 시 RSI 50 상한 (과열 진입 차단)
bb_low_minus = bb_lower * 0.97 # -3%
@@ -357,13 +365,13 @@ def calculate_position_size(
# RSI 기반 동적 변수 스케일링
if rsi is not None:
if rsi <= 20:
multiplier = 1.50 # 군도 과매도 → 6%
multiplier = settings.MEAN_REVERSION_MULTIPLIER_STRONG # 강한 과매도 신호
elif rsi <= 25:
multiplier = 1.25 # 강한 신호 → 5%
multiplier = settings.MEAN_REVERSION_MULTIPLIER_NORMAL # 강한 신호
else:
multiplier = 1.00 # 표준 → 4%
multiplier = settings.MEAN_REVERSION_MULTIPLIER_WEAK # 표준 신호
else:
multiplier = 1.00
multiplier = settings.MEAN_REVERSION_MULTIPLIER_WEAK
# ADX < 15: 특돉한 회황장 → +0.25× 비중 보너스 (최대 2.0× = 8%)
if adx is not None and adx < 15:

View File

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

View File

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

View File

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