## 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>
478 lines
19 KiB
Python
478 lines
19 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
|
|
|
|
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
|