fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
공통 규칙:
|
||||
- EOD 강제 청산: 15:20 이후 전량 시장가 매도
|
||||
- 일일 손실 한도: 전체 자본 -3% 초과 시 신규 진입 중단
|
||||
- 동시 최대 3종목, 종목당 최대 10% 자본
|
||||
- 동시 최대 3종목, 종목당 최대 4% 자본 (CLAUDE.md 5.1)
|
||||
- 전체 데이트레이딩 자본: 총자산의 최대 25% (CLAUDE.md 8.2)
|
||||
|
||||
[.env 설정]
|
||||
@@ -31,6 +31,7 @@ from typing import Optional, Dict, List, Tuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import time
|
||||
from app.core.config import settings
|
||||
from app.services.mean_reversion_strategy import calculate_rsi, calculate_atr, calculate_bollinger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,14 +51,33 @@ def _get_eod_flat() -> time:
|
||||
MARKET_CLOSE = time(15, 30)
|
||||
|
||||
|
||||
# ── 데이터 클래스 ─────────────────────────────────────────────────────────────
|
||||
# ── 동적 설정 접근자 (이슈6 수정) ──────────────────────────────────────────────
|
||||
# [이슈6 수정] 모듈 로드 시점에 settings 값을 상수로 고정하면, 런타임에 .env가
|
||||
# 변경되거나 테스트에서 settings를 오버라이드해도 반영되지 않음.
|
||||
# 접근자 함수로 래핑하여 호출 시점에 settings를 읽도록 변경.
|
||||
# 기존 모듈 레벨 이름(ENTRY_CUTOFF, EOD_FLAT 등)은 하위 호환을 위해 유지하되
|
||||
# 실제 사용부(calculate_day_trade_position_size, is_daily_loss_exceeded)는
|
||||
# 아래 _get_* 함수를 통해 동적으로 읽음.
|
||||
|
||||
def _get_max_positions() -> int:
|
||||
return settings.DAY_TRADE_MAX_POSITIONS
|
||||
|
||||
def _get_max_day_trade_ratio() -> float:
|
||||
return settings.DAY_TRADE_MAX_CAPITAL_RATIO
|
||||
|
||||
def _get_max_capital_ratio() -> float:
|
||||
return settings.DAY_TRADE_MAX_RATIO_DT
|
||||
|
||||
def _get_daily_loss_limit() -> float:
|
||||
return settings.DAY_TRADE_DAILY_LOSS_LIMIT
|
||||
|
||||
# 모듈 레벨 이름 — 하위 호환 유지 (import하는 코드가 있을 경우 그대로 동작)
|
||||
ENTRY_CUTOFF = _get_entry_cutoff()
|
||||
EOD_FLAT = _get_eod_flat()
|
||||
MAX_POSITIONS = settings.DAY_TRADE_MAX_POSITIONS
|
||||
MAX_DAY_TRADE_RATIO = settings.DAY_TRADE_MAX_CAPITAL_RATIO
|
||||
MAX_CAPITAL_RATIO = settings.DAY_TRADE_MAX_RATIO_DT
|
||||
DAILY_LOSS_LIMIT = -settings.DAY_TRADE_DAILY_LOSS_LIMIT / 100
|
||||
MARKET_CLOSE = time(15, 30)
|
||||
DAILY_LOSS_LIMIT = settings.DAY_TRADE_DAILY_LOSS_LIMIT # 이미 비율 (-0.03 = -3%)
|
||||
|
||||
@dataclass
|
||||
class MinuteCandle:
|
||||
@@ -92,6 +112,47 @@ class DayTradeSignal:
|
||||
reason: str
|
||||
confidence: float # 0.0 ~ 1.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate DayTradeSignal field constraints.
|
||||
|
||||
Issues warnings for validation failures but does not raise exceptions
|
||||
to avoid breaking existing code.
|
||||
"""
|
||||
# Validate confidence: 0.0 to 1.0
|
||||
if not (0.0 <= self.confidence <= 1.0):
|
||||
logger.warning(
|
||||
f"[DayTradeSignal] {self.stock_code}: confidence={self.confidence} not in [0.0, 1.0]"
|
||||
)
|
||||
|
||||
# Validate entry_price > 0
|
||||
if self.entry_price <= 0:
|
||||
logger.warning(
|
||||
f"[DayTradeSignal] {self.stock_code}: entry_price={self.entry_price} must be > 0"
|
||||
)
|
||||
|
||||
# Validate stop_loss > 0 and < entry_price
|
||||
if self.stop_loss <= 0:
|
||||
logger.warning(
|
||||
f"[DayTradeSignal] {self.stock_code}: stop_loss={self.stop_loss} must be > 0"
|
||||
)
|
||||
elif self.stop_loss >= self.entry_price:
|
||||
logger.warning(
|
||||
f"[DayTradeSignal] {self.stock_code}: stop_loss={self.stop_loss} must be < entry_price={self.entry_price}"
|
||||
)
|
||||
|
||||
# Validate target_price > entry_price
|
||||
if self.target_price <= self.entry_price:
|
||||
logger.warning(
|
||||
f"[DayTradeSignal] {self.stock_code}: target_price={self.target_price} must be > entry_price={self.entry_price}"
|
||||
)
|
||||
|
||||
# Validate strategy is one of allowed values
|
||||
allowed_strategies = {"ORB", "VWAP_BOUNCE", "RSI_BB"}
|
||||
if self.strategy not in allowed_strategies:
|
||||
logger.warning(
|
||||
f"[DayTradeSignal] {self.stock_code}: strategy='{self.strategy}' not in {allowed_strategies}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DayTradeExit:
|
||||
@@ -162,13 +223,28 @@ def aggregate_to_5min(candles: List[MinuteCandle]) -> List[MinuteCandle]:
|
||||
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def calculate_vwap(candles: List[MinuteCandle]) -> Optional[float]:
|
||||
"""VWAP = 누적거래대금 / 누적거래량 (당일 09:00 기준 누적)"""
|
||||
"""VWAP = 누적거래대금 / 누적거래량 (당일 09:00 기준 누적)
|
||||
|
||||
cum_volume이 0인 경우 (KIS API 분봉에 acml_vol 미제공 시)
|
||||
개별 캔들의 typical_price * volume 합산으로 계산.
|
||||
"""
|
||||
if not candles:
|
||||
return None
|
||||
# 방법 1: 누적 필드 사용
|
||||
last = candles[-1]
|
||||
if last.cum_volume <= 0:
|
||||
if last.cum_volume > 0:
|
||||
return last.cum_amount / last.cum_volume
|
||||
# 방법 2: 개별 캔들에서 합산 (acml_vol 미제공 시 폴백)
|
||||
total_pv = 0.0
|
||||
total_vol = 0
|
||||
for c in candles:
|
||||
if c.volume > 0:
|
||||
typical = (c.high + c.low + c.close) / 3.0
|
||||
total_pv += typical * c.volume
|
||||
total_vol += c.volume
|
||||
if total_vol <= 0:
|
||||
return None
|
||||
return last.cum_amount / last.cum_volume
|
||||
return total_pv / total_vol
|
||||
|
||||
|
||||
def calculate_opening_range(candles_1min: List[MinuteCandle]) -> Optional[OpeningRange]:
|
||||
@@ -186,41 +262,25 @@ def calculate_opening_range(candles_1min: List[MinuteCandle]) -> Optional[Openin
|
||||
|
||||
|
||||
def _rsi(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
|
||||
"""Wrapper: extract closes from MinuteCandle list and delegate to canonical calculate_rsi"""
|
||||
closes = [c.close for c in candles]
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
gains, losses = [], []
|
||||
for i in range(-period, 0):
|
||||
d = closes[i] - closes[i - 1]
|
||||
gains.append(max(d, 0))
|
||||
losses.append(max(-d, 0))
|
||||
avg_gain = sum(gains) / period
|
||||
avg_loss = sum(losses) / period
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
return 100 - (100 / (1 + avg_gain / avg_loss))
|
||||
return calculate_rsi(closes, period)
|
||||
|
||||
|
||||
def _bollinger(candles: List[MinuteCandle], period: int = 20) -> Optional[Tuple[float, float, float]]:
|
||||
"""Wrapper: extract closes from MinuteCandle list and delegate to canonical calculate_bollinger"""
|
||||
closes = [c.close for c in candles]
|
||||
if len(closes) < period:
|
||||
return None
|
||||
window = closes[-period:]
|
||||
mid = sum(window) / period
|
||||
std = (sum((p - mid) ** 2 for p in window) / period) ** 0.5
|
||||
return (mid + 2 * std, mid, mid - 2 * std)
|
||||
return calculate_bollinger(closes, period)
|
||||
|
||||
|
||||
def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
|
||||
"""Wrapper: extract highs/lows/closes from MinuteCandle list and delegate to canonical calculate_atr"""
|
||||
if len(candles) < period + 1:
|
||||
return None
|
||||
trs = []
|
||||
for i in range(1, len(candles)):
|
||||
h, l, cp = candles[i].high, candles[i].low, candles[i - 1].close
|
||||
trs.append(max(h - l, abs(h - cp), abs(l - cp)))
|
||||
if len(trs) < period:
|
||||
return None
|
||||
return sum(trs[-period:]) / period
|
||||
highs = [c.high for c in candles]
|
||||
lows = [c.low for c in candles]
|
||||
closes = [c.close for c in candles]
|
||||
return calculate_atr(highs, lows, closes, period)
|
||||
|
||||
|
||||
# ── 진입 신호 ─────────────────────────────────────────────────────────────────
|
||||
@@ -249,7 +309,7 @@ def check_orb_signal(
|
||||
if not candles_1min:
|
||||
return None
|
||||
recent_vol = candles_1min[-1].volume
|
||||
if recent_vol < orb.avg_vol_per_min * 1.5:
|
||||
if recent_vol < orb.avg_vol_per_min * settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT:
|
||||
return None
|
||||
|
||||
# 직전 5분봉 양봉 확인 (5분봉 2개 이상 없으면 보수적으로 차단)
|
||||
@@ -259,10 +319,21 @@ def check_orb_signal(
|
||||
if candles_5m[-2].close < candles_5m[-2].open:
|
||||
return None # 직전 5분봉 음봉 → 스킵
|
||||
|
||||
stop_loss = max(orb.low, current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT))
|
||||
# [P1] ORB 손절: ATR 하한 + 하드 스탑 상한 클램프
|
||||
atr_orb = _atr(aggregate_to_5min(candles_1min), 14)
|
||||
hard_stop = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT) # 상한 클램프
|
||||
orb_stop = max(orb.low, current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT))
|
||||
# ATR 기반 하한: 최소 ATR 손절폭 확보 (수수료 커버)
|
||||
if atr_orb:
|
||||
atr_floor = current_price - settings.DAY_TRADE_ORB_ATR_FLOOR_MULT * atr_orb
|
||||
orb_stop = max(orb_stop, atr_floor)
|
||||
# 상한 클램프: 일일 손실 한도 대비 종목당 최대 한도
|
||||
stop_loss = max(orb_stop, hard_stop)
|
||||
risk = current_price - stop_loss
|
||||
if risk <= 0:
|
||||
return None # 손절폭이 0 이하면 진입 무의미
|
||||
# 수수료 고려: 목표 = 진입가 + 2R + 수수료 보전
|
||||
target = current_price + max(risk * 2.0, current_price * 0.03)
|
||||
target = current_price + max(risk * settings.DAY_TRADE_TARGET_MULTIPLIER, current_price * settings.DAY_TRADE_ORB_MARGIN_CHECK)
|
||||
|
||||
vol_ratio = recent_vol / orb.avg_vol_per_min
|
||||
confidence = min(0.85, 0.65 + vol_ratio * 0.05)
|
||||
@@ -293,23 +364,23 @@ def check_vwap_bounce_signal(
|
||||
|
||||
조건:
|
||||
1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처)
|
||||
2. 5분봉 RSI(14) ≤ 40
|
||||
3. 최근 봉 거래량 ≥ 직전 봉 × 1.3
|
||||
2. 5분봉 RSI(14) ≤ threshold
|
||||
3. 최근 봉 거래량 ≥ 직전 봉 × vol_mult
|
||||
4. 최근 5분봉 양봉 전환 (close > open)
|
||||
"""
|
||||
if not candles_5min or vwap <= 0 or current_price <= 0:
|
||||
return None
|
||||
|
||||
if not (vwap * 0.99 <= current_price <= vwap * 1.005):
|
||||
if not (vwap * settings.DAY_TRADE_VWAP_PRICE_LOWER <= current_price <= vwap * settings.DAY_TRADE_VWAP_PRICE_UPPER):
|
||||
return None
|
||||
|
||||
rsi = _rsi(candles_5min, 14)
|
||||
if rsi is None or rsi > 40:
|
||||
if rsi is None or rsi > settings.DAY_TRADE_VWAP_RSI_THRESHOLD:
|
||||
return None
|
||||
|
||||
if len(candles_5min) < 2:
|
||||
return None
|
||||
if candles_5min[-1].volume < candles_5min[-2].volume * 1.3:
|
||||
if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER:
|
||||
return None
|
||||
|
||||
# 최근 봉 양봉 확인
|
||||
@@ -318,9 +389,10 @@ 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 - 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
|
||||
raw_stop = (current_price - settings.DAY_TRADE_VWAP_ATR_MULT * atr) if atr else current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT)
|
||||
hard_stop_vwap = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
|
||||
stop_loss = max(raw_stop, hard_stop_vwap, 1.0) # 타이트한 쪽 = 진입가에 가까운 = 높은 가격, 최소 1원 보장
|
||||
target = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03
|
||||
|
||||
return DayTradeSignal(
|
||||
stock_code=stock_code,
|
||||
@@ -346,16 +418,16 @@ def check_rsi_bb_signal(
|
||||
"""5분봉 RSI + 볼린저 과매도 반등 신호
|
||||
|
||||
조건:
|
||||
1. RSI(14) ≤ 30 (강한 과매도)
|
||||
2. 종가 ≤ BB 하단
|
||||
3. 직전 봉 대비 거래량 ≥ 1.3배
|
||||
1. RSI(14) ≤ threshold (강한 과매도)
|
||||
2. 종가 ≤ BB 하단 + buffer
|
||||
3. 직전 봉 대비 거래량 ≥ vol_mult배
|
||||
4. 최근 봉 양봉 (음봉이면 스킵)
|
||||
"""
|
||||
if not candles_5min or current_price <= 0:
|
||||
return None
|
||||
|
||||
rsi = _rsi(candles_5min, 14)
|
||||
if rsi is None or rsi > 30:
|
||||
if rsi is None or rsi > settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:
|
||||
return None
|
||||
|
||||
bb = _bollinger(candles_5min, 20)
|
||||
@@ -363,12 +435,12 @@ def check_rsi_bb_signal(
|
||||
return None
|
||||
bb_upper, bb_middle, bb_lower = bb
|
||||
|
||||
if current_price > bb_lower * 1.01:
|
||||
if current_price > bb_lower * settings.DAY_TRADE_RSI_BB_BB_LOWER_BUFFER:
|
||||
return None
|
||||
|
||||
if len(candles_5min) < 2:
|
||||
return None
|
||||
if candles_5min[-1].volume < candles_5min[-2].volume * 1.3:
|
||||
if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER:
|
||||
return None
|
||||
|
||||
last = candles_5min[-1]
|
||||
@@ -376,11 +448,12 @@ 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 - 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% 이내)
|
||||
raw_stop = (current_price - settings.DAY_TRADE_RSI_BB_ATR_MULT * atr) if atr else current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT)
|
||||
hard_stop_rsi = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
|
||||
stop_loss = max(raw_stop, hard_stop_rsi, 1.0) # 타이트한 쪽 = 진입가에 가까운 = 높은 가격, 최소 1원 보장
|
||||
# 최소 1.5R 보장: bb_middle이 너무 가까우면 R:R 확보를 위해 목표 상향
|
||||
risk = current_price - stop_loss
|
||||
target = max(bb_middle, current_price + risk * 1.5)
|
||||
target = max(bb_middle, current_price + risk * 1.5) # 1.5R 보장
|
||||
if target <= current_price: # 방어 로직 (stop_loss ≥ current_price 극단 케이스)
|
||||
return None
|
||||
|
||||
@@ -409,15 +482,20 @@ def check_day_trade_exit(
|
||||
highest_since_entry: float,
|
||||
candles_5min: List[MinuteCandle],
|
||||
current_time: time,
|
||||
entry_time: Optional["datetime"] = None,
|
||||
) -> Optional[DayTradeExit]:
|
||||
"""데이트레이딩 청산 조건 체크 (우선순위 순)
|
||||
|
||||
1. EOD 강제 청산 (15:20 이후)
|
||||
2. 손절 (현재가 ≤ stop_loss)
|
||||
3. 목표가 달성 (현재가 ≥ target_price)
|
||||
4. 트레일링 스탑 (1.5% 수익 후 ATR 트레일)
|
||||
5. 5분봉 RSI ≥ 70 (과매수 청산)
|
||||
3. 시간 손절 — 진입 후 30분간 +0.3% 미달 시 청산
|
||||
4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2)
|
||||
5. 트레일링 스탑 (1.5% 수익 후 ATR 트레일)
|
||||
6. 5분봉 RSI ≥ 70 (과매수 청산)
|
||||
"""
|
||||
from datetime import datetime as _dt
|
||||
import pytz
|
||||
|
||||
# 1. EOD 강제 청산
|
||||
if current_time >= _get_eod_flat():
|
||||
return DayTradeExit(
|
||||
@@ -433,36 +511,64 @@ def check_day_trade_exit(
|
||||
reason=f"손절 {loss_pct:.1f}% (현재={current_price:.0f} ≤ 손절가={stop_loss:.0f})",
|
||||
)
|
||||
|
||||
# 3. 목표가 달성
|
||||
# 3. [P2] 시간 손절 — 진입 후 설정 시간 경과 시 목표 방향 진행 미미하면 청산
|
||||
if entry_time is not None:
|
||||
try:
|
||||
now_kst = _dt.now(pytz.timezone("Asia/Seoul")).replace(tzinfo=None)
|
||||
elapsed_min = (now_kst - entry_time).total_seconds() / 60
|
||||
if elapsed_min >= settings.DAY_TRADE_TIME_STOP_MINUTES:
|
||||
gain_pct = (current_price - entry_price) / entry_price
|
||||
if gain_pct < settings.DAY_TRADE_TIME_STOP_MIN_GAIN: # 최소 수익 미달 (수수료 커버 못함)
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=f"시간 손절: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도)
|
||||
if current_price >= target_price:
|
||||
gain_pct = (current_price - entry_price) / entry_price * 100
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=f"목표가 달성 +{gain_pct:.1f}% (현재={current_price:.0f} ≥ 목표={target_price:.0f})",
|
||||
action="SELL_PARTIAL",
|
||||
reason=f"목표가 달성 +{gain_pct:.1f}% → 50% 분할 매도",
|
||||
sell_pct=0.50,
|
||||
)
|
||||
|
||||
# 4. 트레일링 스탑 (1.5% 이상 수익 시 발동)
|
||||
profit_pct = (highest_since_entry - entry_price) / entry_price
|
||||
if profit_pct >= settings.DAY_TRADE_TRAILING_TRIGGER:
|
||||
atr = _atr(candles_5min, 14)
|
||||
if 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:
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=(
|
||||
f"트레일링 스탑: 최고={highest_since_entry:.0f}, "
|
||||
f"트레일={trailing_stop:.0f}, 현재={current_price:.0f}"
|
||||
),
|
||||
)
|
||||
# 5. 트레일링 스탑 (최소 보유 시간 경과 + 최소 수익률 이상 시 발동)
|
||||
# [whipsaw 방지] 진입 직후 순간 상승→되돌림에 트레일링이 걸리는 것을 방지
|
||||
_trailing_allowed = True
|
||||
if entry_time is not None:
|
||||
try:
|
||||
now_kst = _dt.now(pytz.timezone("Asia/Seoul")).replace(tzinfo=None)
|
||||
elapsed_sec = (now_kst - entry_time).total_seconds()
|
||||
if elapsed_sec < settings.DAY_TRADE_TRAILING_MIN_HOLD_SEC:
|
||||
_trailing_allowed = False # 최소 보유 시간(5분) 미경과 → 하드스탑만 적용
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. 과매수 청산 (RSI ≥ 70)
|
||||
if _trailing_allowed:
|
||||
profit_pct = (highest_since_entry - entry_price) / entry_price
|
||||
if profit_pct >= settings.DAY_TRADE_TRAILING_TRIGGER:
|
||||
atr_val = _atr(candles_5min, 14)
|
||||
if atr_val:
|
||||
trailing_stop = highest_since_entry - settings.DAY_TRADE_TRAILING_ATR_MULT * atr_val
|
||||
else:
|
||||
trailing_stop = highest_since_entry * (1 - settings.DAY_TRADE_TRAILING_DEFAULT)
|
||||
if current_price <= trailing_stop:
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=(
|
||||
f"트레일링 스탑: 최고={highest_since_entry:.0f}, "
|
||||
f"트레일={trailing_stop:.0f}, 현재={current_price:.0f}"
|
||||
),
|
||||
)
|
||||
|
||||
# 6. 과매수 청산 (RSI ≥ 70)
|
||||
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 > settings.DAY_TRADE_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}%",
|
||||
@@ -480,14 +586,16 @@ def calculate_day_trade_position_size(
|
||||
) -> float:
|
||||
"""데이트레이딩 포지션 크기 계산
|
||||
|
||||
- 종목당 최대: 총자산 × 10%
|
||||
[2026-03-15 CLAUDE.md 정합성 수정]
|
||||
- 종목당 최대: 총자산 × 4% (CLAUDE.md 5.1)
|
||||
- 전체 데이트레이딩 합산: 총자산 × 25% (CLAUDE.md 8.2)
|
||||
- 동시 최대 3종목 (MAX_POSITIONS)
|
||||
"""
|
||||
max_total = total_assets * MAX_DAY_TRADE_RATIO
|
||||
remaining_budget = max_total - current_day_trade_amount
|
||||
per_position_max = total_assets * MAX_CAPITAL_RATIO
|
||||
return max(0.0, min(per_position_max, remaining_budget, available_cash))
|
||||
# [이슈6 수정] 모듈 레벨 상수 대신 동적 접근자 사용
|
||||
total_dt_budget = total_assets * _get_max_capital_ratio() # 전체 DT 한도 (25%)
|
||||
remaining_dt = total_dt_budget - current_day_trade_amount # 남은 DT 한도
|
||||
per_position_budget = total_assets * _get_max_day_trade_ratio() # 종목당 한도 (4%, CLAUDE.md 5.1)
|
||||
return max(0.0, min(per_position_budget, remaining_dt, available_cash))
|
||||
|
||||
|
||||
def is_daily_loss_exceeded(
|
||||
@@ -499,14 +607,5 @@ def is_daily_loss_exceeded(
|
||||
# realized_pnl_today가 0이면 손실 없음 → 한도 미초과
|
||||
if realized_pnl_today >= 0:
|
||||
return False
|
||||
return (realized_pnl_today / total_assets) <= DAILY_LOSS_LIMIT
|
||||
|
||||
|
||||
# 동적 시간 설정 (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)
|
||||
# [이슈6 수정] 동적 접근자로 런타임 설정값 반영
|
||||
return (realized_pnl_today / total_assets) <= _get_daily_loss_limit()
|
||||
Reference in New Issue
Block a user