레디스 및 구조 수정중
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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 캐시 ──────────────────────────────────────────────
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user