- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강 - trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성) - korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계) - swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소) - swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램 - daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거 - main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC) - 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경) - 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기 - CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기) - 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
272 lines
10 KiB
Python
272 lines
10 KiB
Python
"""[2026-10-09 하드닝 R3] DT 기동 복원 검증 + 원장 재구성 연패의 왕복 기준 회귀 테스트.
|
|
|
|
결함 1 (MEDIUM): restore_from_db 가 closed_today·KIS 실보유를 보지 않고 당일 active day_trade 행을
|
|
인메모리로 되살려, 청산 큐 유실 뒤 재기동하면 이미 판 물량을 일반 청산 경로가 다시 팔 수 있었다.
|
|
→ verify_restored_today_positions_v2 (장중 복원 경로 _restore_lost_today_positions_impl 과 같은 규칙).
|
|
결함 2 (LOW): Redis 미스 시 원장 재구성의 연패가 마지막 레그 기준이라 실시간 왕복 기준(R2-4)과 달랐다.
|
|
"""
|
|
import inspect
|
|
from datetime import datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.core.config import settings
|
|
from app.services.daytrading import exit_service as xs
|
|
from app.services.daytrading import position_manager as pm
|
|
from app.services.daytrading.position_manager import DayTradingState
|
|
|
|
|
|
class _MustNotCall(BaseException):
|
|
"""호출되면 안 되는 경로 — 프로덕션 except Exception 에 삼켜지지 않게 BaseException."""
|
|
|
|
|
|
class _KISDown(Exception):
|
|
pass
|
|
|
|
|
|
class _Result:
|
|
def __init__(self, rows):
|
|
self._rows = rows
|
|
|
|
def scalars(self):
|
|
return self
|
|
|
|
def all(self):
|
|
return list(self._rows)
|
|
|
|
|
|
class _Session:
|
|
def __init__(self, rows):
|
|
self._rows = rows
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
async def execute(self, *_a, **_k):
|
|
return _Result(self._rows)
|
|
|
|
|
|
def _factory(rows):
|
|
return lambda: _Session(rows)
|
|
|
|
|
|
def _no_db():
|
|
raise _MustNotCall("DB 조회가 일어나면 안 된다")
|
|
|
|
|
|
async def _no_kis():
|
|
raise _MustNotCall("KIS 잔고 조회가 일어나면 안 된다")
|
|
|
|
|
|
def _holdings(mapping):
|
|
async def _f():
|
|
return {c: {"hldg": q, "sellable": q} for c, q in mapping.items()}
|
|
return _f
|
|
|
|
|
|
async def _kis_down():
|
|
raise _KISDown("balance down")
|
|
|
|
|
|
def _today_at(h=9, m=10):
|
|
return datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=h, minute=m)
|
|
|
|
|
|
def _pos(qty, entry_time=None, restored=True):
|
|
return {
|
|
"stock_name": "T", "strategy": "day_trade", "entry_price": 10_000.0,
|
|
"stop_loss": 9_800.0, "target_price": 10_300.0, "quantity": qty,
|
|
"highest_price": 10_000.0, "entry_time": entry_time or _today_at(),
|
|
"reason": "", "order_id": "", **({"restored_from_db": True} if restored else {}),
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _notify():
|
|
m = AsyncMock(return_value=None)
|
|
with patch.object(pm, "_notify_daily_state_impl", m):
|
|
yield m
|
|
|
|
|
|
def _state(**positions):
|
|
st = DayTradingState()
|
|
st.begin_restore()
|
|
st.positions.update(positions)
|
|
return st
|
|
|
|
|
|
# ── 결함 1: 기동 복원 검증 ────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closed_today_restored_position_is_held_back_and_alerted(_notify):
|
|
"""전량 청산 후 close 큐 유실 → active 행이 남아 복원돼도 DT 전량 청산 기록이면 되살리지 않는다.
|
|
|
|
[R2 하드닝] 근거는 closed_today(재진입 차단)가 아니라 remove_position 이 남기는 dt_exited_today.
|
|
"""
|
|
st = _state(A00001=_pos(7))
|
|
st.closed_today.add("A00001")
|
|
st.dt_exited_today.add("A00001")
|
|
r = await st.verify_restored_today_positions_v2(session_factory=_no_db, holdings_fetcher=_no_kis)
|
|
assert "A00001" not in st.positions
|
|
assert r["held"] == ["A00001"]
|
|
assert "A00001" in st.closed_today # 재진입 차단 유지
|
|
_notify.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kis_holding_minus_swing_below_row_qty_is_held_back(_notify):
|
|
"""실보유 7주 중 스윙 4주 → DT 가용 3주 < 행 7주: 복원하면 스윙 물량을 판다(§3.5)."""
|
|
st = _state(A00002=_pos(7))
|
|
swing = [SimpleNamespace(stock_code="A00002", quantity=4)]
|
|
r = await st.verify_restored_today_positions_v2(
|
|
session_factory=_factory(swing), holdings_fetcher=_holdings({"A00002": 7}),
|
|
)
|
|
assert "A00002" not in st.positions
|
|
assert r["held"] == ["A00002"] and r["unverified"] is False
|
|
_notify.assert_awaited_once()
|
|
assert "A00002" not in st.closed_today # 잔고가 맞으면 장중 복원이 다시 잡을 수 있게
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_sell_upsert_lost_row_qty_exceeds_holding_is_held_back():
|
|
"""분할 매도 후 잔량 upsert 유실 — 행 10주, 실보유 5주 → 보류."""
|
|
st = _state(A00003=_pos(10))
|
|
r = await st.verify_restored_today_positions_v2(
|
|
session_factory=_factory([]), holdings_fetcher=_holdings({"A00003": 5}),
|
|
)
|
|
assert r["held"] == ["A00003"] and "A00003" not in st.positions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consistent_position_is_kept(_notify):
|
|
st = _state(A00004=_pos(7))
|
|
p = st.positions["A00004"]
|
|
swing = [SimpleNamespace(stock_code="A00004", quantity=3)]
|
|
r = await st.verify_restored_today_positions_v2(
|
|
session_factory=_factory(swing), holdings_fetcher=_holdings({"A00004": 10}),
|
|
)
|
|
assert st.positions["A00004"] is p
|
|
assert r["held"] == [] and r["checked"] == 1
|
|
_notify.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prior_day_and_non_restored_positions_are_not_touched():
|
|
"""전일 진입분은 close_stale_positions 담당, 복원 표식 없는 포지션은 대상 아님."""
|
|
yday = _today_at() - timedelta(days=1)
|
|
st = _state(S00001=_pos(5, entry_time=yday), N00001=_pos(5, restored=False))
|
|
st.closed_today |= {"S00001", "N00001"}
|
|
r = await st.verify_restored_today_positions_v2(session_factory=_no_db, holdings_fetcher=_no_kis)
|
|
assert set(st.positions) == {"S00001", "N00001"}
|
|
assert r["checked"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unverified_holds_back_when_orphan_detect_enabled(_notify):
|
|
"""판정 불가(KIS 장애) → 장중 close_stale_positions 가 같은 규칙으로 재복원하므로 보류(경보는 warning)."""
|
|
st = _state(A00005=_pos(3))
|
|
with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True):
|
|
r = await st.verify_restored_today_positions_v2(
|
|
session_factory=_factory([]), holdings_fetcher=_kis_down,
|
|
)
|
|
assert r["unverified"] is True and r["held"] == ["A00005"]
|
|
assert "A00005" not in st.positions
|
|
_notify.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unverified_keeps_when_orphan_detect_disabled():
|
|
"""재복원 경로가 없으면 보류는 곧 손절 공백 — 유지하고 error."""
|
|
st = _state(A00006=_pos(3))
|
|
with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", False):
|
|
r = await st.verify_restored_today_positions_v2(
|
|
session_factory=_factory([]), holdings_fetcher=_kis_down,
|
|
)
|
|
assert r["unverified"] is True and r["held"] == []
|
|
assert "A00006" in st.positions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_failure_is_unverified_and_skips_kis():
|
|
def _db_down():
|
|
raise _KISDown("db down")
|
|
st = _state(A00007=_pos(3))
|
|
with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True):
|
|
r = await st.verify_restored_today_positions_v2(session_factory=_db_down, holdings_fetcher=_no_kis)
|
|
assert r["unverified"] is True and "A00007" not in st.positions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_fetcher_is_exit_service_holdings_common_terminal():
|
|
"""기본 잔고 조회는 exit_service._fetch_kis_holdings_v2_impl (장중 복원 경로와 같은 소스)."""
|
|
st = _state(A00008=_pos(3))
|
|
m = AsyncMock(side_effect=_KISDown("blocked"))
|
|
with patch.object(xs, "_fetch_kis_holdings_v2_impl", m), \
|
|
patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True):
|
|
r = await st.verify_restored_today_positions_v2(session_factory=_factory([]))
|
|
m.assert_awaited_once()
|
|
assert r["unverified"] is True
|
|
|
|
|
|
def test_restore_from_db_signature_unchanged_and_scheduler_wires_verify():
|
|
assert list(inspect.signature(DayTradingState.restore_from_db).parameters) == ["self", "db"]
|
|
from app.services.daytrading import scheduler as sch
|
|
src = inspect.getsource(sch)
|
|
i_restore = src.index("restore_from_db(db)")
|
|
i_verify = src.index("verify_restored_today_positions_v3()") # [C-2 r9] v2 → v3 (v3 가 v2 를 감쌈)
|
|
i_stale = src.index('close_stale_positions(), "기동 시')
|
|
assert i_restore < i_verify < i_stale
|
|
|
|
|
|
# ── 결함 2: 원장 재구성 연패 = 왕복 순손익 ─────────────────────────────────────
|
|
|
|
def _row(i, code, order_type, net=None):
|
|
return SimpleNamespace(
|
|
id=i, stock_code=code, order_type=order_type, status="COMPLETED", exit_reason="",
|
|
net_profit_loss=net, strategy_type="daytrading", created_at=_today_at(9, 10 + i),
|
|
)
|
|
|
|
|
|
def test_partial_profit_then_small_loss_is_round_trip_win():
|
|
"""+1.5R 분할 +3,000 뒤 잔량 -300 → 왕복 +2,700 = 이익 → 연패 리셋 (실시간 R2-4 와 동일)."""
|
|
rows = [
|
|
_row(0, "L00001", "BUY"), _row(1, "L00001", "SELL", -500.0),
|
|
_row(2, "L00002", "BUY"), _row(3, "L00002", "SELL", -500.0),
|
|
_row(4, "P00001", "BUY"), _row(5, "P00001", "SELL", 3000.0), _row(6, "P00001", "SELL", -300.0),
|
|
]
|
|
r = pm._rebuild_daily_state_from_rows(rows)
|
|
assert r["loss_streak_today"] == 0
|
|
assert r["daily_pnl"] == pytest.approx(-500.0 - 500.0 + 3000.0 - 300.0)
|
|
|
|
|
|
def test_partial_small_profit_then_big_loss_is_round_trip_loss():
|
|
rows = [_row(0, "P00002", "BUY"), _row(1, "P00002", "SELL", 100.0), _row(2, "P00002", "SELL", -300.0)]
|
|
assert pm._rebuild_daily_state_from_rows(rows)["loss_streak_today"] == 1
|
|
|
|
|
|
def test_rebuild_streak_matches_realtime_round_trip_rule():
|
|
"""재구성 결과 = 실시간 _apply_round_trip_streak_v2 를 같은 왕복 순서로 적용한 결과."""
|
|
trips = [("A", [-10.0]), ("B", [500.0, -20.0]), ("C", [-5.0]), ("D", [50.0, -80.0]), ("E", [0.0])]
|
|
rows, i = [], 0
|
|
for code, legs in trips:
|
|
rows.append(_row(i, code, "BUY")); i += 1
|
|
for leg in legs:
|
|
rows.append(_row(i, code, "SELL", leg)); i += 1
|
|
rebuilt = pm._rebuild_daily_state_from_rows(rows)["loss_streak_today"]
|
|
|
|
st = xs.day_trading_state
|
|
saved = st.loss_streak_today
|
|
try:
|
|
st.loss_streak_today = 0
|
|
for _code, legs in trips:
|
|
xs._apply_round_trip_streak_v2(sum(legs))
|
|
realtime = st.loss_streak_today
|
|
finally:
|
|
st.loss_streak_today = saved
|
|
assert rebuilt == realtime == 2
|