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:
8
backend/app/services/daytrading/__init__.py
Normal file
8
backend/app/services/daytrading/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""데이트레이딩 서비스 패키지
|
||||
|
||||
당일 청산 원칙의 인트라데이 매매 전담 레이어.
|
||||
- position_manager : 인메모리 포지션 상태 중앙 관리 (DayTradingState 싱글톤)
|
||||
- exit_service : 5분봉 ATR 청산 + 15:20 EOD 강제 청산
|
||||
- entry_service : ORB / VWAP / RSI+BB 진입 신호 처리
|
||||
- scheduler : 30초 주기 독립 스케줄러
|
||||
"""
|
||||
477
backend/app/services/daytrading/entry_service.py
Normal file
477
backend/app/services/daytrading/entry_service.py
Normal 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
|
||||
324
backend/app/services/daytrading/exit_service.py
Normal file
324
backend/app/services/daytrading/exit_service.py
Normal 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
|
||||
325
backend/app/services/daytrading/position_manager.py
Normal file
325
backend/app/services/daytrading/position_manager.py
Normal 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()
|
||||
156
backend/app/services/daytrading/scheduler.py
Normal file
156
backend/app/services/daytrading/scheduler.py
Normal 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
|
||||
Reference in New Issue
Block a user