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:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View File

@@ -0,0 +1,8 @@
"""데이트레이딩 서비스 패키지
당일 청산 원칙의 인트라데이 매매 전담 레이어.
- position_manager : 인메모리 포지션 상태 중앙 관리 (DayTradingState 싱글톤)
- exit_service : 5분봉 ATR 청산 + 15:20 EOD 강제 청산
- entry_service : ORB / VWAP / RSI+BB 진입 신호 처리
- scheduler : 30초 주기 독립 스케줄러
"""

View File

@@ -0,0 +1,477 @@
"""데이트레이딩 진입 서비스
ORB / VWAP / RSI+BB 신호 기반 당일 매매 진입 처리.
day_trading_strategy.py의 신호 함수를 재활용하고
인메모리 상태(DayTradingState)를 업데이트.
진입 기준 (CLAUDE.md 섹션 7.3):
- 감시 종목 대상 (watchlist)
- ORB: 개장 범위 돌파 신호 (09:30 이후)
- VWAP: VWAP 반등 신호
- RSI+BB: 5분봉 RSI < 30, BB 하단 근처
- 포지션 최대 3종목, 종목당 총자본 4% (CLAUDE.md 5.1)
- MDD -10% 이하 시 비활성화
- 14:30 이후 신규 진입 마감 (CLAUDE.md 7.3)
- 점심시간(11:30~13:00) VWAP/RSI_BB 진입 제한
진입 흐름:
1. MDD / 적응형 전략 / 플레이북 / 일일 손실 / 쿨다운 체크
2. 시장 국면 체크 (SIDEWAYS 차단)
3. 시간대 체크 (14:30 이후 마감)
4. 포지션 한도 체크 (최대 3종목)
5. 감시 종목 순회 → 5분봉 데이터 조회 → 신호 체크
6. 포지션 사이징 (confidence 기반 동적 조정)
7. MCP 리스크 평가 (선택적)
8. KIS API 매수 주문 실행
9. 인메모리 상태 + DB 큐 기록
아키텍처 규칙:
- DB 직접 접근 금지 (CLAUDE.md 3.1)
- 체결 기록은 db_worker 큐 경유
- 매수 주문은 safe_place_order() 사용 (세마포어+분산락 보호)
"""
import asyncio
import logging
from datetime import datetime
import pytz
from app.core.config import settings
from app.services.korea_investment_api import korea_investment_api
from app.services.day_trading_strategy import (
parse_minute_candles, aggregate_to_5min,
calculate_vwap, calculate_opening_range,
check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal,
calculate_day_trade_position_size, is_daily_loss_exceeded,
MAX_POSITIONS,
)
from app.services.daytrading.position_manager import day_trading_state
from app.services.db_worker import enqueue_trade_history, enqueue_active_trade_upsert
from app.services.mdd_manager import mdd_manager
from app.services.market_analyzer import check_market_regime
from app.services.loss_tracker import loss_tracker
from app.core.market_schedule import is_opening_range_period, is_day_trade_entry_window
from app.services.market_playbook import (
is_daytrading_allowed_by_playbook, is_vi_active,
)
logger = logging.getLogger(__name__)
# 한국 표준시(KST) 타임존 — 장 시간/시간대 체크에 사용
_KST = pytz.timezone("Asia/Seoul")
# 감시 종목 딕셔너리 (code → name)
# day_trading.py의 _watchlist와 동기화되며, POST /api/daytrading/watchlist 또는
# populate_daytrading_watchlist()로 설정됨
_watchlist: dict[str, str] = {}
def set_watchlist(stocks: list[dict]) -> None:
"""감시 종목 설정.
Args:
stocks: 종목 목록. 각 항목은 {"code": "005930", "name": "삼성전자"} 형태
"""
global _watchlist
_watchlist = {s["code"]: s.get("name", s["code"]) for s in stocks}
logger.info(f"[DayEntry] 감시 종목 설정: {len(_watchlist)}개")
def get_watchlist() -> dict[str, str]:
"""현재 감시 종목 딕셔너리 복사본 반환.
Returns:
dict[str, str]: {종목코드: 종목명} 딕셔너리
"""
return dict(_watchlist)
async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20) -> int:
"""stock_analysis 테이블에서 오늘 분석 결과를 조회하여 watchlist 자동 설정.
배치 분석(batch_daily_analysis) 결과에서 점수가 높은 종목을
자동으로 데이트레이딩 감시 대상으로 설정.
필터 조건:
- 오늘 날짜 분석 결과만 사용
- score >= min_score (기본 1점 이상)
- RSI 값이 존재 (기술적 분석 완료 종목만)
- 현재가 > 5,000원 (CLAUDE.md 5.4 가격 필터)
Args:
min_score: 최소 점수 기준 (기본 1)
max_stocks: 최대 감시 종목 수 (기본 20)
Returns:
int: 설정된 감시 종목 수
Note:
이 함수는 DB 읽기 전용으로, CLAUDE.md 3.1 예외 해당.
populate는 스케줄러 시작 시 1회, 이후 30분마다 갱신.
"""
from app.core.database import AsyncSessionLocal
from app.models.models import StockAnalysis
from sqlalchemy import select
today = datetime.now(_KST).date()
async with AsyncSessionLocal() as session:
result = await session.execute(
select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score)
.where(
StockAnalysis.analysis_date == today,
StockAnalysis.score >= min_score,
StockAnalysis.rsi.isnot(None),
StockAnalysis.current_price > 5000,
)
.order_by(StockAnalysis.score.desc())
.limit(max_stocks)
)
rows = result.all()
if not rows:
logger.info(f"[DayEntry] stock_analysis에 오늘({today}) 데이터 없음")
return 0
stocks = [{"code": r.stock_code, "name": r.stock_name} for r in rows]
set_watchlist(stocks)
logger.info(f"[DayEntry] 자동 watchlist 설정: {len(stocks)}개 (score>={min_score})")
return len(stocks)
async def collect_opening_ranges() -> None:
"""09:00~09:30 ORB(Opening Range Breakout) 데이터 수집.
감시 종목의 시초가 레인지(고가/저가)를 수집하여
ORB 돌파 전략의 기준값으로 사용.
호출 시점: 스케줄러에서 09:00~09:30 사이 30초 주기로 호출
KIS API 호출 간격: 0.3초 (레이트 리밋 준수)
"""
day_trading_state.reset_if_new_day()
for stock_code in list(_watchlist.keys()):
# 이미 ORB가 수집된 종목은 건너뜀
if day_trading_state.has_orb(stock_code):
continue
try:
# 당일 분봉 전체 조회 → 시초가 레인지 계산
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
candles = parse_minute_candles(raw)
if candles:
orb = calculate_opening_range(candles)
if orb:
day_trading_state.set_orb(stock_code, orb)
logger.debug(f"[DayEntry] ORB 수집: {stock_code} {orb}")
# KIS API 레이트 리밋 방지 (초당 약 3회)
await asyncio.sleep(0.3)
except Exception as e:
logger.debug(f"[DayEntry] ORB 수집 실패 ({stock_code}): {e}")
async def scan_and_enter() -> list[dict]:
"""감시 종목의 5분봉 데이터를 스캔하여 진입 신호 발생 시 매수 실행.
09:30~14:30 구간에서 30초 주기로 호출 (14:30 이후 진입 마감).
진입 전 체크 순서 (P0 = 최우선, P1 = 중요):
[P0] MDD -10% 체크 → 단기 전략 비활성화
[P0] 플레이북 체크 → FOMC/옵션만기 등 이벤트 당일 차단
[P0] 일일 손실 한도 -3% 체크
[P1] 3연패 쿨다운 체크
[P0] 시장 국면 체크 → SIDEWAYS/CRASH 등 차단
[P1] 14:30 이후 신규 진입 마감
Returns:
list[dict]: 진입 실행 결과 목록. 각 항목:
- stock_code, stock_name, quantity, price, strategy, reason
"""
day_trading_state.reset_if_new_day()
entries = []
# MDD 체크
short_term_ok = await mdd_manager.is_short_term_strategy_enabled()
if not short_term_ok:
logger.info("[DayEntry] MDD 차단 — 단기 전략 비활성화")
return entries
# 적응형 전략 체크 — Defensive/Crisis 모드에서 데이트레이딩 비활성화
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_dt = get_current_adaptive_params()
if not _adp_dt.fallback_used and not _adp_dt.daytrading_enabled:
logger.info("[DayEntry] 적응형 전략 %s 모드 — 데이트레이딩 비활성화", _adp_dt.trading_mode.upper())
return entries
except Exception:
pass # adaptive 모듈 실패 시 기존 로직 유지
# [P0] 플레이북 체크 — FOMC/옵션만기/서킷브레이커 등 이벤트 당일 차단
if not is_daytrading_allowed_by_playbook():
logger.info("[DayEntry] ⛔ 플레이북 차단 — 데이트레이딩 비활성화 (이벤트 대응)")
return entries
# [P0] 일일 손실 한도 체크 — 청산 후 즉시 재진입 방지
if is_daily_loss_exceeded(day_trading_state.last_total_assets, day_trading_state.daily_pnl):
logger.info("[DayEntry] ⛔ 일일 손실 한도(-3%) 초과 — 신규 진입 차단")
return entries
# [P1] 쿨다운 — 3연패 시 당일 진입 중단
if day_trading_state.is_cooldown_active():
logger.info("[DayEntry] ⛔ 3연패 쿨다운 — 신규 진입 차단")
return entries
# [P0] 시장 국면 체크 — SIDEWAYS/CRASH/HALTED/STRONG_BEAR에서 진입 차단
_BLOCK_REGIMES = {"sideways", "crash", "halted", "strong_bearish"}
try:
regime_info = await check_market_regime()
regime = regime_info.get("regime", "neutral")
if regime in _BLOCK_REGIMES:
logger.info(f"[DayEntry] ⛔ {regime.upper()} 국면 — 데이트레이딩 진입 차단")
return entries
logger.info(f"[DayEntry] 시장 국면: {regime}")
except Exception as e:
logger.info(f"[DayEntry] 시장 국면 조회 실패 (계속 진행): {e}")
# [P1] 시간대 제한 — 점심시간(11:30~13:00) VWAP/RSI_BB 제한, 14:30 이후 전략 진입 마감
now_time = datetime.now(_KST).time()
_now = datetime.now(_KST)
_is_lunch_break = (_now.hour == 11 and _now.minute >= 30) or _now.hour == 12
_is_late_session = now_time >= datetime.strptime("14:30", "%H:%M").time()
if _is_late_session:
logger.info("[DayEntry] ⛔ 14:30 이후 — 신규 진입 마감")
return entries
# 포지션 한도 체크
if day_trading_state.position_count() >= MAX_POSITIONS:
logger.info(f"[DayEntry] ⛔ 포지션 한도 ({MAX_POSITIONS}) 도달")
return entries
# 포트폴리오 총자산 조회 (캐시 활용)
try:
balance = await korea_investment_api.get_balance()
output2 = balance.get("output2", [{}])
summary = output2[0] if output2 else {}
total_assets = float(summary.get("tot_evlu_amt", 0) or 0)
available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0)
day_trading_state.last_total_assets = total_assets
except Exception as e:
logger.debug(f"[DayEntry] 잔고 조회 실패: {e}")
total_assets = day_trading_state.last_total_assets
available_cash = 0.0
if total_assets <= 0:
return entries
scanned = 0
skipped_held = 0
skipped_closed = 0
no_signal = 0
for stock_code, stock_name in list(_watchlist.items()):
# 이미 보유 중이거나 오늘 이미 청산한 종목 제외
if stock_code in day_trading_state.positions:
skipped_held += 1
continue
if day_trading_state.is_closed_today(stock_code):
skipped_closed += 1
continue
if day_trading_state.position_count() >= MAX_POSITIONS:
break
scanned += 1
try:
entry = await _try_enter(
stock_code, stock_name, total_assets, available_cash,
is_lunch_break=_is_lunch_break,
)
if entry:
entries.append(entry)
await asyncio.sleep(0.5) # KIS API 레이트 리밋
else:
no_signal += 1
except Exception as e:
logger.error(f"[DayEntry] 진입 시도 오류 ({stock_code}): {e}", exc_info=True)
logger.info(
f"[DayEntry] 스캔 완료: {scanned}종목 스캔, {len(entries)}진입, "
f"{no_signal}신호없음, {skipped_held}보유중, {skipped_closed}청산완료"
)
return entries
async def _try_enter(
stock_code: str,
stock_name: str,
total_assets: float,
available_cash: float,
is_lunch_break: bool = False,
) -> dict | None:
"""단일 종목에 대해 진입 신호 체크 및 매수 주문 실행.
처리 흐름:
1. VI 쿨다운 체크 (변동성 완화장치 발동 후 10분간 차단)
2. 5분봉 데이터 조회 및 VWAP/ORB 계산
3. 신호 체크 (ORB → VWAP → RSI_BB 순서, 점심시간 VWAP/RSI_BB 제한)
4. confidence 기반 포지션 사이징
5. MCP 리스크 평가 (선택적, 실패 시 진행)
6. safe_place_order()로 시장가 매수 (세마포어+분산락 보호)
7. 인메모리 상태 업데이트 + DB 큐 기록
Args:
stock_code: 종목 코드
stock_name: 종목명
total_assets: 총자산 (포지션 사이징 기준)
available_cash: 가용 현금
is_lunch_break: 점심시간 여부 (11:30~13:00)
Returns:
dict | None: 진입 실행 결과 또는 None (신호 없음/실패)
"""
try:
# loss_tracker ban 체크 — 3연패 종목 42일간 진입 차단 (CLAUDE.md 5.5)
if await loss_tracker.is_banned_safe(stock_code):
logger.debug(f"[DayEntry] loss_tracker ban — {stock_code} 진입 스킵")
return None
# VI 쿨다운 체크 — VI 발동 후 10분간 진입 차단
if is_vi_active(stock_code):
logger.debug(f"[DayEntry] VI 쿨다운 — {stock_code} 진입 스킵")
return None
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
candles = parse_minute_candles(raw)
if not candles:
return None
candles_5m = aggregate_to_5min(candles)
if len(candles_5m) < 5:
return None
vwap = calculate_vwap(candles)
orb = day_trading_state.get_orb(stock_code)
current_price = float(candles_5m[-1].close) if candles_5m else 0.0
if current_price <= 0:
return None
# 신호 체크 (ORB → VWAP → RSI+BB 순)
signal = None
if orb:
signal = check_orb_signal(stock_code, stock_name, candles, orb, current_price)
# [P1] 점심시간(11:30~13:00)에는 VWAP/RSI_BB 진입 제한 — 유동성 저하
if not is_lunch_break:
if not signal and vwap:
signal = check_vwap_bounce_signal(stock_code, stock_name, candles_5m, vwap, current_price)
if not signal:
signal = check_rsi_bb_signal(stock_code, stock_name, candles_5m, current_price)
if not signal:
return None
# [P1] confidence 기반 포지션 사이징 — 신호 품질에 따라 예산 조정
current_day_trade_amount = sum(
pos.get("entry_price", 0) * pos.get("quantity", 0)
for pos in day_trading_state.positions.values()
)
budget = calculate_day_trade_position_size(
total_assets=total_assets,
available_cash=available_cash,
current_day_trade_amount=current_day_trade_amount,
)
# confidence 스케일링: 기준 0.7 대비 비례 조정 (0.6→86%, 0.7→100%, 0.85→121%)
confidence_scale = min(1.2, signal.confidence / 0.7)
budget = budget * confidence_scale
quantity = int(budget // current_price) if current_price > 0 else 0
if quantity <= 0:
return None
# MCP RISK_ASSESSMENT (선택적)
try:
from app.api.endpoints.mcp import request_mcp_risk_assessment
trade_plan = {
"action": "BUY",
"strategy": "daytrading",
"stock_code": stock_code,
"price": current_price,
"quantity": quantity,
"signal": signal.strategy,
}
mcp_approved, mcp_reason = await request_mcp_risk_assessment(
trade_plan, {"strategy": "daytrading"}, {"stock_code": stock_code}
)
if not mcp_approved:
logger.debug(f"[DayEntry] MCP 거부: {stock_code} — {mcp_reason}")
return None
except Exception:
pass # MCP 실패 시 진입 계속
# KIS API 매수 주문 (시장가) — 분산락+세마포어 보호
from app.services.trade_executor import safe_place_order
order_resp = await safe_place_order(
stock_code=stock_code,
order_type="BUY",
quantity=quantity,
price=None, # 시장가
)
if not order_resp or order_resp.get("rt_cd") != "0":
err = order_resp.get("msg1", "unknown") if order_resp else "no response"
logger.error(f"[DayEntry] 매수 주문 실패 ({stock_code}): {err}")
return None
order_id = order_resp.get("output", {}).get("ODNO", "")
# 인메모리 포지션 추가 (DB 직접 접근 금지)
async with day_trading_state.trade_lock:
day_trading_state.add_position(stock_code, {
"stock_name": stock_name,
"strategy": signal.strategy,
"entry_price": current_price,
"stop_loss": signal.stop_loss,
"target_price": signal.target_price,
"quantity": quantity,
"highest_price": current_price,
"entry_time": datetime.now(_KST).replace(tzinfo=None),
"reason": signal.reason,
"order_id": order_id,
})
# DB 기록 (큐 경유)
await enqueue_trade_history({
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "BUY",
"order_price": current_price,
"order_quantity": quantity,
"executed_price": current_price,
"executed_quantity": quantity,
"total_amount": current_price * quantity,
"status": "COMPLETED",
"order_id": order_id,
"entry_reason": signal.reason,
"strategy_type": "daytrading",
})
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"stock_name": stock_name,
"quantity": quantity,
"entry_price": current_price,
"strategy_name": "day_trade",
"entry_reason": signal.reason,
})
logger.info(
f"[DayEntry] ✅ 진입: {stock_code} {stock_name} "
f"수량={quantity} 가격={current_price:,.0f} 신호={signal.strategy}"
)
return {
"stock_code": stock_code,
"stock_name": stock_name,
"quantity": quantity,
"price": current_price,
"strategy": signal.strategy,
"reason": signal.reason,
}
except Exception as e:
logger.error(f"[DayEntry] _try_enter 오류 ({stock_code}): {e}", exc_info=True)
return None

View File

@@ -0,0 +1,324 @@
"""데이트레이딩 청산 서비스
당일 포지션의 청산 조건 체크 및 실행:
- 5분봉 ATR 기반 손절/익절 (day_trading_strategy.check_day_trade_exit 재활용)
- 15:20 EOD 강제 청산 (CLAUDE.md 7.3: 당일 청산 원칙)
- 일일 손실 한도(-3%) 초과 시 즉시 전체 청산
- 분할 매도 지원: 목표가 달성 시 50% 매도, 잔량 트레일링스탑
청산 우선순위:
1. 일일 손실 한도 초과 → 전체 강제 청산
2. EOD 15:20 도달 → 전체 강제 청산
3. 개별 포지션 체크:
- 손절: ATR 기반 또는 하드스탑
- 시간 손절: 30분(6봉) +0.3% 미달
- 과매수 청산: RSI >= 70
- 분할 익절: 목표가 +2R 달성 시 50% 매도
아키텍처 규칙:
- CLAUDE.md 3.1: DB I/O 금지 — 인메모리 상태만 수정, 체결 후 db_task_queue 경유
- loss_tracker 연동: 전량 매도 시 record_trade_result_safe() + save_to_db() 호출
"""
import asyncio
import logging
from datetime import datetime
import pytz
from app.core.market_schedule import is_eod_flat_time
from app.services.korea_investment_api import korea_investment_api
from app.services.day_trading_strategy import (
parse_minute_candles, aggregate_to_5min,
check_day_trade_exit, is_daily_loss_exceeded,
)
from app.services.daytrading.position_manager import day_trading_state
from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade
from app.services.loss_tracker import loss_tracker
logger = logging.getLogger(__name__)
# 한국 표준시(KST) 타임존
_KST = pytz.timezone("Asia/Seoul")
def _kst_now() -> datetime:
"""현재 KST 시간을 timezone-naive datetime으로 반환.
entry_time과 비교 시 timezone 불일치 방지를 위해 tzinfo 제거.
Returns:
datetime: 현재 KST 시간 (timezone-naive)
"""
return datetime.now(_KST).replace(tzinfo=None)
async def check_and_exit_positions() -> list[dict]:
"""활성 데이트레이딩 포지션의 청산 조건 체크 및 실행.
인메모리 positions 순회 → 5분봉 ATR 기반 청산 판단.
스케줄러에서 30초 주기로 호출.
체크 순서:
1. 일일 손실 한도 초과 → 전체 강제 청산
2. EOD 15:20 → 전체 강제 청산
3. 개별 포지션별 check_day_trade_exit() 호출
Returns:
list[dict]: 청산 실행 결과 목록
"""
day_trading_state.reset_if_new_day()
exits = []
if not day_trading_state.positions:
return exits
# 일일 손실 한도 초과 시 전체 강제 청산
if is_daily_loss_exceeded(day_trading_state.last_total_assets, day_trading_state.daily_pnl):
logger.warning("[DayExit] 일일 손실 한도 초과 → 전체 강제 청산")
return await force_close_all(reason="일일 손실 한도 초과")
# EOD 강제 청산 (15:20)
if is_eod_flat_time():
logger.info("[DayExit] EOD (15:20) → 전체 강제 청산")
return await force_close_all(reason="EOD 강제 청산")
# 개별 포지션 청산 체크
codes_snapshot = list(day_trading_state.positions.keys())
for stock_code in codes_snapshot:
try:
exit_result = await _check_single_position(stock_code)
if exit_result:
exits.append(exit_result)
except Exception as e:
logger.error(f"[DayExit] 포지션 체크 오류 ({stock_code}): {e}", exc_info=True)
return exits
async def _check_single_position(stock_code: str) -> dict | None:
"""단일 포지션의 청산 조건 체크.
5분봉 데이터를 조회하여 check_day_trade_exit()로 청산 신호를 판단.
최고가(highest_price)를 갱신하여 트레일링스탑 정확도 향상.
Args:
stock_code: 종목 코드
Returns:
dict | None: 청산 실행 결과 또는 None (청산 조건 미충족)
"""
pos = day_trading_state.get_position(stock_code)
if not pos:
return None
entry_price = pos.get("entry_price", 0)
stop_loss = pos.get("stop_loss", entry_price * 0.94)
target_price = pos.get("target_price", entry_price * 1.06)
quantity = pos.get("quantity", 0)
highest_price = pos.get("highest_price", entry_price)
entry_time = pos.get("entry_time")
# 5분봉 데이터 조회
try:
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
candles = parse_minute_candles(raw)
candles_5m = aggregate_to_5min(candles)
if len(candles_5m) < 5:
return None
current_price = float(candles_5m[-1].close) if candles_5m else 0.0
# highest_price 갱신 — 5분봉 high 값도 반영 (30초 주기 보완)
candle_high = max(c.high for c in candles_5m[-3:]) if len(candles_5m) >= 3 else current_price
new_highest = max(current_price, candle_high, highest_price)
if new_highest > highest_price:
async with day_trading_state.trade_lock:
if stock_code in day_trading_state.positions:
day_trading_state.positions[stock_code]["highest_price"] = new_highest
highest_price = new_highest
# 청산 신호 체크
current_time = _kst_now().time()
exit_signal = check_day_trade_exit(
entry_price=entry_price,
current_price=current_price,
stop_loss=stop_loss,
target_price=target_price,
highest_since_entry=highest_price,
candles_5min=candles_5m,
current_time=current_time,
entry_time=entry_time,
)
if exit_signal:
sell_pct = exit_signal.sell_pct
return await _execute_exit(
stock_code, pos, current_price, exit_signal.reason,
sell_pct=sell_pct,
)
except Exception as e:
logger.debug(f"[DayExit] 5분봉 조회 실패 ({stock_code}): {e}")
return None
async def _execute_exit(
stock_code: str, pos: dict, price: float, reason: str,
sell_pct: float = 1.0,
) -> dict | None:
"""실제 매도 주문 실행 및 상태 업데이트.
처리 흐름:
1. 매도 수량 계산 (분할 매도 시 최소 1주 보장)
2. safe_place_order()로 시장가 매도 (세마포어+분산락 보호)
3. 인메모리 상태 업데이트 (trade_lock 보호)
4. 전량 매도 시 loss_tracker 연동
5. DB 기록 (db_task_queue 경유)
Args:
stock_code: 종목 코드
pos: 포지션 정보 딕셔너리
price: 현재가 (매도 기준가)
reason: 청산 사유 (로그 및 DB 기록용)
sell_pct: 매도 비율 (1.0=전량, 0.5=50% 분할 매도)
Returns:
dict | None: 청산 결과 (stock_code, price, quantity, profit_loss 등)
"""
total_qty = pos.get("quantity", 0)
entry_price = pos.get("entry_price", 0)
stock_name = pos.get("stock_name", stock_code)
if total_qty <= 0 or price <= 0:
return None
# [P1] 분할 매도 수량 계산
if sell_pct >= 1.0:
quantity = total_qty
else:
quantity = max(1, int(total_qty * sell_pct))
if quantity >= total_qty:
quantity = total_qty
is_full_exit = (quantity >= total_qty)
# KIS API 매도 주문 (시장가) — 분산락+세마포어 보호
from app.services.trade_executor import safe_place_order
order_resp = await safe_place_order(
stock_code=stock_code,
order_type="SELL",
quantity=quantity,
price=None, # 시장가
)
if not order_resp or order_resp.get("rt_cd") != "0":
err = order_resp.get("msg1", "unknown") if order_resp else "no response"
logger.error(f"[DayExit] 매도 주문 실패 ({stock_code}): {err}")
return None
order_id = order_resp.get("output", {}).get("ODNO", "")
profit_loss = (price - entry_price) * quantity
profit_loss_rate = ((price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0
# 인메모리 상태 업데이트 (DB 직접 접근 금지)
async with day_trading_state.trade_lock:
if is_full_exit:
day_trading_state.remove_position(stock_code)
else:
# 분할 매도: 잔량 유지, target_price 제거(트레일링만 적용)
remaining = total_qty - quantity
day_trading_state.positions[stock_code]["quantity"] = remaining
day_trading_state.positions[stock_code]["target_price"] = float("inf")
day_trading_state.positions[stock_code]["partial_sold"] = True
day_trading_state.update_pnl(profit_loss)
# [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적
if is_full_exit:
is_loss = profit_loss < 0
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
await loss_tracker.save_to_db(stock_code=stock_code)
# DB 기록 (큐 경유)
await enqueue_trade_history({
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": price,
"order_quantity": quantity,
"executed_price": price,
"executed_quantity": quantity,
"total_amount": price * quantity,
"status": "COMPLETED",
"order_id": order_id,
"exit_reason": reason,
"net_profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"strategy_type": "daytrading",
})
if is_full_exit:
await enqueue_close_active_trade(stock_code, strategy_name="day_trade")
sell_label = f"{int(sell_pct * 100)}%" if sell_pct < 1.0 else "전량"
logger.info(
f"[DayExit] ✅ 청산({sell_label}): {stock_code} {stock_name} "
f"수량={quantity}/{total_qty} 수익률={profit_loss_rate:.1f}% 이유={reason}"
)
return {
"stock_code": stock_code,
"stock_name": stock_name,
"price": price,
"quantity": quantity,
"profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"reason": reason,
"sell_pct": sell_pct,
}
async def force_close_all(reason: str = "강제 청산") -> list[dict]:
"""모든 데이트레이딩 포지션 강제 청산.
호출 시점:
- EOD 15:20 (CLAUDE.md 7.3)
- 일일 손실 한도(-3%) 초과
- 수동 API 호출
현재가 조회 실패 시 진입가를 대체가로 사용.
KIS API 레이트 리밋 방지를 위해 종목 간 0.3초 대기.
Args:
reason: 강제 청산 사유 (로그 및 DB 기록용)
Returns:
list[dict]: 청산 실행 결과 목록
"""
results = []
codes = list(day_trading_state.positions.keys())
for stock_code in codes:
pos = day_trading_state.get_position(stock_code)
if not pos:
continue
try:
# 현재가 조회
price_resp = await korea_investment_api.get_current_price(stock_code)
price = float(
price_resp.get("output", {}).get("stck_prpr", 0) or 0
) if price_resp else 0.0
if price <= 0:
price = float(pos.get("entry_price", 0))
result = await _execute_exit(stock_code, pos, price, reason)
if result:
results.append(result)
await asyncio.sleep(0.3) # KIS API 레이트 리밋
except Exception as e:
logger.error(f"[DayExit] 강제 청산 오류 ({stock_code}): {e}", exc_info=True)
logger.info(f"[DayExit] 강제 청산 완료: {len(results)}건 ({reason})")
return results

View File

@@ -0,0 +1,325 @@
"""데이트레이딩 인메모리 포지션 상태 관리
기존 day_trading.py의 전역 변수들을 DayTradingState 클래스로 캡슐화.
싱글톤 인스턴스 day_trading_state 를 패키지 전체에서 공유.
CLAUDE.md 3.1: 실시간 레이어에서 DB I/O 절대 금지.
포지션 상태는 인메모리 유지, 체결 확정 시에만 db_task_queue 경유.
사용 패턴:
from app.services.daytrading.position_manager import day_trading_state
# 날짜 초기화 (스케줄러 사이클 시작 시 호출)
day_trading_state.reset_if_new_day()
# 포지션 추가/제거 시 반드시 trade_lock 획득
async with day_trading_state.trade_lock:
day_trading_state.add_position(code, data)
주의사항:
- [동시성] positions, daily_pnl, closed_today 수정 시 반드시 trade_lock 획득
- [영속성] 앱 재시작 시 인메모리 상태가 유실됨 (DB 복원 메커니즘 없음)
- [단일 프로세스] 다중 워커 환경에서는 상태 불일치 발생 가능
"""
import asyncio
import logging
from datetime import date, datetime
from typing import Any
import pytz
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# 한국 표준시(KST) 타임존 객체 — 날짜 비교 및 시간 계산에 사용
_KST = pytz.timezone("Asia/Seoul")
def _today_kst() -> date:
"""현재 KST 기준 날짜 반환.
Returns:
date: 오늘 날짜 (KST 기준)
"""
return datetime.now(_KST).date()
class DayTradingState:
"""데이트레이딩 일일 상태 컨테이너.
날짜가 바뀌면 reset_if_new_day()로 자동 초기화.
trade_lock 획득 후 positions / daily_pnl / closed_today 수정.
Attributes:
positions: 활성 포지션 딕셔너리 {종목코드: 포지션정보}
orb_cache: 시초가 레인지(ORB) 캐시 {종목코드: OpeningRange}
daily_pnl: 당일 누적 실현 손익 (원)
last_total_assets: 마지막 조회된 총자산 (포지션 사이징 기준)
closed_today: 당일 청산 완료된 종목 코드 집합 (재진입 방지)
loss_streak_today: 당일 연속 손실 횟수 (3회 이상 시 쿨다운)
trade_lock: 포지션 수정 동시성 보호용 asyncio.Lock
"""
def __init__(self):
"""DayTradingState 초기화.
모든 상태를 기본값으로 설정. 실제 초기화는 reset_if_new_day()에서 수행.
"""
# 활성 포지션: {stock_code: {...entry info...}}
self.positions: dict[str, dict[str, Any]] = {}
# ORB 캐시: {stock_code: OpeningRange}
self.orb_cache: dict[str, Any] = {}
# 당일 실현 손익
self.daily_pnl: float = 0.0
# 마지막으로 조회된 총자산
self.last_total_assets: float = 0.0
# 당일 청산 종목 (재진입 방지)
self.closed_today: set[str] = set()
# 당일 연속 손실 횟수 (쿨다운 트리거)
self.loss_streak_today: int = 0
# 마지막 초기화 날짜
self.last_reset_date: date | None = None
# 포트폴리오 요약 캐시 (총자산, 현금 등)
self.portfolio_summary_cache: dict[str, float] = {}
# 포트폴리오 캐시 타임스탬프
self.portfolio_summary_ts: datetime | None = None
# 포트폴리오 캐시 무효화 플래그
self.portfolio_cache_dirty: bool = True
# 매매 실행 락 — positions 수정 시 반드시 획득
self.trade_lock = asyncio.Lock()
def reset_if_new_day(self) -> bool:
"""날짜가 바뀌면 일일 상태 초기화.
스케줄러 사이클 시작 시 호출하여 전일 데이터가
당일 매매에 영향을 주지 않도록 보장.
Returns:
bool: 초기화가 수행되었으면 True, 아니면 False
"""
today = _today_kst()
if self.last_reset_date != today:
self.positions = {}
self.orb_cache = {}
self.daily_pnl = 0.0
self.last_total_assets = 0.0
self.closed_today = set()
self.loss_streak_today = 0
self.last_reset_date = today
self.portfolio_summary_cache = {}
self.portfolio_summary_ts = None
self.portfolio_cache_dirty = True
logger.info(f"[DayTrading] 일일 상태 초기화: {today}")
return True
return False
def add_position(self, stock_code: str, data: dict) -> None:
"""포지션 추가.
반드시 trade_lock 획득 후 호출해야 함.
Args:
stock_code: 종목 코드 (예: "005930")
data: 포지션 정보 딕셔너리
- stock_name: 종목명
- strategy: 진입 전략 (ORB/VWAP/RSI_BB)
- entry_price: 진입가
- stop_loss: 손절가
- target_price: 목표가
- quantity: 수량
- highest_price: 최고가 (트레일링스탑용)
- entry_time: 진입 시간
- order_id: KIS 주문 ID
"""
self.positions[stock_code] = data
self.portfolio_cache_dirty = True
def remove_position(self, stock_code: str) -> dict | None:
"""포지션 제거 및 반환.
반드시 trade_lock 획득 후 호출해야 함.
제거된 종목은 closed_today에 추가되어 당일 재진입 방지.
Args:
stock_code: 종목 코드
Returns:
dict | None: 제거된 포지션 정보, 없으면 None
"""
pos = self.positions.pop(stock_code, None)
if pos is not None:
self.closed_today.add(stock_code)
self.portfolio_cache_dirty = True
return pos
def update_pnl(self, realized: float) -> None:
"""실현 손익 누적 및 연속 손실 카운트 업데이트.
손실 시 loss_streak_today 증가, 이익 시 리셋.
3연패 이상이면 is_cooldown_active()가 True 반환.
Args:
realized: 실현 손익 (원). 양수=이익, 음수=손실
"""
self.daily_pnl += realized
if realized < 0:
self.loss_streak_today += 1
else:
# 이익 발생 시 연속 손실 카운트 리셋
self.loss_streak_today = 0
def get_position(self, stock_code: str) -> dict | None:
"""특정 종목의 활성 포지션 정보 반환.
Args:
stock_code: 종목 코드
Returns:
dict | None: 포지션 정보 딕셔너리, 없으면 None
"""
return self.positions.get(stock_code)
def is_closed_today(self, stock_code: str) -> bool:
"""해당 종목이 당일 이미 청산되었는지 확인.
데이트레이딩은 동일 종목 당일 재진입을 허용하지 않음.
Args:
stock_code: 종목 코드
Returns:
bool: 당일 청산 완료 여부
"""
return stock_code in self.closed_today
def position_count(self) -> int:
"""현재 활성 포지션 수 반환.
MAX_POSITIONS(3) 한도 체크에 사용.
Returns:
int: 활성 포지션 수
"""
return len(self.positions)
def is_cooldown_active(self) -> bool:
"""당일 3연패 이상이면 쿨다운 (신규 진입 차단).
CLAUDE.md 7.3: 당일 3연패 시 신규 진입 차단.
Returns:
bool: 쿨다운 활성화 여부
"""
return self.loss_streak_today >= 3
def has_orb(self, stock_code: str) -> bool:
"""해당 종목의 ORB(시초가 레인지)가 수집되었는지 확인.
Args:
stock_code: 종목 코드
Returns:
bool: ORB 데이터 존재 여부
"""
return stock_code in self.orb_cache
def set_orb(self, stock_code: str, orb: Any) -> None:
"""ORB(시초가 레인지) 데이터 저장.
09:00~09:30 사이에 collect_opening_ranges()에서 호출.
Args:
stock_code: 종목 코드
orb: OpeningRange 객체 (고가, 저가, VWAP 포함)
"""
self.orb_cache[stock_code] = orb
def get_orb(self, stock_code: str) -> Any | None:
"""해당 종목의 ORB(시초가 레인지) 데이터 반환.
Args:
stock_code: 종목 코드
Returns:
Any | None: OpeningRange 객체, 없으면 None
"""
return self.orb_cache.get(stock_code)
async def restore_from_db(self, db: AsyncSession) -> int:
"""앱 재시작 시 ActiveTrade 테이블에서 당일 데이트레이딩 포지션 복원.
strategy_name='day_trade' AND status='active' AND created_at이 오늘인
레코드를 인메모리 positions 딕셔너리에 복원합니다.
이미 positions에 존재하는 종목은 건너뜁니다.
Args:
db: AsyncSession 인스턴스
Returns:
int: 복원된 포지션 수
"""
from app.models.models import ActiveTrade
today = _today_kst()
restored = 0
try:
result = await db.execute(
select(ActiveTrade).where(
ActiveTrade.strategy_name == "day_trade",
ActiveTrade.status == "active",
)
)
active_trades = result.scalars().all()
async with self.trade_lock:
for trade in active_trades:
# 당일 생성된 포지션만 복원 (데이트레이딩은 당일 청산 원칙)
trade_date = trade.created_at.date() if trade.created_at else None
if trade_date != today:
logger.warning(
f"[DayTrading] 전일 미청산 포지션 발견: {trade.stock_code} "
f"(created_at={trade.created_at}) — EOD 청산 대상"
)
# 전일 포지션도 복원하여 EOD 청산 가능하게 함
if trade.stock_code in self.positions:
continue
self.positions[trade.stock_code] = {
"stock_name": trade.stock_name or trade.stock_code,
"strategy": trade.strategy_name or "day_trade",
"entry_price": float(trade.entry_price) if trade.entry_price else 0,
"stop_loss": float(trade.stop_loss) if trade.stop_loss else 0,
"target_price": float(trade.take_profit) if trade.take_profit else 0,
"quantity": trade.quantity or 0,
"highest_price": float(trade.highest_since_entry) if trade.highest_since_entry else (
float(trade.entry_price) if trade.entry_price else 0
),
"entry_time": trade.created_at,
"reason": trade.entry_reason or "",
"order_id": trade.order_id or "",
"restored_from_db": True,
}
restored += 1
logger.info(
f"[DayTrading] DB에서 포지션 복원: {trade.stock_code} "
f"{trade.stock_name} qty={trade.quantity} "
f"entry={trade.entry_price}"
)
if restored:
logger.info(f"[DayTrading] 총 {restored}개 포지션 DB에서 복원 완료")
else:
logger.debug("[DayTrading] 복원할 데이트레이딩 포지션 없음")
except Exception as e:
logger.error(f"[DayTrading] DB 포지션 복원 실패: {e}", exc_info=True)
return restored
# 싱글톤 인스턴스 — daytrading 패키지 전체에서 공유
# entry_service, exit_service, scheduler에서 동일 인스턴스 참조
day_trading_state = DayTradingState()

View File

@@ -0,0 +1,156 @@
"""데이트레이딩 독립 스케줄러
main.py의 lifespan에서 asyncio.create_task()로 시작되는
백그라운드 태스크. 기존 day_trade_scheduler()를 대체.
30초 주기 실행 흐름:
- 장외 시간: 30분 슬립 (CPU 낭비 방지)
- DAY_TRADE_ENABLED=False: 60초 슬립
- 09:00~09:30: ORB 수집 (collect_opening_ranges)
- 09:30~14:30: 신호 스캔 + 진입 (scan_and_enter) + 청산 체크 (check_and_exit_positions)
- 14:30~15:20: 청산 체크만 (신규 진입 마감)
- 15:20~15:30: EOD 강제 청산 (force_close_all)
MDD 규칙 (CLAUDE.md 5.3):
- MDD -10% 이하 시 신규 진입 차단
- 기존 포지션의 청산 체크는 MDD 무관하게 계속 수행 (포지션 보호)
watchlist 자동 설정:
- ORB 수집 시작 시 watchlist 없으면 stock_analysis에서 자동 로드
- 장중에도 30분 간격으로 watchlist 갱신 시도
"""
import asyncio
import logging
import time
from app.core.config import settings
from app.core.market_schedule import (
is_market_open, is_opening_range_period,
is_day_trade_entry_window, is_eod_flat_time,
)
from app.services.mdd_manager import mdd_manager
from app.services.daytrading.position_manager import day_trading_state
from app.services.daytrading.entry_service import (
collect_opening_ranges, scan_and_enter,
get_watchlist, populate_daytrading_watchlist,
)
from app.services.daytrading.exit_service import check_and_exit_positions, force_close_all
logger = logging.getLogger(__name__)
# watchlist 자동 갱신 타임스탬프 (epoch seconds)
# 30분(1800초)마다 stock_analysis에서 재조회
_last_wl_sync: float = 0.0
async def daytrading_scheduler() -> None:
"""30초 주기 데이트레이딩 스케줄러.
무한 루프로 실행되며 asyncio.CancelledError로만 종료.
각 사이클에서 예외 발생 시 로그 기록 후 다음 사이클 계속.
시간대별 동작:
- 장외: 30분 대기 (is_market_open() 기준)
- EOD: 보유 포지션 강제 청산
- ORB 시간: 시초가 레인지 수집
- 엔트리 윈도우: 진입 + 청산 동시 처리
- 기타: 보유 포지션 청산 체크만
"""
await asyncio.sleep(5)
logger.info("⚡ [DayTrading] Scheduler started (every 30s)")
# 앱 재시작 시 DB에서 당일 데이트레이딩 포지션 복원 (CRITICAL 버그 수정)
try:
from app.core.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
restored = await day_trading_state.restore_from_db(db)
if restored:
logger.info(f"[DayTrading] 시작 시 {restored}개 포지션 복원됨")
except Exception as e:
logger.error(f"[DayTrading] 시작 시 포지션 복원 실패: {e}", exc_info=True)
try:
while True:
try:
day_trading_state.reset_if_new_day()
if not is_market_open():
await asyncio.sleep(1800)
continue
if not settings.DAY_TRADE_ENABLED:
await asyncio.sleep(60)
continue
# MDD 체크 (CLAUDE.md 5.3)
short_term_ok = await mdd_manager.is_short_term_strategy_enabled()
# EOD 강제 청산 (15:20)
if is_eod_flat_time():
if day_trading_state.positions:
exits = await force_close_all(reason="EOD 강제 청산 (15:20)")
if exits:
logger.info(f"[DayTrading] EOD 청산: {len(exits)}건")
await asyncio.sleep(30)
continue
# 09:00~09:30 ORB 수집
if is_opening_range_period():
if not get_watchlist():
try:
count = await populate_daytrading_watchlist()
if count:
logger.info(f"[DayTrading] 자동 watchlist: {count}개")
except Exception as e:
logger.warning(f"[DayTrading] watchlist 자동설정 실패: {e}")
await collect_opening_ranges()
await asyncio.sleep(30)
continue
# 09:30~15:00 신호 스캔 + 청산 체크
if is_day_trade_entry_window():
global _last_wl_sync
wl = get_watchlist()
if not wl and (time.time() - _last_wl_sync) > 1800:
_last_wl_sync = time.time()
try:
await populate_daytrading_watchlist()
wl = get_watchlist()
except Exception:
pass
logger.info(
f"[DayTrading] 🔄 cycle: mdd_ok={short_term_ok}, "
f"watchlist={len(wl)}, positions={day_trading_state.position_count()}"
)
# 청산 체크 (MDD 무관 — 기존 포지션 보호)
exits = await check_and_exit_positions()
if exits:
logger.info(f"[DayTrading] 청산: {len(exits)}건")
# 진입 (MDD 허용 시만)
if short_term_ok:
entries = await scan_and_enter()
if entries:
logger.info(f"[DayTrading] 진입: {len(entries)}건")
else:
logger.info("[DayTrading] ⏸️ MDD -10% 이상 — 신규 진입 차단")
await asyncio.sleep(30)
continue
# 그 외 시간 — 보유 포지션 청산 체크만
if day_trading_state.positions:
exits = await check_and_exit_positions()
if exits:
logger.info(f"[DayTrading] 장외 청산: {len(exits)}건")
except Exception as e:
logger.error(f"[DayTrading] 스케줄러 오류: {e}", exc_info=True)
await asyncio.sleep(30)
except asyncio.CancelledError:
logger.info("[DayTrading] Scheduler stopped")
raise