fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,8 @@ 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) 타임존 — 장 시간/시간대 체크에 사용
|
||||
@@ -130,6 +132,25 @@ _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] = {}
|
||||
|
||||
|
||||
# ── RSI·갭 진단 로깅 (2026-09-30) ─────────────────────────────────────────────
|
||||
# 배경: 장중 5분봉 RSI 는 워밍업 시계열(전일 봉 포함)을 쓰므로 오버나이트 갭이 종가
|
||||
@@ -471,7 +492,17 @@ async def _get_candles_cached(stock_code: str) -> list:
|
||||
cached = _candle_cache.get(stock_code)
|
||||
if cached and (time.monotonic() - cached[0]) < _CANDLE_CACHE_TTL:
|
||||
return cached[1]
|
||||
candles = await _fetch_candles_impl(stock_code)
|
||||
# [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
|
||||
@@ -530,9 +561,26 @@ async def collect_opening_ranges() -> None:
|
||||
|
||||
_collected_before = len(day_trading_state.orb_cache)
|
||||
|
||||
# [H1 2026-10-02] ⚠️ 현행(기본, DAY_TRADE_ORB_FULL_RANGE=False) 실제 동작:
|
||||
# `has_orb()` 단락 때문에 **첫 수집 성공 시점의 레인지로 하루가 고정**된다.
|
||||
# calculate_opening_range 는 1분봉 5개면 값을 내므로 09:00 첫 사이클 직후(대개 09:05
|
||||
# 전후)의 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가 수집된 종목은 건너뜀
|
||||
if day_trading_state.has_orb(stock_code):
|
||||
# 이미 ORB가 수집된 종목은 건너뜀 (FULL_RANGE 면 '확정된' 종목만 건너뜀)
|
||||
if _full_range:
|
||||
if stock_code in _orb_finalized:
|
||||
continue
|
||||
elif day_trading_state.has_orb(stock_code):
|
||||
continue
|
||||
try:
|
||||
# 당일 분봉 전체 조회 → 시초가 레인지 계산
|
||||
@@ -542,6 +590,9 @@ async def collect_opening_ranges() -> None:
|
||||
orb = calculate_opening_range(candles)
|
||||
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
|
||||
@@ -564,9 +615,14 @@ async def collect_opening_ranges() -> None:
|
||||
# KIS API 레이트 리밋 방지 (초당 약 3회)
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception as e:
|
||||
logger.debug(f"[DayEntry] ORB 수집 실패 ({stock_code}): {e}")
|
||||
warn_throttled(logger, "entry_service:567", f"[DayEntry] ORB 수집 실패 ({stock_code}): {e}")
|
||||
|
||||
if len(day_trading_state.orb_cache) != _collected_before:
|
||||
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()
|
||||
|
||||
|
||||
@@ -666,14 +722,27 @@ async def scan_and_enter() -> list[dict]:
|
||||
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()
|
||||
if not _adp_dt.fallback_used and not _adp_dt.daytrading_enabled:
|
||||
logger.info("[DayEntry] 적응형 전략 %s 모드 — 데이트레이딩 비활성화", _adp_dt.trading_mode.upper())
|
||||
return entries
|
||||
except Exception:
|
||||
pass # adaptive 모듈 실패 시 기존 로직 유지
|
||||
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():
|
||||
@@ -693,16 +762,25 @@ async def scan_and_enter() -> list[dict]:
|
||||
# [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.get("regime", "neutral")
|
||||
if regime in _BLOCK_REGIMES_DT:
|
||||
logger.info(f"[DayEntry] ⛔ {regime.upper()} 국면 — 데이트레이딩 진입 차단")
|
||||
return entries
|
||||
logger.info(f"[DayEntry] 시장 국면: {regime}")
|
||||
regime = (regime_info or {}).get("regime") if isinstance(regime_info, dict) else None
|
||||
except Exception as e:
|
||||
logger.info(f"[DayEntry] 시장 국면 조회 실패 (계속 진행): {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,
|
||||
@@ -808,6 +886,29 @@ async def scan_and_enter() -> list[dict]:
|
||||
_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(
|
||||
@@ -821,6 +922,11 @@ async def scan_and_enter() -> list[dict]:
|
||||
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
|
||||
@@ -917,8 +1023,12 @@ async def _try_enter(
|
||||
# 신호 체크 (ORB → VWAP → RSI+BB 순)
|
||||
signal = None
|
||||
if orb:
|
||||
# ORB: 당일 1분봉 — 직전 1분 거래량과 갭 판정이 당일 기준이어야 한다
|
||||
signal = check_orb_signal(stock_code, stock_name, candles_today, orb, current_price)
|
||||
# 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:
|
||||
@@ -969,12 +1079,74 @@ async def _try_enter(
|
||||
mcp_approved, mcp_reason = await request_mcp_risk_assessment(
|
||||
trade_plan, {"strategy": "daytrading"}, {"stock_code": stock_code}
|
||||
)
|
||||
if not mcp_approved:
|
||||
logger.debug(f"[DayEntry] MCP 거부: {stock_code} — {mcp_reason}")
|
||||
return None
|
||||
except Exception:
|
||||
pass # MCP 실패 시 진입 계속
|
||||
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
|
||||
|
||||
# [H2/M5 2026-10-02] 주문 직전 재검사 + 슬롯 예약 (trade_lock 안).
|
||||
# 신호 판정 ~ 주문 사이에 분봉 조회·MCP 평가 등 await 가 있어 그 사이 다른 경로
|
||||
# (수동 /cycle, 같은 사이클의 앞선 진입)가 슬롯·예산을 소비할 수 있다. 보유·당일청산·
|
||||
# 포지션 한도(예약 포함)·DT 예산 잔여를 **주문 직전에** 다시 보고 슬롯을 예약한다.
|
||||
# 리스크 가드이므로 try 로 감싸지 않는다(§4) — 바깥 try 는 로그용.
|
||||
_reject = None
|
||||
async with day_trading_state.trade_lock:
|
||||
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 day_trading_state.position_count() + len(_pending_entries) >= MAX_POSITIONS:
|
||||
_reject = f"포지션 한도 {MAX_POSITIONS} (주문 진행 중 포함)"
|
||||
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 구간). 슬롯 예약은 끝에서 해제한다."""
|
||||
try:
|
||||
# KIS API 매수 주문 (시장가) — 분산락+세마포어 보호
|
||||
from app.services.trade_executor import safe_place_order
|
||||
order_resp = await safe_place_order(
|
||||
@@ -1005,6 +1177,8 @@ async def _try_enter(
|
||||
"reason": signal.reason,
|
||||
"order_id": order_id,
|
||||
})
|
||||
# 예약 → 실제 포지션으로 전환(같은 락 안에서 — 한도 이중 계상 방지)
|
||||
_pending_entries.pop(stock_code, None)
|
||||
|
||||
# DB 기록 (큐 경유)
|
||||
await enqueue_trade_history({
|
||||
@@ -1055,8 +1229,10 @@ async def _try_enter(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DayEntry] _try_enter 오류 ({stock_code}): {e}", exc_info=True)
|
||||
logger.error(f"[DayEntry] 매수 주문/반영 오류 ({stock_code}): {e}", exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
_pending_entries.pop(stock_code, None)
|
||||
|
||||
|
||||
def _schedule_buy_fill_reconcile(
|
||||
@@ -1076,40 +1252,107 @@ def _schedule_buy_fill_reconcile(
|
||||
from app.core.timeutil import now_kst as _now_kst
|
||||
_order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회
|
||||
|
||||
async def _on_fill(filled_qty: int, avg_price: float) -> None:
|
||||
if filled_qty < quantity:
|
||||
logger.warning(
|
||||
f"[DayEntry] 매수 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 "
|
||||
f"order_id={order_id} — 인메모리 수량은 주문 수량 기준(수동 확인 필요)"
|
||||
)
|
||||
slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0
|
||||
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
|
||||
await enqueue_update_trade_history_by_order_id(order_id, {
|
||||
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 포함(당일 재진입 차단)
|
||||
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")
|
||||
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 * quantity, 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:
|
||||
await enqueue_active_trade_upsert({
|
||||
_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:
|
||||
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(stock_code, order_id, "BUY", _on_fill)
|
||||
return fill_reconciler.schedule_v2(stock_code, order_id, "BUY", quantity, _on_result)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,3 +141,157 @@ def schedule(
|
||||
_pending_tasks.add(task)
|
||||
task.add_done_callback(_pending_tasks.discard)
|
||||
return task
|
||||
|
||||
|
||||
# ── [M7 2026-10-02] 부분체결·미체결 판정용 v2 ─────────────────────────────────
|
||||
# 기존 parse_fill_row/fetch_fill/schedule 은 **체결수량>0 인 첫 조회 결과**만 돌려줘
|
||||
# (a) 체결 0 과 조회 실패를 구분하지 못하고 (b) 아직 체결 중인 부분체결도 확정으로 본다.
|
||||
# 매수 쪽 수량 보정은 "주문이 종결됐다"는 근거가 있을 때만 해야 한다 — 미종결 부분체결을
|
||||
# 수량으로 확정하면 나머지가 체결된 뒤 실보유 > 인메모리가 되어 EOD 청산 뒤 잔량이 남는다.
|
||||
# 종결 판정: 체결수량 ≥ 주문수량, 또는 응답의 잔여수량(rmn_qty)=0(취소·거부 포함).
|
||||
# rmn_qty 가 없는 응답에서 체결<주문이면 '미확정' 으로 남긴다(보정하지 않음, warning).
|
||||
|
||||
def parse_fill_row_v2(rows: list, order_id: str) -> Optional[dict]:
|
||||
"""주문번호가 일치하는 행 → {filled_qty, avg_price, ord_qty, rmn_qty}. 없거나 파싱 불가면 None.
|
||||
|
||||
체결수량 0 행도 돌려준다(미체결·취소 판정용). ord_qty/rmn_qty 는 응답에 없으면 None.
|
||||
"""
|
||||
target = _norm_odno(order_id)
|
||||
if not target:
|
||||
return None
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if _norm_odno(row.get("odno")) != target:
|
||||
continue
|
||||
try:
|
||||
qty = int(float(row.get("tot_ccld_qty", 0) or 0))
|
||||
avg = float(row.get("avg_prvs", 0) or 0)
|
||||
if avg <= 0 and qty > 0:
|
||||
amt = float(row.get("tot_ccld_amt", 0) or 0)
|
||||
avg = amt / qty if amt > 0 else 0.0
|
||||
_oq = row.get("ord_qty")
|
||||
ord_qty = int(float(_oq)) if _oq not in (None, "") else None
|
||||
_rq = row.get("rmn_qty")
|
||||
rmn_qty = int(float(_rq)) if _rq not in (None, "") else None
|
||||
_jq = row.get("rjct_qty")
|
||||
rjct_qty = int(float(_jq)) if _jq not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if qty > 0 and avg <= 0:
|
||||
return None
|
||||
cncl_yn = str(row.get("cncl_yn", "") or "").upper() or None
|
||||
return {"filled_qty": max(0, qty), "avg_price": avg, "ord_qty": ord_qty,
|
||||
"rmn_qty": rmn_qty, "cncl_yn": cncl_yn, "rjct_qty": rjct_qty}
|
||||
return None
|
||||
|
||||
|
||||
def is_terminal_fill(info: dict, ordered_qty: int) -> bool:
|
||||
"""주문 종결 여부 — 전량 체결 / 잔여수량 0 / 취소(cncl_yn=Y) / 거부수량>0.
|
||||
|
||||
[Low 2026-10-02] 거부·취소 행은 응답에 따라 rmn_qty 가 비거나 0 이 아닐 수 있어
|
||||
cncl_yn·rjct_qty 도 본다(체결 0주 종결 판정 누락 방지).
|
||||
"""
|
||||
filled = int(info.get("filled_qty", 0) or 0)
|
||||
if ordered_qty > 0 and filled >= ordered_qty:
|
||||
return True
|
||||
if str(info.get("cncl_yn") or "").upper() == "Y":
|
||||
return True
|
||||
rj = info.get("rjct_qty")
|
||||
rmn = info.get("rmn_qty")
|
||||
if rj is not None and int(rj) > 0 and (rmn is None or int(rmn) <= 0):
|
||||
return True
|
||||
return rmn is not None and int(rmn) <= 0
|
||||
|
||||
|
||||
async def _poll_fill_v2(stock_code: str, order_id: str, ordered_qty: int) -> Optional[dict]:
|
||||
"""종결될 때까지(또는 시도 소진까지) 폴링. 마지막으로 본 행 정보를 돌려준다."""
|
||||
attempts = max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS))
|
||||
interval = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC))
|
||||
await asyncio.sleep(max(0.0, float(settings.DAY_TRADE_FILL_CHECK_DELAY_SEC)))
|
||||
last: Optional[dict] = None
|
||||
for i in range(attempts):
|
||||
rows = await korea_investment_api.get_todays_orders(stock_code)
|
||||
info = parse_fill_row_v2(rows, order_id)
|
||||
if info is not None:
|
||||
last = info
|
||||
if is_terminal_fill(info, ordered_qty):
|
||||
return info
|
||||
if i < attempts - 1:
|
||||
await asyncio.sleep(interval)
|
||||
return last
|
||||
|
||||
|
||||
async def fetch_fill_v2(
|
||||
stock_code: str, order_id: str, side: str, ordered_qty: int,
|
||||
) -> Optional[dict]:
|
||||
"""[M7] 주문 체결 상태. 조회 실패·시간초과·행 없음이면 None + warning.
|
||||
|
||||
반환 dict 에 `terminal`(bool) 이 추가된다.
|
||||
"""
|
||||
if not order_id:
|
||||
logger.warning(f"[DTFill] {stock_code} {side}: 주문번호 없음 — 신호가 기록 유지")
|
||||
return None
|
||||
timeout = float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC)
|
||||
try:
|
||||
info = await asyncio.wait_for(
|
||||
_poll_fill_v2(stock_code, order_id, ordered_qty), timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결 조회 시간초과({timeout:.0f}초) "
|
||||
f"order_id={order_id} — 신호가 기록 유지"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결 조회 실패 order_id={order_id}: "
|
||||
f"{type(e).__name__}: {e} — 신호가 기록 유지"
|
||||
)
|
||||
return None
|
||||
if info is None:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 주문 행 미확인 "
|
||||
f"({int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)}회 조회) order_id={order_id} — 신호가 기록 유지"
|
||||
)
|
||||
return None
|
||||
info = dict(info)
|
||||
info["terminal"] = is_terminal_fill(info, ordered_qty)
|
||||
return info
|
||||
|
||||
|
||||
def schedule_v2(
|
||||
stock_code: str,
|
||||
order_id: str,
|
||||
side: str,
|
||||
ordered_qty: int,
|
||||
on_result: Callable[[dict], Awaitable[None]],
|
||||
) -> Optional[asyncio.Task]:
|
||||
"""[M7] fetch_fill_v2 결과(체결 0·부분체결 포함)를 `on_result(info)` 로 넘긴다.
|
||||
|
||||
`DAY_TRADE_FILL_CHECK_ENABLED=False` 면 아무것도 하지 않는다(None).
|
||||
"""
|
||||
if not settings.DAY_TRADE_FILL_CHECK_ENABLED:
|
||||
return None
|
||||
|
||||
async def _run() -> None:
|
||||
info = await fetch_fill_v2(stock_code, order_id, side, ordered_qty)
|
||||
if info is None:
|
||||
return
|
||||
try:
|
||||
await on_result(info)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결 결과 반영 실패 order_id={order_id}: "
|
||||
f"{type(e).__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
task = asyncio.get_running_loop().create_task(_run())
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"[DTFill] {stock_code} {side} 체결 확인 예약 실패: {e}")
|
||||
return None
|
||||
_pending_tasks.add(task)
|
||||
task.add_done_callback(_pending_tasks.discard)
|
||||
return task
|
||||
|
||||
@@ -90,6 +90,12 @@ class DayTradingState:
|
||||
self.portfolio_cache_dirty: bool = True
|
||||
# 매매 실행 락 — positions 수정 시 반드시 획득
|
||||
self.trade_lock = asyncio.Lock()
|
||||
# [H-B 2026-10-02] 청산 주문 진행 중 종목. exit_service._execute_exit_impl 이
|
||||
# trade_lock 안에서 등록하고 shield 된 주문 작업의 finally 에서 해제한다.
|
||||
# 청산 판정 경로(손절 선판정·분봉 판정·폴백 손절·force_close_all)는 이 종목을 건너뛴다
|
||||
# — 주문은 진행 중인데 인메모리 수량이 아직 그대로라 같은 물량을 다시 파는 것을 막는다.
|
||||
# 날짜 초기화(reset_if_new_day)로 지우지 않는다(진행 중 주문의 보호가 풀린다).
|
||||
self.exits_in_progress: set[str] = set()
|
||||
|
||||
def reset_if_new_day(self) -> bool:
|
||||
"""날짜가 바뀌면 일일 상태 초기화.
|
||||
@@ -209,6 +215,10 @@ class DayTradingState:
|
||||
"""
|
||||
self.daily_pnl += float(delta)
|
||||
|
||||
def is_exit_in_progress(self, stock_code: str) -> bool:
|
||||
"""[H-B] 해당 종목의 청산 주문이 진행 중(또는 결과 불명 보류 중)인지."""
|
||||
return stock_code in self.exits_in_progress
|
||||
|
||||
def get_position(self, stock_code: str) -> dict | None:
|
||||
"""특정 종목의 활성 포지션 정보 반환.
|
||||
|
||||
@@ -494,7 +504,7 @@ class DayTradingState:
|
||||
f"기본값 적용: {target_price:.0f}"
|
||||
)
|
||||
|
||||
self.positions[trade.stock_code] = {
|
||||
_restored = {
|
||||
"stock_name": trade.stock_name or trade.stock_code,
|
||||
"strategy": trade.strategy_name or "day_trade",
|
||||
"entry_price": entry_price,
|
||||
@@ -509,6 +519,20 @@ class DayTradingState:
|
||||
"order_id": trade.order_id or "",
|
||||
"restored_from_db": True,
|
||||
}
|
||||
# [M1 2026-10-02] 분할매도 이력 복원. exit_service 의 분할매도가
|
||||
# remaining_pct(<1.0) 를 ActiveTrade 에 남긴다. 복원 시 이를 보지 않으면
|
||||
# target_price 가 원래 목표가로 되살아나 재기동 직후 같은 50% 분할이
|
||||
# 재발동한다(잔량의 절반을 또 판다). 인메모리 분할 후 상태와 동일하게
|
||||
# 목표가를 제거(inf)하고 partial_sold 를 세운다 → 트레일링만 적용.
|
||||
if _is_partial_sold_row(trade):
|
||||
_restored["target_price"] = float("inf")
|
||||
_restored["partial_sold"] = True
|
||||
logger.warning(
|
||||
f"[DayTrading] {trade.stock_code} 분할매도 이력 복원 "
|
||||
f"(remaining_pct={trade.remaining_pct}) — 목표가 분할 재발동 차단, "
|
||||
f"트레일링 고점={_restored['highest_price']:.0f}"
|
||||
)
|
||||
self.positions[trade.stock_code] = _restored
|
||||
restored += 1
|
||||
logger.info(
|
||||
f"[DayTrading] DB에서 포지션 복원: {trade.stock_code} "
|
||||
@@ -527,6 +551,21 @@ class DayTradingState:
|
||||
return restored
|
||||
|
||||
|
||||
def _is_partial_sold_row(trade) -> bool:
|
||||
"""[M1] ActiveTrade 행이 분할매도 이후 상태인지 (remaining_pct < 1.0)."""
|
||||
try:
|
||||
rp = getattr(trade, "remaining_pct", None)
|
||||
return rp is not None and 0.0 < float(rp) < 1.0
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# 싱글톤 인스턴스 — daytrading 패키지 전체에서 공유
|
||||
# entry_service, exit_service, scheduler에서 동일 인스턴스 참조
|
||||
day_trading_state = DayTradingState()
|
||||
|
||||
# [M5 2026-10-02] 데이트레이딩 사이클 전역 락 — 스케줄러와 수동 API(/api/daytrading/cycle)가
|
||||
# 공유한다. 같은 프로세스에서 청산 체크·진입 스캔이 두 경로로 동시에 돌면 같은 포지션을
|
||||
# 두 번 매도하거나 슬롯·예산 검사를 동시에 통과해 한도를 넘겨 매수한다.
|
||||
# trade_lock(인메모리 dict 보호, 짧게 잡음)과 다르다 — 이 락은 '한 사이클 단계' 전체를 감싼다.
|
||||
dt_cycle_lock = asyncio.Lock()
|
||||
|
||||
@@ -30,12 +30,16 @@ from app.core.market_schedule import (
|
||||
seconds_until_market_open,
|
||||
)
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
from app.services.daytrading.position_manager import day_trading_state, dt_cycle_lock
|
||||
from app.services.daytrading.entry_service import (
|
||||
collect_opening_ranges, scan_and_enter,
|
||||
get_watchlist, populate_daytrading_watchlist, is_watchlist_auto,
|
||||
)
|
||||
from app.services.daytrading.exit_service import check_and_exit_positions, force_close_all
|
||||
from app.services.daytrading.exit_service import (
|
||||
check_and_exit_positions, force_close_all, check_stops_only_v2,
|
||||
)
|
||||
|
||||
from app.core.log_throttle import warn_throttled # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -206,9 +210,10 @@ async def daytrading_scheduler() -> None:
|
||||
# EOD 강제 청산 (15:20)
|
||||
if is_eod_flat_time():
|
||||
if day_trading_state.positions:
|
||||
exits = await _guard(
|
||||
force_close_all(reason="EOD 강제 청산 (15:20)"), "EOD 강제청산", 300
|
||||
)
|
||||
async with dt_cycle_lock:
|
||||
exits = await _guard(
|
||||
force_close_all(reason="EOD 강제 청산 (15:20)"), "EOD 강제청산", 300
|
||||
)
|
||||
if exits:
|
||||
logger.info(f"[DayTrading] EOD 청산: {len(exits)}건")
|
||||
await asyncio.sleep(30)
|
||||
@@ -216,6 +221,14 @@ async def daytrading_scheduler() -> None:
|
||||
|
||||
# 09:00~09:30 ORB 수집
|
||||
if is_opening_range_period():
|
||||
# [M4 2026-10-02] 이 분기는 아래에서 continue 하므로 예전에는 09:00~09:30
|
||||
# 동안 청산 판정이 전혀 없었다(전일 미청산·재기동 복원 포지션 방치).
|
||||
# ORB 수집보다 먼저 전일 포지션 청산 + 손절선 판정을 수행한다(§8.1-11).
|
||||
# 진입·익절 규칙은 건드리지 않는다(ORB 구간엔 신규 진입이 없다).
|
||||
async with dt_cycle_lock:
|
||||
orb_exits = await _guard(check_stops_only_v2(), "ORB 구간 손절 체크")
|
||||
if orb_exits:
|
||||
logger.info(f"[DayTrading] ORB 구간 청산: {len(orb_exits)}건")
|
||||
if not get_watchlist():
|
||||
try:
|
||||
count = await populate_daytrading_watchlist()
|
||||
@@ -240,8 +253,8 @@ async def daytrading_scheduler() -> None:
|
||||
try:
|
||||
await populate_daytrading_watchlist()
|
||||
wl = get_watchlist()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _swallowed:
|
||||
warn_throttled(logger, "scheduler:243", "[DayTrade] 감시종목 자동 갱신 실패: %s", _swallowed)
|
||||
|
||||
logger.info(
|
||||
f"[DayTrading] 🔄 cycle: mdd_ok={short_term_ok}, "
|
||||
@@ -249,7 +262,9 @@ async def daytrading_scheduler() -> None:
|
||||
)
|
||||
|
||||
# 청산 체크 (MDD 무관 — 기존 포지션 보호)
|
||||
exits = await _guard(check_and_exit_positions(), "청산 체크")
|
||||
# [M5] 수동 /cycle API 와 공유하는 전역 락 — 같은 포지션 이중 매도 방지
|
||||
async with dt_cycle_lock:
|
||||
exits = await _guard(check_and_exit_positions(), "청산 체크")
|
||||
if exits:
|
||||
logger.info(f"[DayTrading] 청산: {len(exits)}건")
|
||||
|
||||
@@ -268,7 +283,8 @@ async def daytrading_scheduler() -> None:
|
||||
|
||||
# 진입 (MDD 허용 시만)
|
||||
if short_term_ok:
|
||||
entries = await _guard(scan_and_enter(), "진입 스캔")
|
||||
async with dt_cycle_lock:
|
||||
entries = await _guard(scan_and_enter(), "진입 스캔")
|
||||
if entries:
|
||||
logger.info(f"[DayTrading] 진입: {len(entries)}건")
|
||||
else:
|
||||
@@ -279,7 +295,8 @@ async def daytrading_scheduler() -> None:
|
||||
|
||||
# 그 외 시간 — 보유 포지션 청산 체크만
|
||||
if day_trading_state.positions:
|
||||
exits = await _guard(check_and_exit_positions(), "장외 청산 체크")
|
||||
async with dt_cycle_lock:
|
||||
exits = await _guard(check_and_exit_positions(), "장외 청산 체크")
|
||||
if exits:
|
||||
logger.info(f"[DayTrading] 장외 청산: {len(exits)}건")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user