Files
auto-stock/backend/tests/test_daytrading_r9_20261012.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 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>
2026-10-12 15:37:16 +09:00

404 lines
17 KiB
Python

"""하드닝 9회차 그룹 C(데이트레이딩) 회귀 테스트.
C-1 결과 불명 DT 매도 + ActiveTrade 없는 수동·HTS 보유분 → 수동 물량 재매도 방지(v3 판정)
C-2 기동 복원 검증이 주문가능수량·당일 PENDING DT 매도를 본다(v3)
C-3 잔고 조회 직후 일일 손실 한도 재확인
"""
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from app.core.config import settings
from app.services.daytrading import entry_service as ens
from app.services.daytrading import exit_service as es
from app.services.daytrading import position_manager as pm
from app.services.daytrading.position_manager import DayTradingState, day_trading_state
CODE = "005930"
class _MustNotCall(BaseException):
"""호출되면 안 되는 경로 — 프로덕션 except Exception 에 삼켜지지 않게 BaseException."""
class OrderReached(BaseException):
"""safe_place_order 도달 — kwargs 를 실어 수량을 검증한다(값 반환 스텁 대신 예외형)."""
async def _order_reached(*a, **k):
raise OrderReached(k)
def _raise(name):
async def _f(*a, **k):
raise _MustNotCall(name)
return _f
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 _th(ot, st, q, code=CODE):
return SimpleNamespace(stock_code=code, order_type=ot, status=st, executed_quantity=q,
order_quantity=q, strategy_type="daytrading", created_at=datetime.now())
@pytest.fixture(autouse=True)
def _isolate(monkeypatch):
import app.core.redis as _r
es._unresolved_exit_unknown.clear()
es._unknown_lookup_failures.clear()
monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT", 10_000)
monkeypatch.setattr(_r, "setex_json", AsyncMock(return_value=True))
monkeypatch.setattr(_r, "get_json", AsyncMock(return_value=None))
monkeypatch.setattr(pm, "_notify_daily_state_impl", AsyncMock(return_value=None))
yield
es._unresolved_exit_unknown.clear()
es._unknown_lookup_failures.clear()
def _kis(monkeypatch, hldg, sellable, nd=0, orders=(), ledger=None):
async def _h():
return ({CODE: {"hldg": hldg, "sellable": sellable}}, True)
async def _nd(code):
return nd
async def _o(code):
return None if orders is None else list(orders)
monkeypatch.setattr(es, "_fetch_kis_holdings_v3_impl", _h)
monkeypatch.setattr(es, "_fetch_non_dt_active_qty_impl", _nd)
monkeypatch.setattr(es, "_lookup_todays_orders_impl", _raise("v1 주문내역(실패=[]) 사용 금지"))
monkeypatch.setattr(es, "_lookup_todays_orders_v2_impl", _o)
if ledger is not None:
monkeypatch.setattr(es, "_fetch_dt_ledger_net_qty_impl", ledger)
async def _ledger(n):
async def _f(code):
return n
return _f
# ═══ C-1 ═════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_c1_manual_shares_after_filled_unknown_sell_not_resold(monkeypatch):
"""dt.md 재현: DT 10주 불명 매도(실체결) + HTS 5주(ActiveTrade 없음) → 재매도 0."""
_kis(monkeypatch, 5, 5, ledger=_raise("원장 조회 전에 보류되어야 한다"))
st, resp, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
assert (st, resp, cap) == ("ambiguous", None, 0)
# 재시도 경로 종단: 주문이 나가지 않고 보류가 유지된다
es._unresolved_exit_unknown[(es._today_key(), CODE)] = {
"qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0,
}
with patch("app.services.trade_executor.safe_place_order", _order_reached):
res = await es._place_and_finalize_exit_impl(
CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False)
assert res is None
assert (es._today_key(), CODE) in es._unresolved_exit_unknown
assert es._last_exit_order_outcome[CODE] == "unknown"
@pytest.mark.asyncio
async def test_c1_genuine_not_accepted_still_resells(monkeypatch):
"""정상 not_accepted(원장 순체결 10, 잔고 10) → 10주 재매도."""
_kis(monkeypatch, 10, 10, ledger=await _ledger(10))
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
assert (st, cap) == ("not_accepted", 10)
es._unresolved_exit_unknown[(es._today_key(), CODE)] = {
"qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0,
}
with patch("app.services.trade_executor.safe_place_order", _order_reached):
with pytest.raises(OrderReached) as ei:
await es._place_and_finalize_exit_impl(
CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False)
assert ei.value.args[0]["quantity"] == 10
@pytest.mark.asyncio
async def test_c1_ledger_caps_resell(monkeypatch):
"""잔고상 DT 몫이 원장보다 크면(ActiveTrade 없는 수동분) 원장 순체결로 상한."""
_kis(monkeypatch, 15, 15, ledger=await _ledger(10))
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
assert (st, cap) == ("not_accepted", 10)
@pytest.mark.asyncio
async def test_c1_ledger_failure_holds(monkeypatch):
"""원장 조회 예외 → None → ledger_failed(재매도 보류, fail-closed)."""
def _boom():
raise RuntimeError("db down")
monkeypatch.setattr("app.core.database.AsyncSessionLocal", _boom)
assert await es._fetch_dt_ledger_net_qty_impl(CODE) is None
_kis(monkeypatch, 10, 10)
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
assert (st, cap) == ("ledger_failed", 0)
es._unresolved_exit_unknown[(es._today_key(), CODE)] = {
"qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0,
}
with patch("app.services.trade_executor.safe_place_order", _order_reached):
res = await es._place_and_finalize_exit_impl(
CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False)
assert res is None
@pytest.mark.asyncio
async def test_c1_ledger_nonpositive_holds(monkeypatch):
"""원장 순체결 ≤ 0 → 재매도 보류(과다매도보다 보류, §3.5). 재시도 종단에서도 주문 0."""
_kis(monkeypatch, 10, 10, ledger=await _ledger(0))
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
assert (st, cap) == ("ledger_nonpositive", 0)
es._unresolved_exit_unknown[(es._today_key(), CODE)] = {
"qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0,
}
with patch("app.services.trade_executor.safe_place_order", _order_reached):
res = await es._place_and_finalize_exit_impl(
CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False)
assert res is None and (es._today_key(), CODE) in es._unresolved_exit_unknown
@pytest.mark.asyncio
async def test_c1_orders_lookup_none_holds(monkeypatch):
"""주문내역 v2 조회 실패(None) → 보류(원장까지 가지 않는다)."""
_kis(monkeypatch, 10, 10, orders=None, ledger=_raise("주문내역 실패 시 원장 불필요"))
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
assert (st, cap) == ("orders_failed", 0)
@pytest.mark.asyncio
async def test_c1_v_repro_manual_ge_q_filled_not_resold(monkeypatch):
"""v_repro_dt: 수동 M=10 ≥ q=10, DT 10주 체결(잔고 온전해 보임). 주문내역에 등록 이후 행 → 접수 확정."""
now = es._kst_now()
row = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10",
"rjct_qty": "0", "cncl_yn": "N", "odno": "0000999", "ord_tmd": now.strftime("%H%M%S")}
_kis(monkeypatch, 10, 10, orders=[row], ledger=_raise("접수 확정 시 원장 불필요"))
st, resp, _ = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10)
assert st == "accepted" and resp["output"]["ODNO"] == "0000999"
@pytest.mark.asyncio
async def test_c1_v_repro_rows_invisible_ledger_zero_holds(monkeypatch):
"""v_repro_dt 원본(주문내역 [] + 원장 0) → 재매도 0(보류)."""
_kis(monkeypatch, 10, 10, orders=[], ledger=await _ledger(0))
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, None, 10)
assert (st, cap) == ("ledger_nonpositive", 0)
@pytest.mark.asyncio
async def test_c1_empty_orders_positive_ledger_capped_by_q(monkeypatch):
"""주문내역 [](KIS 미접수 실증) + 원장 > 0 → 재매도, 상한 min(DT 매도가능, 원장, q)."""
_kis(monkeypatch, 15, 15, orders=[], ledger=await _ledger(20))
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 5, es._kst_now(), 10)
assert (st, cap) == ("not_accepted", 5)
@pytest.mark.asyncio
async def test_c1_row_before_registration_or_without_time_ignored(monkeypatch):
"""등록 시각(−허용 오차) 이전 행·주문시각 없는 행은 같은 수량이어도 접수로 보지 않는다(r5 L-1 유지)."""
from datetime import timedelta
now = es._kst_now()
early = (now - timedelta(seconds=float(settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC) + 60)).strftime("%H%M%S")
base = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10",
"rjct_qty": "0", "cncl_yn": "N"}
rows = [dict(base, odno="0000111", ord_tmd=early), dict(base, odno="0000222")]
_kis(monkeypatch, 10, 10, orders=rows, ledger=await _ledger(10))
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10)
assert (st, cap) == ("not_accepted", 10)
@pytest.mark.asyncio
async def test_c1_pending_other_order_reduces_sellable_still_resells(monkeypatch):
"""보유 30·주문가능 26·비DT 20: 보유 기준 DT 몫 10 온전 → v2 대로 6주 재매도(보류 아님)."""
_kis(monkeypatch, 30, 26, nd=20, ledger=await _ledger(10))
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
assert (st, cap) == ("not_accepted", 6)
@pytest.mark.asyncio
async def test_c1_matching_order_row_is_accepted(monkeypatch):
now = es._kst_now()
row = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10",
"rjct_qty": "0", "cncl_yn": "N", "odno": "0000777", "ord_tmd": now.strftime("%H%M%S")}
_kis(monkeypatch, 5, 5, orders=[row], ledger=_raise("접수 확정 시 원장 불필요"))
st, resp, _ = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10)
assert st == "accepted" and resp["output"]["ODNO"] == "0000777"
@pytest.mark.asyncio
async def test_c1_ledger_query_nets_pending_sell(monkeypatch):
rows = [_th("BUY", "COMPLETED", 10), _th("SELL", "PENDING", 4), _th("SELL", "FAILED", 6)]
monkeypatch.setattr("app.core.database.AsyncSessionLocal", lambda: _Session(rows))
assert await es._fetch_dt_ledger_net_qty_impl(CODE) == 6
# ═══ C-2 ═════════════════════════════════════════════════════════════════════
def _restored_state(qty=10):
st = DayTradingState()
st.positions[CODE] = {
"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": datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=9, minute=10),
"reason": "", "order_id": "", "restored_from_db": True,
}
return st
def _hold(hldg, sellable):
async def _f():
return {CODE: {"hldg": hldg, "sellable": sellable}}
return _f
def _rows(rows):
async def _f():
return rows
return _f
@pytest.mark.asyncio
async def test_c2_sellable_below_hldg_holds_restore():
st = _restored_state(10)
out = await st.verify_restored_today_positions_v3(
session_factory=lambda: _Session([]), holdings_fetcher=_hold(10, 0), today_rows_fetcher=_rows([]))
assert CODE in out["held"] and CODE not in st.positions
@pytest.mark.asyncio
async def test_c2_pending_dt_sell_holds_restore():
st = _restored_state(10)
out = await st.verify_restored_today_positions_v3(
session_factory=lambda: _Session([]), holdings_fetcher=_raise("PENDING 보류 후 잔고 조회 불필요"),
today_rows_fetcher=_rows([_th("SELL", "PENDING", 10)]))
assert out["held"] == [CODE] and CODE not in st.positions
@pytest.mark.asyncio
async def test_c2_normal_restore_kept():
st = _restored_state(10)
out = await st.verify_restored_today_positions_v3(
session_factory=lambda: _Session([]), holdings_fetcher=_hold(10, 10),
today_rows_fetcher=_rows([_th("BUY", "COMPLETED", 10)]))
assert out["held"] == [] and CODE in st.positions
@pytest.mark.asyncio
async def test_c2_ledger_failure_holds_when_orphan_detect_enabled(monkeypatch):
monkeypatch.setattr(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True)
st = _restored_state(10)
out = await st.verify_restored_today_positions_v3(
session_factory=lambda: _Session([]), holdings_fetcher=_raise("보류 후 잔고 조회 불필요"),
today_rows_fetcher=_raise_plain())
assert out["held"] == [CODE]
def _raise_plain():
async def _f():
raise RuntimeError("db down")
return _f
@pytest.mark.asyncio
async def test_c2_intraday_restore_uses_sellable(monkeypatch):
row = SimpleNamespace(stock_code=CODE, quantity=10)
monkeypatch.setattr(day_trading_state, "positions", {})
n = await es._restore_lost_today_positions_v2_impl([row], {CODE: {"hldg": 10, "sellable": 0}}, {})
assert n == 0 and CODE not in day_trading_state.positions
# ═══ C-3 ═════════════════════════════════════════════════════════════════════
TOTAL = 100_000_000.0
CASH = 50_000_000.0
def _at(hh, mm):
fake_now = datetime.now(ens._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)
return _DT
class _Adp:
fallback_used = False
daytrading_enabled = True
trading_mode = "normal"
async def _scan(monkeypatch, daily_pnl):
captured: list = []
async def fake_try_enter(code, name, total_assets, available_cash, is_lunch_break=False):
captured.append(code)
return None
monkeypatch.setattr(day_trading_state, "positions", {})
monkeypatch.setattr(day_trading_state, "closed_today", set())
monkeypatch.setattr(day_trading_state, "last_total_assets", 0.0) # 재기동 직후
monkeypatch.setattr(day_trading_state, "daily_pnl", daily_pnl)
monkeypatch.setattr(day_trading_state, "last_reset_date", pm._today_kst()) # 날짜 리셋 방지
monkeypatch.setattr(day_trading_state, "loss_streak_today", 0, raising=False)
monkeypatch.setattr(ens, "_non_dt_held_db", set())
monkeypatch.setattr(ens, "_kis_holdings_snapshot", {})
monkeypatch.setattr(ens, "_kis_holdings_verified", True)
monkeypatch.setattr(ens, "datetime", _at(10, 30))
ens.set_watchlist([{"code": CODE, "name": "삼성"}])
balance = {"output1": [], "output2": [{"tot_evlu_amt": str(TOTAL), "prvs_rcdl_excc_amt": str(CASH)}]}
try:
with patch.object(ens.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True)), \
patch.object(ens.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)), \
patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()), \
patch.object(ens, "is_daytrading_allowed_by_playbook", return_value=True), \
patch.object(ens, "check_market_regime", AsyncMock(return_value={"regime": "neutral"})), \
patch.object(ens.korea_investment_api, "get_balance", AsyncMock(return_value=balance)), \
patch.object(ens.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=CASH)), \
patch.object(ens, "_prefetch_minute_charts", AsyncMock(return_value=0)), \
patch.object(ens, "_try_enter", fake_try_enter):
await ens.scan_and_enter()
finally:
ens.set_watchlist([])
return captured
@pytest.mark.asyncio
async def test_c3_loss_limit_rechecked_after_balance(monkeypatch):
from app.services.day_trading_strategy import _get_daily_loss_limit
pnl = TOTAL * _get_daily_loss_limit() * 1.5 # 한도 초과 손실
assert await _scan(monkeypatch, pnl) == []
@pytest.mark.asyncio
async def test_c3_control_no_loss_reaches_entry(monkeypatch):
assert await _scan(monkeypatch, 0.0) == [CODE]