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:
@@ -168,14 +168,132 @@ def test_manual_sell_closes_active_trade():
|
||||
assert '"quantity": original_holding_qty - quantity' in src
|
||||
|
||||
|
||||
class _StubActiveTrade:
|
||||
"""ActiveTrade ORM 대역 — 정합 함수가 실제로 수정하는 필드만 갖는다."""
|
||||
|
||||
def __init__(self, stock_code, quantity, strategy_name="MEAN_REVERSION"):
|
||||
self.stock_code = stock_code
|
||||
self.quantity = quantity
|
||||
self.strategy_name = strategy_name
|
||||
self.status = "active"
|
||||
|
||||
|
||||
def test_sync_reconciles_active_trade_quantity():
|
||||
"""동기화가 ActiveTrade 잔량을 실제 보유량으로 정합해야 드리프트가 누적되지 않는다."""
|
||||
import inspect
|
||||
"""동기화가 ActiveTrade 잔량을 실제 보유량으로 정합해야 드리프트가 누적되지 않는다.
|
||||
|
||||
[2026-09-30 M-5] 종전에는 `inspect.getsource()` + 문자열 매칭이라 정합 루프를
|
||||
리팩터링하면 빗나갔다(실제로 빗나갔다). 이제 **불일치 상태를 주입하고 정합
|
||||
함수를 실행해 교정 결과를 확인**한다 — 프로덕션 함수를 그대로 호출한다.
|
||||
"""
|
||||
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
|
||||
|
||||
# 스윙 단일 행이 3주로 기록돼 있는데 실제 보유는 5주 → 5주로 교정돼야 한다
|
||||
row = _StubActiveTrade("000270", 3)
|
||||
acts = _reconcile_active_trade_quantities(
|
||||
{"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
|
||||
)
|
||||
assert row.quantity == 5, "잔량 교정이 동작해야 한다 (2026-09-23 픽스의 본래 대상)"
|
||||
assert row.status == "active"
|
||||
assert acts == [{"action": "corrected", "stock_code": "000270", "from": 3, "to": 5}]
|
||||
|
||||
# 일치하면 아무 것도 하지 않는다
|
||||
row2 = _StubActiveTrade("000270", 5)
|
||||
assert _reconcile_active_trade_quantities(
|
||||
{"000270": [row2]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
|
||||
) == []
|
||||
assert row2.quantity == 5
|
||||
|
||||
|
||||
def test_sync_closes_active_trade_without_holding():
|
||||
"""보유가 없으면 active 행을 closed 로 정리한다 (유령 행 누적 방지)."""
|
||||
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
|
||||
|
||||
for tag in ("swing", "daytrading", "manual", "external"):
|
||||
row = _StubActiveTrade("000270", 3)
|
||||
acts = _reconcile_active_trade_quantities(
|
||||
{"000270": [row]}, {}, {"000270": tag}, {"000270": 3}
|
||||
)
|
||||
assert row.status == "closed", f"{tag}: 유령 행 정리는 태그와 무관하게 수행"
|
||||
assert row.quantity == 3, "정리 경로에서 수량은 건드리지 않는다"
|
||||
assert acts[0]["action"] == "closed"
|
||||
|
||||
|
||||
def test_sync_skips_reconcile_for_multiple_active_rows():
|
||||
"""스윙+DT 혼재 종목은 자동 교정 대상에서 제외해야 함 (2026-09-23 원래 규칙)."""
|
||||
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
|
||||
|
||||
rows = [_StubActiveTrade("000270", 3), _StubActiveTrade("000270", 2, "day_trade")]
|
||||
assert _reconcile_active_trade_quantities(
|
||||
{"000270": rows}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
|
||||
) == []
|
||||
assert [r.quantity for r in rows] == [3, 2]
|
||||
|
||||
|
||||
def test_reconcile_skips_external_rows_but_keeps_swing_behaviour():
|
||||
"""[C-1] external 행만 교정에서 빠진다 — 스윙 교정은 그대로 살아 있다.
|
||||
|
||||
external 행 수량에는 시스템이 소유하지 않은 유입분이 섞여 있어, 교정하면 그 값이
|
||||
다음 sync 의 '시스템 근거 수량' 이 되어 유입분이 swing 으로 승격된다(고착).
|
||||
"""
|
||||
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
|
||||
|
||||
ext = _StubActiveTrade("105560", 3, "day_trade")
|
||||
acts = _reconcile_active_trade_quantities(
|
||||
{"105560": [ext]}, {"105560": 6}, {"105560": "external"}, {"105560": 3}
|
||||
)
|
||||
assert ext.quantity == 3, "external 행은 교정하지 않는다"
|
||||
assert acts[0]["action"] == "skipped_unattributable"
|
||||
assert acts[0]["from"] == 3 and acts[0]["to"] == 6
|
||||
|
||||
# [M-3] 태그가 swing 인 혼재 행도 교정하지 않는다 — 여기가 실제 주문 사고 경로다
|
||||
mixed = _StubActiveTrade("111111", 3, "MEAN_REVERSION")
|
||||
acts = _reconcile_active_trade_quantities(
|
||||
{"111111": [mixed]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
|
||||
)
|
||||
assert mixed.quantity == 3, "유입분이 섞인 swing 행도 부풀리지 않는다"
|
||||
assert acts[0]["action"] == "skipped_unattributable"
|
||||
|
||||
|
||||
def test_c1_guard_does_not_weaken_the_20260923_intent():
|
||||
"""의도 보존 검증 — C-1 가드를 **C-1 이전 규칙으로 되돌려도** 위 의도는 통과한다.
|
||||
|
||||
즉 이 테스트 묶음이 깨진다면 그것은 2026-09-23 픽스가 깨진 것이고, C-1 가드
|
||||
때문이 아니다. 반대로 C-1 회귀(2회 sync 유지)는 가드가 없으면 실패한다
|
||||
(`tests/test_external_demotion_race.py::test_c1_would_have_regressed_...`).
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.endpoints import portfolio as pf
|
||||
src = inspect.getsource(pf.sync_portfolio)
|
||||
assert "ActiveTrade 잔량 교정" in src
|
||||
assert "len(_rows) != 1" in src, "스윙+DT 혼재 종목은 자동 교정 대상에서 제외해야 함"
|
||||
assert "_row.status = \"closed\"" in src, "보유 없음 → closed 처리 누락"
|
||||
|
||||
def _pre_c1_rule(strategy_type, row_count, actual, system_quantity=None):
|
||||
# C-1/M-3 이전 규칙: 단일 행 + 보유 있음이면 태그·근거수량 무관 교정
|
||||
return int(row_count) == 1 and int(actual) > 0
|
||||
|
||||
with patch.object(pf, "_should_correct_active_trade_quantity", _pre_c1_rule):
|
||||
row = _StubActiveTrade("000270", 3)
|
||||
pf._reconcile_active_trade_quantities(
|
||||
{"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
|
||||
)
|
||||
assert row.quantity == 5, "스윙 잔량 교정은 C-1/M-3 가드와 무관하게 성립한다"
|
||||
|
||||
ghost = _StubActiveTrade("000270", 3)
|
||||
pf._reconcile_active_trade_quantities(
|
||||
{"000270": [ghost]}, {}, {"000270": "swing"}, {"000270": 3}
|
||||
)
|
||||
assert ghost.status == "closed"
|
||||
|
||||
# 그리고 이 규칙에서는 유입분이 섞인 행이 부풀려진다 = C-1/M-3 의 인과
|
||||
ext = _StubActiveTrade("105560", 3, "day_trade")
|
||||
pf._reconcile_active_trade_quantities(
|
||||
{"105560": [ext]}, {"105560": 6}, {"105560": "external"}, {"105560": 3}
|
||||
)
|
||||
assert ext.quantity == 6, "가드가 없으면 C-1 이 재발한다"
|
||||
|
||||
mixed = _StubActiveTrade("111111", 3, "MEAN_REVERSION")
|
||||
pf._reconcile_active_trade_quantities(
|
||||
{"111111": [mixed]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
|
||||
)
|
||||
assert mixed.quantity == 6, "가드가 없으면 M-3 이 재발한다(유입분 과다매도)"
|
||||
|
||||
|
||||
def test_active_trade_patch_supports_quantity():
|
||||
|
||||
570
backend/tests/test_daytrading_atr_session_boundary_20260930.py
Normal file
570
backend/tests/test_daytrading_atr_session_boundary_20260930.py
Normal file
@@ -0,0 +1,570 @@
|
||||
"""C1 회귀 테스트 (2026-09-30): 워밍업 시계열이 진입 ATR 을 오염시키던 결함.
|
||||
|
||||
증상: 진입 판정 경로는 지표 워밍업을 위해 전일 봉이 섞인 5분봉을 쓴다
|
||||
(`get_minute_chart_full_day_v2(include_prev_day=True)`). `true_ranges` 가
|
||||
`TR_i = max(H-L, |H-C_{i-1}|, |L-C_{i-1}|)` 이라 **당일 첫 봉의 TR = 오버나이트 갭**
|
||||
이고, `atr_sma` 는 마지막 14개 TR 의 단순평균이므로 당일 5분봉이 14개 쌓이기
|
||||
전(≈10:10)에는 갭 TR 이 윈도에 남았다. 그 ATR 이 손절·목표가를 직접 만든다.
|
||||
|
||||
수정: `day_trading_strategy._atr` 이 내부에서 `_last_session_candles()` 로 세션을 끊는다.
|
||||
임계값은 하나도 바꾸지 않았다 — ATR 에 무엇을 먹이는지만 고쳤다.
|
||||
|
||||
고정하는 불변식 3건:
|
||||
1. 당일 첫 봉이 갭으로 벌어져도 진입 ATR 이 그 갭을 먹지 않는다
|
||||
2. 진입 ATR 과 청산 트레일링 ATR 이 같은 기준이다 (같은 날 데이터 → 같은 값)
|
||||
3. RSI(14)/BB(20) 판정은 여전히 워밍업 혜택을 받는다 (09:30 에 판정 도달)
|
||||
"""
|
||||
from datetime import time as dtime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.day_trading_strategy import (
|
||||
MinuteCandle, _atr, _bollinger, _rsi,
|
||||
check_day_trade_exit, check_vwap_bounce_signal, check_rsi_bb_signal,
|
||||
)
|
||||
|
||||
PREV = "20260929"
|
||||
TODAY = "20260930"
|
||||
|
||||
|
||||
def _bar(date: str, hhmm: str, close: float, *, spread: float = 10.0,
|
||||
volume: int = 1000) -> MinuteCandle:
|
||||
"""종가 기준 ±spread 의 5분봉 1개."""
|
||||
return MinuteCandle(
|
||||
date=date, time=hhmm + "00",
|
||||
open=close, high=close + spread, low=close - spread, close=close,
|
||||
volume=volume, cum_volume=0, cum_amount=0.0,
|
||||
)
|
||||
|
||||
|
||||
def _series(date: str, n: int, base: float, *, step: float = 0.0,
|
||||
spread: float = 10.0, start_min: int = 0) -> list[MinuteCandle]:
|
||||
out = []
|
||||
for i in range(n):
|
||||
total = 9 * 60 + start_min + i * 5
|
||||
out.append(_bar(
|
||||
date, f"{total // 60:02d}{total % 60:02d}",
|
||||
base + i * step, spread=spread,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _completed_bull_bar(bar: MinuteCandle) -> MinuteCandle:
|
||||
"""마지막 **완성** 5분봉을 '양봉 + 거래량 급증'으로 만든다.
|
||||
|
||||
[2026-09-30] 양봉 판정이 형성 중 버킷(`[-1]`) → 마지막 완성봉(`[-2]`)으로
|
||||
바뀌었으므로, 신호를 발화시키려면 완성봉 쪽이 양봉이어야 한다.
|
||||
"""
|
||||
return MinuteCandle(
|
||||
date=bar.date, time=bar.time,
|
||||
open=bar.close - 20, high=bar.high, low=bar.low, close=bar.close,
|
||||
volume=5000, cum_volume=0, cum_amount=0.0,
|
||||
)
|
||||
|
||||
|
||||
# ── 불변식 1: 갭 TR 이 진입 ATR 에 들어오지 않는다 ────────────────────────────
|
||||
|
||||
def test_entry_atr_ignores_overnight_gap():
|
||||
"""전일 종가가 어디에 있든 당일 ATR 값은 같다 (갭 TR 미포함)."""
|
||||
today = _series(TODAY, 20, 10_000.0, spread=10.0)
|
||||
|
||||
# 전일 종가가 당일 시가와 비슷한 경우 / 30% 아래인 경우
|
||||
small_gap = _series(PREV, 20, 9_900.0, spread=10.0, start_min=300) + today
|
||||
huge_gap = _series(PREV, 20, 7_000.0, spread=10.0, start_min=300) + today
|
||||
|
||||
atr_small = _atr(small_gap, 14)
|
||||
atr_huge = _atr(huge_gap, 14)
|
||||
atr_today = _atr(today, 14)
|
||||
|
||||
assert atr_today is not None
|
||||
assert atr_small == atr_today, "전일 봉이 진입 ATR 에 섞였다"
|
||||
assert atr_huge == atr_today, "오버나이트 갭이 진입 ATR 을 부풀렸다"
|
||||
|
||||
|
||||
def test_entry_atr_is_none_before_todays_warmup():
|
||||
"""당일 봉이 period+1 개 미만이면 None — 워밍업 도입 이전과 같은 동작.
|
||||
|
||||
호출부는 이때 고정 폴백(DAY_TRADE_REVERSION_STOP_LOSS_PCT)을 쓴다.
|
||||
전일 봉으로 ATR 을 '만들어내는' 것이 바로 C1 결함이었다.
|
||||
"""
|
||||
today_6 = _series(TODAY, 6, 10_000.0) # 09:30 시점
|
||||
mixed = _series(PREV, 20, 9_000.0, start_min=300) + today_6
|
||||
assert _atr(today_6, 14) is None
|
||||
assert _atr(mixed, 14) is None, "전일 봉으로 ATR 을 합성하고 있다"
|
||||
|
||||
|
||||
def test_vwap_signal_stop_and_target_ignore_prev_day():
|
||||
"""신호의 손절·목표가가 전일 봉 유무에 영향받지 않는다.
|
||||
|
||||
ATR 이 `target = 현재가 + 1.5R` 과 `raw_stop` 에 직접 들어가므로,
|
||||
같은 당일 데이터에서 두 입력이 같은 값을 내야 한다.
|
||||
"""
|
||||
# 하락 후 마지막 봉 양봉 + 거래량 급증 → VWAP 반등 신호 조건
|
||||
today = _series(TODAY, 25, 10_600.0, step=-20.0, spread=8.0)
|
||||
for c in today:
|
||||
c.volume = 1000
|
||||
today[-2] = _completed_bull_bar(today[-2])
|
||||
last_close = today[-1].close
|
||||
today[-1] = MinuteCandle( # 형성 중 봉: 양봉
|
||||
date=TODAY, time=today[-1].time, open=last_close - 30, high=last_close + 10,
|
||||
low=last_close - 40, close=last_close, volume=1200,
|
||||
cum_volume=0, cum_amount=0.0,
|
||||
)
|
||||
price = today[-1].close
|
||||
vwap = price # VWAP 밴드 중앙
|
||||
|
||||
# 워밍업은 **갭 없이 이어지는** 전일 하락 봉 — RSI 게이트가 양쪽에서 통과해야
|
||||
# 손절·목표가 비교가 성립한다(갭을 주면 RSI 가 튀어 신호 자체가 사라진다).
|
||||
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + today
|
||||
|
||||
sig_today = check_vwap_bounce_signal("005930", "삼성", today, vwap, price)
|
||||
sig_mixed = check_vwap_bounce_signal("005930", "삼성", mixed, vwap, price)
|
||||
|
||||
assert sig_today is not None and sig_mixed is not None, "테스트 데이터가 신호를 못 만든다"
|
||||
assert sig_mixed.stop_loss == sig_today.stop_loss, "전일 봉이 진입 손절을 움직였다"
|
||||
assert sig_mixed.target_price == sig_today.target_price, "전일 봉이 목표가를 움직였다"
|
||||
|
||||
|
||||
def test_rsi_bb_signal_stop_and_target_ignore_prev_day():
|
||||
"""RSI_BB 경로도 동일 (risk → target 전파 차단 확인)."""
|
||||
today = _series(TODAY, 25, 10_600.0, step=-20.0, spread=8.0)
|
||||
today[-2] = _completed_bull_bar(today[-2])
|
||||
last_close = today[-1].close
|
||||
today[-1] = MinuteCandle(
|
||||
date=TODAY, time=today[-1].time, open=last_close - 30, high=last_close + 10,
|
||||
low=last_close - 40, close=last_close, volume=1200,
|
||||
cum_volume=0, cum_amount=0.0,
|
||||
)
|
||||
price = today[-1].close * 0.97 # BB 하단 근처로 내려 조건 충족
|
||||
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + today
|
||||
|
||||
sig_today = check_rsi_bb_signal("005930", "삼성", today, price)
|
||||
sig_mixed = check_rsi_bb_signal("005930", "삼성", mixed, price)
|
||||
|
||||
assert sig_today is not None and sig_mixed is not None, "테스트 데이터가 신호를 못 만든다"
|
||||
assert sig_mixed.stop_loss == sig_today.stop_loss
|
||||
assert sig_mixed.target_price == sig_today.target_price
|
||||
|
||||
|
||||
def test_fallback_stop_used_when_todays_atr_unavailable():
|
||||
"""당일 ATR 이 없으면 고정 폴백 손절폭이 쓰인다 (ATR 합성 금지).
|
||||
|
||||
stop = min(현재가×(1−REVERSION_STOP_LOSS_PCT), 현재가×(1−MIN_STOP_LOSS_PCT))
|
||||
— §7 의 '장중은 느슨한 쪽' 규약. 임계값은 settings 를 참조한다(§4.1).
|
||||
"""
|
||||
# 당일 5분봉 21개면 BB(20)·RSI(14)는 되지만 ATR(14)에는 15개가 필요하므로
|
||||
# 당일 봉을 14개로 맞추고 워밍업으로 RSI/BB 만 채운다.
|
||||
today = _series(TODAY, 14, 10_300.0, step=-20.0, spread=8.0)
|
||||
today[-2] = _completed_bull_bar(today[-2])
|
||||
last_close = today[-1].close
|
||||
today[-1] = MinuteCandle(
|
||||
date=TODAY, time=today[-1].time, open=last_close - 30, high=last_close + 10,
|
||||
low=last_close - 40, close=last_close, volume=1200,
|
||||
cum_volume=0, cum_amount=0.0,
|
||||
)
|
||||
mixed = _series(PREV, 20, 10_700.0, step=-20.0, spread=8.0, start_min=300) + today
|
||||
price = today[-1].close
|
||||
|
||||
assert _atr(mixed, 14) is None, "당일 봉 14개로 ATR 이 나오면 전제가 깨진다"
|
||||
sig = check_vwap_bounce_signal("005930", "삼성", mixed, price, price)
|
||||
assert sig is not None, "워밍업 덕에 RSI 판정은 되어야 한다"
|
||||
|
||||
expected = min(
|
||||
price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT),
|
||||
price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT),
|
||||
)
|
||||
assert sig.stop_loss == pytest.approx(round(expected, 0))
|
||||
|
||||
|
||||
# ── 불변식 2: 진입 ATR == 청산 트레일링 ATR ───────────────────────────────────
|
||||
|
||||
def test_entry_and_exit_atr_share_one_basis():
|
||||
"""진입(워밍업 포함 시계열)과 청산(당일 전용 시계열)의 ATR 이 같다.
|
||||
|
||||
청산 경로(`daytrading/exit_service`)는 `get_minute_chart_full_day`(당일 전용)를
|
||||
쓰고 진입 경로는 `_v2(include_prev_day=True)` 를 쓴다. 두 경로가 다른 ATR 을
|
||||
쓰면 진입 손절과 트레일링 스탑의 기준이 엇갈린다.
|
||||
"""
|
||||
today = _series(TODAY, 20, 10_000.0, spread=25.0)
|
||||
entry_series = _series(PREV, 20, 7_500.0, start_min=300) + today # 진입 경로 입력
|
||||
exit_series = today # 청산 경로 입력
|
||||
|
||||
assert _atr(entry_series, 14) == _atr(exit_series, 14) is not None
|
||||
|
||||
|
||||
def test_trailing_stop_identical_for_both_series():
|
||||
"""같은 포지션·같은 당일 데이터에서 트레일링 청산 판정이 동일하다."""
|
||||
today = _series(TODAY, 20, 10_000.0, spread=25.0)
|
||||
mixed = _series(PREV, 20, 7_500.0, start_min=300) + today
|
||||
|
||||
atr = _atr(today, 14)
|
||||
assert atr is not None
|
||||
entry_price = 9_000.0
|
||||
highest = 10_500.0
|
||||
trail = highest - settings.DAY_TRADE_TRAILING_ATR_MULT * atr
|
||||
current = trail - 1 # 트레일 바로 아래 → SELL_ALL 기대
|
||||
|
||||
kwargs = dict(
|
||||
entry_price=entry_price, current_price=current,
|
||||
stop_loss=entry_price * 0.9, target_price=entry_price * 1.5,
|
||||
highest_since_entry=highest, current_time=dtime(11, 0), entry_time=None,
|
||||
)
|
||||
ex_today = check_day_trade_exit(candles_5min=today, **kwargs)
|
||||
ex_mixed = check_day_trade_exit(candles_5min=mixed, **kwargs)
|
||||
|
||||
assert ex_today is not None and ex_today.action == "SELL_ALL"
|
||||
assert ex_mixed is not None
|
||||
assert ex_today.action == ex_mixed.action
|
||||
assert ex_today.reason == ex_mixed.reason, "트레일링 기준값이 시계열에 따라 달라졌다"
|
||||
|
||||
|
||||
# ── 불변식 3: RSI/BB 워밍업 혜택 유지 (C3 목적 보존) ─────────────────────────
|
||||
|
||||
def test_rsi_and_bb_still_benefit_from_warmup():
|
||||
"""09:30(당일 6봉)에도 워밍업으로 RSI(14)/BB(20) 판정이 가능해야 한다."""
|
||||
today_6 = _series(TODAY, 6, 10_000.0, step=-15.0)
|
||||
mixed = _series(PREV, 20, 10_200.0, step=-5.0, start_min=300) + today_6
|
||||
|
||||
# 당일 봉만으로는 둘 다 불가
|
||||
assert _rsi(today_6, 14) is None
|
||||
assert _bollinger(today_6, 20) is None
|
||||
# 워밍업이 붙으면 판정 도달 — C1 수정이 이걸 깨지 않았는지 확인
|
||||
assert _rsi(mixed, 14) is not None, "워밍업이 무력화됐다 (C3 목적 상실)"
|
||||
assert _bollinger(mixed, 20) is not None, "워밍업이 무력화됐다 (C3 목적 상실)"
|
||||
|
||||
|
||||
def test_atr_session_filter_is_wallclock_independent():
|
||||
"""`_atr` 은 벽시계를 쓰지 않는다 (§2) — **동작으로** 검증한다.
|
||||
|
||||
날짜가 1999년이든 2099년이든 '마지막 봉의 영업일자'만으로 세션이 갈려야 한다.
|
||||
`datetime.now()` 에 의존하면 과거 날짜 입력에서 당일 봉이 0건이 되어 값이 달라진다
|
||||
(휴장일·재기동·백테스트에서 기준이 흔들리는 것과 같은 증상).
|
||||
"""
|
||||
def _pair(prev_date, today_date):
|
||||
prev = [
|
||||
MinuteCandle(date=prev_date, time=f"{13 + i // 12:02d}{(i * 5) % 60:02d}00",
|
||||
open=9_000.0, high=9_020.0, low=8_980.0, close=9_000.0,
|
||||
volume=10, cum_volume=0, cum_amount=0.0)
|
||||
for i in range(20)
|
||||
]
|
||||
today = [
|
||||
MinuteCandle(date=today_date, time=f"{9 + i // 12:02d}{(i * 5) % 60:02d}00",
|
||||
open=10_000.0, high=10_025.0, low=9_975.0, close=10_000.0,
|
||||
volume=10, cum_volume=0, cum_amount=0.0)
|
||||
for i in range(20)
|
||||
]
|
||||
return prev + today
|
||||
|
||||
past = _atr(_pair("19990104", "19990105"), 14)
|
||||
future = _atr(_pair("20990104", "20990105"), 14)
|
||||
recent = _atr(_pair(PREV, TODAY), 14)
|
||||
assert past == future == recent is not None, "세션 판별이 벽시계에 의존한다"
|
||||
# 같은 하루 변동폭(±25)에서 나온 값이므로 갭(1,000원)이 섞이지 않았음도 함께 확인
|
||||
assert past == pytest.approx(50.0)
|
||||
|
||||
# 날짜 필드가 비어 있는 입력(목/레거시)은 기존 동작을 유지한다
|
||||
undated = [
|
||||
MinuteCandle(date="", time=f"{9 + i // 12:02d}{(i * 5) % 60:02d}00",
|
||||
open=100.0, high=101.0, low=99.0, close=100.0,
|
||||
volume=10, cum_volume=0, cum_amount=0.0)
|
||||
for i in range(20)
|
||||
]
|
||||
assert _atr(undated, 14) is not None
|
||||
|
||||
# 날짜 필드가 비어 있는 입력(목/레거시)은 기존 동작을 유지한다
|
||||
undated = [
|
||||
MinuteCandle(date="", time=f"{9 + i // 12:02d}{(i * 5) % 60:02d}00",
|
||||
open=100.0, high=101.0, low=99.0, close=100.0,
|
||||
volume=10, cum_volume=0, cum_amount=0.0)
|
||||
for i in range(20)
|
||||
]
|
||||
assert _atr(undated, 14) is not None
|
||||
|
||||
|
||||
# ── 양봉 판정: 형성 중 버킷 → 마지막 완성봉 (2026-09-30) ──────────────────────
|
||||
|
||||
def _oversold_today(n: int = 25) -> list[MinuteCandle]:
|
||||
"""RSI/BB 게이트를 통과할 하락 시계열 (마지막 완성봉은 호출부가 정한다)."""
|
||||
return _series(TODAY, n, 10_600.0, step=-20.0, spread=8.0)
|
||||
|
||||
|
||||
def _with_forming(bars: list[MinuteCandle], *, bull: bool) -> list[MinuteCandle]:
|
||||
"""형성 중 버킷([-1])을 양봉/음봉으로 바꾼다 (판정에 영향을 주면 안 된다)."""
|
||||
out = list(bars)
|
||||
c = out[-1]
|
||||
out[-1] = MinuteCandle(
|
||||
date=c.date, time=c.time,
|
||||
open=c.close - 30 if bull else c.close + 30,
|
||||
high=c.close + 10, low=c.close - 40, close=c.close,
|
||||
volume=1200, cum_volume=0, cum_amount=0.0,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def test_bull_check_decided_by_completed_bar_not_forming_bucket():
|
||||
"""형성 중 버킷이 양봉이든 음봉이든 판정은 **완성봉**으로 결정된다.
|
||||
|
||||
30초 주기 스캔에서 같은 5분 버킷을 약 10회 보므로, 형성 중 봉으로 판정하면
|
||||
같은 시장 상태에 사이클마다 다른 답이 나온다(재현성 없는 게이트).
|
||||
"""
|
||||
base = _oversold_today()
|
||||
base[-2] = _completed_bull_bar(base[-2]) # 완성봉 = 양봉
|
||||
price = base[-1].close
|
||||
|
||||
sig_bull_forming = check_vwap_bounce_signal(
|
||||
"005930", "삼성", _with_forming(base, bull=True), price, price)
|
||||
sig_bear_forming = check_vwap_bounce_signal(
|
||||
"005930", "삼성", _with_forming(base, bull=False), price, price)
|
||||
|
||||
assert sig_bull_forming is not None
|
||||
assert sig_bear_forming is not None, "형성 중 버킷이 음봉이라고 게이트가 닫혔다"
|
||||
assert sig_bull_forming.stop_loss == sig_bear_forming.stop_loss
|
||||
assert sig_bull_forming.target_price == sig_bear_forming.target_price
|
||||
|
||||
|
||||
def test_bear_completed_bar_blocks_even_if_forming_bucket_is_bullish():
|
||||
"""완성봉이 음봉이면 형성 중 버킷이 양봉이어도 차단된다 (역방향 확인)."""
|
||||
base = _oversold_today()
|
||||
c = base[-2]
|
||||
base[-2] = MinuteCandle( # 완성봉 = 음봉 + 거래량 급증
|
||||
date=c.date, time=c.time, open=c.close + 20, high=c.high, low=c.low,
|
||||
close=c.close, volume=5000, cum_volume=0, cum_amount=0.0,
|
||||
)
|
||||
price = base[-1].close
|
||||
assert check_vwap_bounce_signal(
|
||||
"005930", "삼성", _with_forming(base, bull=True), price, price) is None
|
||||
assert check_rsi_bb_signal(
|
||||
"005930", "삼성", _with_forming(base, bull=True), price * 0.97) is None
|
||||
|
||||
|
||||
def test_bull_check_uses_same_bar_as_volume_gate():
|
||||
"""양봉 판정 대상 봉 == 거래량 게이트가 비교한 봉 (`session[-2]`)."""
|
||||
from app.services.day_trading_strategy import (
|
||||
_last_completed_candle, volume_surge_ratio,
|
||||
)
|
||||
base = _oversold_today()
|
||||
base[-2] = _completed_bull_bar(base[-2])
|
||||
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + base
|
||||
|
||||
assert _last_completed_candle(mixed) is mixed[-2]
|
||||
assert volume_surge_ratio(mixed) == pytest.approx(
|
||||
mixed[-2].volume / mixed[-3].volume
|
||||
)
|
||||
|
||||
|
||||
def test_bull_check_never_crosses_session_boundary():
|
||||
"""당일 완성봉이 없으면 미통과 — 전일 봉으로 게이트를 열지 않는다.
|
||||
|
||||
개장 직후(당일 봉 1개)에는 워밍업 시계열의 마지막 완성봉이 **전일 종가 동시호가
|
||||
봉**이다. 그걸 보면 게이트가 전일 결과로 열린다(C1 과 같은 함정).
|
||||
"""
|
||||
from app.services.day_trading_strategy import _last_completed_candle
|
||||
|
||||
prev_bull = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300)
|
||||
c = prev_bull[-1]
|
||||
prev_bull[-1] = MinuteCandle( # 전일 마지막 봉을 강한 양봉으로
|
||||
date=c.date, time=c.time, open=c.close - 100, high=c.close + 10,
|
||||
low=c.close - 120, close=c.close, volume=9999, cum_volume=0, cum_amount=0.0,
|
||||
)
|
||||
today_one = _series(TODAY, 1, 9_000.0)
|
||||
|
||||
assert _last_completed_candle(prev_bull + today_one) is None, (
|
||||
"전일 봉이 '마지막 완성봉'으로 새어 들어왔다"
|
||||
)
|
||||
# 데이터 부족은 통과 쪽으로 열지 않는다
|
||||
assert check_vwap_bounce_signal(
|
||||
"005930", "삼성", prev_bull + today_one, 9_000.0, 9_000.0) is None
|
||||
|
||||
|
||||
def test_last_completed_candle_is_wallclock_independent():
|
||||
"""§2 순수성 — 과거/미래 날짜에서도 같은 위치의 봉을 고른다 (동작 검증)."""
|
||||
from app.services.day_trading_strategy import _last_completed_candle
|
||||
|
||||
for d_prev, d_today in (("19990104", "19990105"), ("20990104", "20990105"),
|
||||
(PREV, TODAY)):
|
||||
prev = _series(d_prev, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300)
|
||||
today = _series(d_today, 5, 10_000.0, step=-20.0, spread=8.0)
|
||||
got = _last_completed_candle(prev + today)
|
||||
assert got is today[-2], f"{d_today}: 마지막 완성봉 선택이 벽시계에 의존한다"
|
||||
|
||||
|
||||
# ── 진단 정보 (갭·ATR) — 게이트가 아니라 관찰 수단 (2026-09-30) ───────────────
|
||||
|
||||
def test_overnight_gap_pct_from_data_only():
|
||||
"""갭률은 데이터(전일 마지막 종가 vs 당일 첫 시가)로만 계산된다."""
|
||||
from app.services.day_trading_strategy import _overnight_gap_pct
|
||||
|
||||
prev = _series(PREV, 20, 10_000.0, spread=10.0, start_min=300)
|
||||
today = _series(TODAY, 20, 9_700.0, spread=10.0) # 시가 = 종가 = 9,700
|
||||
gap = _overnight_gap_pct(prev + today)
|
||||
assert gap == pytest.approx((9_700 - 10_000) / 10_000 * 100) # -3.00%
|
||||
|
||||
# 전일 봉이 없으면 계산 불가 → None (억지로 만들지 않는다)
|
||||
assert _overnight_gap_pct(today) is None
|
||||
# 날짜 필드가 빈 목/레거시 입력도 None
|
||||
undated = [
|
||||
MinuteCandle(date="", time="0900%02d" % 0, open=100.0, high=101.0,
|
||||
low=99.0, close=100.0, volume=1, cum_volume=0, cum_amount=0.0)
|
||||
]
|
||||
assert _overnight_gap_pct(undated) is None
|
||||
|
||||
|
||||
def test_reason_carries_gap_and_atr_diagnostics():
|
||||
"""진입 reason 에 갭·ATR 이 **진단 정보**로 남는다 (게이트 아님).
|
||||
|
||||
RSI 갭 섭취는 유지 결정이 났고(차트 표준·평균회귀 의도와 방향 일치) 대신 관찰
|
||||
대상이 됐다. 원장(`trade_history.entry_reason`)만으로 "갭 하락 종목에 쏠렸는가"를
|
||||
집계할 수 있어야 한다.
|
||||
"""
|
||||
base = _oversold_today()
|
||||
base[-2] = _completed_bull_bar(base[-2])
|
||||
price = base[-1].close
|
||||
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + base
|
||||
|
||||
sig = check_vwap_bounce_signal("005930", "삼성", mixed, price, price)
|
||||
assert sig is not None
|
||||
assert "[진단 " in sig.reason, "진단 정보가 게이트 조건과 구분되지 않는다"
|
||||
assert "갭=" in sig.reason and "%" in sig.reason
|
||||
assert "ATR=" in sig.reason
|
||||
|
||||
sig_rb = check_rsi_bb_signal("005930", "삼성", mixed, price * 0.97)
|
||||
assert sig_rb is not None and "갭=" in sig_rb.reason and "ATR=" in sig_rb.reason
|
||||
|
||||
|
||||
def test_reason_marks_fallback_stop_when_atr_absent():
|
||||
"""ATR 이 없으면 reason 이 '폴백손절'임을 밝힌다.
|
||||
|
||||
09:30~10:10 은 당일 5분봉 15개 미만이라 ATR 이 없고 손절이
|
||||
`DAY_TRADE_REVERSION_STOP_LOSS_PCT`(폴백)로 결정된다 — 원장에서 구분되어야 한다.
|
||||
"""
|
||||
today = _series(TODAY, 14, 10_300.0, step=-20.0, spread=8.0)
|
||||
today[-2] = _completed_bull_bar(today[-2])
|
||||
mixed = _series(PREV, 20, 10_700.0, step=-20.0, spread=8.0, start_min=300) + today
|
||||
price = today[-1].close
|
||||
|
||||
from app.services.day_trading_strategy import _atr as _atr_fn
|
||||
assert _atr_fn(mixed, 14) is None
|
||||
sig = check_vwap_bounce_signal("005930", "삼성", mixed, price, price)
|
||||
assert sig is not None
|
||||
assert "ATR=없음→폴백손절" in sig.reason
|
||||
assert sig.stop_loss == pytest.approx(round(min(
|
||||
price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT),
|
||||
price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT),
|
||||
), 0))
|
||||
|
||||
|
||||
def test_diagnostics_do_not_change_any_decision():
|
||||
"""진단 정보 추가가 판정·손절·목표를 바꾸지 않는다.
|
||||
|
||||
`reason` 외의 모든 필드가 전일 봉 유무와 무관하게 동일해야 한다
|
||||
(진단 문자열만 달라진다).
|
||||
"""
|
||||
base = _oversold_today()
|
||||
base[-2] = _completed_bull_bar(base[-2])
|
||||
price = base[-1].close
|
||||
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + base
|
||||
|
||||
a = check_vwap_bounce_signal("005930", "삼성", base, price, price)
|
||||
b = check_vwap_bounce_signal("005930", "삼성", mixed, price, price)
|
||||
assert a is not None and b is not None
|
||||
for field in ("strategy", "entry_price", "stop_loss", "target_price", "confidence"):
|
||||
assert getattr(a, field) == getattr(b, field), f"{field} 가 달라졌다"
|
||||
# 당일 전용 입력에는 갭이 없다(계산 불가) → 진단에서 생략된다
|
||||
assert "갭=" not in a.reason and "갭=" in b.reason
|
||||
|
||||
|
||||
def test_orb_reason_has_atr_but_no_gap():
|
||||
"""ORB 경로는 당일 전용 시계열이라 갭을 계산할 수 없다 — ATR 만 남는다."""
|
||||
from app.services.day_trading_strategy import OpeningRange, check_orb_signal
|
||||
|
||||
# 당일 1분봉 60개: 09:00~09:59, 마지막 완성 5분봉 양봉 + 거래량 급증
|
||||
bars = []
|
||||
for i in range(60):
|
||||
total = 9 * 60 + i
|
||||
close = 10_000 + (i if i < 50 else 60 + i)
|
||||
bars.append(MinuteCandle(
|
||||
date=TODAY, time=f"{total // 60:02d}{total % 60:02d}00",
|
||||
open=close - 5, high=close + 5, low=close - 10, close=close,
|
||||
volume=5000 if i >= 50 else 100, cum_volume=0, cum_amount=0.0,
|
||||
))
|
||||
orb = OpeningRange(high=10_020.0, low=9_950.0, total_volume=3000,
|
||||
avg_vol_per_min=100.0, open_price=10_000.0)
|
||||
price = bars[-1].close
|
||||
sig = check_orb_signal("005930", "삼성", bars, orb, price)
|
||||
if sig is not None: # 조건 미충족이면 이 단정은 의미가 없다
|
||||
assert "ATR=" in sig.reason
|
||||
assert "갭=" not in sig.reason
|
||||
|
||||
|
||||
# ── M-1: 1.5R 하한 보장 (2026-09-30) ─────────────────────────────────────────
|
||||
|
||||
def _rr(sig) -> float:
|
||||
"""보상/위험 배수."""
|
||||
return (sig.target_price - sig.entry_price) / (sig.entry_price - sig.stop_loss)
|
||||
|
||||
|
||||
def _signal_pair(n_today: int):
|
||||
"""(VWAP 신호, RSI_BB 신호) — 당일 봉 수로 ATR 유무를 가른다.
|
||||
|
||||
n_today=25 → 당일 ATR(14) 산출 가능 / n_today=14 → ATR 없음(폴백 손절폭).
|
||||
"""
|
||||
today = _series(TODAY, n_today, 10_600.0, step=-20.0, spread=8.0)
|
||||
today[-2] = _completed_bull_bar(today[-2])
|
||||
mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + today
|
||||
price = today[-1].close
|
||||
return (
|
||||
check_vwap_bounce_signal("005930", "삼성", mixed, price, price),
|
||||
check_rsi_bb_signal("005930", "삼성", mixed, price * 0.97),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n_today,label", [(25, "ATR 있음"), (14, "ATR 폴백")])
|
||||
def test_target_guarantees_target_multiplier_r(n_today, label):
|
||||
"""목표가는 항상 위험의 `DAY_TRADE_TARGET_MULTIPLIER` 배 이상이다.
|
||||
|
||||
`target_price` 는 `daytrading/exit_service` 의 분할매도 트리거로 실제 쓰인다(§7).
|
||||
위험보다 작은 목표는 R:R 역전이다 — ATR 유무 양쪽에서 막는다.
|
||||
"""
|
||||
sig_vwap, sig_rb = _signal_pair(n_today)
|
||||
assert sig_vwap is not None and sig_rb is not None, "테스트 데이터가 신호를 못 만든다"
|
||||
for name, sig in (("VWAP", sig_vwap), ("RSI_BB", sig_rb)):
|
||||
risk = sig.entry_price - sig.stop_loss
|
||||
assert risk > 0
|
||||
assert sig.target_price - sig.entry_price >= risk * settings.DAY_TRADE_TARGET_MULTIPLIER - 1e-6, (
|
||||
f"{label}/{name}: {_rr(sig):.2f}R < "
|
||||
f"{settings.DAY_TRADE_TARGET_MULTIPLIER}R "
|
||||
f"(진입 {sig.entry_price:.0f} 손절 {sig.stop_loss:.0f} 목표 {sig.target_price:.0f})"
|
||||
)
|
||||
|
||||
|
||||
def test_low_volatility_vwap_entry_is_not_r_inverted():
|
||||
"""저변동 종목(1.5×ATR < 최소손절폭)에서도 R:R 이 역전되지 않는다.
|
||||
|
||||
수정 전 실측: 진입 10,120 / ATR 29 → 손절 -2.00% / 목표 +0.43% = **0.21R**.
|
||||
손절이 `min()` 으로 -2% 쪽으로 느슨해지는데 목표는 `1.5×ATR` 이라 위험보다 작았다.
|
||||
"""
|
||||
sig_vwap, _ = _signal_pair(25)
|
||||
assert sig_vwap is not None
|
||||
from app.services.day_trading_strategy import _atr as _atr_fn
|
||||
today = _series(TODAY, 25, 10_600.0, step=-20.0, spread=8.0)
|
||||
today[-2] = _completed_bull_bar(today[-2])
|
||||
atr = _atr_fn(today, 14)
|
||||
assert atr is not None
|
||||
# 전제: 이 데이터는 저변동(1.5×ATR < 최소손절폭)이라 옛 공식이면 R:R < 1 이었다
|
||||
assert settings.DAY_TRADE_TARGET_MULTIPLIER * atr < (
|
||||
sig_vwap.entry_price * settings.DAY_TRADE_MIN_STOP_LOSS_PCT
|
||||
)
|
||||
assert _rr(sig_vwap) >= settings.DAY_TRADE_TARGET_MULTIPLIER - 1e-6
|
||||
|
||||
|
||||
def test_target_uses_the_larger_of_two_candidates():
|
||||
"""기존 목표식이 더 크면 그 값이 유지된다 (1.5R 하한은 '하한'일 뿐).
|
||||
|
||||
RSI_BB 는 `max(bb_middle, 1.5R)` 이므로 BB 중앙선이 멀면 그쪽이 이긴다.
|
||||
"""
|
||||
_, sig_rb = _signal_pair(25)
|
||||
assert sig_rb is not None
|
||||
assert _rr(sig_rb) > settings.DAY_TRADE_TARGET_MULTIPLIER, (
|
||||
"BB 중앙선이 1.5R 보다 먼 데이터인데 1.5R 로 깎였다"
|
||||
)
|
||||
530
backend/tests/test_daytrading_watchlist_size_20260930.py
Normal file
530
backend/tests/test_daytrading_watchlist_size_20260930.py
Normal file
@@ -0,0 +1,530 @@
|
||||
"""2026-09-30 감시종목 확대 회귀 테스트 (DAY_TRADE_WATCHLIST_SIZE).
|
||||
|
||||
배경: `populate_daytrading_watchlist(max_stocks=20)` 의 20 이 하드코딩이라 ORB 후보
|
||||
풀을 넓힐 방법이 없었다. 값을 config 로 이관하면서 다음을 고정한다.
|
||||
|
||||
1. 상한은 settings 에서 온다 (하드코딩 20 잔재 없음, 명시 인자는 여전히 우선)
|
||||
2. 확대해도 **리스크 노출은 불변**이다 — 동시 보유·종목당 예산·DT 합산 한도는
|
||||
감시종목 수를 참조하지 않는다
|
||||
3. 스캔 예산 불변식 — 감시종목 수는 `_SCAN_TIME_BUDGET` 안에서 소화 가능한
|
||||
범위여야 한다(값을 올릴 때 이 테스트가 재산정을 강제한다)
|
||||
|
||||
CLAUDE.md §4.1: 기대값에 config 값을 하드코딩하지 않고 `settings.*` 를 참조한다.
|
||||
"""
|
||||
import inspect
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@compiles(JSONB, "sqlite")
|
||||
def _jsonb_as_json(type_, compiler, **kw): # 테스트용 SQLite 호환
|
||||
return "JSON"
|
||||
|
||||
|
||||
from app.models.models import Base, StockAnalysis # noqa: E402
|
||||
from app.services.daytrading import entry_service as es # noqa: E402
|
||||
|
||||
|
||||
async def _mk_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[StockAnalysis.__table__]))
|
||||
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
def _row(code, d, score=3, price=10000):
|
||||
return StockAnalysis(
|
||||
stock_code=code, stock_name=f"N{code}", analysis_date=d,
|
||||
score=score, rsi=40.0, current_price=price,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_watchlist():
|
||||
"""모듈 전역 상태를 테스트마다 격리한다 (§4.1: 테스트 순서 의존 방지).
|
||||
|
||||
`_scan_offset`·`_rsi_diag_cycles`·`_candle_cache` 는 모듈 전역이라
|
||||
남겨 두면 다른 파일의 라운드로빈·억제 카운터 테스트를 오염시킨다.
|
||||
"""
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
|
||||
def _reset():
|
||||
es.set_watchlist([])
|
||||
es._candle_cache.clear()
|
||||
es._scan_offset = 0
|
||||
es._rsi_diag_cycles = 0
|
||||
day_trading_state.positions.clear()
|
||||
day_trading_state.closed_today.clear()
|
||||
|
||||
_reset()
|
||||
yield
|
||||
_reset()
|
||||
|
||||
|
||||
async def _run_scan(fake_try_enter, *, hh: int = 11, mm: int = 0) -> list:
|
||||
"""`scan_and_enter()` 를 게이트 통과 상태로 1회 실행한다 (동작 검증용 하네스).
|
||||
|
||||
§4.1: 주문·잔고·시세 계열은 conftest 가 예외 발생형으로 차단하므로 여기서
|
||||
명시적으로 목킹한다. 목킹 지점은 실제 호출부와 동일하다.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
fake_now = datetime.now(es._KST).replace(hour=hh, minute=mm, second=0)
|
||||
|
||||
class _DT(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return fake_now if tz else fake_now.replace(tzinfo=None)
|
||||
|
||||
class _Adp:
|
||||
fallback_used = False
|
||||
daytrading_enabled = True
|
||||
trading_mode = "normal"
|
||||
|
||||
with patch.object(es, "datetime", _DT), \
|
||||
patch.object(es.mdd_manager, "is_short_term_strategy_enabled",
|
||||
AsyncMock(return_value=True)), \
|
||||
patch.object(es.mdd_manager, "can_open_new_position",
|
||||
AsyncMock(return_value=True)), \
|
||||
patch("app.services.adaptive_strategy.get_current_adaptive_params",
|
||||
return_value=_Adp()), \
|
||||
patch.object(es, "is_daytrading_allowed_by_playbook", return_value=True), \
|
||||
patch.object(es, "check_market_regime",
|
||||
AsyncMock(return_value={"regime": "neutral"})), \
|
||||
patch.object(es.korea_investment_api, "get_balance", AsyncMock(
|
||||
return_value={"output2": [{"tot_evlu_amt": "100000000",
|
||||
"prvs_rcdl_excc_amt": "90000000"}]})), \
|
||||
patch.object(es.korea_investment_api, "get_buyable_cash",
|
||||
AsyncMock(return_value=90_000_000.0)), \
|
||||
patch.object(es, "_prefetch_minute_charts", AsyncMock(return_value=0)), \
|
||||
patch.object(es, "_try_enter", fake_try_enter):
|
||||
return await es.scan_and_enter()
|
||||
|
||||
|
||||
# ── 1. 상한은 settings 에서 온다 ──────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watchlist_size_comes_from_settings():
|
||||
"""인자를 생략하면 settings.DAY_TRADE_WATCHLIST_SIZE 가 상한이 된다."""
|
||||
engine, Session = await _mk_session()
|
||||
today = datetime.now(es._KST).date()
|
||||
async with Session() as s:
|
||||
# 상한보다 5개 많은 후보를 넣는다 (score 내림차순으로 잘려야 한다)
|
||||
s.add_all([
|
||||
_row(f"{i:06d}", today, score=100 - i)
|
||||
for i in range(settings.DAY_TRADE_WATCHLIST_SIZE + 5)
|
||||
])
|
||||
await s.commit()
|
||||
|
||||
with patch("app.core.database.AsyncSessionLocal", Session):
|
||||
n = await es.populate_daytrading_watchlist()
|
||||
assert n == settings.DAY_TRADE_WATCHLIST_SIZE
|
||||
assert len(es.get_watchlist()) == settings.DAY_TRADE_WATCHLIST_SIZE
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watchlist_size_setting_is_honored_when_changed():
|
||||
"""설정을 바꾸면 그대로 반영된다 (20 하드코딩 잔재 없음)."""
|
||||
engine, Session = await _mk_session()
|
||||
today = datetime.now(es._KST).date()
|
||||
async with Session() as s:
|
||||
s.add_all([_row(f"{i:06d}", today, score=100 - i) for i in range(40)])
|
||||
await s.commit()
|
||||
|
||||
for size in (7, 33):
|
||||
with patch.object(settings, "DAY_TRADE_WATCHLIST_SIZE", size), \
|
||||
patch("app.core.database.AsyncSessionLocal", Session):
|
||||
es.set_watchlist([])
|
||||
n = await es.populate_daytrading_watchlist()
|
||||
assert n == size, f"설정 {size} 가 반영되지 않았다 (하드코딩 상한 의심)"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_max_stocks_still_wins():
|
||||
"""기존 시그니처 보존 — 명시 인자는 settings 보다 우선한다 (§4)."""
|
||||
engine, Session = await _mk_session()
|
||||
today = datetime.now(es._KST).date()
|
||||
async with Session() as s:
|
||||
s.add_all([_row(f"{i:06d}", today, score=100 - i) for i in range(10)])
|
||||
await s.commit()
|
||||
|
||||
with patch("app.core.database.AsyncSessionLocal", Session):
|
||||
n = await es.populate_daytrading_watchlist(1, 4)
|
||||
assert n == 4
|
||||
with patch("app.core.database.AsyncSessionLocal", Session):
|
||||
n = await es.populate_daytrading_watchlist(min_score=1, max_stocks=6)
|
||||
assert n == 6
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yesterday_fallback_also_uses_setting():
|
||||
"""당일 분석이 없어 최근 분석일로 폴백하는 경로도 같은 상한을 쓴다."""
|
||||
engine, Session = await _mk_session()
|
||||
yday = datetime.now(es._KST).date() - timedelta(days=1)
|
||||
async with Session() as s:
|
||||
s.add_all([_row(f"{i:06d}", yday, score=100 - i) for i in range(12)])
|
||||
await s.commit()
|
||||
|
||||
with patch.object(settings, "DAY_TRADE_WATCHLIST_SIZE", 9), \
|
||||
patch("app.core.database.AsyncSessionLocal", Session):
|
||||
n = await es.populate_daytrading_watchlist()
|
||||
assert n == 9
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def test_no_hardcoded_default_in_signature():
|
||||
"""기본값은 None(=settings 위임) 이어야 한다 — 숫자 기본값 복귀 방지.
|
||||
|
||||
"settings 를 읽는가"는 소스 문자열이 아니라
|
||||
`test_watchlist_size_setting_is_honored_when_changed` 가 **동작으로** 검증한다.
|
||||
"""
|
||||
sig = inspect.signature(es.populate_daytrading_watchlist)
|
||||
assert sig.parameters["max_stocks"].default is None
|
||||
|
||||
|
||||
# ── 2. 리스크 노출 불변 ───────────────────────────────────────────────────────
|
||||
|
||||
def test_position_and_capital_limits_do_not_depend_on_watchlist():
|
||||
"""감시종목 수는 동시 보유·예산 한도에 전혀 관여하지 않는다."""
|
||||
from app.services import day_trading_strategy as dts
|
||||
|
||||
assert dts.MAX_POSITIONS == settings.DAY_TRADE_MAX_POSITIONS
|
||||
|
||||
# 사이징은 총자산·현금·기존 DT 투입액만의 함수다 (감시종목 수 무관)
|
||||
sig = inspect.signature(dts.calculate_day_trade_position_size)
|
||||
assert set(sig.parameters) == {
|
||||
"total_assets", "available_cash", "current_day_trade_amount",
|
||||
}
|
||||
budget = dts.calculate_day_trade_position_size(
|
||||
total_assets=100_000_000, available_cash=100_000_000, current_day_trade_amount=0,
|
||||
)
|
||||
assert budget == pytest.approx(100_000_000 * settings.DAY_TRADE_MAX_CAPITAL_RATIO)
|
||||
|
||||
# DT 합산 한도 — 종목당 한도 × 동시 보유 한도가 DT 총한도를 넘지 않는다
|
||||
assert (
|
||||
settings.DAY_TRADE_MAX_CAPITAL_RATIO * settings.DAY_TRADE_MAX_POSITIONS
|
||||
<= settings.DAY_TRADE_MAX_RATIO_DT + 1e-9
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_stops_at_position_cap_behaviorally():
|
||||
"""감시종목이 한도의 4배여도 **실제 진입은 MAX_POSITIONS 에서 멈춘다**.
|
||||
|
||||
소스 문자열 매칭이 아니라 `scan_and_enter()` 를 돌려 결과 건수로 확인한다.
|
||||
"""
|
||||
from app.services.day_trading_strategy import MAX_POSITIONS
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
|
||||
day_trading_state.positions.clear()
|
||||
day_trading_state.closed_today.clear()
|
||||
es.set_watchlist([
|
||||
{"code": f"{i:06d}", "name": f"N{i}"} for i in range(MAX_POSITIONS * 4)
|
||||
])
|
||||
|
||||
async def fake_try_enter(code, name, total_assets, available_cash,
|
||||
is_lunch_break=False):
|
||||
# 실제 경로처럼 인메모리 포지션을 늘린다 (루프의 break 조건 대상)
|
||||
day_trading_state.add_position(code, {
|
||||
"stock_name": name, "strategy": "VWAP_BOUNCE", "entry_price": 1000.0,
|
||||
"stop_loss": 980.0, "target_price": 1030.0, "quantity": 1,
|
||||
"highest_price": 1000.0, "entry_time": None, "reason": "t", "order_id": "",
|
||||
})
|
||||
return {"stock_code": code, "quantity": 1, "price": 1000.0}
|
||||
|
||||
entries = await _run_scan(fake_try_enter)
|
||||
assert len(entries) == MAX_POSITIONS, (
|
||||
f"감시종목 {MAX_POSITIONS * 4}개인데 진입 {len(entries)}건 — 한도 {MAX_POSITIONS} 초과/미달"
|
||||
)
|
||||
assert day_trading_state.position_count() == MAX_POSITIONS
|
||||
day_trading_state.positions.clear()
|
||||
es.set_watchlist([])
|
||||
|
||||
|
||||
def test_budget_clamp_depends_only_on_its_arguments():
|
||||
"""사이징 클램프는 인자만의 함수다 — 감시목록 크기가 새어들 여지가 없다."""
|
||||
args = dict(total_assets=100_000_000, available_cash=100_000_000,
|
||||
current_day_trade_amount=0)
|
||||
before = es._clamp_day_trade_budget(10_000_000, **args)
|
||||
es.set_watchlist([{"code": f"{i:06d}", "name": "x"} for i in range(50)])
|
||||
after = es._clamp_day_trade_budget(10_000_000, **args)
|
||||
es.set_watchlist([])
|
||||
assert before == after == 100_000_000 * settings.DAY_TRADE_MAX_CAPITAL_RATIO
|
||||
|
||||
|
||||
def test_portfolio_holdings_cap_is_unrelated_to_daytrading_watchlist():
|
||||
"""MAX_HOLDINGS_COUNT(포트폴리오 전체 보유 한도)는 DT 진입 경로에 없다."""
|
||||
import app.services.daytrading.entry_service as mod
|
||||
assert "MAX_HOLDINGS_COUNT" not in inspect.getsource(mod)
|
||||
|
||||
|
||||
# ── 3. 스캔 예산 불변식 ───────────────────────────────────────────────────────
|
||||
|
||||
def test_candle_cache_ttl_covers_one_cycle():
|
||||
"""[M3] 캐시 TTL 은 한 사이클 상한보다 커야 한다 (선조회 재조회 방지)."""
|
||||
assert es._CANDLE_CACHE_TTL >= es._SCAN_TIME_BUDGET
|
||||
|
||||
|
||||
def test_watchlist_size_fits_scan_budget():
|
||||
"""감시종목 수가 선조회 단계 예산 안에 들어오는지 검증.
|
||||
|
||||
비용 모델(2026-09-30 산정):
|
||||
선조회 wall ≈ 종목수 × 페이지수 × 페이지비용 ÷ _SCAN_PREFETCH_CONCURRENCY
|
||||
|
||||
- 페이지비용 = 요청시간 3.0초 + in-loop sleep(`API_MIN_REQUEST_INTERVAL`+0.05)
|
||||
· 요청시간은 운영 `monitoring/api-stats` avg_duration 실측(재시도 포함)
|
||||
· sleep 은 `korea_investment_api._get_minute_chart_full_day_impl`
|
||||
- 페이지수는 시간대 비례(당일 1분봉÷30봉): 09:30 4장 / 14:00 10장 / 15:00 12장
|
||||
- **판정 기준은 14:00(10장)**: 진입 마감 15:00 전 대부분의 시간을 커버하는 지점이다.
|
||||
15:00 직전(12장)은 부분 커버리지를 감수하고 라운드로빈에 맡긴다.
|
||||
|
||||
DAY_TRADE_WATCHLIST_SIZE 를 올리려면 이 계산을 다시 해야 한다 —
|
||||
그게 이 테스트의 목적이다.
|
||||
"""
|
||||
req_sec = 3.0
|
||||
page_sec = req_sec + settings.API_MIN_REQUEST_INTERVAL + 0.05
|
||||
pages_afternoon = 10 # 14:00 기준
|
||||
|
||||
max_stocks = int(
|
||||
es._SCAN_PREFETCH_TOTAL_BUDGET * es._SCAN_PREFETCH_CONCURRENCY
|
||||
/ (pages_afternoon * page_sec)
|
||||
)
|
||||
assert settings.DAY_TRADE_WATCHLIST_SIZE <= max_stocks, (
|
||||
f"감시종목 {settings.DAY_TRADE_WATCHLIST_SIZE}개는 14:00 기준 선조회 예산 상한 "
|
||||
f"{max_stocks}개를 초과한다 (페이지 {page_sec:.2f}초 × {pages_afternoon}장, "
|
||||
f"동시성 {es._SCAN_PREFETCH_CONCURRENCY}, 예산 {es._SCAN_PREFETCH_TOTAL_BUDGET:.0f}초)"
|
||||
)
|
||||
|
||||
|
||||
def test_per_stock_prefetch_timeout_covers_full_session():
|
||||
"""장 마감 직전(12페이지) 종목도 per-stock 타임아웃 안에 들어와야 한다.
|
||||
|
||||
넘으면 12:00 이후 전 종목이 선조회 타임아웃되고, 순차 루프가 타임아웃 없는
|
||||
직렬 재조회를 떠안아 오히려 느려진다(2026-09-30 이전 상태).
|
||||
"""
|
||||
page_sec = 3.0 + settings.API_MIN_REQUEST_INTERVAL + 0.05
|
||||
pages_max = 12 # 15:00, 당일 1분봉 360봉 ÷ 30
|
||||
assert pages_max * page_sec <= es._SCAN_PREFETCH_TIMEOUT, (
|
||||
f"15:00 기준 종목당 {pages_max * page_sec:.0f}초 > 타임아웃 "
|
||||
f"{es._SCAN_PREFETCH_TIMEOUT:.0f}초"
|
||||
)
|
||||
|
||||
|
||||
def test_prefetch_phase_deadline_protects_scan_and_guard():
|
||||
"""선조회 단계 데드라인이 순차 구간과 스케줄러 가드를 모두 보호한다.
|
||||
|
||||
per-stock 타임아웃만으로는 최악의 경우 ceil(N/동시성) 웨이브 × 타임아웃이
|
||||
_guard(STEP_TIMEOUT=180)를 넘겨 스캔 전체가 조용히 취소된다.
|
||||
"""
|
||||
from app.services.daytrading import scheduler as sch
|
||||
|
||||
# 단계 데드라인 + 순차 구간 몫 ≤ 사이클 예산 ≤ 스케줄러 가드
|
||||
assert es._SCAN_PREFETCH_TOTAL_BUDGET < es._SCAN_TIME_BUDGET
|
||||
assert es._SCAN_TIME_BUDGET < sch.STEP_TIMEOUT
|
||||
# 순차 구간에 최소 30초(= 최대 5건 주문 여유)를 남긴다
|
||||
assert es._SCAN_TIME_BUDGET - es._SCAN_PREFETCH_TOTAL_BUDGET >= 30
|
||||
# 데드라인이 per-stock 타임아웃보다 커야 1웨이브라도 완주한다
|
||||
assert es._SCAN_PREFETCH_TOTAL_BUDGET > es._SCAN_PREFETCH_TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefetch_phase_returns_within_deadline(monkeypatch):
|
||||
"""느린 종목이 많아도 선조회 **단계**가 데드라인 안에 반환된다 (동작 검증).
|
||||
|
||||
상수만 있고 실제로 걸려 있지 않은 상태를 소스 매칭 없이 잡는다.
|
||||
"""
|
||||
import asyncio
|
||||
import time as _time
|
||||
|
||||
slow_started = {"n": 0}
|
||||
|
||||
async def slow_fetch(code):
|
||||
slow_started["n"] += 1
|
||||
await asyncio.sleep(30) # per-stock 타임아웃보다도 길다
|
||||
return [object()]
|
||||
|
||||
monkeypatch.setattr(es, "_fetch_candles_impl", slow_fetch)
|
||||
monkeypatch.setattr(es, "_SCAN_PREFETCH_TIMEOUT", 5.0)
|
||||
monkeypatch.setattr(es, "_SCAN_PREFETCH_TOTAL_BUDGET", 0.3)
|
||||
es._candle_cache.clear()
|
||||
|
||||
t0 = _time.monotonic()
|
||||
n = await es._prefetch_minute_charts([f"{i:06d}" for i in range(40)])
|
||||
elapsed = _time.monotonic() - t0
|
||||
|
||||
assert elapsed < 3.0, (
|
||||
f"단계 데드라인(0.3초)이 걸려 있지 않다 — {elapsed:.1f}초 소요 "
|
||||
f"(per-stock 타임아웃 5초 × 웨이브만큼 걸린 것)"
|
||||
)
|
||||
assert n == 0, "타임아웃된 종목이 캐시에 담겨선 안 된다"
|
||||
assert slow_started["n"] > 0, "선조회가 아예 시작되지 않았다"
|
||||
es._candle_cache.clear()
|
||||
|
||||
|
||||
def test_prefetch_concurrency_within_api_limits():
|
||||
"""선조회 동시성이 KIS 레이트리밋·세마포어 안에 있는지.
|
||||
|
||||
- 동시성 ≤ API_MAX_CONCURRENT_REQUESTS: 초과분은 세마포어에서 대기만 하므로 무의미
|
||||
- 실효 요청률 = 동시성 ÷ 페이지비용 ≤ 전역 쓰로틀 상한(1/API_MIN_REQUEST_INTERVAL)
|
||||
의 50% — 나머지는 스윙 스케줄러·배치·주문 경합 몫으로 남긴다
|
||||
"""
|
||||
assert es._SCAN_PREFETCH_CONCURRENCY <= settings.API_MAX_CONCURRENT_REQUESTS
|
||||
page_sec = 3.0 + settings.API_MIN_REQUEST_INTERVAL + 0.05
|
||||
req_rate = es._SCAN_PREFETCH_CONCURRENCY / page_sec
|
||||
throttle_cap = 1.0 / settings.API_MIN_REQUEST_INTERVAL
|
||||
assert req_rate <= throttle_cap * 0.5, (
|
||||
f"선조회 요청률 {req_rate:.2f} req/s 가 쓰로틀 상한 {throttle_cap:.2f} req/s 의 "
|
||||
f"50% 를 넘는다 — 타 스케줄러 경합 여유가 없다"
|
||||
)
|
||||
|
||||
|
||||
def test_prefetch_is_read_only():
|
||||
"""선조회는 읽기 전용 — 주문·포지션 변경·DB 쓰기가 섞여선 안 된다.
|
||||
|
||||
주문이 순차 루프에만 있어야 MAX_POSITIONS·예산 클램프 순서가 보장된다
|
||||
(동시성을 올렸으므로 이 불변식이 더 중요해졌다).
|
||||
"""
|
||||
src = inspect.getsource(es._prefetch_minute_charts)
|
||||
for forbidden in (
|
||||
"safe_place_order", "place_order", "add_position", "enqueue_",
|
||||
"position_count", "_clamp_day_trade_budget",
|
||||
):
|
||||
assert forbidden not in src, f"선조회에 {forbidden} 가 섞였다"
|
||||
# 순차 진입 루프는 여전히 for 문 안에서 하나씩 await 한다
|
||||
scan_src = inspect.getsource(es.scan_and_enter)
|
||||
assert "await _try_enter(" in scan_src
|
||||
assert "gather" not in scan_src, "진입이 병렬화되면 포지션 한도가 깨진다"
|
||||
|
||||
|
||||
def test_orb_collection_fits_opening_window():
|
||||
"""ORB 수집(09:00~09:29)이 감시종목 전부를 소화할 수 있는지.
|
||||
|
||||
수집은 종목당 **1페이지**(당일 09:00~ 분봉은 30봉 이내) + 0.3초 간격 순차다.
|
||||
첫 유효 수집은 봉 5개가 모이는 09:05 경이므로 가용 시간은 약 24분.
|
||||
"""
|
||||
req_sec = 3.0
|
||||
per_stock = req_sec + 0.3
|
||||
window_sec = 24 * 60
|
||||
max_stocks = int(window_sec / per_stock)
|
||||
assert settings.DAY_TRADE_WATCHLIST_SIZE <= max_stocks
|
||||
# 한 사이클 가드(300초) 안에서도 소화 가능해야 중간 취소로 유실되지 않는다
|
||||
assert settings.DAY_TRADE_WATCHLIST_SIZE <= int(300 / per_stock)
|
||||
|
||||
|
||||
# ── RSI·갭 기저 분포 진단 로깅 (2026-09-30) ───────────────────────────────────
|
||||
|
||||
def _mk_5m(date: str, n: int, base: float, step: float = -20.0) -> list:
|
||||
"""5분봉 n개 (MinuteCandle)."""
|
||||
from app.services.day_trading_strategy import MinuteCandle
|
||||
out = []
|
||||
for i in range(n):
|
||||
total = 9 * 60 + i * 5
|
||||
c = base + i * step
|
||||
out.append(MinuteCandle(
|
||||
date=date, time=f"{total // 60:02d}{total % 60:02d}00",
|
||||
open=c, high=c + 10, low=c - 10, close=c,
|
||||
volume=1000, cum_volume=0, cum_amount=0.0,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def test_rsi_diagnostics_logs_one_line_per_cycle_window(caplog):
|
||||
"""후보 전체의 (RSI, 갭) 분포가 **한 줄로** 남는다 — 통과 여부와 무관하게."""
|
||||
import logging
|
||||
|
||||
es._candle_cache.clear()
|
||||
es._rsi_diag_cycles = 0
|
||||
cands = []
|
||||
for i in range(3):
|
||||
code = f"00000{i}"
|
||||
prev = _mk_5m("20260929", 20, 11_000.0)
|
||||
today = _mk_5m("20260930", 25, 10_600.0 - i * 100)
|
||||
es._candle_cache[code] = (1e12, prev + today) # 만료 없는 타임스탬프
|
||||
cands.append((code, f"N{code}"))
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=es.logger.name):
|
||||
es._log_rsi_gap_diagnostics(cands)
|
||||
|
||||
msgs = [r.message for r in caplog.records if "RSI 진단" in r.message]
|
||||
assert len(msgs) == 1, "사이클당 1줄이어야 한다"
|
||||
line = msgs[0]
|
||||
assert "n=3" in line
|
||||
# 임계값은 하드코딩하지 않고 settings 실효값이 찍힌다 (§4)
|
||||
assert f"RSI≤{settings.DAY_TRADE_VWAP_RSI_THRESHOLD:.0f}" in line
|
||||
assert f"RSI≤{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}" in line
|
||||
assert "갭" in line and "최저 RSI" in line
|
||||
es._candle_cache.clear()
|
||||
|
||||
|
||||
def test_rsi_diagnostics_is_volume_suppressed(caplog):
|
||||
"""최초 1회 + N 사이클마다 1회만 남긴다 (로그 폭주 방지)."""
|
||||
import logging
|
||||
|
||||
es._candle_cache.clear()
|
||||
es._rsi_diag_cycles = 0
|
||||
es._candle_cache["000001"] = (
|
||||
1e12, _mk_5m("20260929", 20, 11_000.0) + _mk_5m("20260930", 25, 10_600.0)
|
||||
)
|
||||
cands = [("000001", "N")]
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=es.logger.name):
|
||||
for _ in range(es._RSI_DIAG_LOG_EVERY + 1):
|
||||
es._log_rsi_gap_diagnostics(cands)
|
||||
|
||||
msgs = [r.message for r in caplog.records if "RSI 진단" in r.message]
|
||||
assert len(msgs) == 2, (
|
||||
f"최초 1회 + {es._RSI_DIAG_LOG_EVERY}회째 = 2줄이어야 하는데 {len(msgs)}줄"
|
||||
)
|
||||
es._candle_cache.clear()
|
||||
es._rsi_diag_cycles = 0
|
||||
|
||||
|
||||
def test_rsi_diagnostics_is_read_only_and_uses_cache_only():
|
||||
"""진단은 KIS 호출·DB I/O·상태 변경을 하지 않는다 (§3.1)."""
|
||||
import inspect
|
||||
src = inspect.getsource(es._log_rsi_gap_diagnostics)
|
||||
for forbidden in (
|
||||
"korea_investment_api", "AsyncSessionLocal", "enqueue_", "await",
|
||||
"set_orb", "add_position", "safe_place_order",
|
||||
):
|
||||
assert forbidden not in src, f"진단에 {forbidden} 가 섞였다"
|
||||
assert "_candle_cache" in src, "선조회 캐시만 읽어야 한다(추가 조회 금지)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_failure_does_not_lose_entries(monkeypatch, caplog):
|
||||
"""진단이 예외를 던져도 진입 결과가 유실되지 않고 warning 이 남는다 (동작 검증)."""
|
||||
import logging
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
|
||||
def boom(_candidates):
|
||||
raise RuntimeError("diag boom")
|
||||
|
||||
monkeypatch.setattr(es, "_log_rsi_gap_diagnostics", boom)
|
||||
day_trading_state.positions.clear()
|
||||
day_trading_state.closed_today.clear()
|
||||
es.set_watchlist([{"code": "000001", "name": "N"}])
|
||||
|
||||
async def fake_try_enter(code, name, total_assets, available_cash,
|
||||
is_lunch_break=False):
|
||||
return {"stock_code": code, "quantity": 1, "price": 1000.0}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=es.logger.name):
|
||||
entries = await _run_scan(fake_try_enter)
|
||||
|
||||
assert len(entries) == 1, "진단 예외가 진입 결과를 삼켰다"
|
||||
assert any("진단 로깅 실패" in r.message for r in caplog.records)
|
||||
assert all(r.levelno >= logging.WARNING
|
||||
for r in caplog.records if "진단 로깅 실패" in r.message)
|
||||
es.set_watchlist([])
|
||||
@@ -89,16 +89,22 @@ def _warmup_scenario() -> tuple[list, list]:
|
||||
|
||||
당일: 09:00~09:29 (5분봉 6개). 전일 13:00~15:19 (5분봉 28개 워밍업).
|
||||
가격은 완만한 하락 후 마지막 완성봉에서 급락 → RSI 과매도 + BB 하단 이탈,
|
||||
형성 중 봉은 양봉. 거래량은 마지막 완성봉이 직전 완성봉의 2배.
|
||||
**그 완성봉이 양봉 마감**. 거래량은 마지막 완성봉이 직전 완성봉의 2배.
|
||||
|
||||
[2026-09-30] 원래 시나리오는 반등(양봉)을 **형성 중 버킷**에 두고 있었다. 양봉
|
||||
판정이 형성 중 버킷 → 마지막 완성봉으로 고쳐졌으므로(같은 버킷을 30초마다 보며
|
||||
답이 흔들리던 문제) 반등 봉을 완성봉 쪽으로 옮긴다. 이 파일의 목적(워밍업이
|
||||
09:30 판정을 가능하게 하는가)은 그대로다.
|
||||
"""
|
||||
prev_day = _minute_seq(_prev(), 13, 0, 140, lambda i: 1000.0 - i * 0.2)
|
||||
# 당일 09:00~09:24 (완성 5분봉 5개) — 계속 하락
|
||||
today_bars = _minute_seq(_today(), 9, 0, 20, lambda i: 972.0 - i * 0.5)
|
||||
# 09:20~09:24 = 마지막 완성봉: 급락 + 거래량 2배
|
||||
# 09:20~09:24 = 마지막 완성봉: 급락(직전 버킷 942 → 906) 후 양봉 마감 + 거래량 2배
|
||||
today_bars += _minute_seq(
|
||||
_today(), 9, 20, 5, lambda i: 940.0 - i * 6.0, lambda i: 2000
|
||||
_today(), 9, 20, 5,
|
||||
lambda i: (906.0, 908.0, 911.0, 914.0, 916.0)[i], lambda i: 2000,
|
||||
)
|
||||
# 09:25~09:29 = 형성 중 봉: 양봉 (반등 시작)
|
||||
# 09:25~09:29 = 형성 중 봉 (양봉 판정 대상이 아니다)
|
||||
today_bars += [
|
||||
_c(_today(), "092500", 912.0, 300, open_=910.0),
|
||||
_c(_today(), "092600", 913.0, 300, open_=912.0),
|
||||
|
||||
1078
backend/tests/test_external_demotion_race.py
Normal file
1078
backend/tests/test_external_demotion_race.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -45,22 +45,52 @@ def test_dt_position_without_system_evidence_still_external():
|
||||
|
||||
# ── sync_portfolio 불변식 (소스 수준) ──────────────────────────────────────
|
||||
|
||||
def _compiled(stmt):
|
||||
from sqlalchemy.dialects import postgresql
|
||||
return str(stmt.compile(dialect=postgresql.dialect(),
|
||||
compile_kwargs={"literal_binds": True}))
|
||||
|
||||
|
||||
def test_sync_collects_system_buy_evidence_without_time_window():
|
||||
"""근거 조회는 기간 제한이 없어야 한다 — 당일만 보면 자정 후 재편입된다."""
|
||||
src = inspect.getsource(pf.sync_portfolio)
|
||||
i = src.index("유입(external) 잔고 격리용")
|
||||
block = src[i:i + 2500]
|
||||
assert 'TradeHistory.order_type == "BUY"' in block
|
||||
assert "recent_point" not in block, "유입 판정에 당일 시간창을 쓰면 안 된다"
|
||||
assert "_SEEDED_AT_STRATEGY_NAMES" in block, "시딩 ActiveTrade 를 근거에서 제외해야 한다"
|
||||
"""근거 조회는 기간 제한이 없어야 한다 — 당일만 보면 자정 후 재편입된다.
|
||||
|
||||
[2026-09-30 M-5] 소스 문자열이 아니라 **컴파일된 SQL** 을 본다.
|
||||
"""
|
||||
sql = _compiled(pf._system_buy_codes_stmt())
|
||||
assert "trade_history.order_type = 'BUY'" in sql
|
||||
assert "created_at" not in sql and "executed_at" not in sql, (
|
||||
"유입 판정에 당일 시간창을 쓰면 안 된다"
|
||||
)
|
||||
|
||||
|
||||
def test_sync_never_demotes_managed_rows_to_external():
|
||||
"""운용 중인 swing/daytrading 행을 external 로 강등하지 않는다(승격만 허용)."""
|
||||
def test_sync_demotes_only_through_guarded_helper():
|
||||
"""[2026-09-30 사용자 결정] 강등을 허용하되 **반드시 레이스 가드를 거친다**.
|
||||
|
||||
"스윙으로 편입하는 건 없어야 해" → swing → external 강등이 필요하지만,
|
||||
조건 없이 강등하면 매수 직후(근거 DB 반영 전) 정상 포지션이 리스크 관리 밖으로
|
||||
떨어진다. 그래서 강등은 `_should_demote_to_external()` 단일 관문만 통과한다.
|
||||
"""
|
||||
src = inspect.getsource(pf.sync_portfolio)
|
||||
assert '== EXTERNAL_STRATEGY_TYPE and _classified == "swing"' in src
|
||||
# 강등 코드가 들어오면 (h.strategy_type = external) 패턴으로 나타난다
|
||||
assert "h.strategy_type = EXTERNAL_STRATEGY_TYPE" not in src
|
||||
assert "_classify_durable(stock_code, quantity) == \"swing\"" in src, (
|
||||
"승격 경로 유지 — 단 durable 근거(레지스트리 제외)만 본다 (M-2)"
|
||||
)
|
||||
# 국내·해외 두 경로 모두 강등한다
|
||||
assert src.count("h.strategy_type = EXTERNAL_STRATEGY_TYPE") == 2
|
||||
# 무조건 강등 금지 — 가드 함수를 거친 분기에서만 대입된다
|
||||
for seg in src.split("h.strategy_type = EXTERNAL_STRATEGY_TYPE")[:-1]:
|
||||
assert "_should_demote_to_external(" in seg[-400:], (
|
||||
"강등 대입 직전 분기가 _should_demote_to_external() 이어야 한다"
|
||||
)
|
||||
|
||||
|
||||
def test_sync_excludes_legacy_seeded_active_trade_from_evidence():
|
||||
"""2026-09-29 이전 시딩 행(strategy_name='swing')은 entry_reason 으로 걸러낸다.
|
||||
|
||||
걸러내지 않으면 유입 잔고가 자기 시딩 행을 근거로 삼아 격리가 스스로 풀린다.
|
||||
"""
|
||||
sql = _compiled(pf._evidence_active_trade_codes_stmt())
|
||||
assert "NOT IN ('synced')" in sql
|
||||
assert "NOT LIKE '외부 유입 포지션" in sql
|
||||
|
||||
|
||||
def test_sync_skips_active_trade_seeding_for_external():
|
||||
@@ -196,13 +226,46 @@ def _external_row():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_buy_promotes_external_row_on_swing_buy():
|
||||
"""격리 행에 스윙 시스템 매수가 들어오면 승격 — 다음 sync 까지 무관리 방지."""
|
||||
async def test_reflect_buy_refuses_partial_mix_promotion_on_swing_buy():
|
||||
"""[2026-09-30 정정] 수량이 남아 있는 격리 행은 스윙 매수로도 승격하지 않는다.
|
||||
|
||||
종전 동작(무조건 승격)은 **유입분 세탁 경로**였다: Portfolio 는 stock_code
|
||||
UNIQUE 로 수량이 합산되므로(§3.5) 승격하면 유입 8주까지 스윙 자동 손절 대상이
|
||||
된다. 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주
|
||||
→ swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 위반이다.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.services import portfolio_cache_bridge as bridge
|
||||
|
||||
row = _external_row()
|
||||
row = _external_row() # external 8주
|
||||
|
||||
class _Cache:
|
||||
holdings = [row]
|
||||
apply_buy = AsyncMock()
|
||||
|
||||
_enq = AsyncMock()
|
||||
with patch.object(bridge, "_cache", return_value=_Cache()), \
|
||||
patch("app.services.db_worker.enqueue_portfolio_upsert", _enq):
|
||||
await bridge.reflect_buy("000100", "유한양행", 5, 11_000, strategy_type="swing")
|
||||
|
||||
assert row.strategy_type == EXTERNAL_STRATEGY_TYPE, "부분 혼재는 승격 금지"
|
||||
assert "strategy_type" not in _enq.await_args.args[0], "DB 태그도 바꾸지 않는다"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_buy_promotes_only_when_no_inflow_quantity_remains():
|
||||
"""유입 잔여 수량이 0 인 행은 승격한다 — 관리 공백을 만들지 않기 위해."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.models.models import Portfolio
|
||||
from app.services import portfolio_cache_bridge as bridge
|
||||
|
||||
row = Portfolio(
|
||||
stock_code="000100", stock_name="유한양행", quantity=0,
|
||||
average_price=10_000, current_price=10_000, market="KRX",
|
||||
strategy_type=EXTERNAL_STRATEGY_TYPE,
|
||||
)
|
||||
|
||||
class _Cache:
|
||||
holdings = [row]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
추가: F2(ORB 전일 캔들 혼입 차단), F3(종료 drain 이 포지션 추적 계열 커밋),
|
||||
F4(DLQ 재처리가 큐 경유).
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import date, datetime, time as dtime, timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
@@ -382,12 +383,15 @@ async def test_replay_dlq_requeues_and_moves_files(tmp_path, monkeypatch):
|
||||
|
||||
import json as _json
|
||||
good = dlq / "20260928_100000_upsert_active_trade_abcd1234.json"
|
||||
# failed_at 은 _save_to_dlq 가 항상 기록한다. C2(나이 제한) 이후 상태 덮어쓰기
|
||||
# 계열은 최근 것만 재큐잉되므로 '방금 실패'로 둔다(나이 정책 자체는 전용 테스트에서 검증).
|
||||
good.write_text(_json.dumps({
|
||||
"task": {"type": "upsert_active_trade",
|
||||
"data": {"stock_code": "005930", "quantity": 10,
|
||||
"entry_price": 70000},
|
||||
"_retry_count": 3},
|
||||
"error": "shutdown_drain",
|
||||
"failed_at": datetime.now().isoformat(),
|
||||
}), encoding="utf-8")
|
||||
broken = dlq / "20260928_100001_upsert_portfolio_deadbeef.json"
|
||||
broken.write_text("{not json", encoding="utf-8")
|
||||
@@ -399,6 +403,11 @@ async def test_replay_dlq_requeues_and_moves_files(tmp_path, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue)
|
||||
|
||||
async def _no_conflict(task, entry):
|
||||
return False, ""
|
||||
|
||||
monkeypatch.setattr(db_worker, "_dlq_state_conflict", _no_conflict)
|
||||
|
||||
count = await db_worker.replay_dlq()
|
||||
|
||||
assert count == 1
|
||||
@@ -512,9 +521,69 @@ def test_scan_time_budget_below_guard_timeout():
|
||||
from app.services.daytrading import scheduler as sched
|
||||
|
||||
assert es._SCAN_TIME_BUDGET < sched.STEP_TIMEOUT
|
||||
src = __import__("inspect").getsource(es.scan_and_enter)
|
||||
assert "_scan_offset" in src, "라운드로빈 오프셋 없음"
|
||||
assert "미스캔" in src, "잘린 종목 수를 로그에 남겨야 한다"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncated_scan_rotates_start_offset_next_cycle(monkeypatch, caplog):
|
||||
"""[M-5 동작 검증] 예산에 걸려 잘리면 다음 사이클은 다른 종목부터 스캔한다.
|
||||
|
||||
소스에 '_scan_offset' 이 있는지 보는 대신, 두 사이클을 실제로 돌려 **스캔된 종목
|
||||
순서**가 회전하는지 본다. 잘린 종목 수가 로그에 남는지도 함께 확인한다.
|
||||
"""
|
||||
import logging
|
||||
from app.services.daytrading import entry_service as es
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
|
||||
scanned_order = []
|
||||
|
||||
async def slow_try_enter(code, name, ta, ac, is_lunch_break=False):
|
||||
scanned_order.append(code)
|
||||
await asyncio.sleep(0.05) # 예산을 넘기도록 느리게
|
||||
return None
|
||||
|
||||
async def fake_prefetch(codes):
|
||||
return len(codes)
|
||||
|
||||
monkeypatch.setattr(es, "_try_enter", slow_try_enter)
|
||||
monkeypatch.setattr(es, "_prefetch_minute_charts", fake_prefetch)
|
||||
monkeypatch.setattr(es, "_SCAN_TIME_BUDGET", 0.04)
|
||||
monkeypatch.setattr(es.mdd_manager, "is_short_term_strategy_enabled",
|
||||
AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(es.mdd_manager, "can_open_new_position",
|
||||
AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(es, "is_daytrading_allowed_by_playbook", lambda *a, **k: True)
|
||||
monkeypatch.setattr(es, "check_market_regime",
|
||||
AsyncMock(return_value={"regime": "neutral"}))
|
||||
monkeypatch.setattr(es.korea_investment_api, "get_balance", AsyncMock(
|
||||
return_value={"output2": [{"tot_evlu_amt": "100000000",
|
||||
"prvs_rcdl_excc_amt": "50000000"}]}))
|
||||
monkeypatch.setattr(es.korea_investment_api, "get_buyable_cash",
|
||||
AsyncMock(return_value=50_000_000.0))
|
||||
# 벽시계 의존 제거 — 컷오프(15:00) 이후에 돌리면 조기 리턴해 빈 리스트가 된다
|
||||
_freeze_entry_window(monkeypatch, es)
|
||||
|
||||
day_trading_state.positions.clear()
|
||||
day_trading_state.closed_today.clear()
|
||||
day_trading_state.daily_pnl = 0.0
|
||||
day_trading_state.last_total_assets = 100_000_000.0
|
||||
es._scan_offset = 0
|
||||
es.set_watchlist([{"code": f"00000{i}", "name": f"종목{i}"} for i in range(4)])
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await es.scan_and_enter()
|
||||
first_cycle = list(scanned_order)
|
||||
scanned_order.clear()
|
||||
await es.scan_and_enter()
|
||||
second_cycle = list(scanned_order)
|
||||
|
||||
assert first_cycle and second_cycle
|
||||
assert first_cycle[0] != second_cycle[0], (
|
||||
f"두 사이클이 같은 종목부터 시작했다 ({first_cycle[0]}) — 뒤쪽 종목이 영구 소외된다"
|
||||
)
|
||||
assert any("미스캔" in r.getMessage() for r in caplog.records), \
|
||||
"잘린 종목 수가 로그에 남아야 한다"
|
||||
es.set_watchlist([])
|
||||
es._scan_offset = 0
|
||||
|
||||
|
||||
# ── F7: 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 ─────────────────
|
||||
@@ -672,15 +741,61 @@ async def test_watchlist_refresh_preserves_orb_holders(monkeypatch):
|
||||
es.set_watchlist([])
|
||||
|
||||
|
||||
def test_watchlist_uses_latest_analysis_date_not_today_only():
|
||||
"""e0178aa(2026-09-22)의 `max(analysis_date <= today)` 완화가 살아 있는지."""
|
||||
import inspect
|
||||
@pytest.mark.asyncio
|
||||
async def test_watchlist_uses_latest_analysis_date_not_today_only(monkeypatch):
|
||||
"""[M-5 동작 검증] 당일 분석이 없어도 최근 분석일 결과로 watchlist 가 채워진다.
|
||||
|
||||
e0178aa(2026-09-22) 완화. 소스 문자열이 아니라 **결과**로 본다: 조회 결과에서
|
||||
최신 분석일이 어제여도 종목이 채워져야 한다(아니면 09:00 ORB 수집이 0건이 된다).
|
||||
"""
|
||||
from app.services.daytrading import entry_service as es
|
||||
|
||||
src = inspect.getsource(es.populate_daytrading_watchlist)
|
||||
assert "func.max(StockAnalysis.analysis_date)" in src
|
||||
assert "StockAnalysis.analysis_date <= today" in src
|
||||
assert "StockAnalysis.analysis_date == latest_date" in src
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
|
||||
class _Row:
|
||||
def __init__(self, code):
|
||||
self.stock_code = code
|
||||
self.stock_name = f"종목{code}"
|
||||
self.score = 5
|
||||
|
||||
class _Res:
|
||||
def __init__(self, rows=None, scalar=None):
|
||||
self._rows = rows or []
|
||||
self._scalar = scalar
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._scalar
|
||||
|
||||
class _Session:
|
||||
def __init__(self):
|
||||
self._n = 0
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def execute(self, *a, **k):
|
||||
self._n += 1
|
||||
if self._n == 1:
|
||||
return _Res(scalar=yesterday) # max(analysis_date <= today) = 어제
|
||||
return _Res(rows=[_Row("005930"), _Row("000660")])
|
||||
|
||||
import app.core.database as dbmod
|
||||
monkeypatch.setattr(dbmod, "AsyncSessionLocal", lambda: _Session())
|
||||
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
day_trading_state.orb_cache.clear()
|
||||
es.set_watchlist([])
|
||||
|
||||
count = await es.populate_daytrading_watchlist()
|
||||
assert count == 2, "당일 분석이 없으면 watchlist 가 비어 ORB 수집이 0건이 된다"
|
||||
assert set(es.get_watchlist()) == {"005930", "000660"}
|
||||
es.set_watchlist([])
|
||||
|
||||
|
||||
def test_scheduler_populates_watchlist_before_orb_collection():
|
||||
@@ -835,7 +950,10 @@ async def test_daily_guard_redis_failure_is_not_silent(monkeypatch, caplog):
|
||||
|
||||
|
||||
def test_daily_guard_save_is_wired_into_exit_path():
|
||||
"""청산 경로가 변경 직후 저장을 호출하는지 (배선 누락 방지)."""
|
||||
"""청산 경로가 변경 직후 저장을 호출하는지 (배선 누락 방지).
|
||||
|
||||
[M-5] 배선·순서 확인 전용. 저장/복원 동작은 별 테스트가 실제 호출로 검증한다.
|
||||
"""
|
||||
import inspect
|
||||
from app.services.daytrading import exit_service as es
|
||||
from app.services.daytrading import scheduler as sched
|
||||
@@ -1232,7 +1350,13 @@ def test_candle_cache_ttl_covers_one_cycle():
|
||||
# ── M4: swing 청산 경로도 일일 안전장치를 저장한다 ──────────────────────────
|
||||
|
||||
def test_swing_exit_persists_daily_guards():
|
||||
"""스윙 손절이 등록한 closed_today 가 재기동 후에도 유지되어야 한다."""
|
||||
"""스윙 손절이 등록한 closed_today 가 재기동 후에도 유지되어야 한다.
|
||||
|
||||
[M-5] 이것은 **배선 확인**이다(값·분기 판정이 아니다). 저장 동작 자체는
|
||||
test_daily_guards_restored_on_same_day_restart / test_mdd_reduction_persists_daily_guards
|
||||
가 실제 호출로 검증한다. 이 경로는 대형 함수 내부의 깊은 분기라 동작 검증에
|
||||
필요한 목이 과도해지므로, 호출이 남아 있는지만 고정한다.
|
||||
"""
|
||||
import inspect
|
||||
from app.services.swing import exit_service as ses
|
||||
|
||||
@@ -1274,8 +1398,10 @@ async def test_replay_dlq_restores_types_and_task_commits(tmp_path, monkeypatch)
|
||||
"strategy_name": "day_trade",
|
||||
},
|
||||
}
|
||||
degraded = _json.loads(_json.dumps({"task": original, "error": "shutdown_drain"},
|
||||
default=str))
|
||||
degraded = _json.loads(_json.dumps(
|
||||
{"task": original, "error": "shutdown_drain",
|
||||
"failed_at": _dt.now().isoformat()}, # C2: 나이 제한 통과용(방금 실패)
|
||||
default=str))
|
||||
assert isinstance(degraded["task"]["data"]["entry_price"], str)
|
||||
(dlq / "20260928_103000_upsert_active_trade_aaaa1111.json").write_text(
|
||||
_json.dumps(degraded), encoding="utf-8"
|
||||
@@ -1288,6 +1414,11 @@ async def test_replay_dlq_restores_types_and_task_commits(tmp_path, monkeypatch)
|
||||
|
||||
monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue)
|
||||
|
||||
async def _no_conflict(task, entry):
|
||||
return False, ""
|
||||
|
||||
monkeypatch.setattr(db_worker, "_dlq_state_conflict", _no_conflict)
|
||||
|
||||
assert await db_worker.replay_dlq() == 1
|
||||
data = enqueued[0]["data"]
|
||||
assert isinstance(data["entry_price"], Decimal), "Decimal 이 문자열로 남았다"
|
||||
@@ -1318,7 +1449,7 @@ async def test_replay_dlq_restores_date_for_bulk_rows(tmp_path, monkeypatch):
|
||||
_json.dumps({"task": {
|
||||
"type": "bulk_upsert_stock_analysis",
|
||||
"data": [{"stock_code": "005930", "analysis_date": "2026-09-28"}],
|
||||
}}), encoding="utf-8")
|
||||
}, "failed_at": datetime.now().isoformat()}), encoding="utf-8")
|
||||
|
||||
enqueued = []
|
||||
|
||||
@@ -1400,5 +1531,440 @@ def test_scan_clears_cache_before_prefetch():
|
||||
import inspect
|
||||
from app.services.daytrading import entry_service as es
|
||||
|
||||
src = inspect.getsource(es.scan_and_enter)
|
||||
# autouse 가드가 모듈 속성을 감싸므로 원본을 풀어서 본다
|
||||
_fn = getattr(es.scan_and_enter, "__wrapped__", es.scan_and_enter)
|
||||
src = inspect.getsource(_fn)
|
||||
assert src.index("_candle_cache.clear()") < src.index("_prefetch_minute_charts(")
|
||||
|
||||
|
||||
# ── C2(2026-09-30): replay_dlq 나이 제한 + 상태 충돌 격리 ───────────────────
|
||||
|
||||
def _write_dlq(dlq_dir, task_type, data, failed_at, name=None):
|
||||
import json as _json
|
||||
name = name or f"20260930_100000_{task_type}_{abs(hash(str(data))) % 10**8:08x}.json"
|
||||
(dlq_dir / name).write_text(_json.dumps({
|
||||
"task": {"type": task_type, "data": data},
|
||||
"error": "shutdown_drain",
|
||||
"failed_at": failed_at.isoformat() if hasattr(failed_at, "isoformat") else failed_at,
|
||||
}), encoding="utf-8")
|
||||
return dlq_dir / name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dlq_env(tmp_path, monkeypatch):
|
||||
"""DLQ 디렉터리 3종을 tmp 로 돌리고 enqueue 를 캡처한다."""
|
||||
from app.services import db_worker
|
||||
|
||||
dlq = tmp_path / "dlq"
|
||||
dlq.mkdir()
|
||||
monkeypatch.setattr(db_worker, "DLQ_DIR", dlq)
|
||||
monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed")
|
||||
monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired")
|
||||
|
||||
enqueued = []
|
||||
|
||||
async def _fake_enqueue(task):
|
||||
enqueued.append(task)
|
||||
|
||||
monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue)
|
||||
|
||||
# 상태 충돌 확인은 기본적으로 '충돌 없음' (개별 테스트가 필요 시 덮는다)
|
||||
async def _no_conflict(task, entry):
|
||||
return False, ""
|
||||
|
||||
monkeypatch.setattr(db_worker, "_dlq_state_conflict", _no_conflict)
|
||||
return dlq, enqueued
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_dlq_quarantines_stale_state_task(dlq_env, caplog):
|
||||
"""오래된 upsert_active_trade 는 재큐잉하지 않고 expired/ 로 격리된다.
|
||||
|
||||
9/25 drain 으로 빠진 payload 가 그 사이 전량 청산된 뒤 9/30 기동에 재큐잉되면,
|
||||
핸들러가 active 행을 못 찾고 필수 키가 다 있으니 **새 active 행을 INSERT** 한다
|
||||
→ §8.4.1 의 '9/23 유령 행 129개' 실패 모드 자동화.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
from app.services import db_worker
|
||||
|
||||
dlq, enqueued = dlq_env
|
||||
stale = _write_dlq(dlq, "upsert_active_trade",
|
||||
{"stock_code": "005930", "quantity": 10, "entry_price": 70000},
|
||||
_dt.now() - _td(days=5), name="20260925_100000_upsert_active_trade_a1.json")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert await db_worker.replay_dlq() == 0
|
||||
|
||||
assert enqueued == [], "오래된 상태 태스크가 재큐잉됐다"
|
||||
assert not stale.exists()
|
||||
assert (dlq / "expired" / stale.name).exists(), "증거 보존을 위해 격리(삭제 금지)"
|
||||
msgs = " ".join(r.getMessage() for r in caplog.records)
|
||||
assert "격리" in msgs and "upsert_active_trade" in msgs and "나이" in msgs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_dlq_requeues_recent_state_task(dlq_env):
|
||||
"""최근(2시간 이내, 같은 날) 상태 태스크는 정상 재큐잉된다."""
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
from app.services import db_worker
|
||||
|
||||
dlq, enqueued = dlq_env
|
||||
recent = _write_dlq(dlq, "upsert_active_trade",
|
||||
{"stock_code": "005930", "quantity": 10, "entry_price": 70000},
|
||||
_dt.now() - _td(minutes=3))
|
||||
|
||||
assert await db_worker.replay_dlq() == 1
|
||||
assert len(enqueued) == 1 and enqueued[0]["type"] == "upsert_active_trade"
|
||||
assert (dlq / "processed" / recent.name).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_dlq_record_types_allow_longer_age(dlq_env):
|
||||
"""기록 계열은 12시간 전도 허용, 48시간 전은 격리 (타입별 차등)."""
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
from app.services import db_worker
|
||||
|
||||
dlq, enqueued = dlq_env
|
||||
ok = _write_dlq(dlq, "insert_trade_history", {"stock_code": "005930"},
|
||||
_dt.now() - _td(hours=12), name="a_insert_trade_history_1.json")
|
||||
too_old = _write_dlq(dlq, "insert_trade_history", {"stock_code": "000660"},
|
||||
_dt.now() - _td(hours=48), name="b_insert_trade_history_2.json")
|
||||
|
||||
assert await db_worker.replay_dlq() == 1
|
||||
assert len(enqueued) == 1
|
||||
assert enqueued[0]["data"]["stock_code"] == "005930"
|
||||
assert (dlq / "processed" / ok.name).exists()
|
||||
assert (dlq / "expired" / too_old.name).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_dlq_state_task_without_failed_at_is_quarantined(dlq_env):
|
||||
"""failed_at 이 없으면 상태 계열은 보수적으로 격리, 기록 계열은 허용."""
|
||||
from app.services import db_worker
|
||||
|
||||
dlq, enqueued = dlq_env
|
||||
st = _write_dlq(dlq, "upsert_portfolio", {"stock_code": "005930", "quantity": 5},
|
||||
"", name="a_upsert_portfolio_x.json")
|
||||
rec = _write_dlq(dlq, "insert_trade_journal", {"stock_code": "005930"},
|
||||
"", name="b_insert_trade_journal_y.json")
|
||||
|
||||
assert await db_worker.replay_dlq() == 1
|
||||
assert len(enqueued) == 1 and enqueued[0]["type"] == "insert_trade_journal"
|
||||
assert (dlq / "expired" / st.name).exists()
|
||||
assert (dlq / "processed" / rec.name).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_dlq_quarantines_on_state_conflict(tmp_path, monkeypatch, caplog):
|
||||
"""나이를 통과해도 현재 상태와 충돌하면 격리한다 (유령 행 차단)."""
|
||||
import logging
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
from app.services import db_worker
|
||||
|
||||
dlq = tmp_path / "dlq"
|
||||
dlq.mkdir()
|
||||
monkeypatch.setattr(db_worker, "DLQ_DIR", dlq)
|
||||
monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed")
|
||||
monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired")
|
||||
|
||||
enqueued = []
|
||||
|
||||
async def _fake_enqueue(task):
|
||||
enqueued.append(task)
|
||||
|
||||
monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue)
|
||||
|
||||
f = _write_dlq(dlq, "upsert_active_trade",
|
||||
{"stock_code": "005930", "quantity": 10, "entry_price": 70000},
|
||||
_dt.now() - _td(minutes=5))
|
||||
|
||||
# active 행 없음 + Portfolio 수량 0 → 유령 생성 위험
|
||||
class _Sess:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def execute(self, *a, **k):
|
||||
class _S:
|
||||
def first(self_inner):
|
||||
return None
|
||||
class _R:
|
||||
def scalars(self_inner):
|
||||
return _S()
|
||||
return _R()
|
||||
|
||||
import app.services.db_worker as dbw
|
||||
monkeypatch.setattr(dbw, "AsyncSessionLocal", lambda: _Sess())
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert await db_worker.replay_dlq() == 0
|
||||
|
||||
assert enqueued == []
|
||||
assert (dlq / "expired" / f.name).exists()
|
||||
assert "충돌" in " ".join(r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dlq_state_conflict_degrades_to_age_only_on_db_failure(monkeypatch):
|
||||
"""DB 판정 실패는 '충돌 아님'으로 돌려 기동을 막지 않는다."""
|
||||
from app.services import db_worker
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(db_worker, "AsyncSessionLocal", _boom)
|
||||
conflict, reason = await db_worker._dlq_state_conflict(
|
||||
{"type": "upsert_active_trade", "data": {"stock_code": "005930"}},
|
||||
{"failed_at": "2026-09-30T10:00:00"},
|
||||
)
|
||||
assert conflict is False and reason == ""
|
||||
|
||||
|
||||
# ── M-2: DB 장애 시 충돌 판정 단락 (기동 지연 유계화) ───────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conflict_check_short_circuits_after_first_db_failure(tmp_path, monkeypatch, caplog):
|
||||
"""DB 가 안 뜬 상태에서 파일마다 접속을 재시도하지 않는다.
|
||||
|
||||
replay_dlq() 는 lifespan 에서 await 되므로 기동을 막는다. 단락이 없으면
|
||||
(파일 수 × 접속 타임아웃)만큼 기동이 정지한다. 단락 후에도 **나이 제한은 계속
|
||||
적용**되어야 한다.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
from app.services import db_worker
|
||||
|
||||
dlq = tmp_path / "dlq"
|
||||
dlq.mkdir()
|
||||
monkeypatch.setattr(db_worker, "DLQ_DIR", dlq)
|
||||
monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed")
|
||||
monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired")
|
||||
|
||||
enqueued = []
|
||||
|
||||
async def _fake_enqueue(task):
|
||||
enqueued.append(task)
|
||||
|
||||
monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue)
|
||||
|
||||
attempts = []
|
||||
|
||||
def _boom_session():
|
||||
attempts.append(1)
|
||||
raise RuntimeError("connection refused")
|
||||
|
||||
monkeypatch.setattr(db_worker, "AsyncSessionLocal", _boom_session)
|
||||
|
||||
# 최근 상태 파일 5개 (모두 충돌 판정 대상)
|
||||
for i in range(5):
|
||||
_write_dlq(dlq, "upsert_active_trade",
|
||||
{"stock_code": f"00593{i}", "quantity": 1, "entry_price": 1000},
|
||||
_dt.now() - _td(minutes=1), name=f"f{i}_upsert_active_trade_{i}.json")
|
||||
# 오래된 상태 파일 1개 — 단락 후에도 나이 제한으로 격리되어야 한다
|
||||
old = _write_dlq(dlq, "upsert_active_trade",
|
||||
{"stock_code": "000660", "quantity": 1, "entry_price": 1000},
|
||||
_dt.now() - _td(days=3), name="z_upsert_active_trade_old.json")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
requeued = await db_worker.replay_dlq()
|
||||
|
||||
assert len(attempts) == 1, (
|
||||
f"DB 접속을 {len(attempts)}회 시도했다 — 첫 실패에서 단락해야 한다"
|
||||
)
|
||||
assert requeued == 5, "최근 파일은 나이 제한만으로 재큐잉된다"
|
||||
assert (dlq / "expired" / old.name).exists(), "단락 후에도 나이 제한은 적용되어야 한다"
|
||||
# 반복 로그 억제: 실패 warning 은 1회만
|
||||
fails = [r for r in caplog.records if "상태 충돌 확인 실패" in r.getMessage()]
|
||||
assert len(fails) == 1, f"충돌 판정 실패 로그가 {len(fails)}회 — 1회만 남겨야 한다"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conflict_availability_resets_each_replay(tmp_path, monkeypatch):
|
||||
"""이전 호출의 단락을 다음 기동이 물려받지 않는다."""
|
||||
from app.services import db_worker
|
||||
|
||||
dlq = tmp_path / "dlq"
|
||||
dlq.mkdir()
|
||||
monkeypatch.setattr(db_worker, "DLQ_DIR", dlq)
|
||||
db_worker._dlq_conflict_check_available = False
|
||||
assert await db_worker.replay_dlq() == 0 # 파일 없음 → 조기 반환
|
||||
|
||||
# 파일이 하나라도 있으면 리셋된다
|
||||
from datetime import datetime as _dt
|
||||
_write_dlq(dlq, "insert_trade_journal", {"stock_code": "005930"}, _dt.now())
|
||||
monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed")
|
||||
monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired")
|
||||
|
||||
async def _fake_enqueue(task):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue)
|
||||
await db_worker.replay_dlq()
|
||||
assert db_worker._dlq_conflict_check_available is True
|
||||
|
||||
|
||||
# ── M-4: close_active_trade 는 오래돼도 재큐잉, 새 포지션에는 걸린다 ────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_close_active_trade_is_requeued(dlq_env):
|
||||
"""야간 재기동(2시간 초과)에도 close 는 재큐잉된다.
|
||||
|
||||
격리하면 이미 청산된 포지션의 ActiveTrade 가 active 로 영구 잔존해
|
||||
§8.4.1 '유령 행' 과 같은 형태가 되고, 그 잔량이 유입 잔고 격리의 '시스템 보유'
|
||||
하한으로 흘러들어 다른 안전장치를 무력화한다.
|
||||
"""
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
from app.services import db_worker
|
||||
|
||||
dlq, enqueued = dlq_env
|
||||
f = _write_dlq(dlq, "close_active_trade",
|
||||
{"stock_code": "005930", "strategy_name": "day_trade"},
|
||||
_dt.now() - _td(days=3), name="a_close_active_trade_old.json")
|
||||
|
||||
assert await db_worker.replay_dlq() == 1
|
||||
assert enqueued[0]["type"] == "close_active_trade"
|
||||
assert (dlq / "processed" / f.name).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_close_is_quarantined_when_newer_position_exists(tmp_path, monkeypatch):
|
||||
"""단, 현재 active 행이 failed_at 이후 생성이면(새 포지션) 격리한다."""
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
from app.services import db_worker
|
||||
|
||||
dlq = tmp_path / "dlq"
|
||||
dlq.mkdir()
|
||||
monkeypatch.setattr(db_worker, "DLQ_DIR", dlq)
|
||||
monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed")
|
||||
monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired")
|
||||
|
||||
enqueued = []
|
||||
|
||||
async def _fake_enqueue(task):
|
||||
enqueued.append(task)
|
||||
|
||||
monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue)
|
||||
|
||||
failed_at = _dt.now() - _td(days=3)
|
||||
f = _write_dlq(dlq, "close_active_trade",
|
||||
{"stock_code": "005930", "strategy_name": "day_trade"},
|
||||
failed_at, name="a_close_active_trade_conflict.json")
|
||||
|
||||
newer = _FakeActiveTrade(stock_code="005930", quantity=5)
|
||||
newer.created_at = _dt.now() - _td(hours=1) # failed_at 이후 = 새 포지션
|
||||
|
||||
class _Sess:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def execute(self, *a, **k):
|
||||
rec = newer
|
||||
|
||||
class _S:
|
||||
def first(self_inner):
|
||||
return rec
|
||||
class _R:
|
||||
def scalars(self_inner):
|
||||
return _S()
|
||||
return _R()
|
||||
|
||||
monkeypatch.setattr(db_worker, "AsyncSessionLocal", lambda: _Sess())
|
||||
|
||||
assert await db_worker.replay_dlq() == 0
|
||||
assert enqueued == []
|
||||
assert (dlq / "expired" / f.name).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_age_limit_tightens_when_conflict_check_unavailable():
|
||||
"""충돌 판정이 단락되면 close 허용 나이가 기록 계열 상한으로 내려간다."""
|
||||
from app.services import db_worker
|
||||
|
||||
db_worker._dlq_conflict_check_available = True
|
||||
assert db_worker._dlq_max_age_seconds("close_active_trade") == \
|
||||
db_worker.DLQ_REPLAY_MAX_AGE_CLOSE_SECONDS
|
||||
db_worker._dlq_conflict_check_available = False
|
||||
assert db_worker._dlq_max_age_seconds("close_active_trade") == \
|
||||
db_worker.DLQ_REPLAY_MAX_AGE_RECORD_SECONDS
|
||||
# 상태 계열은 단락과 무관하게 2시간
|
||||
assert db_worker._dlq_max_age_seconds("upsert_active_trade") == \
|
||||
db_worker.DLQ_REPLAY_MAX_AGE_STATE_SECONDS
|
||||
db_worker._dlq_conflict_check_available = True
|
||||
|
||||
# ── 재발 방지: 벽시계 의존 차단 (autouse) ───────────────────────────────────
|
||||
#
|
||||
# 이 파일에서 `scan_and_enter()` 를 시각 고정 없이 호출해 **두 번** 깨졌다
|
||||
# (컷오프 15:00 이후 실행 시 조기 리턴 → 빈 결과). 개별 테스트의 주의력에 맡기면
|
||||
# 새 테스트가 같은 함정에 다시 빠지므로, 호출 자체를 구조적으로 막는다.
|
||||
#
|
||||
# 가드는 **현재 시각을 보지 않고 무조건** 실패시킨다. 벽시계를 참고하면 낮에는
|
||||
# 통과하고 밤에는 깨지는 '운 좋은 통과'가 생겨 재발 방지가 되지 않는다.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_frozen_clock_for_time_gated_calls(monkeypatch):
|
||||
"""`scan_and_enter()` 호출 시 `_freeze_entry_window` 적용을 강제한다.
|
||||
|
||||
청산 계열(`check_and_exit_positions`/`force_close_all`/`close_stale_positions`)도
|
||||
내부에서 시각을 읽는다(EOD·시간손절). 이 파일에는 현재 호출부가 없지만, 추가할
|
||||
때는 같은 방식으로 해당 모듈의 `datetime` 을 고정하고 이 가드를 확장할 것.
|
||||
"""
|
||||
from app.services.daytrading import entry_service as es
|
||||
|
||||
_real_scan = es.scan_and_enter
|
||||
|
||||
async def _guarded_scan(*args, **kwargs):
|
||||
if es.datetime is datetime:
|
||||
raise AssertionError(
|
||||
"scan_and_enter() 를 시각 고정 없이 호출했다 — 진입 윈도우"
|
||||
"(09:30~DAY_TRADE_ENTRY_CUTOFF) 밖에서는 조기 리턴해 테스트가 시간대에 따라"
|
||||
" 깨진다. 테스트 안에서 `_freeze_entry_window(monkeypatch, es)` 를 먼저 호출하라."
|
||||
)
|
||||
return await _real_scan(*args, **kwargs)
|
||||
|
||||
# 원본을 노출해 소스 검사 테스트가 가드가 아닌 실제 함수를 보게 한다
|
||||
_guarded_scan.__wrapped__ = _real_scan
|
||||
monkeypatch.setattr(es, "scan_and_enter", _guarded_scan)
|
||||
|
||||
|
||||
def test_wallclock_guard_rejects_unfrozen_scan(monkeypatch):
|
||||
"""가드 자체가 동작하는지 — 시각 고정 없이 호출하면 실패해야 한다."""
|
||||
from app.services.daytrading import entry_service as es
|
||||
|
||||
with pytest.raises(AssertionError, match="시각 고정"):
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
es.scan_and_enter()
|
||||
)
|
||||
|
||||
|
||||
def test_every_scan_call_site_freezes_the_clock():
|
||||
"""[전수 확인] 이 파일의 scan_and_enter 호출부가 모두 시각을 고정했는지.
|
||||
|
||||
가드(autouse)가 런타임에 막지만, 이 테스트는 '어느 테스트가 빠뜨렸는지'를
|
||||
파일 단위로 드러내 리뷰에서 바로 보이게 한다.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
src = pathlib.Path(__file__).read_text(encoding="utf-8")
|
||||
# 테스트 함수 단위로 쪼개 scan_and_enter 호출과 freeze 호출을 함께 본다
|
||||
blocks = re.split(r"\n(?=@pytest\.|def test_|async def test_)", src)
|
||||
offenders = []
|
||||
for b in blocks:
|
||||
m = re.search(r"(?:async )?def (test_\w+)", b)
|
||||
if not m:
|
||||
continue
|
||||
name = m.group(1)
|
||||
if name in ("test_wallclock_guard_rejects_unfrozen_scan",
|
||||
"test_every_scan_call_site_freezes_the_clock"):
|
||||
continue
|
||||
if "es.scan_and_enter()" in b and "_freeze_entry_window(" not in b:
|
||||
offenders.append(name)
|
||||
assert not offenders, (
|
||||
f"시각 고정 없이 scan_and_enter() 를 호출하는 테스트: {offenders} — "
|
||||
"_freeze_entry_window(monkeypatch, es) 를 추가하라"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user