Files
auto-stock/backend/app/services/daytrading/entry_service.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

2441 lines
127 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""데이트레이딩 진입 서비스
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
import time
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,
# 진단 전용(읽기 전용 순수 함수). 게이트에 쓰이지 않는다 —
# `_log_rsi_gap_diagnostics()` 가 탈락 후보의 분포를 남기는 데만 쓴다.
_rsi as _rsi5, _overnight_gap_pct,
)
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,
)
from app.core.log_throttle import warn_throttled # noqa: E402
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
# ── [F6 2026-09-28] 스캔 처리량 ───────────────────────────────────────────────
# 증상: 감시종목 20개인데 매 사이클 앞쪽 5~6종목만 검사되고 뒤쪽은 영구히 기회를
# 못 얻었다. 스케줄러의 _guard(scan_and_enter(), "진입 스캔") 가 180초에서 코루틴을
# 취소하는데, 종목별 분봉 조회가 순차라 20종목이 예산에 들어오지 못했다.
#
# 대책 3종:
# 1) 분봉 조회를 제한 동시성으로 **선조회**(읽기 전용)해 지배적 지연을 제거한다.
# 주문은 그대로 순차 실행 → MAX_POSITIONS·예산 클램프 순서가 바뀌지 않는다.
# 2) 순차 실행 구간에 시간 예산을 둬 **잘린 종목 수를 warning 으로 남긴다**
# (기존에는 _guard 가 조용히 취소했다).
# 3) 라운드로빈 시작 오프셋 — 그래도 잘리면 다음 사이클은 다른 종목부터 본다.
#
# 동시성은 보수적으로 잡는다. KIS 호출은 korea_investment_api 내부에서
# semaphore(API_MAX_CONCURRENT_REQUESTS=8) + _throttle_lock(min_request_interval)
# 으로 이미 직렬화·쓰로틀되므로 여기서 병렬도를 올려도 레이트리밋을 넘지 않는다.
# [2026-09-30] 4 → 8. 실제 병목은 전역 쓰로틀이 아니라 **이 동시성**이었다.
# 종목당 분봉 페이지 1장의 체인 비용은 약 3.2초(운영 실측 요청 3.0초 + in-loop
# sleep 0.2초)이므로 동시성 4 에서는 4/3.35 ≈ 1.2 req/s 밖에 나오지 않는다 —
# 전역 쓰로틀 상한(1/API_MIN_REQUEST_INTERVAL = 6.67 req/s)의 5분의 1 이다.
# 8 로 올려도 8/3.35 ≈ 2.4 req/s 로 쓰로틀 상한에 닿지 않고, 오후(종목당 10~12장)
# 구간의 선조회 시간이 절반으로 줄어 스캔 포화가 풀린다.
# ⚠️ 상한은 8 이다: korea_investment_api 의 `semaphore(API_MAX_CONCURRENT_REQUESTS=8)`
# 와 같은 값이라 이 이상 올려도 실효가 없고, 세마포어를 완전히 점유해 다른 경로
# (스윙 청산·주문)의 대기만 늘린다. 세마포어는 FIFO 이므로 대기는 요청 1건
# (약 3초) 으로 유계다.
_SCAN_PREFETCH_CONCURRENCY: int = 8
# 종목 1건 선조회 상한(초) — 한 종목이 느려도 나머지가 스캔되게 한다.
# [2026-09-30] 20 → 45. 종목당 페이지 수는 시간대에 비례해 늘어난다
# (당일 1분봉 ÷ 30봉): 09:30 4장 → 12:00 6장 → 14:00 10장 → 15:00 12장.
# 12장 × 3.2초 ≈ 38초이므로 20초에서는 **12:00 이후 전 종목이 타임아웃**했고,
# 그러면 순차 루프가 종목별 재조회를 떠안아(타임아웃 없음·직렬) 오히려 느려졌다.
# VWAP 이 세션 앵커 누적이라 페이지 수를 줄이는 방식은 지표 정의를 바꾸게 되므로
# 택하지 않았다. 대신 아래 **단계 전체 데드라인**으로 예산을 보호한다.
_SCAN_PREFETCH_TIMEOUT: float = 45.0
# 선조회 **단계 전체** 데드라인(초). per-stock 타임아웃만 올리면 최악의 경우
# ceil(N/동시성) 웨이브 × 45초가 스케줄러 _guard(STEP_TIMEOUT=180)를 넘겨 스캔 전체가
# 조용히 취소된다(진입 0건 + 로그 없음). 단계 자체에 상한을 둬서 **순차 진입 구간이
# 항상 실행되게** 한다. 이미 캐시에 담긴 종목은 취소돼도 남는다(_one 이 완료 즉시 저장).
# _SCAN_TIME_BUDGET(150초)에서 순차 구간·주문 몫 30초를 남긴 값이다.
_SCAN_PREFETCH_TOTAL_BUDGET: float = 120.0
# 순차 진입 구간 시간 예산(초). 스케줄러 _guard(STEP_TIMEOUT=180) 보다 작게 잡아
# 취소가 아니라 '남은 종목 수 로그'로 끝나게 한다.
_SCAN_TIME_BUDGET: float = 150.0
# 선조회 분봉 캐시 {code: (저장시각, candles)} — 한 사이클 안에서만 유효
_candle_cache: dict[str, tuple[float, list]] = {}
# [F11 2026-09-28] 수명은 '시간'이 아니라 '한 사이클'이다 — scan_and_enter() 가 진입
# 시점에 캐시를 비운다. TTL 만 180초로 두면 30초 주기 스케줄러에서 같은 스냅샷이 5개
# 사이클을 덮어, 세 신호 함수가 (candles, orb, price) 의 순수 함수인 탓에 t=0 에
# 신호가 없으면 t=150 까지 무조건 no-signal 이 되고 진입가·손절·수량이 최대 3분 묵는다.
# [M3 2026-09-28] TTL 은 **한 사이클 상한(_SCAN_TIME_BUDGET)보다 커야** 한다.
# 이전 값 25초는 선조회 자체보다 짧았다: 모든 KIS 요청이 싱글톤 _throttle_lock 안에서
# min_request_interval(0.15초) 만큼 sleep 하므로 실효 처리율은 ≈6.7 req/s 로 전역
# 고정이고(semaphore(8) 은 상한만 제공), 20종목 × 종목당 다회 호출이면 선조회가 수십
# 초를 쓴다 → 앞쪽 항목이 순차 루프 시작 전에 만료돼 재조회됐다(종목당 호출 2배).
_CANDLE_CACHE_TTL: float = 180.0
# 라운드로빈 시작 오프셋
_scan_offset: int = 0
# [H1 2026-10-02] DAY_TRADE_ORB_FULL_RANGE=True 일 때 확정된 ORB 종목(당일). 이 시각 이후의
# 첫 계산을 확정본으로 둔다 — 레인지 창은 09:00~09:29(calculate_opening_range 정의).
_ORB_FULL_RANGE_FINALIZE_AT = dtime(9, 29)
_orb_finalized: set[str] = set()
_orb_finalized_date = None
def _reset_orb_finalized_if_new_day() -> None:
global _orb_finalized_date
_today = datetime.now(_KST).date()
if _orb_finalized_date != _today:
_orb_finalized.clear()
_orb_finalized_date = _today
# [H2/M5 2026-10-02] 주문 진행 중 슬롯 예약 {stock_code: 예약 금액}. 주문 직전 trade_lock
# 안에서 등록하고, 포지션 등록(같은 락) 또는 주문 실패 시 해제한다.
_pending_entries: dict[str, float] = {}
# ── [하드닝 R2 2026-10-09] 타전략 보유 종목 DT 진입 차단 ─────────────────────────
# 배경: DT 진입은 DT 인메모리 포지션·closed_today 만 보고, 같은 종목의 스윙/외부/동기화
# 보유를 확인하지 않았다. Portfolio 는 stock_code UNIQUE 라 수량이 합산되고(§3.5),
# 청산 쪽 차감·Portfolio 태그 소유권·고아 판정이 모두 모호해진다(실측: 105560 synced 3주
# 보유 중 DT 가 10/01·10/06 두 차례 매수 → Portfolio 행이 'daytrading' 태그로 바뀜).
# 스윙 쪽 누적 상한(trade_executor._apply_position_cap_room_v2)은 DT 수량을 세지만
# DT 쪽에는 역방향 검사가 없어 한 종목이 4%(스윙)+6%(DT) 까지 쌓일 수 있었다.
#
# 정책(더 안전한 쪽): **비-DT 보유가 있는 종목에는 DT 가 신규 진입하지 않는다.**
# 두 소스를 쓴다 — 둘 다 실시간 루프에 새 DB I/O 를 만들지 않는다(§3.1).
# ① DB 스냅샷: populate_daytrading_watchlist 의 읽기 전용 세션(§3.2 ③)에서
# active 비-DT ActiveTrade(not_dt_strategy_clause) + 비-DT Portfolio 행을 SELECT.
# 실패하면 warning 후 빈 집합(② 가 주 가드이므로 진입 전체를 막지 않는다).
# ② KIS 잔고 스냅샷: scan_and_enter 가 사이클마다 이미 호출하는 get_balance() 의
# output1(추가 호출 없음). DT 인메모리 포지션이 아닌 보유 종목 = 타전략/고아 보유.
# 수동 watchlist(set_watchlist) 도 이 가드를 탄다.
# ⚠️ get_balance 는 첫 페이지만 조회한다(§8.5-37) — 보유가 많으면 ① 이 보완한다.
_non_dt_held_db: set[str] = set()
# 마지막 사이클의 KIS 보유 {code: {"qty": int, "name": str, "eval": float}}
_kis_holdings_snapshot: dict[str, dict] = {}
# output1 을 실제로 해석했는지(응답 이상이면 False — 레버리지 한도 검증 불가로 본다)
_kis_holdings_verified: bool = False
def _parse_kis_holdings_v2(balance) -> dict[str, dict] | None:
"""잔고 응답 output1 → {code: {"qty","name","eval"}}. output1 이 없으면 None(검증 불가)."""
if not isinstance(balance, dict):
return None
rows = balance.get("output1")
if rows is None or not isinstance(rows, list):
return None
out: dict[str, dict] = {}
for h in rows:
if not isinstance(h, dict):
continue
code = str(h.get("pdno", "") or "").strip()
if not code:
continue
try:
qty = int(float(str(h.get("hldg_qty", 0) or 0).replace(",", "")))
except (TypeError, ValueError):
qty = 0
if qty <= 0:
continue
try:
ev = float(str(h.get("evlu_amt", 0) or 0).replace(",", ""))
except (TypeError, ValueError):
ev = 0.0
if ev <= 0:
try:
ev = qty * float(str(h.get("prpr", 0) or 0).replace(",", ""))
except (TypeError, ValueError):
ev = 0.0
out[code] = {"qty": qty, "name": str(h.get("prdt_name", "") or ""), "eval": ev}
return out
def _non_dt_holding_reason_v2(stock_code: str) -> str | None:
"""타전략(스윙/외부/동기화) 또는 DT 인메모리에 없는 KIS 보유가 있으면 차단 사유."""
if stock_code in _non_dt_held_db:
return "타전략 보유(DB: 비-DT ActiveTrade/Portfolio)"
if (
stock_code in _kis_holdings_snapshot
and stock_code not in day_trading_state.positions
and stock_code not in _pending_entries
):
_q = _kis_holdings_snapshot[stock_code].get("qty", 0)
return f"KIS 잔고 {_q}주 보유(DT 인메모리 포지션 아님 — 타전략/고아)"
return None
async def _refresh_non_dt_held_db_v2() -> None:
"""비-DT 보유 종목 코드 스냅샷 갱신 — populate_daytrading_watchlist 전용(읽기 전용).
실패하면 warning 후 **이전 스냅샷을 유지**한다(비우면 가드가 조용히 풀린다).
"""
global _non_dt_held_db
from app.core.database import AsyncSessionLocal
from app.models.models import ActiveTrade, Portfolio
from app.services.db_worker import not_dt_strategy_clause
from sqlalchemy import select
try:
# DB-READ-EXCEPTION: §3.2 ③ populate_daytrading_watchlist 경로 — SELECT 전용, commit 없음
async with AsyncSessionLocal() as session:
held_rows = (await session.execute(
select(ActiveTrade.stock_code).where(
ActiveTrade.status == "active",
ActiveTrade.quantity > 0,
not_dt_strategy_clause(ActiveTrade.strategy_name),
).union(
select(Portfolio.stock_code).where(
Portfolio.quantity > 0,
not_dt_strategy_clause(Portfolio.strategy_type),
)
)
)).all()
except Exception as e:
logger.warning(
f"[DayEntry] 타전략 보유 스냅샷 조회 실패(읽기 전용) — 이전 스냅샷 "
f"{len(_non_dt_held_db)}종목 유지, KIS 잔고 가드로 보완: {e}"
)
return
codes: set[str] = set()
for r in held_rows:
try:
c = r[0]
except (TypeError, IndexError, KeyError):
c = getattr(r, "stock_code", None)
if c:
codes.add(str(c))
_non_dt_held_db = codes
def _price_filter_block_v2(stock_name: str, price: float) -> str | None:
"""§5.1 가격 필터(국내 MIN_PRICE_KRW '초과'만 매수). ETF/ETN/리츠 예외는 trade_executor 와 동일 정의."""
if price > settings.MIN_PRICE_KRW:
return None
from app.services.trade_executor import _is_price_filter_exempt_name_v2
if _is_price_filter_exempt_name_v2(stock_name):
return None
return f"가격 필터: 현재가 {price:,.0f}원 ≤ {settings.MIN_PRICE_KRW:,}원"
def _leverage_block_v2(stock_code: str, stock_name: str, budget: float, total_assets: float) -> str | None:
"""§5.1 레버리지/인버스 ETF 20%(LEVERAGE_ETF_MAX_RATIO) — 이번 주문 포함.
보유 평가액은 사이클 KIS 잔고 스냅샷 + 스냅샷 이후 들어온 DT 포지션(진입가×수량).
스냅샷을 해석하지 못했으면 검증 불가 → 레버리지 종목만 차단(fail-closed,
trade_executor 7.7 단계와 같은 정책).
"""
from app.services.trade_helpers import get_leverage_check_keywords, is_leverage_etf
kw = get_leverage_check_keywords()
if not is_leverage_etf(stock_name, kw):
return None
if not _kis_holdings_verified or total_assets <= 0:
return (
f"레버리지 한도 {settings.LEVERAGE_ETF_MAX_RATIO:.0%} 검증 불가"
f"(잔고 보유목록 미확인) — fail-closed"
)
lev_total = sum(
float(h.get("eval", 0) or 0)
for h in _kis_holdings_snapshot.values()
if is_leverage_etf(h.get("name", ""), kw)
)
for code, pos in day_trading_state.positions.items():
if code in _kis_holdings_snapshot:
continue
if is_leverage_etf(str(pos.get("stock_name", "") or ""), kw):
lev_total += float(pos.get("entry_price", 0) or 0) * float(pos.get("quantity", 0) or 0)
ratio_after = (lev_total + max(0.0, budget)) / total_assets
if ratio_after > settings.LEVERAGE_ETF_MAX_RATIO:
return (
f"레버리지 ETF 비중 {ratio_after:.1%}(보유 {lev_total:,.0f} + 신규 {budget:,.0f}) "
f"> {settings.LEVERAGE_ETF_MAX_RATIO:.0%} 한도"
)
return None
# ── [하드닝 루프 R1 2026-10-09] §5.1 섹터캡·최대 보유 종목 수 (플래그, 기본 OFF) ─────────
# 배경: 스윙/수동 경로는 섹터당 3종목(correlation_filter)·MAX_HOLDINGS_COUNT 를 적용하지만
# DT 진입은 어느 쪽도 보지 않아 같은 섹터 5종목(5×6%=30%) 쏠림, 스윙 20 + DT 5 = 25종목이
# 가능했다. §7 에는 DT 섹터 규칙이 없고 거래 빈도에 직접 영향(백테스트 미검증)이 있으므로
# **설정 플래그로 출시하고 기본은 OFF**(현행 동작 유지)다. 플래그는 config.py 에 아직 없을 수
# 있어 getattr 폴백(False)으로 읽는다.
# - DAY_TRADE_SECTOR_CAP_ENABLED : 섹터당 상한(correlation_filter 기본값과 동일 출처)
# - DAY_TRADE_HOLDINGS_CAP_ENABLED : 전체 보유 종목 수 상한(settings.MAX_HOLDINGS_COUNT)
# 실시간 계층이므로 DB·네트워크 I/O 없이 이미 있는 스냅샷만 읽는다(§3.1):
# KIS 잔고 스냅샷(사이클별) ∪ 비-DT DB 스냅샷(populate 시 읽기 전용) ∪ DT 인메모리 ∪ 주문 진행.
# 섹터는 sector_mapper.get_sector(큐레이션 표 → 파일 캐시, 동기). correlation_filter.
# get_stock_sector 는 캐시 미스 시 DB 세션을 열므로 여기서 쓰지 않는다.
# 미매핑 처리는 correlation_filter 와 같은 SECTOR_CAP_UNMAPPED_ENABLED 규약을 따른다.
# 플래그가 켜졌는데 KIS 보유목록을 해석하지 못했으면 검증 불가 → 차단(fail-closed,
# 레버리지 가드와 같은 정책). ⚠️ get_balance 는 첫 페이지만 본다(§8.5-37) — DB 스냅샷이 보완.
def _dt_sector_cap_limit_v2() -> int:
"""섹터당 상한 — correlation_filter.check_sector_concentration 기본값을 단일 출처로 쓴다."""
import inspect
from app.services.correlation_filter import check_sector_concentration
return int(inspect.signature(check_sector_concentration).parameters["max_per_sector"].default)
def _dt_cap_flags_v2() -> tuple[bool, bool]:
"""(섹터캡, 보유수 상한) DT 적용 플래그. config.py 에 필드가 없으면 False(현행 동작)."""
return (
bool(getattr(settings, "DAY_TRADE_SECTOR_CAP_ENABLED", False)),
bool(getattr(settings, "DAY_TRADE_HOLDINGS_CAP_ENABLED", False)),
)
def _dt_current_holding_codes_v2(exclude: str) -> set[str]:
codes: set[str] = set()
codes.update(str(c) for c in _kis_holdings_snapshot.keys())
codes.update(str(c) for c in _non_dt_held_db)
codes.update(str(c) for c in day_trading_state.positions.keys())
codes.update(str(c) for c in _pending_entries.keys())
codes.discard(str(exclude))
return codes
def _portfolio_cap_block_v2(stock_code: str) -> str | None:
"""§5.1 섹터캡·최대 보유 종목 수(DT 적용은 플래그). 차단 사유 또는 None.
리스크 가드이므로 호출부에서 try 로 감싸지 않는다(§4). 섹터 조회 예외만 내부에서
흡수하되 플래그 ON 상태에서는 차단(fail-closed)한다.
"""
sector_on, holdings_on = _dt_cap_flags_v2()
if not sector_on and not holdings_on:
return None
if not _kis_holdings_verified:
return "섹터/보유수 상한 검증 불가(잔고 보유목록 미확인) — fail-closed"
held = _dt_current_holding_codes_v2(exclude=stock_code)
if holdings_on and len(held) >= settings.MAX_HOLDINGS_COUNT:
return f"최대 보유 종목 수 {len(held)} ≥ {settings.MAX_HOLDINGS_COUNT}"
if sector_on:
from app.services import sector_mapper
cap_unmapped = bool(getattr(settings, "SECTOR_CAP_UNMAPPED_ENABLED", False))
max_per_sector = _dt_sector_cap_limit_v2()
try:
sector = sector_mapper.get_sector(stock_code)
held_sectors = [sector_mapper.get_sector(c) for c in held]
except Exception as e:
logger.warning(f"[DayEntry] 섹터 조회 실패 ({stock_code}) — fail-closed: {e}")
return f"섹터 조회 실패 — fail-closed ({e})"
if not sector:
if not cap_unmapped:
return None
sector = "UNKNOWN"
if cap_unmapped:
held_sectors = [s or "UNKNOWN" for s in held_sectors]
count = sum(1 for s in held_sectors if s == sector)
if count >= max_per_sector:
return f"섹터 {sector} 이미 {count}종목 보유(최대 {max_per_sector}종목)"
return None
# ── [하드닝 R2 2026-10-09] ORB 지연 보충 ──────────────────────────────────────────
# collect_opening_ranges 는 09:00~09:29 분기에서만, backfill_opening_ranges 는 재기동 시
# orb_cache 가 통째로 비었을 때만 돈다. 그래서 (a) 30분 갱신으로 새로 들어온 종목,
# (b) 09:00~09:29 에 봉 부족(VI 등)으로 수집에 실패한 종목은 하루 종일 ORB 가 없었다.
# 진입 스캔이 이미 선조회한 분봉(당일 09:00 부터 포함 — VWAP 세션 앵커와 같은 시계열)으로
# 09:30 이후 ORB 를 계산한다 — **추가 KIS 호출 없음**. 계산식은 backfill 과 동일
# (_opening_range_v2: FULL_RANGE=False 면 창 안 첫 N봉, True 면 09:00~09:29 전체).
_ORB_WINDOW_END = dtime(9, 30)
# 당일 분봉이 개장 직후부터 들어 있어야 레인지가 잘리지 않는다(페이지 누락 방어).
_ORB_BACKFILL_FIRST_BAR_MAX = "090459"
_ORB_BACKFILL_WINDOW_CLOSED_MIN = "093000"
# [하드닝 R2-2 2026-10-09] ORB 레인지 정의 통일.
# FULL_RANGE=False(기본, §8.2 사용자 결정 '첫 5분 레인지')의 평상시 실측 동작은 `has_orb()`
# 단락 때문에 "09:00 이후 첫 5봉"(calculate_opening_range 의 최소 봉 수)으로 하루가 고정되는
# 것이다. 그런데 09:30 이후 소급(backfill_opening_ranges → collect_opening_ranges)과 지연
# 보충(_backfill_orb_from_cache_v2)은 당일 분봉 전체를 넘겨 **09:00~09:29 30봉** 레인지를
# 만들었다 → 같은 날 같은 종목의 ORB 고저·분당평균거래량·폭 필터가 재기동 여부로 달라졌다.
# 이제 FULL_RANGE=False 면 모든 경로가 창(09:00~09:29) 안의 **시각순 첫 N봉**만 쓴다.
# 평상시 첫 수집은 N번째 봉이 **완성된 뒤**(R6 2026-10-10, `_orb_bar_closed_v2`) 성공하므로
# 소급·지연 보충과 값이 같다(그 전에는 형성 중 봉의 부분값으로 고정돼 달랐다).
# FULL_RANGE=True 면 기존대로 창 전체.
_ORB_FIRST_BARS = 5 # calculate_opening_range 의 최소 봉 수와 동일(첫 성공 시점의 봉 수)
_ORB_WINDOW_START_HHMMSS = "090000"
_ORB_WINDOW_END_HHMMSS = "092959"
def _opening_range_v2(candles: list, full_range: bool):
"""FULL_RANGE 설정에 맞는 ORB 정의로 계산한다(순수 계산)."""
if full_range:
return calculate_opening_range(candles)
in_window = sorted(
(c for c in (candles or [])
if _ORB_WINDOW_START_HHMMSS <= str(getattr(c, "time", "") or "") <= _ORB_WINDOW_END_HHMMSS),
key=lambda c: str(getattr(c, "time", "") or ""),
)
first = in_window[:_ORB_FIRST_BARS]
# [하드닝 R6 2026-10-10] 마지막으로 쓰는 봉이 완성됐을 때만 확정한다.
# 정기 수집은 30초 주기 + has_orb() 단락이라, 5번째 봉(대개 09:04)이 막 생긴 사이클
# (예: 09:04:05)에 그 **형성 중 봉**의 부분 고가·저가·거래량으로 하루 ORB 가 고정됐다.
# 재기동 소급·지연 보충은 완성된 봉을 써서 같은 날 같은 종목의 ORB 가 달라졌다.
# 레인지 정의(첫 N봉)는 바꾸지 않고 확정 시점만 '마지막 봉 시각 + 60초 ≤ 현재'로 늦춘다.
if len(first) >= _ORB_FIRST_BARS and not _orb_bar_closed_v2(first[-1]):
return None
return calculate_opening_range(first)
_ORB_BAR_SECONDS = 60 # 1분봉 길이
def _orb_bar_closed_v2(candle) -> bool:
"""1분봉이 완성됐는지(봉 시작 + 60초 ≤ 현재 KST). 판정 불가면 False(미확정 → 다음 사이클 재시도)."""
now = datetime.now(_KST)
bar_date = str(getattr(candle, "date", "") or "")
today = now.strftime("%Y%m%d")
if bar_date and bar_date < today:
return True # 과거 날짜 봉은 완성됨
t = str(getattr(candle, "time", "") or "")
if len(t) != 6 or not t.isdigit():
logger.warning(f"[DayEntry] ORB 봉 시각 해석 불가({t!r}) — 확정 보류")
return False
bar_start = int(t[0:2]) * 3600 + int(t[2:4]) * 60 + int(t[4:6])
now_sec = now.hour * 3600 + now.minute * 60 + now.second
if bar_date and bar_date > today:
return False
return bar_start + _ORB_BAR_SECONDS <= now_sec
def _orb_from_candles_v2(candles: list):
"""당일 봉으로 ORB 계산. 레인지 창이 덮이지 않았으면 None."""
today = filter_today_candles(candles or [])
if not today:
return None
times = sorted(str(getattr(c, "time", "") or "") for c in today)
times = [t for t in times if t]
if not times or times[0] > _ORB_BACKFILL_FIRST_BAR_MAX or times[-1] < _ORB_BACKFILL_WINDOW_CLOSED_MIN:
return None
return _opening_range_v2(today, bool(settings.DAY_TRADE_ORB_FULL_RANGE))
def _backfill_orb_from_cache_v2(codes: list[str]) -> int:
"""선조회 캐시로 ORB 없는 종목의 ORB 를 채운다. 채운 종목 수 반환(순수 계산 + 인메모리)."""
if datetime.now(_KST).time() < _ORB_WINDOW_END:
return 0
_full_range = bool(settings.DAY_TRADE_ORB_FULL_RANGE)
if _full_range:
_reset_orb_finalized_if_new_day()
filled = 0
for code in codes:
if day_trading_state.has_orb(code):
continue
cached = _candle_cache.get(code)
if not cached:
continue
orb = _orb_from_candles_v2(cached[1])
if not orb:
continue
day_trading_state.set_orb(code, orb)
if _full_range:
_orb_finalized.add(code)
filled += 1
_w = (orb.high - orb.low) / orb.low * 100 if orb.low > 0 else 0.0
logger.info(
f"[DayEntry] ORB 지연 보충(09:30 이후, 선조회 분봉): {code} "
f"고={orb.high:,.0f} 저={orb.low:,.0f} 폭={_w:.2f}%"
)
return filled
# ── RSI·갭 진단 로깅 (2026-09-30) ─────────────────────────────────────────────
# 배경: 장중 5분봉 RSI 는 워밍업 시계열(전일 봉 포함)을 쓰므로 오버나이트 갭이 종가
# 차분 한 개로 RSI 에 들어간다(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3). 사용자
# 결정은 **유지 + 관찰**인데, 관찰 수단이 없었다: `trade_history.entry_reason` 의
# `RSI=` 는 **게이트를 통과한 진입에만** 존재해 분포가 임계값 바로 아래에 고인다
# (실측 56건 29.3~44.9, 임계 45 절단). 기저 분포를 보려면 **탈락 후보**의 값이 필요하다.
#
# 볼륨 억제: 종목마다 한 줄이면 30종목 × 30초 주기 × 5.5시간 ≈ 2만 줄/일이고,
# `docker logs` 가 이미 수 분씩 걸린다. 그래서 **사이클당 1줄**로 집계하고, 다시
# N 사이클마다 1회만 남긴다(market_analyzer._ML_SKIP_LOG_EVERY 와 같은 패턴).
# 10 사이클 = 약 5분 → 하루 약 60줄.
_RSI_DIAG_LOG_EVERY: int = 10
_rsi_diag_cycles: int = 0
# 한 줄에 싣는 최저 RSI 종목 수 (전량 싣으면 줄이 길어져 그렙이 어려워진다)
_RSI_DIAG_TOP_N: int = 8
def _log_rsi_gap_diagnostics(candidates: list[tuple[str, str]]) -> None:
"""후보 전체의 (RSI, 갭) 분포를 **한 줄로** 남긴다 — 읽기 전용 진단.
진입 판정에 개입하지 않는다. 이미 선조회된 `_candle_cache` 만 읽으므로
**추가 KIS 호출·DB I/O 가 없다**(§3.1). 통과 여부와 무관하게 전 후보의 값을
남기므로 `entry_reason` 이 가진 **절단 편향이 없는 기저 분포**가 쌓인다.
로깅 위치를 전략 모듈이 아니라 **호출부**로 잡은 이유:
- `day_trading_strategy` 의 신호 함수는 `(candles, vwap, price)` 의 순수 함수다.
거기서 남기면 종목마다 한 줄이 되어 볼륨을 억제할 방법이 없고, 억제 카운터를
두면 모듈에 가변 상태가 생겨 순수성·테스트 순서 독립성이 깨진다(§4.1).
- 호출부는 사이클 경계를 알기 때문에 집계가 자연스럽다.
- 필요한 값(RSI·갭)은 `_rsi5`/`_overnight_gap_pct` 로 **같은 입력에서 재계산**된다.
둘 다 순수 함수이므로 신호 함수가 본 값과 동일하다(5분봉 45개 내외 연산).
"""
global _rsi_diag_cycles
_rsi_diag_cycles += 1
if _rsi_diag_cycles != 1 and _rsi_diag_cycles % _RSI_DIAG_LOG_EVERY != 0:
return
rows: list[tuple[float, float | None, str]] = []
for code, _ in candidates:
cached = _candle_cache.get(code)
if not cached:
continue
c5 = aggregate_to_5min(cached[1])
rsi = _rsi5(c5, 14)
if rsi is None:
continue
rows.append((rsi, _overnight_gap_pct(c5), code))
if not rows:
logger.info(
f"[DayEntry] RSI 진단: 후보 {len(candidates)}종목 중 RSI 산출 0건 "
f"(5분봉 부족 — 워밍업 미달)"
)
return
rows.sort()
_rsis = [r[0] for r in rows]
_gaps = [r[1] for r in rows if r[1] is not None]
_vwap_th = settings.DAY_TRADE_VWAP_RSI_THRESHOLD
_bb_th = settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD
_pass_vwap = sum(1 for v in _rsis if v <= _vwap_th)
_pass_bb = sum(1 for v in _rsis if v <= _bb_th)
_gap_down = sum(1 for g in _gaps if g <= -1.0)
_top = ", ".join(
f"{c}:{r:.1f}" + (f"/{g:+.1f}%" if g is not None else "")
for r, g, c in rows[:_RSI_DIAG_TOP_N]
)
logger.info(
f"[DayEntry] RSI 진단(사이클 {_rsi_diag_cycles}): n={len(rows)} "
f"평균={sum(_rsis) / len(_rsis):.1f} 최저={_rsis[0]:.1f} 최고={_rsis[-1]:.1f} | "
f"RSI≤{_vwap_th:.0f} {_pass_vwap}종목, RSI≤{_bb_th:.0f} {_pass_bb}종목 | "
f"갭 산출 {len(_gaps)}종목, 그중 갭≤-1% {_gap_down}종목 | "
f"최저 RSI(종목:RSI/갭): {_top}"
)
def _clamp_day_trade_budget(
budget: float,
total_assets: float,
available_cash: float,
current_day_trade_amount: float,
) -> float:
"""[H3] 데이트레이딩 주문 예산을 세 가지 한도로 재클램프.
calculate_day_trade_position_size()와 동일한 한도를 적용한다
(종목당 DAY_TRADE_MAX_CAPITAL_RATIO / DT 전체 DAY_TRADE_MAX_RATIO_DT 잔여 / 보유 현금).
confidence 배율처럼 클램프 이후 예산을 변형하는 로직 뒤에 반드시 호출해야 한다.
calculate_day_trade_position_size() 자체는 변경하지 않는다(시그니처 보존).
Args:
budget: 클램프 대상 예산 (원)
total_assets: 총자산
available_cash: 주문 가능 현금
current_day_trade_amount: 현재 데이트레이딩 투입 금액
Returns:
float: 세 한도를 모두 만족하는 예산 (0 이상)
"""
per_position_cap = total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO
remaining_dt_cap = total_assets * settings.DAY_TRADE_MAX_RATIO_DT - current_day_trade_amount
# [하드닝 R5 2026-10-09] §5.1 전 전략 합산 현금 사용 상한(MAX_TOTAL_CAPITAL_USAGE, 95%)을
# **주문 후** 기준으로 지킨다. 예전에는 사이클 가드가 주문 전 사용률(> 95%)만 보고 이
# 함수는 현금 전액까지 허용해, 스윙 70% + DT 30% 로 현금 유보 5% 가 0 이 될 수 있었다.
# 투자액 = 총자산 − 가용현금 이므로 "주문 후 사용률 ≤ 상한" ⇔
# budget ≤ 가용현금 − 총자산 × (1 − 상한). 리스크 클램프라 try 밖(§4).
cash_room = _total_usage_cash_room_v2(total_assets, available_cash)
return max(0.0, min(budget, per_position_cap, remaining_dt_cap, available_cash, cash_room))
def _total_usage_cash_room_v2(total_assets: float, available_cash: float) -> float:
"""전 전략 합산 사용 상한(MAX_TOTAL_CAPITAL_USAGE)을 넘지 않고 쓸 수 있는 현금(원, 0 이상).
유보 비율은 ``1 − MAX_TOTAL_CAPITAL_USAGE`` 를 [0, 1] 로 자른다(상한이 1 초과로
잘못 설정돼도 유보가 음수가 되어 현금 이상을 허용하지 않게).
"""
reserve_ratio = min(1.0, max(0.0, 1.0 - float(settings.MAX_TOTAL_CAPITAL_USAGE)))
return max(0.0, float(available_cash) - float(total_assets) * reserve_ratio)
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 | None = None) -> int:
"""stock_analysis 테이블에서 오늘 분석 결과를 조회하여 watchlist 자동 설정.
배치 분석(batch_daily_analysis) 결과에서 점수가 높은 종목을
자동으로 데이트레이딩 감시 대상으로 설정.
필터 조건:
- 오늘 날짜 분석 결과만 사용
- score >= min_score (기본 1점 이상)
- RSI 값이 존재 (기술적 분석 완료 종목만)
- 현재가 > settings.MIN_PRICE_KRW (CLAUDE.md §5.1 가격 필터)
- 타전략(스윙/외부/동기화) 보유 종목 제외 (하드닝 R2)
Args:
min_score: 최소 점수 기준 (기본 1)
max_stocks: 최대 감시 종목 수. ``None`` 이면
``settings.DAY_TRADE_WATCHLIST_SIZE`` (하드코딩 상한 없음).
Returns:
int: 설정된 감시 종목 수
Note:
이 함수는 DB 읽기 전용으로, CLAUDE.md 3.1 예외 해당.
populate는 스케줄러 시작 시 1회, 이후 30분마다 갱신.
[2026-09-30] 상한이 20 으로 하드코딩돼 있어 ORB 후보 풀을 넓힐 방법이 없었다.
``settings.DAY_TRADE_WATCHLIST_SIZE`` 로 이관했다 — 상한 근거·경합 계산은
config.py 의 해당 키 주석 참조. 기존 시그니처는 보존한다(§4): 인자를 명시한
호출부는 그대로 동작하고, 인자를 생략한 호출부만 settings 를 따른다.
"""
from app.core.database import AsyncSessionLocal
from app.models.models import StockAnalysis
from sqlalchemy import select, func
# [§4] 한도 결정은 try 밖에서 — 예외로 조용히 우회되면 상한이 사라진다.
if max_stocks is None:
max_stocks = settings.DAY_TRADE_WATCHLIST_SIZE
today = datetime.now(_KST).date()
# [하드닝 R2 2026-10-09] 타전략 보유 종목 스냅샷(읽기 전용, §3.2 ③ 같은 함수 안).
# 별도 세션 — 실패해도 아래 분석 조회와 무관하게 진행한다(KIS 잔고 가드가 주 가드).
await _refresh_non_dt_held_db_v2()
_held = set(_non_dt_held_db)
# [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유]
# 이 세션은 SELECT 2건만 수행하며 commit()/flush()/add() 를 호출하지 않는다.
# 세션 수명은 아래 블록으로 최소화하고, 실패 시 warning 으로 운영에 노출한다.
latest_date = None
rows: list = []
try:
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
# 가격 필터는 settings(§5.1 '초과') — 하드코딩 5000 제거. 타전략 보유 종목을
# 걸러낸 뒤에도 상한을 채울 수 있게 그만큼 더 읽는다.
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 > settings.MIN_PRICE_KRW,
)
# [하드닝 R5 2026-10-09] 결정적 정렬 — 규칙 점수는 정수(실측 score=3 동점군
# 59~74종목)라 컷오프가 동점군 한가운데 걸리고, PostgreSQL 은 동점 행 순서를
# 보장하지 않아 30분 갱신마다 구성원이 임의로 바뀔 수 있었다. 동점은 일간
# 거래대금(volume×current_price, NULL 마지막) → stock_code 오름차순으로 끊는다.
# 선정 기준 자체(스윙 점수)를 DT 용 지표로 바꾸는 것은 백테스트 후 별도 작업.
.order_by(
StockAnalysis.score.desc(),
(StockAnalysis.volume * StockAnalysis.current_price).desc().nulls_last(),
StockAnalysis.stock_code.asc(),
)
.limit(max_stocks + len(_held))
)
rows = result.all()
except Exception as e:
logger.warning(f"[DayEntry] watchlist 자동 구성용 분석 조회 실패(읽기 전용): {e}")
return 0
if latest_date != today:
logger.info(f"[DayEntry] 당일({today}) 분석 없음 → 최근 분석일 {latest_date} 결과로 watchlist 구성")
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]
_excluded_held = [x["code"] for x in stocks if x["code"] in _held]
if _excluded_held:
logger.info(
f"[DayEntry] watchlist 에서 타전략 보유 {len(_excluded_held)}종목 제외 "
f"(DT 중첩 진입 방지): {_excluded_held[:10]}"
)
stocks = [x for x in stocks if x["code"] not in _held][:max_stocks]
# [하드닝 R2 2026-10-09] 갱신은 **점수 순위**로 교체한다(§7 "30분마다 갱신").
# 예전(F8)에는 ORB 보유 종목을 앞에 고정하고 상한으로 잘라, 09:00 목록 30종목이 모두
# ORB 를 가지면 갱신이 no-op 이 되어 장중 stock_analysis 갱신이 반영되지 않았다.
# 새로 들어온 종목의 ORB 는 진입 스캔이 선조회 분봉으로 보충한다
# (`_backfill_orb_from_cache_v2`, 추가 KIS 호출 없음) — ORB 를 잃지 않으려고 목록을
# 동결할 이유가 없어졌다. 단 점수 후보가 상한에 못 미치면 기존 ORB 보유 종목으로
# 빈자리를 채운다(F8 의 의도 — 수집한 ORB 를 버리지 않기 — 는 여유분에서 유지).
if len(stocks) < max_stocks:
_codes = {x["code"] for x in stocks}
_pad = [
{"code": c, "name": _watchlist.get(c, c)}
for c in _watchlist
if day_trading_state.has_orb(c) and c not in _codes and c not in _held
][: max_stocks - len(stocks)]
if _pad:
logger.info(
f"[DayEntry] watchlist 갱신: 점수 후보 {len(stocks)}개 < 상한 {max_stocks} — "
f"기존 ORB 보유 {len(_pad)}종목으로 보충"
)
stocks = stocks + _pad
_prev = set(_watchlist)
_new = {x["code"] for x in stocks}
if _prev:
_added = sorted(_new - _prev)
_removed = sorted(_prev - _new)
if _added or _removed:
logger.info(
f"[DayEntry] watchlist 갱신 변경: +{len(_added)} {_added[:10]} / "
f"-{len(_removed)} {_removed[:10]} (ORB 없는 신규 종목은 09:30 이후 스캔 시 보충)"
)
else:
logger.info(f"[DayEntry] watchlist 갱신: 변경 없음 ({len(_new)}종목)")
set_watchlist(stocks)
global _watchlist_auto
_watchlist_auto = True
logger.info(f"[DayEntry] 자동 watchlist 설정: {len(stocks)}개 (score>={min_score})")
return len(stocks)
async def _fetch_candles_impl(stock_code: str) -> list:
"""분봉 조회 + 파싱. 실패 시 빈 리스트.
⚠️ **전일 봉이 섞인 시계열을 반환한다** — 진입 판정 경로 전용이다.
`get_minute_chart_full_day_v2(include_prev_day=True)` 를 쓰는 이유는 **RSI(14)/
BB(20) 판정 게이트의 워밍업**이다: 5분봉 RSI(14)=15봉·BB(20)=20봉 요건 때문에
당일 봉만으로는 VWAP 이 약 10:15, RSI_BB 가 약 10:40 이전에 구조적으로 발화하지
못했다 (`indicators_core.rsi_wilder` 는 봉 부족 시 `None`). 원장 실측으로 필터
도입(1b9257c, 2026-09-28) 이전에는 09:30·10:02 진입이 실재했음이 확인됐다.
⚠️ **"워밍업이 RSI/BB 에만 영향을 준다"는 단언은 사실이 아니었다** (2026-09-30
정정). 같은 시계열이 `_atr` 로도 흘러가 **당일 첫 봉의 TR = 오버나이트 갭**을
윈도에 넣어 진입 손절·목표가를 부풀렸고, 청산 트레일링(당일 전용 분봉)과 기준이
엇갈렸다. 지금은 `day_trading_strategy._atr` 이 내부에서 `_last_session_candles()`
로 세션을 끊는다 — 진입/청산 ATR 이 같은 기준이다. **봉 간 차분을 쓰는 지표를
새로 추가할 때 이 시계열을 그대로 먹이지 말 것.**
⚠️ **얼마나 앞당겨지는지는 확보된 워밍업 분량에 달렸고, 아직 미검증이다.**
조회 계층이 전일로 거슬러 가려면 미래 `ref_time` 에 KIS 가 전일 데이터를 준다는
전제가 필요한데 그 전제는 확인되지 않았다(`get_minute_chart_full_day_v2` docstring).
전일 봉이 경계 페이지 1장만 들어오면 발화 시각이 소폭 앞당겨지는 데 그친다.
실제 확보량은 조회 계층의 `[분봉] ... 워밍업 5분봉 N봉` 로그로 확인할 것.
ORB 수집(`collect_opening_ranges`)·DT 청산(`daytrading/exit_service`)·레거시
엔드포인트는 **기존 메서드(`get_minute_chart_full_day`)를 그대로 쓴다** — 당일 봉만
받는다.
⚠️ 호출부(`_try_enter`)는 용도를 반드시 분리해야 한다:
- 워밍업 허용(전일 포함): `aggregate_to_5min(candles)` → RSI/BB
- 당일 봉만: `calculate_vwap`, `check_orb_signal`, 현재가, 갭·당일 거래량
→ `filter_today_candles()` 로 분리 (CLAUDE.md §7 마지막 항목)
세션 경계 비교는 순수 계산 모듈 쪽에서 막는다 —
`day_trading_strategy.volume_surge_ratio()` 와 `check_orb_signal()` 의 거래량
게이트는 **마지막 봉과 같은 영업일자의 봉끼리만** 비교한다(`_last_session_candles`).
"""
raw = await korea_investment_api.get_minute_chart_full_day_v2(
stock_code, include_prev_day=True
)
return parse_minute_candles(raw)
async def _prefetch_minute_charts(codes: list[str]) -> int:
"""[F6] 후보 종목의 분봉을 제한 동시성으로 선조회해 캐시에 담는다.
읽기 전용이며 주문·상태 변경을 하지 않는다. 실패·타임아웃 종목은 캐시에
담기지 않고, 뒤이은 순차 스캔이 개별적으로 재시도한다(기존 동작과 동일).
Returns:
int: 캐시에 담긴 종목 수
"""
if not codes:
return 0
sem = asyncio.Semaphore(_SCAN_PREFETCH_CONCURRENCY)
now = time.monotonic()
async def _one(code: str) -> None:
cached = _candle_cache.get(code)
if cached and (now - cached[0]) < _CANDLE_CACHE_TTL:
return
async with sem:
try:
candles = await asyncio.wait_for(
_fetch_candles_impl(code), timeout=_SCAN_PREFETCH_TIMEOUT
)
if candles:
_candle_cache[code] = (time.monotonic(), candles)
except asyncio.TimeoutError:
logger.warning(
f"[DayEntry] 분봉 선조회 {_SCAN_PREFETCH_TIMEOUT:.0f}초 초과: {code} — 이번 사이클 스킵"
)
except Exception as e:
logger.warning(f"[DayEntry] 분봉 선조회 실패 ({code}): {e}")
# 단계 전체 데드라인 — 초과분은 포기하고 순차 진입 구간으로 넘어간다(§8.1-11:
# 청산·진입 판정이 선조회 때문에 통째로 취소되는 것이 최악이다).
_gather = asyncio.gather(*(_one(c) for c in codes), return_exceptions=True)
try:
await asyncio.wait_for(_gather, timeout=_SCAN_PREFETCH_TOTAL_BUDGET)
except asyncio.TimeoutError:
logger.warning(
f"[DayEntry] ⏱️ 분봉 선조회 단계 예산 {_SCAN_PREFETCH_TOTAL_BUDGET:.0f}초 초과 "
f"— {sum(1 for c in codes if c in _candle_cache)}/{len(codes)}종목만 선조회됨. "
f"나머지는 순차 루프가 개별 조회한다(시간 예산에 걸리면 다음 사이클로 미룸)"
)
# 오래된 항목 정리 (감시목록 교체로 무한 증식 방지)
stale = [c for c, (ts, _) in _candle_cache.items() if (time.monotonic() - ts) > _CANDLE_CACHE_TTL * 2]
for c in stale:
_candle_cache.pop(c, None)
return sum(1 for c in codes if c in _candle_cache)
async def _get_candles_cached(stock_code: str) -> list:
"""[F6] 선조회 캐시를 우선 사용하고, 없으면 그 자리에서 조회한다.
``_try_enter()`` 의 시그니처를 바꾸지 않기 위해 인자 전달 대신 모듈 캐시를 쓴다
(CLAUDE.md §4: 기존 시그니처 변경 금지 / §4.1: 목킹 지점이 빗나가지 않게 유지).
"""
cached = _candle_cache.get(stock_code)
if cached and (time.monotonic() - cached[0]) < _CANDLE_CACHE_TTL:
return cached[1]
# [H2 2026-10-02] miss 경로에도 종목별 상한 — 한 종목 무응답이 스캔 전체를 붙잡아
# 스케줄러 _guard 에 통째로 취소되던 경로를 막는다(선조회와 같은 상한).
try:
candles = await asyncio.wait_for(
_fetch_candles_impl(stock_code), timeout=_SCAN_PREFETCH_TIMEOUT
)
except asyncio.TimeoutError:
logger.warning(
f"[DayEntry] 분봉 조회 {_SCAN_PREFETCH_TIMEOUT:.0f}초 초과: {stock_code} — 이번 사이클 스킵"
)
return []
if candles:
_candle_cache[stock_code] = (time.monotonic(), candles)
return candles
def filter_today_candles(candles: list) -> list:
"""당일(KST) 캔들만 남긴다.
[CLAUDE.md §7 마지막 항목] ``get_minute_chart_full_day`` 는
``FID_PW_DATA_INCU_YN="Y"`` 로 과거 데이터를 포함해 요청하므로 **전일 캔들이
섞인다.** ``calculate_opening_range`` 는 시각(090000~092959)만 보고 날짜를
보지 않기 때문에, 필터 없이 넘기면 전일 09:00~09:29 봉이 ORB 고저·거래량에
합산되어 레인지가 오염된다(레인지 과대 → 돌파 미발생, 평균분당거래량 과대 →
거래량 게이트 통과 불가).
[M2 2026-09-28] "날짜 필드가 없다"와 "날짜가 다 있는데 전부 당일이 아니다"를
**구분한다**. 전자는 목/레거시 응답 방어를 위해 원본을 통과시키지만, 후자는 §7 이
경고하는 오염 그 자체이므로 ``[]`` 를 반환하고 ``warning`` 을 남긴다. 통과시키면
전일 ORB 고저·평균분당거래량이 당일 ORB 로 Redis(TTL 24시간)에 저장돼 하루 남는다.
(`market_schedule` 에 휴장일 판정이 없어 휴장일에 당일 봉이 0건인 경로가 실재한다.)
"""
today_str = datetime.now(_KST).strftime("%Y%m%d")
todays = [c for c in candles if getattr(c, "date", "") == today_str]
if todays:
return todays
if not candles:
return candles
dated = [c for c in candles if getattr(c, "date", "")]
if len(dated) == len(candles):
# 전 캔들에 날짜가 있는데 당일이 0건 → 전일 데이터만 돌아온 것(오염)
_dates = sorted({getattr(c, "date", "") for c in candles})
logger.warning(
f"[DayEntry] ⚠️ 당일({today_str}) 분봉 0건 — 응답 날짜 {_dates[:3]} "
f"(총 {len(candles)}봉). 전일 캔들로 ORB 를 계산하면 고저·거래량이 오염되므로 "
f"폐기한다(휴장일 또는 분봉 조회 이상)."
)
return []
# 날짜 필드가 비어 있는 응답(목/레거시) — 기존 동작 유지
return candles
async def collect_opening_ranges() -> None:
"""09:00~09:30 ORB(Opening Range Breakout) 데이터 수집.
감시 종목의 시초가 레인지(고가/저가)를 수집하여
ORB 돌파 전략의 기준값으로 사용.
호출 시점: 스케줄러에서 09:00~09:30 사이 30초 주기로 호출.
장중 재기동으로 09:30 을 지나 기동한 경우는 backfill_opening_ranges() 가
같은 로직을 소급 호출한다(당일 분봉 전체 조회이므로 시각 제약 없음).
KIS API 호출 간격: 0.3초 (레이트 리밋 준수)
"""
day_trading_state.reset_if_new_day()
# 재기동으로 인메모리가 비었을 수 있으므로 먼저 Redis 캐시를 복원한다
await day_trading_state.load_orb_cache()
_collected_before = len(day_trading_state.orb_cache)
# [H1 2026-10-02] ⚠️ 현행(기본, DAY_TRADE_ORB_FULL_RANGE=False) 실제 동작:
# `has_orb()` 단락 때문에 **첫 수집 성공 시점의 레인지로 하루가 고정**된다.
# _opening_range_v2 는 첫 5봉이 모두 완성된 뒤(R6: 5번째 봉 시각+60초 ≤ 현재, 대개 09:05:00
# 이후 첫 사이클)의 5분 레인지를 ORB 로 낸다 — 문서(§7)의 09:00~09:29 레인지가 아니다.
# 돌파 신호가 더 많이 나는 쪽이라 사용자 결정으로 현행을 유지한다.
# ON: 09:29 이전에는 매 수집마다 재계산(확정 아님), 09:29 이후 첫 계산을 확정본으로 두고
# 그때부터만 Redis 에 저장한다(09:29 이전 저장분은 재기동 시 확정본으로 쓰지 않는다).
_full_range = bool(settings.DAY_TRADE_ORB_FULL_RANGE)
_now_t = datetime.now(_KST).time()
_finalize_now = _now_t >= _ORB_FULL_RANGE_FINALIZE_AT
if _full_range:
_reset_orb_finalized_if_new_day()
_finalized_changed = False
for stock_code in list(_watchlist.keys()):
# 이미 ORB가 수집된 종목은 건너뜀 (FULL_RANGE 면 '확정된' 종목만 건너뜀)
if _full_range:
if stock_code in _orb_finalized:
continue
elif day_trading_state.has_orb(stock_code):
continue
try:
# 당일 분봉 전체 조회 → 시초가 레인지 계산
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
candles = filter_today_candles(parse_minute_candles(raw))
if candles:
# [R2-2] FULL_RANGE=False 면 첫 N봉 — 09:30 이후 소급도 평상시와 같은 정의
orb = _opening_range_v2(candles, _full_range)
if orb:
day_trading_state.set_orb(stock_code, orb)
if _full_range and _finalize_now:
_orb_finalized.add(stock_code)
_finalized_changed = True
# [2026-09-30] debug → info. **레인지가 몇 개 봉으로 확정됐는지**가
# 로그에 남아야 한다: `calculate_opening_range` 는 봉 5개면 값을
# 확정하고 이 루프는 `has_orb()` 로 단락하므로, 실제로는 09:00~09:29
# 전체가 아니라 **첫 성공 시점까지의 레인지**로 하루가 고정될 수 있다
# (의도된 창은 09:00~09:29 — `is_day_trade_entry_window()` 가 09:30
# 부터라 조기 확정에 실익이 없다). 봉 수는 저장 필드에서 정확히
# 복원된다: total_volume / avg_vol_per_min = 봉 수.
_bars = (
round(orb.total_volume / orb.avg_vol_per_min)
if orb.avg_vol_per_min > 0 else 0
)
_width_pct = (
(orb.high - orb.low) / orb.low * 100 if orb.low > 0 else 0.0
)
logger.info(
f"[DayEntry] ORB 수집: {stock_code} {_bars}봉 "
f"고={orb.high:,.0f} 저={orb.low:,.0f} 폭={_width_pct:.2f}% "
f"분당평균거래량={orb.avg_vol_per_min:,.0f}"
)
# KIS API 레이트 리밋 방지 (초당 약 3회)
await asyncio.sleep(0.3)
except Exception as e:
warn_throttled(logger, "entry_service:567", f"[DayEntry] ORB 수집 실패 ({stock_code}): {e}")
if _full_range:
# 확정 전(09:29 이전) 레인지는 저장하지 않는다 — 재기동 시 부분 레인지가 확정본으로
# 복원되는 것을 막는다.
if _finalized_changed:
await day_trading_state.save_orb_cache()
elif len(day_trading_state.orb_cache) != _collected_before:
await day_trading_state.save_orb_cache()
async def backfill_opening_ranges() -> int:
"""장중 재기동 시 당일 ORB 를 소급 수집. 수집된 종목 수 반환.
[2026-09-28 재기동 내구성 수정]
``collect_opening_ranges()`` 의 유일한 호출부가 스케줄러의
``is_opening_range_period()``(09:00~09:29) 분기였다. 09:30 이후에 기동하면
재수집 경로가 없어 **당일 ORB 전략이 영구 0건**이 됐다(Redis 캐시가 살아
있으면 복원되지만, 캐시가 없거나 09:30 전에 한 번도 수집되지 않았으면 불가).
``get_minute_chart_full_day`` 는 당일 전체 분봉을 주므로 09:30 이후에도
ORB 레인지를 정확히 소급 계산할 수 있다(FULL_RANGE=False 면 평상시와 같은 첫 N봉,
`_opening_range_v2` — 2026-10-09 R2-2 정의 통일). 전일 캔들 혼입은
``filter_today_candles()`` 가 차단한다.
진입 게이트는 건드리지 않는다 — 수집만 수행한다.
Returns:
int: 소급 수집 후 orb_cache 에 존재하는 종목 수
"""
if not _watchlist:
try:
count = await populate_daytrading_watchlist()
if count:
logger.info(f"[DayEntry] ORB 소급수집용 watchlist 구성: {count}개")
except Exception as e:
logger.warning(f"[DayEntry] ORB 소급수집용 watchlist 구성 실패: {e}")
if not _watchlist:
logger.warning(
"[DayEntry] ⚠️ 장중 기동 ORB 소급수집 불가 — watchlist 가 비어 있다 "
"(당일 stock_analysis 부재 가능). 당일 ORB 전략 0건이 된다."
)
return len(day_trading_state.orb_cache)
before = len(day_trading_state.orb_cache)
await collect_opening_ranges()
after = len(day_trading_state.orb_cache)
if after:
logger.info(
f"[DayEntry] 장중 기동 ORB 소급수집: {before}개 → {after}개 "
f"(watchlist {len(_watchlist)}개)"
)
else:
logger.warning(
f"[DayEntry] ⚠️ 장중 기동 ORB 소급수집 0건 (watchlist {len(_watchlist)}개) — "
"당일 ORB 전략 불가. 분봉 조회 실패 또는 당일 09:00~09:29 봉 부족."
)
return after
def _dt_daily_loss_limit_v2() -> float:
"""[C-3 r9] 로그용 실효 일일 손실 한도(is_daily_loss_exceeded 와 같은 접근자)."""
from app.services.day_trading_strategy import _get_daily_loss_limit
return float(_get_daily_loss_limit())
async def scan_and_enter() -> list[dict]:
"""감시 종목의 5분봉 데이터를 스캔하여 진입 신호 발생 시 매수 실행.
09:30~14:30 구간에서 30초 주기로 호출 (14:30 이후 진입 마감).
진입 전 체크 순서 (P0 = 최우선, P1 = 중요):
[P0] MDD -10% 체크 → 단기 전략 비활성화
[P0] MDD 공통 진입 게이트 can_open_new_position() (is_paused + 갱신 장애 fail-safe)
[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 체크 ① — 단기/데이트레이딩 전략 비활성화 여부 (MDD -10% 시 pause)
short_term_ok = await mdd_manager.is_short_term_strategy_enabled()
if not short_term_ok:
logger.info("[DayEntry] MDD 차단 — 단기 전략 비활성화")
return entries
# MDD 체크 ② — 공통 신규 진입 게이트 (CLAUDE.md §5.3)
# [항목7 2026-09-27] DT 진입 경로는 is_short_term_strategy_enabled() 만 봤기 때문에
# can_open_new_position() 의 fail-safe(MDD 갱신 연속 실패 = peak/drawdown 정지로
# -10%/-20% 게이트 무력화)와 is_paused 가 적용되지 않았다. 두 게이트를 모두
# 통과해야 진입한다. 이 블록은 try 밖에 두어 예외로 우회되지 않게 하고,
# 종목 루프/분봉 API 호출 전에 배치해 불필요한 KIS 호출을 없앤다.
# 청산 경로(exit_service)에는 영향 없음.
if not await mdd_manager.can_open_new_position():
_health = mdd_manager.get_update_health()
if _health.get("blind_block_active"):
logger.error(
"[DayEntry] ⛔ MDD 갱신 장애(fail-safe) — 데이트레이딩 신규 진입 차단 "
f"(연속 실패 {_health.get('consecutive_failures')}회 / 임계 "
f"{_health.get('blind_block_threshold')}, 마지막 오류: {_health.get('last_error')})"
)
else:
logger.warning("[DayEntry] ⛔ MDD 임계 초과(is_paused) — 데이트레이딩 신규 진입 차단")
return entries
# 적응형 전략 체크 — Defensive/Crisis 모드에서 데이트레이딩 비활성화
# [M6 2026-10-02] 예전에는 판정까지 try 안에 있고 `except: pass` 라, 조회가 예외를
# 내면 Crisis 모드 차단이 조용히 통과됐다. 조회만 try 안에서 하고 **실패 시 진입 차단
# (fail-closed)** + warning, 판정은 try 밖에서 한다(§4, §8.1-10). 청산 경로는 무관.
_adp_dt = None
_adp_err = None
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_dt = get_current_adaptive_params()
except Exception as e:
_adp_err = e
if _adp_dt is None:
logger.warning(
f"[DayEntry] ⛔ 적응형 모드 조회 실패 — 신규 진입 차단(fail-closed): {_adp_err}"
)
return entries
if not getattr(_adp_dt, "fallback_used", False) and not getattr(_adp_dt, "daytrading_enabled", True):
logger.info(
"[DayEntry] 적응형 전략 %s 모드 — 데이트레이딩 비활성화",
str(getattr(_adp_dt, "trading_mode", "?")).upper(),
)
return entries
# [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(f"[DayEntry] ⛔ 일일 손실 한도({_dt_daily_loss_limit_v2():.1%}) 초과 — 신규 진입 차단")
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 전략 독립성)
# [M6 2026-10-02] 조회 실패 시 "계속 진행" 이던 것을 fail-closed 로. CRASH/HALTED 를
# 확인할 수 없는 상태에서 진입하면 차단 게이트가 KIS/네이버 장애 때 정확히 사라진다.
_BLOCK_REGIMES_DT = {"crash", "halted"}
regime = None
try:
regime_info = await check_market_regime()
regime = (regime_info or {}).get("regime") if isinstance(regime_info, dict) else None
except Exception as e:
logger.warning(f"[DayEntry] ⛔ 시장 국면 조회 실패 — 신규 진입 차단(fail-closed): {e}")
return entries
if not regime:
logger.warning(
f"[DayEntry] ⛔ 시장 국면 판정값 없음({regime_info!r:.80}) — 신규 진입 차단(fail-closed)"
)
return entries
if regime in _BLOCK_REGIMES_DT:
logger.info(f"[DayEntry] ⛔ {regime.upper()} 국면 — 데이트레이딩 진입 차단")
return entries
logger.info(f"[DayEntry] 시장 국면: {regime}")
# [시간대 제한 완화 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
# 포트폴리오 총자산 조회 (캐시 활용)
# [하드닝 R2] 이번 사이클의 KIS 보유 스냅샷 — 잔고 응답을 해석하지 못하면 비운다.
global _kis_holdings_snapshot, _kis_holdings_verified
_kis_holdings_snapshot = {}
_kis_holdings_verified = False
try:
balance = await korea_investment_api.get_balance()
_parsed_holdings = _parse_kis_holdings_v2(balance)
if _parsed_holdings is None:
warn_throttled(
logger, "entry_service:holdings_unverified",
"[DayEntry] ⚠️ 잔고 응답에 보유목록(output1) 없음 — 타전략 보유 가드는 DB 스냅샷만, "
"레버리지 ETF 진입은 차단(fail-closed)",
)
else:
_kis_holdings_snapshot = _parsed_holdings
_kis_holdings_verified = True
# 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:
# [§4 2026-09-30] debug → warning. 잔고 조회가 실패하면 available_cash=0 이
# 되어 **모든 진입이 조용히 차단**된다(사이징이 0 → quantity 0). 원인 추적이
# 불가능한 무증상 정지의 전형이라 반드시 노출한다.
logger.warning(
f"[DayEntry] ⚠️ 잔고 조회 실패 — 가용현금 0 으로 진행(신규 진입 사실상 차단): {e}"
)
total_assets = day_trading_state.last_total_assets
available_cash = 0.0
if total_assets <= 0:
return entries
# [C-3 r9] 일일 손실 한도 재확인 — 위 [P0] 체크는 잔고 조회 **전** last_total_assets 로만 판정해
# 재기동 직후(last_total_assets=0)엔 통과했다. 방금 조회한 총자산으로 다시 본다. 가드는 try 밖(§4).
if is_daily_loss_exceeded(total_assets, day_trading_state.daily_pnl):
logger.info(
f"[DayEntry] ⛔ 일일 손실 한도({_dt_daily_loss_limit_v2():.1%}) 초과(잔고 조회 후 재확인: "
f"실현 {day_trading_state.daily_pnl:,.0f} / 총자산 {total_assets:,.0f}) — 신규 진입 차단"
)
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
skipped_other_strategy = 0
no_signal = 0
truncated = 0
# [F6] 후보 선별 → 라운드로빈 회전 → 분봉 선조회 → 순차 진입
candidates: list[tuple[str, str]] = []
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
# [하드닝 R2] 타전략(스윙/외부/동기화) 보유 종목은 DT 신규 진입 제외(§3.5 소유권 모호 방지)
_held_reason = _non_dt_holding_reason_v2(stock_code)
if _held_reason:
skipped_other_strategy += 1
continue
candidates.append((stock_code, stock_name))
# 라운드로빈: 시간 예산에 걸려 잘려도 다음 사이클은 다른 종목부터 본다
global _scan_offset
if candidates:
_scan_offset %= len(candidates)
candidates = candidates[_scan_offset:] + candidates[:_scan_offset]
# [F11] 사이클 경계 — 이전 사이클 스냅샷을 버려 매 사이클 새 데이터로 재평가한다.
# 재조회 비용은 get_minute_chart_full_day 의 Redis 1분 버킷 캐시가 대부분 흡수한다.
_candle_cache.clear()
# 선조회(읽기 전용, 제한 동시성) — 순차 조회로 예산을 다 쓰던 지배 원인 제거
_scan_started = time.monotonic()
prefetched = await _prefetch_minute_charts([c for c, _ in candidates])
_prefetch_sec = time.monotonic() - _scan_started
# [하드닝 R2] 09:30 이후 ORB 없는 감시종목(30분 갱신 신규·09시대 수집 실패)의 ORB 를
# 선조회 분봉으로 보충한다 — 추가 KIS 호출 없음. Redis 저장 실패는 진입과 무관하다.
_orb_filled = _backfill_orb_from_cache_v2([c for c, _ in candidates])
if _orb_filled:
try:
await day_trading_state.save_orb_cache()
except Exception as e:
logger.warning(f"[DayEntry] ORB 지연 보충분 Redis 저장 실패(인메모리는 유지): {e}")
for idx, (stock_code, stock_name) in enumerate(candidates):
if day_trading_state.position_count() >= MAX_POSITIONS:
break
_elapsed = time.monotonic() - _scan_started
if _elapsed > _SCAN_TIME_BUDGET:
# 기존에는 스케줄러 _guard(180초)가 코루틴을 조용히 취소해 '뒤쪽 종목이
# 통째로 검사되지 않는다'는 사실이 로그에 전혀 남지 않았다.
truncated = len(candidates) - idx
logger.warning(
f"[DayEntry] ⏱️ 스캔 시간 예산 {_SCAN_TIME_BUDGET:.0f}초 초과 "
f"({_elapsed:.0f}초 경과) — 남은 {truncated}종목 미스캔. "
f"다음 사이클은 오프셋 {(_scan_offset + idx) % max(1, len(candidates))} 부터 시작"
)
_scan_offset = (_scan_offset + idx) % len(candidates)
break
# [M8 2026-10-02] 한 사이클 안에서 앞선 진입이 쓴 현금·사용률을 반영한다.
# 예전에는 사이클 시작 시점의 available_cash 를 모든 종목에 그대로 넘기고 95% 가드도
# 시작 시 1회만 봐서, 한 사이클 다건 진입이 현금·전체 사용률 한도를 함께 넘을 수 있었다.
# 리스크 가드이므로 try 밖에서 판정한다(§4).
if available_cash <= 0:
truncated = len(candidates) - idx
logger.warning(
f"[DayEntry] ⛔ 사이클 내 가용현금 소진 — 남은 {truncated}종목 진입 중단"
)
break
_dt_inv_now = sum(
pos.get("entry_price", 0) * pos.get("quantity", 0)
for pos in day_trading_state.positions.values()
)
_usage_now = max(0.0, swing_invested + _dt_inv_now) / total_assets
if _usage_now > settings.MAX_TOTAL_CAPITAL_USAGE:
truncated = len(candidates) - idx
logger.warning(
f"[DayEntry] ⛔ 사이클 내 전체 자본 사용률 초과 {_usage_now:.1%} > "
f"{settings.MAX_TOTAL_CAPITAL_USAGE:.0%} — 남은 {truncated}종목 진입 중단"
)
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)
entry = None
if entry:
# 신호가 기준 차감(체결가 보정은 백그라운드). 음수로 내려가지 않게 0 하한.
_spent = float(entry.get("price", 0) or 0) * int(entry.get("quantity", 0) or 0)
available_cash = max(0.0, available_cash - _spent)
else:
# 전 종목을 소화했으면 오프셋을 리셋해 다음 사이클도 앞에서부터 본다
_scan_offset = 0
logger.info(
f"[DayEntry] 스캔 완료: 후보 {len(candidates)}종목 중 {scanned}종목 스캔"
f"{f' (미스캔 {truncated})' if truncated else ''}, {len(entries)}진입, "
f"{no_signal}신호없음, {skipped_held}보유중, {skipped_closed}청산완료, "
f"{skipped_other_strategy}타전략보유 "
f"(선조회 {prefetched}/{len(candidates)}종목 {_prefetch_sec:.1f}초, "
f"총 {time.monotonic() - _scan_started:.1f}초)"
)
# RSI·갭 기저 분포 진단 — 읽기 전용, 사이클당 1줄(N 사이클마다). 진단 실패가
# 스캔 결과를 잃게 만들면 안 되므로 여기서만 흡수하고 warning 으로 드러낸다
# (§4: logger.debug 금지). 리스크 가드가 아니므로 try 안에 두어도 무해하다.
try:
_log_rsi_gap_diagnostics(candidates)
except Exception as e:
logger.warning(f"[DayEntry] RSI 진단 로깅 실패(진단 전용, 매매 영향 없음): {e}")
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
# [F6] 선조회 캐시 사용 (miss 면 이 자리에서 조회).
# candles 에는 **전일 워밍업 봉이 섞여 있다**(_fetch_candles_impl docstring).
candles = await _get_candles_cached(stock_code)
if not candles:
return None
# 세션 앵커 지표(VWAP)·당일 거래량·갭·현재가는 '당일 봉'이라는 보장이 필요하다.
candles_today = filter_today_candles(candles)
if not candles_today:
# 당일 봉이 0건 = 휴장일·조회 이상. 현재가·VWAP·거래량 기준을 만들 수 없다.
# (filter_today_candles 가 날짜 필드가 채워진 경우 warning 을 남긴다)
return None
# 5분봉 집계 — 버킷 키에 날짜가 포함돼 서로 다른 날의 1분봉이 한 봉으로
# 합쳐지지 않는다. 전일 봉이 앞쪽에 붙어 RSI(14)/BB(20) 워밍업을 **부분적으로든
# 전부든** 채운다 → 채워진 만큼만 판정이 가능해진다(확보량은 조회 계층 로그 참조.
# 개장 직후 발화를 단언할 수 없다 — _fetch_candles_impl docstring 의 미검증 전제).
# 세션 경계 비교(거래량 게이트)는 day_trading_strategy 쪽에서 막는다.
candles_5m = aggregate_to_5min(candles)
if len(candles_5m) < 5:
return None
# VWAP: 당일 누적 (세션 앵커)
vwap = calculate_vwap(candles_today)
orb = day_trading_state.get_orb(stock_code)
# 현재가: 당일 마지막 1분봉 종가 (형성 중 5분봉 종가보다 최신)
current_price = float(candles_today[-1].close)
if current_price <= 0:
return None
# 신호 체크 (ORB → VWAP → RSI+BB 순)
signal = None
if orb:
# ORB: 당일 1분봉 — 직전 1분 거래량과 갭 판정이 당일 기준이어야 한다.
# [M3 2026-10-02] DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED=True 일 때만 워밍업(전일 포함)
# 시계열을 넘긴다 — check_orb_signal 이 전일 종가 대비 갭을 계산한 뒤 내부에서
# 세션을 당일로 자르므로 거래량·양봉 판정은 동일하다. OFF(기본)는 현행 그대로.
_orb_input = candles if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED else candles_today
signal = check_orb_signal(stock_code, stock_name, _orb_input, 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
# [하드닝 R2] §5.1 가격 필터 — 자동 목록은 일간 스냅샷 기준으로만 걸렀고 수동 목록은
# 필터가 없었다. 신호가 난 종목만 현재가로 재검사한다(ETF/ETN/리츠 예외는 trade_executor 와 동일 정의).
_px_block = _price_filter_block_v2(stock_name, current_price)
if _px_block:
logger.info(f"[DayEntry] ⛔ {stock_code} {_px_block} — 진입 스킵")
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%)
# [H3 fix 2026-09-27] 예전 코드는 calculate_day_trade_position_size()가 이미
# min(종목당 6%, DT 잔여 30%, 보유현금)로 클램프한 값에 최대 1.2배를 곱해
# 세 한도를 모두 최대 20% 초과했다(ORB confidence 상한 0.85 → 실제로 1.2배 발동).
# → 배율 적용 후 동일한 세 한도로 재클램프한다. 감액(<1.0)은 그대로 적용된다.
confidence_scale = min(1.2, signal.confidence / 0.7)
budget = _clamp_day_trade_budget(
budget * confidence_scale,
total_assets=total_assets,
available_cash=available_cash,
current_day_trade_amount=current_day_trade_amount,
)
quantity = int(budget // current_price) if current_price > 0 else 0
if quantity <= 0:
return None
# [하드닝 R2] §5.1 레버리지/인버스 ETF 20% — 이번 주문 포함, 검증 불가면 차단.
_lev_block = _leverage_block_v2(stock_code, stock_name, budget, total_assets)
if _lev_block:
logger.warning(f"[DayEntry] ⛔ {stock_code} {stock_name}: {_lev_block} — 진입 스킵")
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}
)
except Exception as _mcp_err:
# MCP 는 선택적 계층 — 실패 시 진입은 계속하되 조용히 삼키지 않는다(§4)
warn_throttled(
logger, "entry_service:mcp_risk",
f"[DayEntry] MCP 리스크 평가 실패 — 평가 없이 진행 ({stock_code}): {_mcp_err}",
)
mcp_approved, mcp_reason = True, None
# 거부 판정은 try 밖(§4: 가드가 예외로 우회되지 않게)
if not mcp_approved:
logger.info(f"[DayEntry] MCP 거부: {stock_code} — {mcp_reason}")
return None
# [R1-2 2026-10-09] 주문 직전 신선가 재검증. 위 판정의 current_price 는 선조회 분봉의
# 마지막 종가라 최대 수 분 묵을 수 있고, 손절·목표가 그 값으로 절대가 고정된 뒤 시장가로
# 나간다. 현재가를 1회 조회해 **같은 신호 함수**로 다시 판정하고(밴드·돌파 마진·BB 하단),
# 통과하면 신선가 기준의 손절·목표·수량으로 교체한다. 탈락하면 진입하지 않는다.
# 괴리 상한(예: 0.3%) 같은 새 임계값은 두지 않는다 — 값은 백테스트로 정할 사항.
# 조회 실패면 기존 동작(분봉 종가)을 유지한다(warning).
_fresh = await _fetch_fresh_price_v2_impl(stock_code)
if _fresh is not None and _fresh != current_price:
_rechecked = _recheck_signal_with_fresh_price_v2(
signal, stock_code, stock_name,
candles=candles, candles_today=candles_today, candles_5m=candles_5m,
vwap=vwap, orb=orb, fresh_price=_fresh,
)
if _rechecked is None:
logger.info(
f"[DayEntry] ⛔ {stock_code} 신선가 재검증 탈락 — {signal.strategy} "
f"분봉가={current_price:,.0f} → 현재가={_fresh:,.0f} "
f"({(_fresh - current_price) / current_price * 100:+.2f}%)"
)
return None
_fresh_qty = int(budget // _fresh) if _fresh > 0 else 0
# MCP 가 승인한 수량을 넘지 않는다(가격 하락으로 수량이 늘어나는 경우)
quantity = min(quantity, _fresh_qty)
if quantity <= 0:
return None
logger.info(
f"[DayEntry] {stock_code} 신선가 재검증 통과 — 분봉가={current_price:,.0f} → "
f"현재가={_fresh:,.0f}, 손절 {signal.stop_loss:,.0f}→{_rechecked.stop_loss:,.0f}, "
f"목표 {signal.target_price:,.0f}→{_rechecked.target_price:,.0f}, 수량={quantity}"
)
signal = _rechecked
current_price = _fresh
# [H2/M5 2026-10-02] 주문 직전 재검사 + 슬롯 예약 (trade_lock 안).
# 신호 판정 ~ 주문 사이에 분봉 조회·MCP 평가 등 await 가 있어 그 사이 다른 경로
# (수동 /cycle, 같은 사이클의 앞선 진입)가 슬롯·예산을 소비할 수 있다. 보유·당일청산·
# 포지션 한도(예약 포함)·DT 예산 잔여를 **주문 직전에** 다시 보고 슬롯을 예약한다.
# 리스크 가드이므로 try 로 감싸지 않는다(§4) — 바깥 try 는 로그용.
_reject = None
async with day_trading_state.trade_lock:
_prune_unknown_buys_for_today()
if stock_code in day_trading_state.positions or stock_code in _pending_entries:
_reject = "이미 보유/주문 진행 중"
elif day_trading_state.is_closed_today(stock_code):
_reject = "당일 청산 종목"
elif _non_dt_holding_reason_v2(stock_code):
_reject = _non_dt_holding_reason_v2(stock_code)
elif _price_filter_block_v2(stock_name, current_price):
_reject = _price_filter_block_v2(stock_name, current_price)
elif day_trading_state.position_count() + len(_pending_entries) >= MAX_POSITIONS:
_reject = f"포지션 한도 {MAX_POSITIONS} (주문 진행 중 포함)"
elif _portfolio_cap_block_v2(stock_code):
# [하드닝 루프 R1] §5.1 섹터캡·보유수 상한(플래그, 기본 OFF) — 락 안이라
# 같은 사이클 앞선 진입(_pending_entries)까지 반영된다.
_reject = _portfolio_cap_block_v2(stock_code)
else:
_amt_now = sum(
pos.get("entry_price", 0) * pos.get("quantity", 0)
for pos in day_trading_state.positions.values()
) + sum(_pending_entries.values())
_final_budget = _clamp_day_trade_budget(
budget,
total_assets=total_assets,
available_cash=available_cash,
current_day_trade_amount=_amt_now,
)
_final_qty = int(_final_budget // current_price) if current_price > 0 else 0
if _final_qty <= 0:
_reject = "DT 예산 소진(주문 직전 재검사)"
else:
if _final_qty < quantity:
logger.warning(
f"[DayEntry] {stock_code} 주문 직전 예산 재검사로 수량 축소 "
f"{quantity} → {_final_qty}주"
)
quantity = _final_qty
_pending_entries[stock_code] = current_price * quantity
if _reject:
logger.info(f"[DayEntry] ⛔ {stock_code} 주문 직전 재검사 거부 — {_reject}")
return None
# [H2] 주문 전송 ~ 인메모리·ActiveTrade·TradeHistory 반영을 shield 한다(청산 쪽
# _execute_exit_impl 과 같은 패턴). 스케줄러 _guard(180초)가 이 구간을 취소하면
# "브로커는 샀는데 인메모리·DB 에 포지션이 없다" → 손절·EOD 청산 대상에서 빠진 고아 보유.
return await asyncio.shield(_place_and_record_entry_impl(
stock_code, stock_name, signal, quantity, current_price,
))
except Exception as e:
logger.error(f"[DayEntry] _try_enter 오류 ({stock_code}): {e}", exc_info=True)
return None
async def _place_and_record_entry_impl(
stock_code: str, stock_name: str, signal, quantity: int, current_price: float,
) -> dict | None:
"""[H2] 매수 주문 + 체결 후 상태 반영 (shield 구간). 슬롯 예약은 끝에서 해제한다.
[R1-1 2026-10-09] 주문 결과를 accepted / rejected / unknown 으로 나눈다(청산 쪽
`exit_service._classify_order_response` 와 같은 분류). **unknown(전송 후 응답 유실·응답
없음·래퍼 예외)은 실패가 아니다** — KIS 가 받았을 수 있으므로 당일 재진입을 막고
(closed_today), 슬롯·예산 예약을 유지한 채 백그라운드에서 당일 주문내역·잔고로 판정한다
(`_register_unknown_buy_v2_impl` → `_resolve_unknown_buy_v2_impl`).
"""
keep_reservation = False
try:
# KIS API 매수 주문 (시장가) — 분산락+세마포어 보호
from app.services.trade_executor import safe_place_order
since = datetime.now(_KST).replace(tzinfo=None)
try:
order_resp = await safe_place_order(
stock_code=stock_code,
order_type="BUY",
quantity=quantity,
price=None, # 시장가
)
except Exception as _oe:
# safe_place_order 는 내부에서 예외를 dict 로 감싼다. 그래도 새어 나왔다면 전송 여부를
# 알 수 없으므로 '불명'으로 다룬다(거부로 보면 같은 종목을 다시 산다).
logger.error(
f"[DayEntry] 매수 주문 호출 예외 ({stock_code}) — 결과 불명으로 처리: "
f"{type(_oe).__name__}: {_oe}"
)
order_resp = {"rt_cd": "-1", "msg_cd": _ENTRY_TRANSPORT_UNKNOWN_MSG_CD,
"msg1": f"exception: {type(_oe).__name__}"}
outcome = _classify_entry_order_response_v2(order_resp)
if outcome == "unknown":
keep_reservation = await _register_unknown_buy_v2_impl(
stock_code, stock_name, signal, quantity, current_price, since, order_resp,
)
return None
if outcome != "accepted":
err = order_resp.get("msg1", "unknown") if isinstance(order_resp, dict) else "no response"
logger.error(f"[DayEntry] 매수 주문 거부 ({stock_code}): {err}")
return None
order_id = (order_resp.get("output") or {}).get("ODNO", "")
return await _record_accepted_entry_impl(
stock_code, stock_name, signal, quantity, current_price, order_id,
)
except Exception as e:
logger.error(f"[DayEntry] 매수 주문/반영 오류 ({stock_code}): {e}", exc_info=True)
return None
finally:
if not keep_reservation:
_pending_entries.pop(stock_code, None)
async def _record_accepted_entry_impl(
stock_code: str, stock_name: str, signal, quantity: int, current_price: float,
order_id: str, entry_time: datetime | None = None, reason_suffix: str = "",
) -> dict | None:
"""접수된 매수 주문 → 인메모리 포지션 + TradeHistory + ActiveTrade(day_trade) + 체결가 확인.
정상 접수(rt_cd=0)와 결과 불명 → 주문내역으로 접수 확인된 경로가 공유한다.
슬롯 예약은 포지션 등록과 같은 락 안에서 해제한다(한도 이중 계상 방지).
"""
_reason = f"{signal.reason}{reason_suffix}"
_entry_time = entry_time or datetime.now(_KST).replace(tzinfo=None)
# 인메모리 포지션 추가 (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": _entry_time,
"reason": _reason,
"order_id": order_id,
})
# 예약 → 실제 포지션으로 전환(같은 락 안에서 — 한도 이중 계상 방지)
_pending_entries.pop(stock_code, None)
# 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": _reason,
"strategy_type": "daytrading",
# [R8 2026-10-10] 체결 시각(KST naive) — 없으면 sync_portfolio 의 executed_at 시간창
# 조회(오늘 마지막 BUY ghost 보존·최근 체결 팬텀 close 유예)가 DT 체결을 보지 못했다
# (운영 10/01 이후 DT 58행 전부 NULL). 결과불명→확인 경로는 주문 전송 시각(since).
"executed_at": _entry_time,
})
# 포트폴리오 화면 즉시 반영 (strategy_type='daytrading' 태깅 → 스윙 청산 로직 대상에서 제외)
from app.services.portfolio_cache_bridge import reflect_buy
await reflect_buy(stock_code, stock_name, quantity, current_price, 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": _reason,
"stop_loss": signal.stop_loss,
"take_profit": signal.target_price,
# [R3 2026-10-09] ODNO 영속화 — 재기동 복원 포지션(_active_trade_row_to_position)이
# 빈 order_id 로 만들어지면 불명 매수 판정의 known ODNO 제외·체결가 보정 콜백의
# 주문 동일성 검사가 복원 포지션에서 항상 빗나간다(운영 154/154 NULL).
# ORM 컬럼(ActiveTrade.order_id)이 이미 있어 db_worker 계약은 그대로다.
"order_id": order_id,
})
# [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 위 기록의 executed_price 는
# 신호 시점 가격이다. 확인되면 원장·인메모리 진입가·ActiveTrade 를 체결가로 보정하고,
# 실패하면 신호가 기록이 그대로 남는다(warning).
_schedule_buy_fill_reconcile(stock_code, order_id, current_price, quantity)
logger.info(
f"[DayEntry] ✅ 진입: {stock_code} {stock_name} "
f"수량={quantity} 가격={current_price:,.0f} 신호={signal.strategy}{reason_suffix}"
)
return {
"stock_code": stock_code,
"stock_name": stock_name,
"quantity": quantity,
"price": current_price,
"strategy": signal.strategy,
"reason": _reason,
}
# ── [R1-2 2026-10-09] 주문 직전 신선가 ────────────────────────────────────────
# 현재가 1회 조회 상한(초). 신호가 난 종목에서만 호출되므로 스캔 예산 영향은 작다.
_FRESH_PRICE_TIMEOUT_SEC: float = 5.0
async def _fetch_fresh_price_v2_impl(stock_code: str) -> float | None:
"""주문 직전 현재가(stck_prpr). 실패·0 이면 None(warning) — 호출부는 분봉 종가를 유지한다."""
try:
resp = await asyncio.wait_for(
korea_investment_api.get_current_price(stock_code), timeout=_FRESH_PRICE_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
warn_throttled(
logger, f"entry_service:fresh_price:{stock_code}",
f"[DayEntry] 주문 직전 현재가 {_FRESH_PRICE_TIMEOUT_SEC:.0f}초 초과 ({stock_code}) — 분봉 종가로 진행",
)
return None
except Exception as e:
warn_throttled(
logger, f"entry_service:fresh_price:{stock_code}",
f"[DayEntry] 주문 직전 현재가 조회 실패 ({stock_code}): {type(e).__name__}: {e} — 분봉 종가로 진행",
)
return None
try:
price = float(((resp or {}).get("output") or {}).get("stck_prpr", 0) or 0)
except (TypeError, ValueError, AttributeError) as e:
warn_throttled(
logger, f"entry_service:fresh_price:{stock_code}",
f"[DayEntry] 현재가 응답 파싱 실패 ({stock_code}): {e} — 분봉 종가로 진행",
)
return None
return price if price > 0 else None
def _recheck_signal_with_fresh_price_v2(
signal, stock_code: str, stock_name: str, *,
candles: list, candles_today: list, candles_5m: list, vwap, orb, fresh_price: float,
):
"""발화한 신호와 같은 함수로 신선가를 다시 판정한다. 탈락이면 None.
신호 함수는 (봉, 지표, 현재가) 의 순수 함수이고 손절·목표를 현재가 기준으로 계산하므로,
통과한 새 신호의 손절·목표가 곧 신선가 기준값이다(최소 손절폭 등 §7 규칙 그대로).
알 수 없는 전략명이면 원래 신호를 그대로 돌려준다(warning) — 재검증 불가가 진입 차단 사유는 아니다.
"""
strat = str(getattr(signal, "strategy", "") or "")
if strat == "ORB":
if not orb:
return None
_orb_input = candles if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED else candles_today
return check_orb_signal(stock_code, stock_name, _orb_input, orb, fresh_price)
if strat == "VWAP_BOUNCE":
if not vwap:
return None
return check_vwap_bounce_signal(stock_code, stock_name, candles_5m, vwap, fresh_price)
if strat == "RSI_BB":
return check_rsi_bb_signal(stock_code, stock_name, candles_5m, fresh_price)
logger.warning(f"[DayEntry] 신선가 재검증: 알 수 없는 전략명 {strat!r} ({stock_code}) — 원 신호 유지")
return signal
# ── [R1-1 2026-10-09] 결과 불명 매수 ──────────────────────────────────────────
# safe_place_order 가 전송 후 응답을 잃으면 {rt_cd:"-1", msg_cd:"KIS_TRANSPORT_UNKNOWN"} 를
# 돌려준다. 예전에는 이것을 거부처럼 다뤄 (a) 다음 사이클에 같은 종목을 다시 사고
# (b) 실제로 접수된 물량은 인메모리·ActiveTrade 가 없어 손절·EOD 없이 남았다(고아 보유).
#
# 처리: 당일 재진입 차단(closed_today) + 슬롯·예산 예약 유지 → 백그라운드에서
# ① 당일 주문내역에 이 주문(매수·수량 일치·주문 시각 이후·미사용 ODNO)이 있으면 접수 →
# 정상 진입과 같은 기록(인메모리·TradeHistory·ActiveTrade(day_trade)) + 체결가 확인
# (체결 0 종결이면 fill 보정이 포지션을 제거한다).
# ② 주문내역 조회가 **성공**했는데 행이 없고 KIS 잔고에 그 종목이 0주면 미접수 → 예약 해제
# (재진입 차단은 당일 유지) + 감시 창 동안 재확인(R4 — 아래 _watch_released_unknown_buy_v2_impl).
# ③ 조회 실패·잔고 조회 실패는 라운드 간격을 두고 재판정, 끝내 불가면(또는 잔고에 같은 종목이
# 있는데 주문내역 미확인 — 스윙 보유분과 구별 불가) 예약 유지 + error + 텔레그램 수동 확인 요청.
# get_todays_orders 는 조회 실패에도 [] 를 돌려주므로 쓰지 않는다(_fetch_todays_orders_strict_v2_impl).
_ENTRY_TRANSPORT_UNKNOWN_MSG_CD = "KIS_TRANSPORT_UNKNOWN"
# {(KST 날짜 iso, 종목코드): {"qty", "since", "price", "stock_name", "signal", "status", "reserved"}}
_unknown_buy_entries: dict[tuple[str, str], dict] = {}
_unknown_buy_tasks: set = set()
def _classify_entry_order_response_v2(resp) -> str:
"""주문 응답 → "accepted" | "rejected" | "unknown" (청산 쪽 분류의 정본을 그대로 쓴다)."""
from app.services.daytrading.exit_service import _classify_order_response
return _classify_order_response(resp)
def _today_iso() -> str:
return datetime.now(_KST).date().isoformat()
def _prune_unknown_buys_for_today() -> None:
"""날짜가 바뀐 불명 매수 상태와 그 예약을 버린다. trade_lock 안에서 호출한다."""
today = _today_iso()
for key in [k for k in _unknown_buy_entries if k[0] != today]:
ent = _unknown_buy_entries.pop(key, None) or {}
if ent.get("reserved"):
_pending_entries.pop(key[1], None)
def get_unknown_buy_entries() -> dict[str, dict]:
"""관측용 — 당일 결과 불명 매수 {종목코드: {qty, since, status}} (읽기 전용 사본)."""
today = _today_iso()
return {
k[1]: {"qty": v.get("qty"), "since": v.get("since"), "status": v.get("status")}
for k, v in _unknown_buy_entries.items() if k[0] == today
}
async def _register_unknown_buy_v2_impl(
stock_code: str, stock_name: str, signal, quantity: int, current_price: float,
since: datetime, order_resp,
) -> bool:
"""결과 불명 매수 등록 — 재진입 차단 + 예약 유지 + 판정 예약. 예약을 유지하면 True.
가드(재진입 차단·예약 유지)는 try 밖에서 먼저 확정한다(§4). 판정 태스크 예약 실패는
예약을 유지한 채 error 로 남긴다(수동 확인).
"""
key = (_today_iso(), stock_code)
async with day_trading_state.trade_lock:
day_trading_state.closed_today.add(stock_code)
_unknown_buy_entries[key] = {
"qty": int(quantity), "since": since, "price": float(current_price),
"stock_name": stock_name, "signal": signal, "status": "pending", "reserved": True,
}
_msg = (order_resp or {}).get("msg_cd") if isinstance(order_resp, dict) else None
logger.error(
f"[DayEntry] 🚨 매수 주문 결과 불명 ({stock_code} {quantity}주, msg_cd={_msg or 'none'}) — "
f"당일 재진입 차단·슬롯 예약 유지, 주문내역·잔고로 접수 여부 확인 예정"
)
await day_trading_state.save_daily_state()
try:
task = asyncio.get_running_loop().create_task(_resolve_unknown_buy_v2_impl(stock_code, key))
except RuntimeError as e:
logger.error(f"[DayEntry] 결과 불명 매수 판정 예약 실패 ({stock_code}): {e} — 수동 확인 필요")
return True
_unknown_buy_tasks.add(task)
task.add_done_callback(_unknown_buy_tasks.discard)
return True
def _find_unknown_buy_row_v2(
rows, stock_code: str, qty: int, since: datetime, known_odnos: set[str],
) -> tuple[dict | None, bool]:
"""당일 주문내역에서 이 불명 매수에 해당하는 행 → (유효 접수 행|None, 거부/취소 행 존재 여부).
조건: 종목 일치, 매수(sll_buy_dvsn_cd="02"), 주문수량 일치, 주문시각 ≥ 주문 전 −창,
이미 DT 포지션이 쓰는 ODNO 아님. 체결 0 이면서 거부·취소된 행은 '접수 아님'의 근거로만 쓴다.
"""
from app.services.daytrading.fill_reconciler import _norm_odno
from datetime import timedelta
_w = float(settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC)
since_hms = (since - timedelta(seconds=_w)).strftime("%H%M%S")
dead_seen = False
for r in rows or []:
if not isinstance(r, dict):
continue
if str(r.get("pdno", stock_code) or stock_code) != stock_code:
continue
if str(r.get("sll_buy_dvsn_cd", "")) != "02":
continue
try:
oq = int(float(r.get("ord_qty", 0) or 0))
cq = int(float(r.get("tot_ccld_qty", 0) or 0))
rj = int(float(r.get("rjct_qty", 0) or 0))
except (TypeError, ValueError):
continue
if oq != int(qty):
continue
t = str(r.get("ord_tmd", "") or "")
if t and t < since_hms:
continue
odno = _norm_odno(r.get("odno", ""))
if odno and odno in known_odnos:
continue
if cq == 0 and (rj > 0 or str(r.get("cncl_yn", "") or "").upper() == "Y"):
dead_seen = True
continue
return r, dead_seen
return None, dead_seen
async def _release_unknown_buy_reservation_impl(stock_code: str, key: tuple[str, str], status: str) -> None:
async with day_trading_state.trade_lock:
ent = _unknown_buy_entries.get(key)
if ent is not None:
ent["status"] = status
if ent.get("reserved"):
ent["reserved"] = False
_pending_entries.pop(stock_code, None)
def _alert_unknown_buy_manual_check(stock_code: str, qty: int, detail: str) -> None:
try:
from app.services.telegram_notifier import telegram_notifier
_t = asyncio.get_running_loop().create_task(asyncio.wait_for(
telegram_notifier.notify_system_warning(
"DT_ENTRY_UNKNOWN",
f"{stock_code} 데이트레이딩 매수 {qty}주 결과 불명 — {detail}. "
f"계좌를 확인하고, 보유 중이면 수동 청산하세요(DT 손절·EOD 대상이 아님).",
),
timeout=10.0,
))
_unknown_buy_tasks.add(_t)
_t.add_done_callback(_unknown_buy_tasks.discard)
except Exception as e:
logger.error(f"[DayEntry] 결과 불명 매수 경보 발송 실패({stock_code}): {e}")
# [R4 2026-10-09] 미접수 확정 근거 강화.
# 이전에는 "주문내역에 행 없음(get_todays_orders — 조회 실패에도 [])" + "잔고 0주" 로 약 8초 안에
# 미접수를 확정했다. 잔고는 '체결된' 수량만 보여, 접수됐지만 아직 체결되지 않은 시장가 주문
# (VI 단일가 등)과 미접수를 구분하지 못한다 → 나중에 체결된 물량이 DT 손절·EOD 밖 보유가 된다.
# 지금은: 미접수 확정 = **주문내역 조회 성공(rt_cd=0) 에서 행 없음** + 잔고 0주.
# - 조회가 한 번도 성공하지 못했으면 판정 불가 → 예약 유지, 일정 시간 뒤 재판정(라운드).
# - 확정 후에도 감시 창 동안 주문내역·잔고를 다시 본다. 늦게 행이 나타나면 DT 포지션으로
# 등록(손절·EOD 관리 하에 둔다), 행 없이 잔고만 나타나면 경보(스윙 매수와 구별 불가).
# 기본값은 settings 에 키가 없을 때의 폴백이다(config.py 편입 전). 전략 파라미터가 아니라
# 판정 타이밍이다.
_UNKNOWN_BUY_RESOLVE_ROUNDS_DEFAULT = 3
_UNKNOWN_BUY_RESOLVE_RETRY_SEC_DEFAULT = 60.0
_UNKNOWN_BUY_WATCH_CHECKS_DEFAULT = 5
_UNKNOWN_BUY_WATCH_INTERVAL_SEC_DEFAULT = 60.0
def _unknown_buy_timing_v2() -> tuple[int, float, int, float]:
"""(재판정 라운드 수, 라운드 간격초, 확정 후 감시 횟수, 감시 간격초)."""
rounds = max(1, int(getattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_RESOLVE_ROUNDS",
_UNKNOWN_BUY_RESOLVE_ROUNDS_DEFAULT)))
retry = max(0.0, float(getattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_RESOLVE_RETRY_SEC",
_UNKNOWN_BUY_RESOLVE_RETRY_SEC_DEFAULT)))
checks = max(0, int(getattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_WATCH_CHECKS",
_UNKNOWN_BUY_WATCH_CHECKS_DEFAULT)))
w_int = max(0.0, float(getattr(settings, "DAY_TRADE_ENTRY_UNKNOWN_WATCH_INTERVAL_SEC",
_UNKNOWN_BUY_WATCH_INTERVAL_SEC_DEFAULT)))
return rounds, retry, checks, w_int
async def _fetch_todays_orders_strict_v2_impl(stock_code: str) -> list | None:
"""당일 주문내역 — 조회 성공(rt_cd=0)이면 행 리스트(빈 리스트 포함), 실패·시간초과·예외면 None.
`get_todays_orders` 는 실패에도 [] 를 돌려줘 '행 없음'과 '조회 실패'가 섞인다. 미접수 확정은
'성공한 빈 응답'에서만 내려야 하므로 같은 요청(_daily_ccld_fetch_v2)을 직접 부른다.
BaseException(테스트 차단 등)은 삼키지 않는다.
"""
_timeout = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC)
try:
result, _ = await asyncio.wait_for(
korea_investment_api._daily_ccld_fetch_v2(stock_code=stock_code), timeout=_timeout,
)
except asyncio.TimeoutError:
logger.warning(f"[DayEntry] 불명 매수 확인: 주문내역 조회 시간초과 ({stock_code})")
return None
except Exception as e:
logger.warning(f"[DayEntry] 불명 매수 확인: 주문내역 조회 실패 ({stock_code}): {type(e).__name__}: {e}")
return None
if not isinstance(result, dict) or result.get("rt_cd") != "0":
_r = result if isinstance(result, dict) else {}
logger.warning(
f"[DayEntry] 불명 매수 확인: 주문내역 조회 실패 ({stock_code}) "
f"rt_cd={_r.get('rt_cd')} msg_cd={_r.get('msg_cd')}"
)
return None
out = result.get("output1") or []
return out if isinstance(out, list) else [out]
def _known_dt_odnos_v2() -> set[str]:
from app.services.daytrading.fill_reconciler import _norm_odno
return {
_norm_odno(p.get("order_id", ""))
for p in day_trading_state.positions.values() if p.get("order_id")
}
async def _accept_unknown_buy_row_v2_impl(
stock_code: str, key: tuple[str, str], ent: dict, row: dict, *, late: bool,
) -> str:
"""주문내역에서 찾은 접수 행 → DT 포지션 등록. 이미 포지션이 있으면 충돌 경보."""
qty = int(ent["qty"])
odno = str(row.get("odno", "") or "")
if stock_code in day_trading_state.positions:
# 다른 경로(복원 등)가 이미 포지션을 만들었다 — 덮어쓰지 않는다
await _release_unknown_buy_reservation_impl(stock_code, key, "accepted_conflict")
logger.error(
f"[DayEntry] 🚨 {stock_code} 불명 매수가 접수로 확인됐으나(ODNO={odno}) 이미 DT 포지션이 있다 "
f"— 수량 합산하지 않음, 수동 확인 필요"
)
_alert_unknown_buy_manual_check(stock_code, qty, f"접수 확인(ODNO={odno}) / 기존 포지션과 충돌")
return "unresolved"
if late:
logger.error(
f"[DayEntry] 🚨 {stock_code} 미접수로 판정했던 불명 매수가 주문내역에 나타났다(ODNO={odno}, {qty}주) "
f"— DT 포지션으로 등록해 손절·EOD 관리 하에 둔다"
)
_alert_unknown_buy_manual_check(
stock_code, qty, f"미접수 판정 후 주문내역에서 접수 확인(ODNO={odno}) — DT 포지션으로 등록함",
)
else:
logger.warning(
f"[DayEntry] {stock_code} 결과 불명 매수 → 접수로 판정 (ODNO={odno}, {qty}주) — 포지션 등록"
)
async with day_trading_state.trade_lock:
ent["status"] = "late_accepted" if late else "accepted"
ent["reserved"] = False # 예약은 _record_accepted_entry_impl 이 같은 락에서 포지션으로 전환
await _record_accepted_entry_impl(
stock_code, ent["stock_name"], ent["signal"], qty, float(ent["price"]), odno,
entry_time=ent["since"],
reason_suffix=" [결과불명→주문내역 확인(지연)]" if late else " [결과불명→주문내역 확인]",
)
return "late_accepted" if late else "accepted"
async def _resolve_unknown_buy_v2_impl(stock_code: str, key: tuple[str, str]) -> str:
"""결과 불명 매수 판정 → "accepted" | "not_accepted" | "unresolved" | "gone"."""
ent = _unknown_buy_entries.get(key)
if ent is None:
return "gone"
qty = int(ent["qty"])
since = ent["since"]
rounds, round_retry, _, _ = _unknown_buy_timing_v2()
await asyncio.sleep(max(0.0, float(settings.DAY_TRADE_FILL_CHECK_DELAY_SEC)))
attempts = max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS))
interval = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC))
detail = ""
for rnd in range(rounds):
if _unknown_buy_entries.get(key) is not ent:
return "gone" # 날짜 변경 prune 등
row, dead_seen, orders_ok = None, False, False
for i in range(attempts):
rows = await _fetch_todays_orders_strict_v2_impl(stock_code)
if rows is not None:
orders_ok = True
row, _dead = _find_unknown_buy_row_v2(rows, stock_code, qty, since, _known_dt_odnos_v2())
dead_seen = dead_seen or _dead
if row is not None:
break
if i < attempts - 1:
await asyncio.sleep(interval)
if row is not None:
return await _accept_unknown_buy_row_v2_impl(stock_code, key, ent, row, late=False)
# 주문내역에서 못 찾음 — 잔고로 '체결분 없음'을 확인한다
from app.services.daytrading.exit_service import _fetch_kis_holdings_v2_impl
holdings = await _fetch_kis_holdings_v2_impl()
hldg = None if holdings is None else int((holdings.get(stock_code) or {}).get("hldg", 0) or 0)
if hldg == 0 and orders_ok:
await _release_unknown_buy_reservation_impl(stock_code, key, "not_accepted")
logger.warning(
f"[DayEntry] {stock_code} 결과 불명 매수 → 미접수로 판정(주문내역 조회 성공·행 없음"
f"{'·거부/취소 행 확인' if dead_seen else ''}, 잔고 0주) — 슬롯 예약 해제, 당일 재진입 차단은 유지"
)
if not dead_seen:
_schedule_unknown_buy_watch_v2(stock_code, key)
return "not_accepted"
if hldg is not None and hldg > 0 and orders_ok:
detail = f"주문내역에 없음·잔고 {hldg}주(스윙 보유분과 구별 불가)"
break # 재판정해도 구별 근거가 생기지 않는다
detail = ("잔고 조회 실패" if hldg is None
else f"주문내역 조회 실패·잔고 {hldg}주(접수·미체결 주문과 미접수를 구별 불가)")
if rnd < rounds - 1:
async with day_trading_state.trade_lock:
ent["status"] = "retrying"
logger.warning(
f"[DayEntry] {stock_code} 결과 불명 매수 판정 보류({detail}) — 예약 유지, "
f"{round_retry:.0f}초 뒤 재판정 ({rnd + 1}/{rounds})"
)
await asyncio.sleep(round_retry)
async with day_trading_state.trade_lock:
if _unknown_buy_entries.get(key) is not ent:
return "gone"
ent["status"] = "unresolved"
logger.error(
f"[DayEntry] 🚨 {stock_code} 결과 불명 매수 판정 불가 — {detail}. 슬롯 예약 유지·재진입 차단, "
f"수동 확인 필요(접수됐다면 DT 손절·EOD 대상이 아니다)"
)
_alert_unknown_buy_manual_check(stock_code, qty, detail)
return "unresolved"
def _schedule_unknown_buy_watch_v2(stock_code: str, key: tuple[str, str]) -> None:
"""미접수 확정 후 감시 태스크 예약. 실패는 error(감시 없이 진행)."""
_, _, checks, _ = _unknown_buy_timing_v2()
if checks <= 0:
return
try:
task = asyncio.get_running_loop().create_task(_watch_released_unknown_buy_v2_impl(stock_code, key))
except RuntimeError as e:
logger.error(f"[DayEntry] 미접수 판정 후 감시 예약 실패 ({stock_code}): {e} — 계좌 수동 확인 권장")
return
_unknown_buy_tasks.add(task)
task.add_done_callback(_unknown_buy_tasks.discard)
async def _watch_released_unknown_buy_v2_impl(stock_code: str, key: tuple[str, str]) -> str:
"""미접수 확정 뒤 감시 → "late_accepted" | "holding_alert" | "unresolved" | "clean" | "gone".
- 주문내역에 이 주문 행이 나타나면(조회 지연·늦은 반영) DT 포지션으로 등록한다 — 손절·EOD
관리 밖 보유를 막는 쪽이 안전하다. 체결 0 종결이면 체결가 보정이 포지션을 제거한다.
- 행 없이 잔고에 이 종목이 나타나면 경보만 보낸다(스윙 매수와 구별 불가 — 임의 등록 금지).
"""
ent = _unknown_buy_entries.get(key)
if ent is None:
return "gone"
_, _, checks, w_int = _unknown_buy_timing_v2()
qty = int(ent["qty"])
for _ in range(checks):
await asyncio.sleep(w_int)
if _unknown_buy_entries.get(key) is not ent or ent.get("status") != "not_accepted":
return "gone"
rows = await _fetch_todays_orders_strict_v2_impl(stock_code)
row, _dead = _find_unknown_buy_row_v2(rows, stock_code, qty, ent["since"], _known_dt_odnos_v2())
if row is not None:
return await _accept_unknown_buy_row_v2_impl(stock_code, key, ent, row, late=True)
if _dead:
return "clean"
from app.services.daytrading.exit_service import _fetch_kis_holdings_v2_impl
holdings = await _fetch_kis_holdings_v2_impl()
if holdings is None:
continue
hldg = int((holdings.get(stock_code) or {}).get("hldg", 0) or 0)
if hldg > 0 and stock_code not in day_trading_state.positions:
async with day_trading_state.trade_lock:
ent["status"] = "holding_after_not_accepted"
logger.error(
f"[DayEntry] 🚨 {stock_code} 미접수로 판정했던 불명 매수 뒤 잔고 {hldg}주가 나타났다 "
f"(주문내역 미확인 — 스윙 매수와 구별 불가). DT 손절·EOD 대상이 아니다 — 수동 확인 필요"
)
_alert_unknown_buy_manual_check(stock_code, qty, f"미접수 판정 후 잔고 {hldg}주 발견")
return "holding_alert"
return "clean"
def _dt_inmem_qty(stock_code: str) -> int:
"""DT 인메모리 포지션 수량(없으면 0)."""
pos = day_trading_state.positions.get(stock_code)
if not pos:
return 0
try:
return max(0, int(pos.get("quantity", 0) or 0))
except (TypeError, ValueError):
return 0
async def _revert_unfilled_buy_portfolio_v2_impl(
stock_code: str, unfilled_qty: int, price: float, floor_qty: int,
) -> int | None:
"""[R3 2026-10-09] 매수 0체결·부분체결 종결 시 reflect_buy 반영분 중 미체결 수량을 되돌린다.
- 대상은 **'daytrading' 태그 행만**이다. 다른 전략 보유분이 섞였을 수 있는 행(swing/external/
synced 등)은 건드리지 않고 sync_portfolio 에 맡긴다(§3.5 — 타 전략 수량을 깎지 않는다).
고아 감지(_detect_dt_orphans_impl)도 DT 태그 행만 후보로 삼으므로 경보 오발은 이것으로 막힌다.
- 잔량 = max(floor_qty, Portfolio 수량 − 미체결). floor_qty(=DT 인메모리 잔량) 아래로는 내리지
않는다 — 그 사이 sync 가 이미 실잔고로 맞춰 놓았으면 이중 차감이 되기 때문이다.
잔량은 기존 수량을 넘지 않는다(되돌림이 수량을 늘리는 일은 없다).
- reflect_sell 은 쓰지 않는다: note_sell(최근 매도 기록)이 5분간 sync 의 실보유 행을
'반영 지연'으로 오판해 지울 수 있고, 매도가 아니라 매수 취소이기 때문이다.
- 쓰기는 db_worker 큐 경유(§3.2). 수량 조회는 bridge 의 읽기 전용 헬퍼를 그대로 쓴다.
반환: 기록한 잔량, 건너뛰었으면 None. 실패는 warning — 화면·장부 보정이며 리스크 가드가 아니다
(실패 시 수량이 과대로 남아 누적 4% 한도가 더 보수적으로 작동할 뿐이다).
"""
try:
unfilled = int(unfilled_qty or 0)
except (TypeError, ValueError):
unfilled = 0
if unfilled <= 0:
return None
floor = max(0, int(floor_qty or 0))
try:
from app.services import portfolio_cache_bridge as _pcb
from app.services.db_worker import enqueue_portfolio_upsert, DT_STRATEGY_NAMES
cache = _pcb._cache()
existing = next((h for h in cache.holdings if h.stock_code == stock_code), None)
if existing is None:
logger.info(
f"[DayEntry] {stock_code} 미체결 {unfilled}주 Portfolio 되돌림 생략 — 캐시 행 없음(sync 반영 추정)"
)
return None
_tag = str(getattr(existing, "strategy_type", "") or "")
if _tag not in DT_STRATEGY_NAMES:
logger.warning(
f"[DayEntry] {stock_code} 미체결 {unfilled}주 Portfolio 되돌림 생략 — 행 태그 '{_tag or 'NULL'}'"
f"(타 전략 보유분 혼재 가능), sync_portfolio 가 KIS 실잔고로 보정"
)
return None
db_qty = await _pcb._db_quantity(stock_code)
base = db_qty if db_qty is not None else int(existing.quantity or 0)
remaining = min(base, max(floor, base - unfilled))
if remaining <= 0:
cache.holdings = [h for h in cache.holdings if h.stock_code != stock_code]
else:
existing.quantity = remaining
await enqueue_portfolio_upsert({
"stock_code": stock_code,
"quantity": remaining, # 0 → 목록에서 제외, 다음 동기화에서 삭제
"current_price": price,
})
_reverted = max(0, base - remaining)
if _reverted > 0 and price > 0:
# reflect_buy 의 apply_buy(예수금 차감)를 같은 가격으로 상쇄 — 손익 영향 0
await cache.apply_sell(stock_code, _reverted, price, price)
logger.warning(
f"[DayEntry] {stock_code} 매수 미체결 {unfilled}주 → Portfolio(daytrading) {base}→{remaining}주 되돌림"
f" (하한 DT 인메모리 {floor}주)"
)
return remaining
except Exception as e:
logger.warning(f"[DayEntry] {stock_code} 미체결 Portfolio 되돌림 실패: {type(e).__name__}: {e} — sync 가 보정")
return None
def _schedule_buy_fill_reconcile(
stock_code: str, order_id: str, signal_price: float, quantity: int,
):
"""[2026-10-01] 매수 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단).
확인되면:
- TradeHistory(order_id) executed_price·total_amount·slippage(%) 갱신 (db_worker 큐)
- 인메모리 포지션 entry_price 를 체결가로 교체(같은 주문번호의 포지션이 남아 있을 때만).
손절가·목표가는 신호 시점 절대가격 그대로 둔다(전략 파라미터 불변).
- ActiveTrade(day_trade) entry_price 갱신 — 재기동 복원도 체결가 기준이 된다.
이미 일부/전량 청산된 뒤에 확인되면 그 청산 손익은 신호가 기준으로 남는다(보고된 잔여 위험).
"""
from app.services.daytrading import fill_reconciler
from app.core.timeutil import now_kst as _now_kst
_order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회
async def _on_result(info: dict) -> None:
# [M7 2026-10-02] 부분체결·미체결 보정. 예전에는 부분체결이면 warning 만 남기고
# 인메모리·ActiveTrade 수량을 주문 수량으로 둬서, 청산이 실보유보다 많이 매도
# (주문거부 또는 같은 종목 스윙 물량 매도)했다. 보정은 **주문 종결**(전량 체결 또는
# 잔여수량 0) 근거가 있을 때만 한다 — 미종결 부분체결을 확정하면 나머지 체결분이
# 인메모리에서 빠져 EOD 청산 후 잔량이 남는다.
from decimal import Decimal
from app.services.db_worker import (
enqueue_update_trade_history_by_order_id, enqueue_close_active_trade,
)
filled_qty = int(info.get("filled_qty", 0) or 0)
avg_price = float(info.get("avg_price", 0) or 0)
terminal = bool(info.get("terminal"))
_ident = {
# 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다
"order_date": _order_date,
"order_type": "BUY",
"strategy_type": "daytrading",
}
if filled_qty <= 0:
if not terminal:
logger.warning(
f"[DayEntry] 매수 체결 0 (미종결) 확인: {stock_code} order_id={order_id} "
f"— 인메모리 유지, 수동 확인 필요"
)
return
removed = False
async with day_trading_state.trade_lock:
pos = day_trading_state.positions.get(stock_code)
if pos is not None and str(pos.get("order_id", "")) == str(order_id):
day_trading_state.remove_position(stock_code) # closed_today 포함(당일 재진입 차단)
# [R2-2 2026-10-09] dt_exited_today 는 여기서 지우지 않는다. 화면 '청산 N' 에
# 0체결 매수가 1건 섞이는 표시 결함(LOW)이 있지만, dt_exited_today 는
# close_stale_positions 의 당일 active 행 복원 차단 근거
# (position_manager.restore_block_reason_v2)이기도 하다. 지우면 close 큐 지연
# 중 잔존 active 행이 복원 후보가 된다 → 표시는 별도 집합으로 분리해야 한다.
removed = True
await enqueue_update_trade_history_by_order_id(order_id, {
**_ident, "executed_quantity": 0, "total_amount": Decimal("0"),
"status": "CANCELLED",
})
if removed:
await day_trading_state.save_daily_state()
await enqueue_close_active_trade(stock_code, strategy_name="day_trade")
# [R3] 진입 때 reflect_buy 로 올린 Portfolio(daytrading) 수량을 되돌린다.
# 남겨 두면 sync 전까지 고아 감지가 '원장 순체결 0주' 수동확인 경보를 오발한다.
await _revert_unfilled_buy_portfolio_v2_impl(
stock_code, quantity, signal_price, _dt_inmem_qty(stock_code),
)
logger.warning(
f"[DayEntry] ⚠️ 매수 미체결 종결: {stock_code} 0/{quantity}주 order_id={order_id} "
+ ("— 인메모리 포지션 제거·ActiveTrade(day_trade) 정리" if removed
else "— 포지션 이미 청산/교체됨, 원장만 보정")
)
return
partial_final = terminal and filled_qty < quantity
if filled_qty < quantity and not terminal:
logger.warning(
f"[DayEntry] 매수 부분체결(미종결) 확인: {stock_code} {filled_qty}/{quantity}주 "
f"order_id={order_id} — 종결 미확인이라 수량 보정 보류(수동 확인 필요)"
)
_book_qty = filled_qty if partial_final else quantity
slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0
_upd = {
**_ident,
"executed_price": Decimal(str(round(avg_price, 2))),
"total_amount": Decimal(str(round(avg_price * _book_qty, 2))),
"slippage": Decimal(str(round(slippage_pct, 4))),
}
if partial_final:
_upd["executed_quantity"] = filled_qty
await enqueue_update_trade_history_by_order_id(order_id, _upd)
updated = False
new_qty = None
async with day_trading_state.trade_lock:
pos = day_trading_state.positions.get(stock_code)
if pos is not None and str(pos.get("order_id", "")) == str(order_id):
pos["entry_price"] = float(avg_price)
updated = True
if partial_final:
# 이미 일부 청산됐을 수 있으므로 '미체결분'만큼만 뺀다
new_qty = max(0, int(pos.get("quantity", 0) or 0) - (quantity - filled_qty))
if new_qty > 0:
pos["quantity"] = new_qty
else:
day_trading_state.remove_position(stock_code)
if updated:
_at = {
"stock_code": stock_code,
"strategy_name": "day_trade",
"entry_price": float(avg_price),
}
if partial_final and new_qty:
_at["quantity"] = new_qty
if partial_final and not new_qty:
await day_trading_state.save_daily_state()
await enqueue_close_active_trade(stock_code, strategy_name="day_trade")
else:
await enqueue_active_trade_upsert(_at)
if partial_final:
# [R3] 미체결분만큼 Portfolio(daytrading) 반영을 되돌린다(하한 = DT 인메모리 잔량).
await _revert_unfilled_buy_portfolio_v2_impl(
stock_code, quantity - filled_qty, signal_price, _dt_inmem_qty(stock_code),
)
if partial_final:
logger.warning(
f"[DayEntry] ⚠️ 매수 부분체결 종결: {stock_code} {filled_qty}/{quantity}주 "
f"order_id={order_id} — 인메모리·ActiveTrade 수량 보정 → {new_qty if updated else '(포지션 없음)'}"
)
logger.info(
f"[DayEntry] 매수 체결가 확인: {stock_code} 신호가={signal_price:,.0f} "
f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%)"
+ ("" if updated else " — 포지션 이미 청산/교체됨, 원장만 보정")
)
return fill_reconciler.schedule_v2(stock_code, order_id, "BUY", quantity, _on_result)