Files
auto-stock/backend/app/services/daytrading/entry_service.py
shuim 496529782b fix: 수동 매도 즉시 반영 + 매도/매수 결과 토스트 + 당일 매도대금 가용현금 반영
- 프론트: 토스트 알림 시스템(lib/toast + ToastHost) 추가, 매도/매수 성공·실패를 화면에 표시
  (기존엔 console.log만, 실패는 블로킹 alert). 성공 토스트에 손익 표시.
- 매도 직후 예수금/평가액 요약 즉시 갱신(apply_sell), 최근 5분 내 수동 매도 종목은
  KIS 잔고 반영 지연으로 동기화가 되살리지 않도록 스킵(recent_sells).
- 예수금이 D+2 결제 기준(dnca_tot_amt)이라 당일 매도대금이 화면·데이트레이딩 가용현금에
  반영되지 않던 문제: 매수가능조회(inquire-psbl-order) ord_psbl_cash 를 우선 사용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:40:39 +09:00

529 lines
21 KiB
Python

"""데이트레이딩 진입 서비스
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, time as dtime
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] = {}
# True = populate_daytrading_watchlist()로 자동 구성된 목록 (스케줄러가 30분마다 갱신 가능)
# False = API로 수동 설정된 목록 (자동 갱신으로 덮어쓰지 않음)
_watchlist_auto: bool = False
def set_watchlist(stocks: list[dict]) -> None:
"""감시 종목 설정 (수동 — 이후 자동 갱신 대상에서 제외).
Args:
stocks: 종목 목록. 각 항목은 {"code": "005930", "name": "삼성전자"} 형태
"""
global _watchlist, _watchlist_auto
_watchlist = {s["code"]: s.get("name", s["code"]) for s in stocks}
_watchlist_auto = False
logger.info(f"[DayEntry] 감시 종목 설정: {len(_watchlist)}개")
def is_watchlist_auto() -> bool:
"""현재 감시 목록이 자동 구성(populate)된 것인지 여부."""
return _watchlist_auto
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, func
today = datetime.now(_KST).date()
async with AsyncSessionLocal() as session:
# [ORB 복구] 09:00 시점엔 당일 배치 분석이 아직 없어 watchlist가 비고 → ORB 수집 0 →
# ORB 전략이 하루 종일 불가했음(2026-09-22). 당일 데이터가 없으면 가장 최근
# 분석일(전일 등)의 결과로 채운다. 이후 30분 주기 갱신에서 당일 데이터로 교체됨.
latest_date = (
await session.execute(
select(func.max(StockAnalysis.analysis_date)).where(
StockAnalysis.analysis_date <= today
)
)
).scalar_one_or_none()
if latest_date is None:
logger.info("[DayEntry] stock_analysis 데이터 없음")
return 0
if latest_date != today:
logger.info(f"[DayEntry] 당일({today}) 분석 없음 → 최근 분석일 {latest_date} 결과로 watchlist 구성")
result = await session.execute(
select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score)
.where(
StockAnalysis.analysis_date == latest_date,
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({latest_date})에 조건 충족 종목 없음")
return 0
stocks = [{"code": r.stock_code, "name": r.stock_name} for r in rows]
set_watchlist(stocks)
global _watchlist_auto
_watchlist_auto = True
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] 시장 국면 체크 — CRASH/HALTED에서만 진입 차단
# 데이트레이딩은 분봉 기반이라 일봉 SIDEWAYS/strong_bearish와 무관하게
# 장중 변동성이 있으면 진입 가능 (CLAUDE.md 7.3 전략 독립성)
_BLOCK_REGIMES_DT = {"crash", "halted"}
try:
regime_info = await check_market_regime()
regime = regime_info.get("regime", "neutral")
if regime in _BLOCK_REGIMES_DT:
logger.info(f"[DayEntry] ⛔ {regime.upper()} 국면 — 데이트레이딩 진입 차단")
return entries
logger.info(f"[DayEntry] 시장 국면: {regime}")
except Exception as e:
logger.info(f"[DayEntry] 시장 국면 조회 실패 (계속 진행): {e}")
# [시간대 제한 완화 2026-09-22] 점심시간(11:30~13:00) VWAP/RSI_BB 제한 제거.
# 신규 진입 마감만 유지 — settings.DAY_TRADE_ENTRY_CUTOFF_HOUR/MIN (기본 15:00,
# EOD 15:20 강제청산 전 최소 20분 확보). 하드코딩 14:30 → 설정값.
now_time = datetime.now(_KST).time()
_is_lunch_break = False
_cutoff = dtime(settings.DAY_TRADE_ENTRY_CUTOFF_HOUR, settings.DAY_TRADE_ENTRY_CUTOFF_MIN)
if now_time >= _cutoff:
logger.info(f"[DayEntry] ⛔ {_cutoff.strftime('%H:%M')} 이후 — 신규 진입 마감")
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가 빈 리스트([])로 오는 경우까지 방어 (get의 default는 키가 없을 때만 적용됨)
summary = (balance.get("output2") or [{}])[0]
total_assets = float(summary.get("tot_evlu_amt", 0) or 0)
available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0)
# 당일 매도대금 재사용 가능 — 주문가능현금이 조회되면 우선 사용
_buyable = await korea_investment_api.get_buyable_cash()
if _buyable > 0:
available_cash = _buyable
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
# [BUG FIX] 글로벌 자본 사용률 가드 — 전체 포트폴리오 투자액이 MAX_TOTAL_CAPITAL_USAGE 초과 시 진입 차단
total_invested = sum(
pos.get("entry_price", 0) * pos.get("quantity", 0)
for pos in day_trading_state.positions.values()
)
# 스윙 포트폴리오 투자액도 합산 (잔고 조회에서 available_cash로 간접 계산)
swing_invested = total_assets - available_cash - total_invested
all_invested = max(0, total_invested + swing_invested)
if total_assets > 0 and all_invested / total_assets > settings.MAX_TOTAL_CAPITAL_USAGE:
logger.warning(
f"[DayEntry] 전체 자본 사용률 초과: {all_invested/total_assets:.1%} > "
f"{settings.MAX_TOTAL_CAPITAL_USAGE:.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,
"stop_loss": signal.stop_loss,
"take_profit": signal.target_price,
})
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