fix: 스윙 자동편입 차단 + DT 처리량 복구 + ATR/양봉 세션 경계 + DLQ 안전
사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 KIS
계좌 잔고가 strategy_type='swing' 이 되는 경로를 전부 닫았다. 2026-09-30
09:26 에 유한양행 8주가 -6.49%/-40,000원 손절된 것이 직접 계기다.
## 스윙 자동편입 차단
- 강등 허용(swing→external). 기존엔 승격만 가능해 유입 잔고가 영구히
자동 손절 대상이었다. 3중 방어로 정상 포지션 강등을 막는다:
system_buy_registry(주문 즉시 인메모리) / 연속2회+120초 / db_worker 큐 적체 보류
※ 레이스는 이론이 아니다 — _verify_and_sync_execution 이 매수 직후
_delayed_sync_portfolio(0) 을 지연 0초로 부른다
- 수량 인식 분류(_classify_holding_strategy_type_v2):
_sys<=0<qty → external / 0<_sys<qty AND _sys==_dt_held → external
== 을 쓴 이유: 과소·과대 어느 쪽으로 틀려도 swing(관리 유지)으로 실패한다
- _system_qty 를 과대 집계 쪽으로 편향 — 오판이 안전한 방향으로 나게
- external 행에 스윙 추가매수 차단(혼재 발생 자체를 봉쇄, 청산은 통과)
- 근거를 두 벌로 분리: 신규태깅·강등방어는 registry 포함("있다고 보는 게 안전"),
승격·ActiveTrade 교정은 durable("없다고 보는 게 안전"). 안전 방향이 반대다
- 정합 루프가 external/혼재 행을 교정하지 않게(전제 "active 1행 = 전량 대표"를
직접 검사). 미교정 시 ActiveTrade 가 부풀어 스윙 청산이 유입분까지 판다(§3.5)
- status 허용목록 → 거부목록(FAILED 만 제외). 진짜 구멍은 FILLED 가 아니라
PENDING 이었다(체결확인 실패 시 영구 PENDING, 주식은 보유 중)
- SELL 중복제거 키에 nullif+trim — order_id 는 NULL 이 아니라 '' 로 저장된다
(build_kis_order_id 가 미발견 시 "" 반환). coalesce 만으로는 그룹이 붕괴해
SELL 이 과소 집계되고 유입 행이 영구히 swing 이 된다
## DT 처리량
- 감시종목 20 → 30 (DAY_TRADE_WATCHLIST_SIZE, 예산 상한 32에서 마진)
- 선조회 동시성 4→8, per-stock 45초, 단계 데드라인 120초 신설
(120 < 150 _SCAN_TIME_BUDGET < 180 _guard). 오후는 종목당 32초가
타임아웃 20초를 넘겨 4~7종목만 스캔되던 포화 상태였다 — 약 9배 복구
- 리스크 노출 불변: 동시 5종목·종목당 6%·DT 30% 무변경. 기회만 증가
## 세션 경계 (ATR·양봉)
- _atr() 이 내부에서 세션을 끊는다. 워밍업 시계열의 당일 첫 봉 TR =
오버나이트 갭이라 갭 3%에서 ATR 1.50배, 10%에서 2.75배 부풀었고
클램프 없는 target=현재가+1.5×ATR 을 직접 왜곡했다
→ 진입 ATR == 청산 트레일링 ATR 보장
※ 부작용 승인됨: 09:30~10:10 은 ATR 부재로 폴백 -3.5% 가 실효 손절폭
- 양봉 판정이 형성 중 버킷을 보던 것 → 마지막 완성봉(거래량 게이트와 같은 봉)
- 1.5R 하한 보장(두 분기). risk=max(1.5×ATR,2%) 라 저변동 종목은
R:R 0.21 까지 떨어졌다 — §7 "1.5R 분할" 규약 복귀, RSI_BB 와 동일
## DLQ 안전
- replay_dlq 나이 제한: 상태 2시간+같은날짜 / 기록 24시간 / close 는
충돌판정만(7일). "닫아라"는 지시는 시간이 지나도 거짓이 되지 않는다 —
격리하면 반대로 유령 active 행이 남는다(§8.4.1 129개와 같은 형태)
- 상태 충돌 확인(유령 생성 위험 시 expired/ 격리, 삭제 안 함)
- DB 장애 시 단락 — 없으면 500파일 × 접속타임아웃만큼 기동이 정지한다
## 진단·테스트
- 진입 reason 에 [진단 ATR=…, 갭=…], 탈락 후보 RSI·갭 집계 로그(하루 ~60줄)
- 잔고조회 실패 debug→warning (available_cash=0 → 모든 진입 조용히 차단)
- 벽시계 의존 테스트 재발 방지: autouse 가드(시각 미고정 시 AssertionError)
+ 호출부 전수 테스트
- 소스 문자열 매칭 단언을 동작 검증으로 전환(값·분기 판정 한정)
테스트 848 → 865 passed / 0 failed. 전략 임계값 변경 0건. alembic 불필요.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,9 @@ from app.services.day_trading_strategy import (
|
||||
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
|
||||
@@ -84,9 +87,31 @@ _watchlist_auto: bool = False
|
||||
# 동시성은 보수적으로 잡는다. KIS 호출은 korea_investment_api 내부에서
|
||||
# semaphore(API_MAX_CONCURRENT_REQUESTS=8) + _throttle_lock(min_request_interval)
|
||||
# 으로 이미 직렬화·쓰로틀되므로 여기서 병렬도를 올려도 레이트리밋을 넘지 않는다.
|
||||
_SCAN_PREFETCH_CONCURRENCY: int = 4
|
||||
# 종목 1건 선조회 상한(초) — 한 종목이 느려도 나머지가 스캔되게 한다
|
||||
_SCAN_PREFETCH_TIMEOUT: float = 20.0
|
||||
# [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
|
||||
@@ -106,6 +131,79 @@ _CANDLE_CACHE_TTL: float = 180.0
|
||||
_scan_offset: int = 0
|
||||
|
||||
|
||||
# ── 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,
|
||||
@@ -159,7 +257,7 @@ def get_watchlist() -> dict[str, str]:
|
||||
return dict(_watchlist)
|
||||
|
||||
|
||||
async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20) -> int:
|
||||
async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int | None = None) -> int:
|
||||
"""stock_analysis 테이블에서 오늘 분석 결과를 조회하여 watchlist 자동 설정.
|
||||
|
||||
배치 분석(batch_daily_analysis) 결과에서 점수가 높은 종목을
|
||||
@@ -173,7 +271,8 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20
|
||||
|
||||
Args:
|
||||
min_score: 최소 점수 기준 (기본 1)
|
||||
max_stocks: 최대 감시 종목 수 (기본 20)
|
||||
max_stocks: 최대 감시 종목 수. ``None`` 이면
|
||||
``settings.DAY_TRADE_WATCHLIST_SIZE`` (하드코딩 상한 없음).
|
||||
|
||||
Returns:
|
||||
int: 설정된 감시 종목 수
|
||||
@@ -181,11 +280,20 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20
|
||||
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()
|
||||
# [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유]
|
||||
# 이 세션은 SELECT 2건만 수행하며 commit()/flush()/add() 를 호출하지 않는다.
|
||||
@@ -266,12 +374,19 @@ async def _fetch_candles_impl(stock_code: str) -> list:
|
||||
"""분봉 조회 + 파싱. 실패 시 빈 리스트.
|
||||
|
||||
⚠️ **전일 봉이 섞인 시계열을 반환한다** — 진입 판정 경로 전용이다.
|
||||
`get_minute_chart_full_day_v2(include_prev_day=True)` 를 쓰는 이유는 **지표
|
||||
워밍업 하나뿐**이다: 5분봉 RSI(14)=15봉·BB(20)=20봉 요건 때문에 당일 봉만으로는
|
||||
VWAP 이 약 10:15, RSI_BB 가 약 10:40 이전에 구조적으로 발화하지 못했다
|
||||
(`indicators_core.rsi_wilder` 는 봉 부족 시 `None`). 원장 실측으로 필터
|
||||
`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).
|
||||
@@ -329,7 +444,17 @@ async def _prefetch_minute_charts(codes: list[str]) -> int:
|
||||
except Exception as e:
|
||||
logger.warning(f"[DayEntry] 분봉 선조회 실패 ({code}): {e}")
|
||||
|
||||
await asyncio.gather(*(_one(c) for c in codes), return_exceptions=True)
|
||||
# 단계 전체 데드라인 — 초과분은 포기하고 순차 진입 구간으로 넘어간다(§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:
|
||||
@@ -417,7 +542,25 @@ async def collect_opening_ranges() -> None:
|
||||
orb = calculate_opening_range(candles)
|
||||
if orb:
|
||||
day_trading_state.set_orb(stock_code, orb)
|
||||
logger.debug(f"[DayEntry] ORB 수집: {stock_code} {orb}")
|
||||
# [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:
|
||||
@@ -589,7 +732,12 @@ async def scan_and_enter() -> list[dict]:
|
||||
available_cash = _buyable
|
||||
day_trading_state.last_total_assets = total_assets
|
||||
except Exception as e:
|
||||
logger.debug(f"[DayEntry] 잔고 조회 실패: {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
|
||||
|
||||
@@ -684,6 +832,15 @@ async def scan_and_enter() -> list[dict]:
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user