- 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>
2441 lines
127 KiB
Python
2441 lines
127 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
|
||
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)
|