- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
908 lines
41 KiB
Python
908 lines
41 KiB
Python
"""
|
||
데이트레이딩 전략 (CLAUDE.md 섹션 8.2 확장)
|
||
|
||
전략 3종:
|
||
1. ORB — Opening Range Breakout (09:00~09:30 고점 돌파)
|
||
2. VWAP — VWAP 지지 + 과매도 반등
|
||
3. RSI_BB — 5분봉 RSI+볼린저 과매도 반등
|
||
|
||
공통 규칙:
|
||
- EOD 강제 청산: 15:20 이후 전량 시장가 매도
|
||
- 일일 손실 한도: 전체 자본 -3% 초과 시 신규 진입 중단
|
||
- 동시 최대 3종목, 종목당 최대 4% 자본 (CLAUDE.md 5.1)
|
||
- 전체 데이트레이딩 자본: 총자산의 최대 25% (CLAUDE.md 8.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
|
||
from app.services.mean_reversion_strategy import calculate_rsi, calculate_atr, calculate_bollinger
|
||
# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈)
|
||
from app.services.sizing_core import budget_by_ratios
|
||
|
||
from app.core.log_throttle import warn_throttled # noqa: E402
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── 장 시간 상수 (KST) ──────────────────────────────────────────────────────
|
||
MARKET_OPEN = time(9, 0)
|
||
ORB_END = time(9, 30) # Opening Range 집계 종료
|
||
|
||
# 동적 시간 설정 (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)
|
||
|
||
|
||
# ── 동적 설정 접근자 (이슈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 # 이미 비율 (-0.03 = -3%)
|
||
|
||
@dataclass
|
||
class MinuteCandle:
|
||
"""KIS API output2 데이터 정규화 결과"""
|
||
date: str # YYYYMMDD
|
||
time: str # HHMMSS
|
||
open: float
|
||
high: float
|
||
low: float
|
||
close: float
|
||
volume: int
|
||
cum_volume: int # 누적 거래량 (acml_vol)
|
||
cum_amount: float # 누적 거래대금 원 (acml_tr_pbmn)
|
||
|
||
|
||
@dataclass
|
||
class OpeningRange:
|
||
high: float
|
||
low: float
|
||
total_volume: int
|
||
avg_vol_per_min: float # 분당 평균 거래량 (ORB 기간)
|
||
open_price: float = 0.0 # M4: 시가 (첫 ORB 봉의 open, gap 필터용)
|
||
|
||
|
||
@dataclass
|
||
class DayTradeSignal:
|
||
stock_code: str
|
||
stock_name: str
|
||
strategy: str # "ORB" | "VWAP_BOUNCE" | "RSI_BB"
|
||
entry_price: float
|
||
stop_loss: float
|
||
target_price: float
|
||
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:
|
||
action: str # "SELL_ALL"
|
||
reason: str
|
||
sell_pct: float = 1.0
|
||
|
||
|
||
# [2026-10-01] 시간손절 사유 접두어 — 사유 문자열 생성과 판별이 이 상수 하나를 공유한다.
|
||
# 시간손절은 "진입 후 N분간 목표 방향 진행 미미"를 정리하는 **기회비용 청산**이지
|
||
# 손절선 이탈이 아니다. 이것까지 loss_tracker(3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지)에
|
||
# 집계하면 소폭 손실 시간손절 3회만으로 그 종목의 스윙 진입까지 42일간 막힌다.
|
||
# 판별은 exit_service 가 청산 사유로 한다(_execute_exit 시그니처 불변 유지).
|
||
TIME_STOP_REASON_PREFIX = "시간 손절"
|
||
|
||
|
||
def is_time_stop_exit_reason(reason: Optional[str]) -> bool:
|
||
"""청산 사유가 DT 시간손절인지 판별 (check_day_trade_exit 가 만든 사유 기준)."""
|
||
return bool(reason) and str(reason).startswith(TIME_STOP_REASON_PREFIX)
|
||
|
||
|
||
# ── 분봉 파싱 헬퍼 ────────────────────────────────────────────────────────────
|
||
|
||
def parse_minute_candles(raw_output2: list) -> List[MinuteCandle]:
|
||
"""KIS API output2 리스트를 MinuteCandle 리스트로 변환
|
||
|
||
KIS 응답 필드:
|
||
stck_bsop_date / stck_cntg_hour / stck_oprc / stck_hgpr /
|
||
stck_lwpr / stck_prpr (종가) / cntg_vol / acml_vol / acml_tr_pbmn
|
||
"""
|
||
candles: List[MinuteCandle] = []
|
||
for row in raw_output2:
|
||
try:
|
||
candles.append(MinuteCandle(
|
||
date=row.get("stck_bsop_date", ""),
|
||
time=row.get("stck_cntg_hour", ""),
|
||
open=float(row.get("stck_oprc", 0) or 0),
|
||
high=float(row.get("stck_hgpr", 0) or 0),
|
||
low=float(row.get("stck_lwpr", 0) or 0),
|
||
close=float(row.get("stck_prpr", 0) or 0),
|
||
volume=int(row.get("cntg_vol", 0) or 0),
|
||
cum_volume=int(row.get("acml_vol", 0) or 0),
|
||
cum_amount=float(row.get("acml_tr_pbmn", 0) or 0),
|
||
))
|
||
except (ValueError, TypeError):
|
||
continue
|
||
return candles
|
||
|
||
|
||
def aggregate_to_5min(candles: List[MinuteCandle]) -> List[MinuteCandle]:
|
||
"""1분봉 → 5분봉 집계 (09:00 기준 5분 버킷)"""
|
||
if not candles:
|
||
return []
|
||
|
||
buckets: Dict[tuple, List[MinuteCandle]] = {}
|
||
for c in candles:
|
||
if not c.time:
|
||
continue
|
||
h = int(c.time[:2])
|
||
m = int(c.time[2:4])
|
||
key = (c.date, (h * 60 + m) // 5) # 날짜 포함 → 다른 날 캔들 혼합 방지
|
||
buckets.setdefault(key, []).append(c)
|
||
|
||
result: List[MinuteCandle] = []
|
||
for key in sorted(buckets):
|
||
bkt = buckets[key]
|
||
result.append(MinuteCandle(
|
||
date=bkt[0].date,
|
||
time=bkt[0].time,
|
||
open=bkt[0].open,
|
||
high=max(b.high for b in bkt),
|
||
low=min(b.low for b in bkt),
|
||
close=bkt[-1].close,
|
||
volume=sum(b.volume for b in bkt),
|
||
cum_volume=bkt[-1].cum_volume,
|
||
cum_amount=bkt[-1].cum_amount,
|
||
))
|
||
return result
|
||
|
||
|
||
def _last_session_candles(candles: List[MinuteCandle]) -> List[MinuteCandle]:
|
||
"""마지막 봉과 **같은 영업일자**의 봉만 남긴다 (세션 경계 차단, 순수 함수).
|
||
|
||
진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있다
|
||
(`korea_investment_api.get_minute_chart_full_day_v2(include_prev_day=True)`).
|
||
RSI·BB 처럼 연속 시계열이 필요한 지표는 그대로 써도 되지만, **거래량 비교는
|
||
같은 세션 안에서만 의미가 있다.** 예컨대 09:30 에 5분봉 `[-3]` 이 전일 마지막
|
||
5분봉(15:15~15:19, 종가 동시호가 물량)이면 당일 개장봉과의 비교가 등가가 아니고,
|
||
거래량 게이트가 개장 직후 자동 통과하거나 반대로 영구 탈락한다.
|
||
|
||
영업일자 필드가 비어 있는 응답(목/레거시)은 기존 동작을 유지하기 위해
|
||
그대로 통과시킨다.
|
||
"""
|
||
if not candles:
|
||
return []
|
||
last_date = getattr(candles[-1], "date", "") or ""
|
||
if not last_date:
|
||
return list(candles)
|
||
return [c for c in candles if (getattr(c, "date", "") or "") == last_date]
|
||
|
||
|
||
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
|
||
|
||
def calculate_vwap(candles: List[MinuteCandle]) -> Optional[float]:
|
||
"""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:
|
||
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 total_pv / total_vol
|
||
|
||
|
||
def calculate_opening_range(candles_1min: List[MinuteCandle]) -> Optional[OpeningRange]:
|
||
"""09:00~09:29 분봉으로 Opening Range (H/L) 계산"""
|
||
orb = [c for c in candles_1min if "090000" <= c.time <= "092959"]
|
||
if len(orb) < 5:
|
||
return None
|
||
total_vol = sum(c.volume for c in orb)
|
||
# M4: 시가 = ORB 기간 첫 봉의 open (gap 필터용)
|
||
sorted_orb = sorted(orb, key=lambda c: c.time)
|
||
return OpeningRange(
|
||
high=max(c.high for c in orb),
|
||
low=min(c.low for c in orb),
|
||
total_volume=total_vol,
|
||
avg_vol_per_min=total_vol / len(orb),
|
||
open_price=sorted_orb[0].open,
|
||
)
|
||
|
||
|
||
def _rsi(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
|
||
"""Wrapper: MinuteCandle → closes 추출 후 canonical calculate_rsi 위임.
|
||
|
||
알고리즘: Wilder 평활 RSI (indicators_core.rsi_wilder) — 5분봉도 일봉과 동일 방식.
|
||
"""
|
||
closes = [c.close for c in candles]
|
||
return calculate_rsi(closes, period)
|
||
|
||
|
||
def _bollinger(candles: List[MinuteCandle], period: int = 20) -> Optional[Tuple[float, float, float]]:
|
||
"""Wrapper: MinuteCandle → closes 추출 후 canonical calculate_bollinger 위임.
|
||
|
||
알고리즘: SMA 중앙선 + 모표준편차 2σ (indicators_core.bollinger_sma, num_std=2.0).
|
||
"""
|
||
closes = [c.close for c in candles]
|
||
return calculate_bollinger(closes, period)
|
||
|
||
|
||
def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
|
||
"""Wrapper: MinuteCandle → highs/lows/closes 추출 후 canonical calculate_atr 위임.
|
||
|
||
알고리즘: TR 의 단순이동평균 (indicators_core.atr_sma) — Wilder ATR 아님.
|
||
|
||
⚠️ **세션 경계를 먼저 끊는다** (`_last_session_candles`) — 2026-09-30 수정.
|
||
진입 판정 경로의 시계열에는 지표 워밍업용 **전일 봉**이 섞여 있다
|
||
(`get_minute_chart_full_day_v2(include_prev_day=True)`). TR 은
|
||
`max(H-L, |H-C_prev|, |L-C_prev|)` 이라 **당일 첫 봉의 TR = 오버나이트 갭**이고,
|
||
`atr_sma` 가 마지막 `period` 개 TR 의 단순평균이므로 당일 5분봉이 14개 쌓이기
|
||
전(대략 10:10 이전)에는 갭 TR + 전일 오후 TR 이 윈도에 남는다. 그 구간이 바로
|
||
DT 진입이 가장 많이 나는 구간이다.
|
||
|
||
영향은 임계값이 아니라 **산출값**이었다:
|
||
- `check_vwap_bounce_signal` / `check_rsi_bb_signal` 의
|
||
`target = 현재가 + DAY_TRADE_TARGET_MULTIPLIER × ATR` 은 **클램프가 없어**
|
||
부푼 ATR 이 1.5R 목표를 그대로 멀리 밀었다.
|
||
- `raw_stop = 현재가 − ATR_MULT × ATR` 은 보통 최소손절폭(-2%)이 이기지만
|
||
고변동 종목에서는 -2% 를 뚫고 손절이 깊어졌다.
|
||
- 청산 경로(`daytrading/exit_service`)는 당일 전용 분봉을 쓰므로 트레일링 ATR 은
|
||
깨끗했다 → **진입은 갭 ATR, 청산은 당일 ATR** 로 기준이 엇갈렸다.
|
||
|
||
여기서 한 번 끊으면 네 호출부(ORB 손절·VWAP·RSI_BB·트레일링) 전부가 같은 기준을
|
||
쓴다. 당일 봉이 `period+1` 개 미만이면 `None` 이고, 호출부는 기존 고정 폴백
|
||
(`DAY_TRADE_REVERSION_STOP_LOSS_PCT` 등)을 쓴다 — 워밍업 도입 이전과 같은 동작이다.
|
||
**임계값은 하나도 바뀌지 않는다.** RSI(14)/BB(20) 는 연속 시계열이 필요한 판정
|
||
게이트이므로 워밍업을 그대로 받는다(`_rsi`/`_bollinger` 는 손대지 않았다).
|
||
"""
|
||
session = _last_session_candles(candles)
|
||
if len(session) < period + 1:
|
||
return None
|
||
highs = [c.high for c in session]
|
||
lows = [c.low for c in session]
|
||
closes = [c.close for c in session]
|
||
return calculate_atr(highs, lows, closes, period)
|
||
|
||
|
||
# ── 진입 신호 ─────────────────────────────────────────────────────────────────
|
||
|
||
def _overnight_gap_pct(candles: List[MinuteCandle]) -> Optional[float]:
|
||
"""오버나이트 갭률(%) = (당일 첫 봉 시가 − 전일 마지막 봉 종가) / 전일 종가 × 100.
|
||
|
||
**진단용 순수 함수다 — 어떤 게이트도 이 값을 쓰지 않는다.**
|
||
진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있으므로
|
||
(`get_minute_chart_full_day_v2(include_prev_day=True)`) 벽시계나 별도 조회 없이
|
||
데이터만으로 계산된다. 전일 봉이 없거나 날짜 필드가 비면 `None`.
|
||
|
||
용도(2026-09-30): 장중 5분봉 RSI 는 워밍업 시계열을 쓰므로 **오버나이트 갭이
|
||
종가 차분 한 개로 RSI 에 들어간다**(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3).
|
||
이는 차트 플랫폼 표준 동작이고 평균회귀가 과매도를 사는 의도와 방향이 맞아
|
||
**그대로 유지하기로 결정**됐으나, 의도한 설계가 아니었으므로 관찰 대상이다.
|
||
진입 `reason` 에 이 값을 남겨 두면 나중에 원장(`trade_history.entry_reason`)만으로
|
||
"갭 하락 종목에 쏠렸는가"를 집계할 수 있다.
|
||
"""
|
||
if not candles:
|
||
return None
|
||
today = _last_session_candles(candles)
|
||
if not today:
|
||
return None
|
||
today_date = getattr(today[0], "date", "") or ""
|
||
if not today_date or len(today) == len(candles):
|
||
return None # 전일 봉이 없다 / 날짜 필드 없음
|
||
prev = [c for c in candles if (getattr(c, "date", "") or "") != today_date]
|
||
if not prev:
|
||
return None
|
||
prev_close = prev[-1].close
|
||
first_open = today[0].open
|
||
if prev_close <= 0 or first_open <= 0:
|
||
return None
|
||
return (first_open - prev_close) / prev_close * 100.0
|
||
|
||
|
||
def _diag_suffix(candles: List[MinuteCandle], atr: Optional[float]) -> str:
|
||
"""진입 `reason` 뒤에 붙는 **진단 정보** 문자열 (게이트 아님).
|
||
|
||
- `갭=` : `_overnight_gap_pct` (RSI 갭 섭취 관찰용, 없으면 생략)
|
||
- `ATR=` : 손절·목표 산출에 실제로 쓰인 ATR. `None` 이면 폴백 손절이 쓰였음을
|
||
명시한다 — 09:30~10:10 구간은 당일 5분봉 15개 미만이라 ATR 이 없고
|
||
`DAY_TRADE_REVERSION_STOP_LOSS_PCT` 폴백이 적용된다(CLAUDE.md §7 예외).
|
||
|
||
값만 넣는다. 임계값을 문자열에 하드코딩하지 않는다(§4).
|
||
"""
|
||
parts = ["ATR=없음→폴백손절" if atr is None else f"ATR={atr:.0f}"]
|
||
gap = _overnight_gap_pct(candles)
|
||
if gap is not None:
|
||
parts.append(f"갭={gap:+.2f}%")
|
||
return " [진단 " + ", ".join(parts) + "]"
|
||
|
||
|
||
def check_orb_signal(
|
||
stock_code: str,
|
||
stock_name: str,
|
||
candles_1min: List[MinuteCandle],
|
||
orb: OpeningRange,
|
||
current_price: float,
|
||
) -> Optional[DayTradeSignal]:
|
||
"""ORB 상향 돌파 매수 신호
|
||
|
||
조건:
|
||
1. 현재가 > ORB 고점 × (1 + DAY_TRADE_ORB_BREAKOUT_MARGIN)
|
||
2. **마지막 완성 1분봉** 거래량 ≥ ORB 분당평균 × DAY_TRADE_ORB_BREAKOUT_VOL_MULT
|
||
3. 직전 5분봉이 양봉 (음봉이면 Fake-out 위험)
|
||
|
||
[2026-09-29] 2번은 `candles_1min[-1]`(형성 중 봉)을 쓰고 있었다. 30초 주기 스캔의
|
||
사이클 대부분에서 마지막 1분봉은 절반만 채워져 있어 **실효 요구가 임계값의 약 2배**가
|
||
됐다(volume_surge_ratio 의 F7 과 같은 클래스의 버그). 비교 대상만 마지막 **완성봉**
|
||
으로 고치고 임계값은 건드리지 않는다. `orb.avg_vol_per_min` 이 당일 기준이므로
|
||
분자도 당일이어야 한다 — `_last_session_candles()` 로 세션을 고정한다.
|
||
"""
|
||
if not orb or current_price <= 0:
|
||
return None
|
||
|
||
# [M3 2026-10-02] 오버나이트 갭(전일 종가 대비)은 **세션을 자르기 전** 시계열에서만
|
||
# 계산된다. 플래그 OFF 면 계산하지 않는다(현행 판정 유지).
|
||
_max_gap = float(settings.DAY_TRADE_ORB_MAX_GAP_PCT)
|
||
_overnight_gap = (
|
||
_overnight_gap_pct(candles_1min) if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED else None
|
||
)
|
||
|
||
# 전일 워밍업 봉이 섞여 들어와도 ORB 판정(갭·거래량·양봉·ATR)은 당일 봉만 쓴다
|
||
candles_1min = _last_session_candles(candles_1min)
|
||
|
||
# M4: Gap filter.
|
||
# ⚠️ 현행(기본) 판정은 **오버나이트 갭이 아니다** — |현재가 − 당일 09:00 시가| / 시가 로
|
||
# "시가 이후 장중 이동폭"을 본다(§7 문서의 '갭 4%↑ 거부'와 정의가 다름).
|
||
# DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED=True 면 §7 대로 전일 종가 대비 갭으로 판정하고,
|
||
# 전일 종가를 확보하지 못하면 이 현행 판정으로 폴백한다(warning).
|
||
if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED and _overnight_gap is not None:
|
||
if abs(_overnight_gap) / 100.0 > _max_gap:
|
||
logger.info(
|
||
"ORB skip %s: overnight gap %+.2f%% > %.2f%%",
|
||
stock_code, _overnight_gap, _max_gap * 100,
|
||
)
|
||
return None
|
||
else:
|
||
if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED:
|
||
warn_throttled(
|
||
logger, "dt_strategy:orb_overnight_gap_unavailable",
|
||
"[DayTrade] ORB 오버나이트 갭 산출 불가(전일 봉 없음) — 당일시가 대비 판정으로 폴백",
|
||
)
|
||
if orb.open_price > 0:
|
||
_gap_ratio = abs(current_price - orb.open_price) / orb.open_price
|
||
if _gap_ratio > _max_gap:
|
||
logger.debug(
|
||
"ORB skip %s: gap ratio %.2f%% > %.2f%%",
|
||
stock_code, _gap_ratio * 100, _max_gap * 100,
|
||
)
|
||
return None
|
||
|
||
# M4: Range width filter — ORB 범위가 너무 좁으면 노이즈 돌파 위험
|
||
_min_range = float(settings.DAY_TRADE_ORB_MIN_RANGE_PCT)
|
||
if orb.low > 0:
|
||
_range_width = (orb.high - orb.low) / orb.low
|
||
if _range_width < _min_range:
|
||
logger.debug(
|
||
"ORB skip %s: range width %.3f%% < %.3f%%",
|
||
stock_code, _range_width * 100, _min_range * 100,
|
||
)
|
||
return None
|
||
|
||
if current_price < orb.high * (1 + settings.DAY_TRADE_ORB_BREAKOUT_MARGIN):
|
||
return None
|
||
|
||
# 거래량 체크 — 마지막 **완성** 1분봉([-2]) 기준. 형성 중 봉([-1])은 부분 거래량이라
|
||
# 임계값을 실질적으로 부풀린다. 완성봉이 없으면(당일 봉 2개 미만) 게이트 미통과.
|
||
if len(candles_1min) < 2:
|
||
return None
|
||
recent_vol = candles_1min[-2].volume
|
||
if orb.avg_vol_per_min <= 0:
|
||
return None
|
||
if recent_vol < orb.avg_vol_per_min * settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT:
|
||
return None
|
||
|
||
# 직전 5분봉 양봉 확인 (5분봉 2개 이상 없으면 보수적으로 차단)
|
||
candles_5m = aggregate_to_5min(candles_1min)
|
||
if len(candles_5m) < 2:
|
||
return None # 데이터 부족 시 진입 차단
|
||
if candles_5m[-2].close < candles_5m[-2].open:
|
||
return None # 직전 5분봉 음봉 → 스킵
|
||
|
||
# [P1] ORB 손절: ATR 하한 + 하드 스탑 상한 클램프
|
||
atr_orb = _atr(aggregate_to_5min(candles_1min), 14)
|
||
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)
|
||
# 최소 손절폭 보장: 노이즈 손절 방지
|
||
min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT)
|
||
stop_loss = min(orb_stop, min_stop)
|
||
risk = current_price - stop_loss
|
||
if risk <= 0:
|
||
return None # 손절폭이 0 이하면 진입 무의미
|
||
# 수수료 고려: 목표 = 진입가 + 2R + 수수료 보전
|
||
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)
|
||
|
||
return DayTradeSignal(
|
||
stock_code=stock_code,
|
||
stock_name=stock_name,
|
||
strategy="ORB",
|
||
entry_price=current_price,
|
||
stop_loss=round(stop_loss, 0),
|
||
target_price=round(target, 0),
|
||
reason=(
|
||
f"ORB 상향 돌파: ORB고={orb.high:.0f}, "
|
||
f"현재={current_price:.0f}, 거래량={vol_ratio:.1f}배"
|
||
f"(≥{settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT}배, 완성봉 기준)"
|
||
# ORB 경로의 시계열은 당일 전용이라 오버나이트 갭을 계산할 수 없다
|
||
# (_diag_suffix 가 갭을 생략한다). ATR 만 남는다.
|
||
+ _diag_suffix(candles_1min, atr_orb)
|
||
),
|
||
confidence=round(confidence, 2),
|
||
)
|
||
|
||
|
||
def _last_completed_candle(candles_5min: List[MinuteCandle]) -> Optional[MinuteCandle]:
|
||
"""**당일** 마지막 완성 5분봉. 없으면 None (순수 함수).
|
||
|
||
[2026-09-30] `aggregate_to_5min` 은 **형성 중인 5분 버킷도 하나의 봉으로 만든다.**
|
||
따라서 `candles_5min[-1]` 로 양봉/음봉을 판정하면 30초 주기 스캔에서 같은 버킷을
|
||
약 10회 보며 **같은 시장 상태에 사이클마다 다른 답**이 나온다(버킷 초반에 음봉이면
|
||
차단, 후반에 양봉이 되면 통과). 재현성이 없는 게이트다.
|
||
|
||
F7(2026-09-29)이 거래량 게이트를 완성봉으로 고칠 때 **양봉 판정은 빠졌다.**
|
||
`volume_surge_ratio()` 가 비교하는 봉과 **같은 봉**(`session[-2]`)을 돌려주므로
|
||
"거래량이 튄 그 봉이 양봉인가"라는 원래 의도가 성립한다 —
|
||
`check_orb_signal` 은 이미 `candles_5m[-2]` 로 그렇게 하고 있었다(`:407`).
|
||
|
||
세션 경계도 함께 끊는다(`_last_session_candles`): 진입 경로의 시계열에는 지표
|
||
워밍업용 전일 봉이 섞여 있어(`include_prev_day=True`) 개장 직후에는 마지막 완성봉이
|
||
**전일 종가 동시호가 봉**이 된다. 그걸로 양봉 판정을 하면 게이트가 전일 결과로
|
||
열린다(C1 과 같은 함정).
|
||
|
||
완성봉이 없으면(당일 봉 2개 미만) `None` — 호출부는 **미통과**로 닫는다.
|
||
데이터 부족을 통과 쪽으로 열지 않는다.
|
||
"""
|
||
session = _last_session_candles(candles_5min)
|
||
if len(session) < 2:
|
||
return None
|
||
return session[-2]
|
||
|
||
|
||
def volume_surge_ratio(candles_5min: List[MinuteCandle]) -> Optional[float]:
|
||
"""직전 거래량 증가 배율 — **완성된 5분봉끼리** 비교한 값.
|
||
|
||
[F7 2026-09-28] 기존 게이트는 ``candles_5min[-1].volume >= candles_5min[-2].volume
|
||
* DAY_TRADE_VOLUME_MULTIPLIER`` 였다. 그런데 ``aggregate_to_5min`` 은 **형성 중인
|
||
5분 버킷도 하나의 봉으로 만든다.** 즉 좌변은 "아직 채워지는 중인 봉의 부분
|
||
거래량"이고 우변은 "완성된 봉의 전체 거래량"이라, 실제로 거래량이 1.3배로 늘어난
|
||
구간에서도 **버킷의 마지막 1분 남짓에만** 조건이 성립했다. 30초 주기 스캔에서
|
||
5분 버킷은 약 10사이클에 걸치므로 ~80% 사이클이 구조적으로 전멸했다.
|
||
|
||
비교 대상만 고친다(임계값 1.3 은 그대로): 마지막 **완성봉**([-2])을 그 직전
|
||
완성봉([-3])과 비교한다. 두 항이 모두 전체 5분치라 비교가 등가이고, 벽시계에
|
||
의존하지 않아 순수 함수로 남는다.
|
||
|
||
[2026-09-29] 진입 경로의 시계열에 전일 워밍업 봉이 포함되므로, 비교는
|
||
`_last_session_candles()` 로 **마지막 봉과 같은 세션**에 한정한다. 세션을 넘겨
|
||
비교하면 전일 종가 동시호가 물량과 당일 개장봉을 견주게 되어 게이트가 무의미해진다.
|
||
|
||
Returns:
|
||
float | None: 배율. **당일** 완성봉이 2개 미만이거나 분모가 0이면 None
|
||
(= 게이트 미통과. 데이터 부족은 통과 쪽으로 열지 않는다)
|
||
"""
|
||
session = _last_session_candles(candles_5min)
|
||
if len(session) < 3:
|
||
return None
|
||
prev_completed = session[-3].volume
|
||
last_completed = session[-2].volume
|
||
if prev_completed <= 0:
|
||
return None
|
||
return last_completed / prev_completed
|
||
|
||
|
||
def check_vwap_bounce_signal(
|
||
stock_code: str,
|
||
stock_name: str,
|
||
candles_5min: List[MinuteCandle],
|
||
vwap: float,
|
||
current_price: float,
|
||
) -> Optional[DayTradeSignal]:
|
||
"""VWAP 지지 반등 매수 신호
|
||
|
||
조건:
|
||
1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처)
|
||
2. 5분봉 RSI(14) ≤ threshold
|
||
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
|
||
4. **그 완성봉이 양봉** (close > open) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30)
|
||
"""
|
||
if not candles_5min or vwap <= 0 or current_price <= 0:
|
||
return None
|
||
|
||
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 > settings.DAY_TRADE_VWAP_RSI_THRESHOLD:
|
||
return None
|
||
|
||
# [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경)
|
||
vol_ratio = volume_surge_ratio(candles_5min)
|
||
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
|
||
return None
|
||
|
||
# 양봉 확인 — **당일 마지막 완성봉**(거래량 게이트가 본 그 봉). 형성 중 버킷을
|
||
# 보면 사이클마다 답이 바뀐다(_last_completed_candle docstring). 임계값·조건 정의는
|
||
# 그대로이고 '어느 봉을 보는가'만 고친다.
|
||
last = _last_completed_candle(candles_5min)
|
||
if last is None or last.close <= last.open:
|
||
return None
|
||
|
||
atr = _atr(candles_5min, 14)
|
||
raw_stop = (current_price - settings.DAY_TRADE_VWAP_ATR_MULT * atr) if atr else current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
|
||
# 최소 손절폭 보장: 노이즈 손절 방지
|
||
min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT)
|
||
stop_loss = min(raw_stop, min_stop)
|
||
stop_loss = max(stop_loss, 1.0) # 최소 1원 보장
|
||
# [M-1 2026-09-30] **1.5R 하한 보장** — RSI_BB 경로와 같은 방식(`max(기존목표, 진입가
|
||
# + risk × TARGET_MULTIPLIER)`)으로 맞춘다. 새 임계값은 없다: 두 값 중 큰 쪽을 고를 뿐이다.
|
||
#
|
||
# 고치지 않으면 `target_price` 가 위험보다 작아진다 — 이 값은
|
||
# `daytrading/exit_service` 를 통해 **1.5R 분할매도 트리거로 실제 쓰인다**(§7).
|
||
# · ATR 폴백 구간(09:30~약 10:10, 당일 5분봉 15개 미만): 손절 -3.5%(폴백) vs
|
||
# 목표 `vwap×1.03`(≈+3%) → **위험 > 보상**
|
||
# · ATR 이 있는 구간도 마찬가지다: `risk = max(1.5×ATR, 2%)` 인데(최소 손절폭 보장의
|
||
# `min()` 때문에 손절이 -2% 쪽으로 느슨해진다) 목표는 `1.5×ATR` 이라
|
||
# **1.5×ATR < 2% 인 저변동 종목에서는 R:R 이 0.2~0.5** 로 떨어진다.
|
||
# 실측: 진입 10,120 / ATR 29 → 손절 9,918(-2.0%) / 목표 10,164(+0.43%) = **0.21R**.
|
||
# §7 은 "1.5R 도달 시 50% 분할"을 규약으로 못박고 있으므로 이는 규약 복귀다.
|
||
_target_base = (
|
||
(current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr
|
||
else vwap * settings.DAY_TRADE_VWAP_TARGET_FALLBACK_MULT
|
||
)
|
||
risk = current_price - stop_loss
|
||
target = max(_target_base, current_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER)
|
||
|
||
return DayTradeSignal(
|
||
stock_code=stock_code,
|
||
stock_name=stock_name,
|
||
strategy="VWAP_BOUNCE",
|
||
entry_price=current_price,
|
||
stop_loss=round(stop_loss, 0),
|
||
target_price=round(target, 0),
|
||
reason=(
|
||
f"VWAP 반등: VWAP={vwap:.0f}, RSI={rsi:.1f}(≤{settings.DAY_TRADE_VWAP_RSI_THRESHOLD:.0f}), "
|
||
f"현재={current_price:.0f}, 거래량={vol_ratio:.2f}배"
|
||
f"(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)"
|
||
+ _diag_suffix(candles_5min, atr)
|
||
),
|
||
confidence=0.65,
|
||
)
|
||
|
||
|
||
def check_rsi_bb_signal(
|
||
stock_code: str,
|
||
stock_name: str,
|
||
candles_5min: List[MinuteCandle],
|
||
current_price: float,
|
||
) -> Optional[DayTradeSignal]:
|
||
"""5분봉 RSI + 볼린저 과매도 반등 신호
|
||
|
||
조건:
|
||
1. RSI(14) ≤ threshold (강한 과매도)
|
||
2. 종가 ≤ BB 하단 + buffer
|
||
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
|
||
4. **그 완성봉이 양봉** (음봉이면 스킵) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30)
|
||
"""
|
||
if not candles_5min or current_price <= 0:
|
||
return None
|
||
|
||
rsi = _rsi(candles_5min, 14)
|
||
if rsi is None or rsi > settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:
|
||
return None
|
||
|
||
bb = _bollinger(candles_5min, 20)
|
||
if bb is None:
|
||
return None
|
||
bb_upper, bb_middle, bb_lower = bb
|
||
|
||
if current_price > bb_lower * settings.DAY_TRADE_RSI_BB_BB_LOWER_BUFFER:
|
||
return None
|
||
|
||
# [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경)
|
||
vol_ratio = volume_surge_ratio(candles_5min)
|
||
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
|
||
return None
|
||
|
||
# 양봉 확인 — VWAP 경로와 동일하게 당일 마지막 완성봉 기준
|
||
last = _last_completed_candle(candles_5min)
|
||
if last is None or last.close <= last.open:
|
||
return None
|
||
|
||
atr = _atr(candles_5min, 14)
|
||
raw_stop = (current_price - settings.DAY_TRADE_RSI_BB_ATR_MULT * atr) if atr else current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
|
||
# 최소 손절폭 보장: 노이즈 손절 방지
|
||
min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT)
|
||
stop_loss = min(raw_stop, min_stop)
|
||
stop_loss = max(stop_loss, 1.0) # 최소 1원 보장
|
||
# 최소 1.5R 보장: bb_middle이 너무 가까우면 R:R 확보를 위해 목표 상향
|
||
risk = current_price - stop_loss
|
||
# [L1 2026-10-02] 리터럴 1.5 → DAY_TRADE_TARGET_MULTIPLIER(1.5, 같은 값·같은 의미 "목표=진입+risk×R")
|
||
target = max(bb_middle, current_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER)
|
||
if target <= current_price: # 방어 로직 (stop_loss ≥ current_price 극단 케이스)
|
||
return None
|
||
|
||
return DayTradeSignal(
|
||
stock_code=stock_code,
|
||
stock_name=stock_name,
|
||
strategy="RSI_BB",
|
||
entry_price=current_price,
|
||
stop_loss=round(stop_loss, 0),
|
||
target_price=round(target, 0),
|
||
reason=(
|
||
f"5분봉 과매도 반등: RSI={rsi:.1f}(≤{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}), "
|
||
f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}, "
|
||
f"거래량={vol_ratio:.2f}배(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)"
|
||
+ _diag_suffix(candles_5min, atr)
|
||
),
|
||
confidence=0.60,
|
||
)
|
||
|
||
|
||
# ── 청산 신호 ─────────────────────────────────────────────────────────────────
|
||
|
||
def _elapsed_minutes(entry_time) -> Optional[float]:
|
||
"""진입 후 경과 시간(분). 값이 신뢰할 수 없으면 None.
|
||
|
||
[2026-09-23] DB에서 복원한 포지션의 entry_time 이 UTC(예전 기본값)면 KST now 와 비교 시
|
||
경과가 +540분으로 부풀어, 재배포 직후 '시간 손절'이 즉시 발동해 포지션이 몇 분 만에
|
||
청산됐다(산일전기 565분·미스토홀딩스 568분 = 실제 25·28분 + 9시간).
|
||
음수이거나 정규장 길이(09:00~15:30 = 390분)를 넘는 값은 시각 기준 불일치로 보고 무시한다.
|
||
"""
|
||
if entry_time is None:
|
||
return None
|
||
try:
|
||
from app.core.timeutil import now_kst as _now_kst
|
||
|
||
now_kst = _now_kst()
|
||
elapsed = (now_kst - entry_time).total_seconds() / 60
|
||
except Exception as e:
|
||
warn_throttled(logger, "day_trading_strategy:732", f"[DayTradeExit] 경과 시간 계산 실패: {e}")
|
||
return None
|
||
if elapsed < 0 or elapsed > 390:
|
||
logger.warning(
|
||
f"[DayTradeExit] entry_time 기준 불일치 추정 — 경과 {elapsed:.0f}분 무시 "
|
||
f"(entry_time={entry_time})"
|
||
)
|
||
return None
|
||
return elapsed
|
||
|
||
|
||
def check_day_trade_exit(
|
||
entry_price: float,
|
||
current_price: float,
|
||
stop_loss: float,
|
||
target_price: float,
|
||
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. 시간 손절 — 진입 후 DAY_TRADE_TIME_STOP_MINUTES 경과 이후 수익률 < DAY_TRADE_TIME_STOP_MIN_GAIN 이면 청산
|
||
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(
|
||
action="SELL_ALL",
|
||
reason=f"EOD 강제 청산 ({current_time.strftime('%H:%M')} ≥ {_get_eod_flat().strftime('%H:%M')})",
|
||
)
|
||
|
||
# 2. 손절
|
||
if current_price <= stop_loss:
|
||
loss_pct = (current_price - entry_price) / entry_price * 100
|
||
return DayTradeExit(
|
||
action="SELL_ALL",
|
||
reason=f"손절 {loss_pct:.1f}% (현재={current_price:.0f} ≤ 손절가={stop_loss:.0f})",
|
||
)
|
||
|
||
# 3. [P2] 시간 손절 — 진입 후 설정 시간 경과 시 목표 방향 진행 미미하면 청산
|
||
elapsed_min = _elapsed_minutes(entry_time)
|
||
if elapsed_min is not None and 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"{TIME_STOP_REASON_PREFIX}: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.2f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.2f}%",
|
||
)
|
||
|
||
# 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도)
|
||
# [FIX] target_price<=0 방어 — 0이면 즉시 조건 충족되어 오매도되는 것을 방지
|
||
if target_price > 0 and current_price >= target_price:
|
||
gain_pct = (current_price - entry_price) / entry_price * 100
|
||
return DayTradeExit(
|
||
action="SELL_PARTIAL",
|
||
reason=f"목표가 달성 +{gain_pct:.1f}% → 50% 분할 매도",
|
||
sell_pct=0.50,
|
||
)
|
||
|
||
# 5. 트레일링 스탑 (최소 보유 시간 경과 + 최소 수익률 이상 시 발동)
|
||
# [whipsaw 방지] 진입 직후 순간 상승→되돌림에 트레일링이 걸리는 것을 방지
|
||
_trailing_allowed = True
|
||
if elapsed_min is not None and elapsed_min * 60 < settings.DAY_TRADE_TRAILING_MIN_HOLD_SEC:
|
||
_trailing_allowed = False # 최소 보유 시간(5분) 미경과 → 하드스탑만 적용
|
||
|
||
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 ≥ DAY_TRADE_RSI_EXIT_OVERBOUGHT, 기본 70)
|
||
rsi = _rsi(candles_5min, 14)
|
||
_rsi_ob = float(settings.DAY_TRADE_RSI_EXIT_OVERBOUGHT)
|
||
if rsi is not None and rsi >= _rsi_ob:
|
||
gain_pct = (current_price - entry_price) / entry_price * 100
|
||
if gain_pct > settings.DAY_TRADE_COST_PCT * 100:
|
||
return DayTradeExit(
|
||
action="SELL_ALL",
|
||
reason=f"과매수 청산: RSI={rsi:.1f}≥{_rsi_ob:g}, 수익={gain_pct:.1f}%",
|
||
)
|
||
|
||
return None
|
||
|
||
|
||
# ── 포지션 사이징 ─────────────────────────────────────────────────────────────
|
||
|
||
def calculate_day_trade_position_size(
|
||
total_assets: float,
|
||
available_cash: float,
|
||
current_day_trade_amount: float,
|
||
) -> float:
|
||
"""데이트레이딩 포지션 크기 계산
|
||
|
||
[2026-03-15 CLAUDE.md 정합성 수정]
|
||
- 종목당 최대: 총자산 × 4% (CLAUDE.md 5.1)
|
||
- 전체 데이트레이딩 합산: 총자산 × 25% (CLAUDE.md 8.2)
|
||
- 동시 최대 3종목 (MAX_POSITIONS)
|
||
"""
|
||
# [이슈6 수정] 모듈 레벨 상수 대신 동적 접근자 사용 (한도 값의 출처는 settings)
|
||
# [§8.5-2 통합 2026-09-27] 3중 min 식을 sizing_core.budget_by_ratios 로 위임.
|
||
# 비율 자체는 여전히 settings 소유(_get_* 접근자) — 수식만 공유한다.
|
||
return budget_by_ratios(
|
||
total_assets=total_assets,
|
||
available_cash=available_cash,
|
||
current_allocation=current_day_trade_amount,
|
||
max_per_position=_get_max_day_trade_ratio(), # 종목당 한도 (DAY_TRADE_MAX_CAPITAL_RATIO)
|
||
max_strategy_ratio=_get_max_capital_ratio(), # 전체 DT 한도 (DAY_TRADE_MAX_RATIO_DT)
|
||
)
|
||
|
||
|
||
def is_daily_loss_exceeded(
|
||
total_assets: float,
|
||
realized_pnl_today: float,
|
||
) -> bool:
|
||
if total_assets <= 0:
|
||
return False
|
||
# realized_pnl_today가 0이면 손실 없음 → 한도 미초과
|
||
if realized_pnl_today >= 0:
|
||
return False
|
||
# [이슈6 수정] 동적 접근자로 런타임 설정값 반영
|
||
return (realized_pnl_today / total_assets) <= _get_daily_loss_limit() |