"""데이트레이딩 안전·정합성 결함 회귀 테스트 (2026-10-02, 검증 루프 2회차). H2 진입 주문~상태 반영 shield / 주문 직전 재검사·슬롯 예약 / 분봉 조회 상한 H3 stale 반복 매도 금지 / 매도 수량 = min(행, KIS 실보유 − 타 전략) / 실보유 0 정리 H4 손절 선판정(현재가) / 포지션별 상한 + 제한 동시성 / 주문~finalize 전체 shield M1 분할매도 상태 영속(remaining_pct·highest) + 복원 시 분할 재발동 차단 M2 /positions inf 직렬화 M4 ORB 구간 손절 체크 M5 수동 /cycle 전역 락·게이트 M6 적응형/국면 조회 실패 시 fail-closed M7 매수 부분체결·미체결 보정 M8 사이클 내 가용현금 차감 L5 청산 측 총자산 확보 M3/H1/L1 config 이관(값 동일)·플래그 기본 OFF 목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형, 공통 종단(_execute_exit_impl, safe_place_order)에 건다. 기대값은 settings.* 에서 계산한다. """ import asyncio import inspect import time as _time 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 import day_trading_strategy as dts from app.services.daytrading import entry_service as ens from app.services.daytrading import exit_service as es from app.services.daytrading import fill_reconciler as fr from app.services.daytrading.position_manager import day_trading_state, dt_cycle_lock class MustNotBeCalled(BaseException): """호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게 BaseException).""" def _boom(name): async def _f(*a, **k): raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") return _f def _boom_sync(name): def _f(*a, **k): raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") return _f @pytest.fixture(autouse=True) def _isolate_state(): saved = dict(day_trading_state.positions) saved_closed = set(day_trading_state.closed_today) saved_assets = day_trading_state.last_total_assets saved_pnl = day_trading_state.daily_pnl day_trading_state.positions.clear() day_trading_state.closed_today.clear() day_trading_state.daily_pnl = 0.0 day_trading_state.last_reset_date = es._kst_now().date() es._stale_sell_attempted.clear() es._stale_sell_failures.clear() es._last_exit_order_outcome.clear() es._unresolved_exit_unknown.clear() es._finalized_sell_odnos.clear() es._unknown_lookup_failures.clear() es._unknown_lookup_alerted.clear() es._stale_cleaned.clear() es._exit_tokens.clear() day_trading_state.exits_in_progress.clear() es._total_assets_refreshed_at = 0.0 ens._pending_entries.clear() ens._candle_cache.clear() with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)): yield day_trading_state.positions.clear() day_trading_state.positions.update(saved) day_trading_state.closed_today.clear() day_trading_state.closed_today.update(saved_closed) day_trading_state.last_total_assets = saved_assets day_trading_state.daily_pnl = saved_pnl es._stale_sell_attempted.clear() es._stale_sell_failures.clear() es._last_exit_order_outcome.clear() es._unresolved_exit_unknown.clear() es._finalized_sell_odnos.clear() es._unknown_lookup_failures.clear() es._unknown_lookup_alerted.clear() es._stale_cleaned.clear() es._exit_tokens.clear() day_trading_state.exits_in_progress.clear() ens._pending_entries.clear() def _pos(entry=10_000.0, qty=10, stop=9_700.0, target=10_450.0, order_id="0000999", entry_time=None): return { "stock_name": "테스트", "strategy": "VWAP", "entry_price": entry, "quantity": qty, "stop_loss": stop, "target_price": target, "highest_price": entry, "entry_time": entry_time or es._kst_now(), "reason": "", "order_id": order_id, } # ═════════════════════════════════════════════════════════════════════════════ # H2 — 진입 # ═════════════════════════════════════════════════════════════════════════════ @pytest.fixture def entry_env(monkeypatch): """_try_enter 를 신호까지 통과시키는 최소 목. 주문(safe_place_order)은 테스트가 정한다.""" import app.services.portfolio_cache_bridge as pcb import app.api.endpoints.mcp as mcp_mod captured = {"history": [], "upserts": []} async def _hist(p): captured["history"].append(p) async def _ups(p): captured["upserts"].append(p) candles = [SimpleNamespace(close=10_000.0, date="", time="100000") for _ in range(6)] signal = SimpleNamespace( strategy="RSI_BB", confidence=0.7, stop_loss=9_700.0, target_price=10_450.0, reason="test", ) monkeypatch.setattr(ens.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) monkeypatch.setattr(ens, "is_vi_active", lambda code: False) monkeypatch.setattr(ens, "_get_candles_cached", AsyncMock(return_value=candles)) monkeypatch.setattr(ens, "filter_today_candles", lambda c: c) monkeypatch.setattr(ens, "aggregate_to_5min", lambda c: c) monkeypatch.setattr(ens, "calculate_vwap", lambda c: None) monkeypatch.setattr(ens, "check_rsi_bb_signal", lambda *a, **k: signal) monkeypatch.setattr(ens, "enqueue_trade_history", _hist) monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups) monkeypatch.setattr(pcb, "reflect_buy", AsyncMock()) monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment", AsyncMock(return_value=(True, "")), raising=False) # [R1-2 2026-10-09] 주문 직전 신선가 조회 — 이 픽스처의 테스트는 분봉가 경로(조회 실패 폴백)를 본다 monkeypatch.setattr(ens, "_fetch_fresh_price_v2_impl", AsyncMock(return_value=None)) return captured @pytest.mark.asyncio async def test_h2_entry_record_survives_cancellation_during_order(entry_env, monkeypatch): """주문 대기 중 스캔이 취소돼도 체결 후 인메모리·DB 반영이 완주한다(고아 보유 방지).""" import app.services.trade_executor as te started = asyncio.Event() async def _slow_order(**kw): started.set() await asyncio.sleep(0.05) return {"rt_cd": "0", "output": {"ODNO": "0000777"}} monkeypatch.setattr(te, "safe_place_order", _slow_order) task = asyncio.create_task(ens._try_enter("111111", "T", 100_000_000.0, 50_000_000.0)) await started.wait() task.cancel() with pytest.raises(asyncio.CancelledError): await task await asyncio.sleep(0.2) assert "111111" in day_trading_state.positions, "취소로 인메모리 반영 누락 — shield 없음" assert day_trading_state.positions["111111"]["order_id"] == "0000777" assert entry_env["history"] and entry_env["upserts"] assert "111111" not in ens._pending_entries, "슬롯 예약이 해제되지 않았다" @pytest.mark.asyncio async def test_h2_pre_order_recheck_blocks_when_slots_full(entry_env, monkeypatch): """신호 판정 뒤 슬롯이 찼으면(예약 포함) 주문을 내지 않는다.""" import app.services.trade_executor as te monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order")) for i in range(dts.MAX_POSITIONS - 1): day_trading_state.positions[f"9{i:05d}"] = _pos(qty=1) ens._pending_entries["888888"] = 10_000.0 # 다른 경로가 주문 진행 중 assert await ens._try_enter("111111", "T", 100_000_000.0, 50_000_000.0) is None @pytest.mark.asyncio async def test_h2_pre_order_recheck_shrinks_to_remaining_dt_budget(entry_env, monkeypatch): """주문 직전 DT 잔여 한도로 재클램프 — 예산을 넘는 수량으로 주문하지 않는다.""" import app.services.trade_executor as te sent = {} async def _order(**kw): sent.update(kw) return {"rt_cd": "0", "output": {"ODNO": "1"}} monkeypatch.setattr(te, "safe_place_order", _order) total = 100_000_000.0 # 다른 경로 예약으로 DT 잔여 한도가 1주 분량만 남은 상태 ens._pending_entries["888888"] = total * settings.DAY_TRADE_MAX_RATIO_DT - 10_000.0 await ens._try_enter("111111", "T", total, 50_000_000.0) assert sent.get("quantity") == 1 @pytest.mark.asyncio async def test_h2_candle_fetch_miss_has_timeout(monkeypatch): async def _slow(code): await asyncio.sleep(5) return [1] monkeypatch.setattr(ens, "_fetch_candles_impl", _slow) monkeypatch.setattr(ens, "_SCAN_PREFETCH_TIMEOUT", 0.05) t0 = _time.monotonic() assert await ens._get_candles_cached("111111") == [] assert _time.monotonic() - t0 < 1.0 # ═════════════════════════════════════════════════════════════════════════════ # H3 — 전일 포지션 청산 # ═════════════════════════════════════════════════════════════════════════════ class _SeqSession: """execute() 호출 순서대로 결과를 돌려주는 가짜 세션(SELECT 전용).""" def __init__(self, results): self._results = list(results) async def __aenter__(self): return self async def __aexit__(self, *a): return False async def execute(self, *a, **k): rows = self._results.pop(0) if self._results else [] return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows)) def _sess_factory(*results): return lambda: _SeqSession(results) def _bal(code, qty): return AsyncMock(return_value={"output1": [{"pdno": code, "hldg_qty": str(qty)}], "output2": [{}]}) def _stale_pos(qty=4): return _pos(qty=qty, entry_time=es._kst_now() - timedelta(days=1)) def _impl_pos(mock): return mock.await_args.args[1] @pytest.mark.asyncio async def test_h3_stale_sell_not_reordered_in_same_process(): """실패 직후(백오프 안)에는 재주문하지 않는다.""" day_trading_state.positions["064400"] = _stale_pos() exec_mock = AsyncMock(return_value=None) # 주문 실패 with patch.object(es, "is_market_open", return_value=True), \ patch("app.core.database.AsyncSessionLocal", _sess_factory([], [])), \ patch.object(es.korea_investment_api, "get_balance", _bal("064400", 4)), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "10000"}})), \ patch.object(es, "_execute_exit_impl", exec_mock): await es.close_stale_positions() with patch("app.core.database.AsyncSessionLocal", _sess_factory([], [])): await es.close_stale_positions() assert exec_mock.await_count == 1, "같은 프로세스에서 stale 매도를 재주문했다" @pytest.mark.asyncio async def test_h3_sell_qty_subtracts_other_strategy_holdings(): """매도 수량 = min(행 수량, KIS 실보유 − 스윙 보유분) (§3.5).""" day_trading_state.positions["064400"] = _stale_pos(qty=4) swing_row = SimpleNamespace(stock_code="064400", quantity=7) exec_mock = AsyncMock(return_value={"stock_code": "064400"}) with patch.object(es, "is_market_open", return_value=True), \ patch("app.core.database.AsyncSessionLocal", _sess_factory([], [swing_row])), \ patch.object(es.korea_investment_api, "get_balance", _bal("064400", 10)), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "10000"}})), \ patch.object(es, "_execute_exit_impl", exec_mock): await es.close_stale_positions() assert _impl_pos(exec_mock)["quantity"] == 3 @pytest.mark.asyncio async def test_h3_no_sell_when_holdings_lookup_fails(): day_trading_state.positions["064400"] = _stale_pos() with patch.object(es, "is_market_open", return_value=True), \ patch("app.core.database.AsyncSessionLocal", _sess_factory([], [])), \ patch.object(es.korea_investment_api, "get_balance", AsyncMock(side_effect=RuntimeError("KIS down"))), \ patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): assert await es.close_stale_positions() == [] assert "064400" in day_trading_state.positions assert not es._stale_sell_attempted, "주문하지 않았으면 재시도 가능해야 한다" @pytest.mark.asyncio async def test_h3_zero_holding_closes_active_trade_only(): day_trading_state.positions["064400"] = _stale_pos() close_mock = AsyncMock() with patch.object(es, "is_market_open", return_value=True), \ patch("app.core.database.AsyncSessionLocal", _sess_factory([], [])), \ patch.object(es.korea_investment_api, "get_balance", AsyncMock(return_value={"output1": [], "output2": [{}]})), \ patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ patch.object(es, "enqueue_close_active_trade", close_mock), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): assert await es.close_stale_positions() == [] close_mock.assert_awaited_once_with("064400", strategy_name="day_trade") assert "064400" not in day_trading_state.positions assert "064400" in day_trading_state.closed_today # ═════════════════════════════════════════════════════════════════════════════ # H4 / L5 — 청산 체크 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_h4_fast_stop_exits_without_minute_chart(): day_trading_state.positions["222222"] = _pos(stop=9_700.0) day_trading_state.last_total_assets = 100_000_000.0 exec_mock = AsyncMock(return_value={"stock_code": "222222"}) with patch.object(es, "is_eod_flat_time", return_value=False), \ patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), \ patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=1e8)), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "9600"}})), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("get_minute_chart_full_day")), \ patch.object(es, "_execute_exit_impl", exec_mock): out = await es.check_and_exit_positions() assert out == [{"stock_code": "222222"}] args = exec_mock.await_args.args assert args[4] == 1.0 and "손절" in args[3] @pytest.mark.asyncio async def test_h4_slow_position_does_not_block_others(monkeypatch): day_trading_state.positions["111111"] = _pos() day_trading_state.positions["222222"] = _pos() monkeypatch.setattr(settings, "DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC", 0.1) async def _single(code): if code == "111111": await asyncio.sleep(5) return {"stock_code": code} t0 = _time.monotonic() with patch.object(es, "is_eod_flat_time", return_value=False), \ patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), \ patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=1e8)), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "10000"}})), \ patch.object(es, "_check_single_position", _single): out = await es.check_and_exit_positions() assert out == [{"stock_code": "222222"}] assert _time.monotonic() - t0 < 2.0 @pytest.mark.asyncio async def test_h4_cancel_during_sell_order_still_finalizes(monkeypatch): """주문 대기 중 취소돼도 finalize 가 완주(중복 매도 방지) — shield 범위가 주문 포함.""" import app.services.trade_executor as te started = asyncio.Event() finished = [] async def _slow_order(**kw): started.set() await asyncio.sleep(0.05) return {"rt_cd": "0", "output": {"ODNO": "1"}} async def _fin(*a, **k): finished.append(True) return {"ok": True} monkeypatch.setattr(te, "safe_place_order", _slow_order) monkeypatch.setattr(es, "_finalize_exit", _fin) day_trading_state.positions["333333"] = _p = _pos() task = asyncio.create_task(es._execute_exit("333333", _p, 10_000.0, "test")) await started.wait() task.cancel() with pytest.raises(asyncio.CancelledError): await task await asyncio.sleep(0.2) assert finished, "주문 대기 중 취소로 상태 반영이 유실됐다" @pytest.mark.asyncio async def test_l5_exit_side_fetches_total_assets_and_triggers_daily_limit(): """last_total_assets=0 이어도 청산 측이 총자산을 확보해 일일 한도를 판정한다.""" total = 100_000_000.0 day_trading_state.last_total_assets = 0.0 day_trading_state.daily_pnl = total * settings.DAY_TRADE_DAILY_LOSS_LIMIT * 1.1 day_trading_state.positions["222222"] = _pos() force = AsyncMock(return_value=["closed"]) with patch.object(es.korea_investment_api, "get_balance", AsyncMock(return_value={"output2": [{"tot_evlu_amt": str(int(total))}]})), \ patch.object(es, "force_close_all", force): out = await es.check_and_exit_positions() assert out == ["closed"] assert day_trading_state.last_total_assets == total @pytest.mark.asyncio async def test_l5_falls_back_to_redis_saved_assets(): day_trading_state.last_total_assets = 0.0 from app.services.daytrading.position_manager import _today_kst saved = {"date": _today_kst().isoformat(), "last_total_assets": 55_000_000.0} with patch.object(es.korea_investment_api, "get_balance", AsyncMock(side_effect=RuntimeError("down"))), \ patch("app.core.redis.get_json", AsyncMock(return_value=saved)): v = await es._ensure_total_assets_for_exit_impl() assert v == 55_000_000.0 and day_trading_state.last_total_assets == 55_000_000.0 # ═════════════════════════════════════════════════════════════════════════════ # M1 — 분할매도 영속·복원 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_m1_partial_exit_persists_remaining_pct_and_highest(monkeypatch): import app.services.trade_executor as te import app.services.portfolio_cache_bridge as pcb ups = [] async def _ups(p): ups.append(p) monkeypatch.setattr(te, "safe_place_order", AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "1"}})) monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock()) monkeypatch.setattr("app.services.db_worker.enqueue_active_trade_upsert", _ups) monkeypatch.setattr(pcb, "reflect_sell", AsyncMock()) pos = _pos(qty=10) pos["highest_price"] = 10_600.0 day_trading_state.positions["444444"] = pos await es._execute_exit("444444", pos, 10_500.0, "목표가 달성", sell_pct=0.5) p = [u for u in ups if u.get("stock_code") == "444444"][-1] assert p["quantity"] == 5 and p["strategy_name"] == "day_trade" assert 0 < p["remaining_pct"] < 1.0 assert p["highest_since_entry"] == 10_600.0 def _at_row(remaining_pct, take_profit=10_450.0): return SimpleNamespace( stock_code="444444", stock_name="T", strategy_name="day_trade", status="active", entry_price=10_000.0, stop_loss=9_700.0, take_profit=take_profit, quantity=5, highest_since_entry=10_600.0, created_at=datetime.now(), entry_reason="", order_id="1", remaining_pct=remaining_pct, ) class _OneShotDb: def __init__(self, rows): self.rows = rows async def execute(self, *a, **k): return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: self.rows)) @pytest.mark.asyncio async def test_m1_restore_blocks_partial_retrigger(): from app.services.daytrading.position_manager import DayTradingState st = DayTradingState() st.begin_restore() await st.restore_from_db(_OneShotDb([_at_row(0.5)])) p = st.positions["444444"] assert p["target_price"] == float("inf") and p.get("partial_sold") is True assert p["highest_price"] == 10_600.0 # 분할 전(remaining_pct=1.0)은 기존 목표가 그대로 st2 = DayTradingState() st2.begin_restore() await st2.restore_from_db(_OneShotDb([_at_row(1.0)])) assert st2.positions["444444"]["target_price"] == 10_450.0 assert not st2.positions["444444"].get("partial_sold") # ═════════════════════════════════════════════════════════════════════════════ # M2 — /positions inf 직렬화 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_m2_positions_endpoint_serializes_inf(): from fastapi.responses import JSONResponse from app.api.endpoints import day_trading as dt pos = _pos() pos["target_price"] = float("inf") day_trading_state.positions["555555"] = pos with patch.object(dt, "_get_current_price_v2", AsyncMock(return_value=10_100.0)): res = await dt.get_positions() JSONResponse(content=res) # inf 가 남아 있으면 ValueError(=500) assert res["positions"][0]["target_price"] is None # ═════════════════════════════════════════════════════════════════════════════ # M4 — ORB 구간 손절 # ═════════════════════════════════════════════════════════════════════════════ def test_m4_orb_branch_runs_stop_check_before_collection(): from app.services.daytrading import scheduler as sched src = inspect.getsource(sched.daytrading_scheduler) orb = src[src.index("if is_opening_range_period():"):] orb = orb[:orb.index("await _cycle_sleep(30)")] assert "check_stops_only_v2()" in orb assert orb.index("check_stops_only_v2()") < orb.index("collect_opening_ranges()") @pytest.mark.asyncio async def test_m4_stops_only_check_sells_breached_and_ignores_others(): day_trading_state.positions["111111"] = _pos(stop=9_700.0) day_trading_state.positions["222222"] = _pos(stop=9_700.0) prices = {"111111": "9600", "222222": "10500"} exec_mock = AsyncMock(return_value={"stock_code": "111111"}) with patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(side_effect=lambda c: {"output": {"stck_prpr": prices[c]}})), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("get_minute_chart_full_day")), \ patch.object(es, "_execute_exit_impl", exec_mock): out = await es.check_stops_only_v2() assert out == [{"stock_code": "111111"}] assert exec_mock.await_count == 1 # 익절·목표가 판정은 하지 않는다 # ═════════════════════════════════════════════════════════════════════════════ # M5 — 수동 사이클 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_m5_manual_cycle_rejects_while_scheduler_holds_lock(): from fastapi import HTTPException from app.api.endpoints import day_trading as dt with patch.object(dt, "is_market_open", return_value=True), \ patch.object(settings, "DAY_TRADE_ENABLED", True), \ patch.object(es, "check_and_exit_positions", _boom("check_and_exit_positions")): async with dt_cycle_lock: with pytest.raises(HTTPException) as ei: # 락을 기다리면(=회귀) 교착 대신 TimeoutError 로 실패하게 상한을 둔다 await asyncio.wait_for(dt.manual_cycle(), timeout=1.0) assert ei.value.status_code == 409 @pytest.mark.asyncio async def test_m5_manual_cycle_gates_and_holds_lock(): from fastapi import HTTPException from app.api.endpoints import day_trading as dt # 장 운영 시간 밖이면 409 (청산·진입 모두 호출하지 않음 — 예외형 목) with patch.object(dt, "is_market_open", return_value=False), \ patch.object(es, "check_and_exit_positions", _boom("check_and_exit_positions")), \ patch.object(ens, "scan_and_enter", _boom("scan_and_enter")): with pytest.raises(HTTPException) as ei: await dt.manual_cycle() assert ei.value.status_code == 409 seen = [] async def _exit(): seen.append(("exit", dt_cycle_lock.locked())) return [] async def _scan(): seen.append(("scan", dt_cycle_lock.locked())) return [] with patch.object(dt, "is_market_open", return_value=True), \ patch.object(dt, "is_day_trade_entry_window", return_value=False), \ patch.object(settings, "DAY_TRADE_ENABLED", True), \ patch.object(es, "check_and_exit_positions", _exit), \ patch.object(ens, "scan_and_enter", _scan): res = await dt.manual_cycle() assert seen == [("exit", True)], "진입창 밖에서는 스캔하지 않고, 청산은 락 안에서" assert res["entry_skipped"] @pytest.mark.asyncio async def test_r4_manual_cycle_runs_exit_when_dt_disabled(): """[R4 2026-10-09] DAY_TRADE_ENABLED=False 여도 청산 체크는 수행(스케줄러 규약과 일치). 진입 시간창 안이어도 scan_and_enter 는 호출되면 안 된다 — 예외형 목으로 고정. """ from app.api.endpoints import day_trading as dt seen = [] async def _exit(): seen.append(("exit", dt_cycle_lock.locked())) return [{"stock_code": "111111", "reason": "손절"}] with patch.object(dt, "is_market_open", return_value=True), \ patch.object(dt, "is_day_trade_entry_window", return_value=True), \ patch.object(settings, "DAY_TRADE_ENABLED", False), \ patch.object(es, "check_and_exit_positions", _exit), \ patch.object(ens, "scan_and_enter", _boom("scan_and_enter")): res = await dt.manual_cycle() assert seen == [("exit", True)], "비활성이어도 청산은 락 안에서 1회 수행" assert res["exits"] == [{"stock_code": "111111", "reason": "손절"}] assert res["entries"] == [] assert res["entry_skipped"] == "DAY_TRADE_ENABLED=False" @pytest.mark.asyncio async def test_r4_manual_cycle_enabled_in_window_scans(): """활성 + 진입창 안이면 청산 후 진입 스캔(기존 동작 유지).""" from app.api.endpoints import day_trading as dt order = [] async def _exit(): order.append("exit") return [] async def _scan(): order.append("scan") return [{"stock_code": "222222"}] with patch.object(dt, "is_market_open", return_value=True), \ patch.object(dt, "is_day_trade_entry_window", return_value=True), \ patch.object(settings, "DAY_TRADE_ENABLED", True), \ patch.object(es, "check_and_exit_positions", _exit), \ patch.object(ens, "scan_and_enter", _scan): res = await dt.manual_cycle() assert order == ["exit", "scan"] assert res["entry_skipped"] is None assert res["entries"] == [{"stock_code": "222222"}] # ═════════════════════════════════════════════════════════════════════════════ # M6 / M8 — scan_and_enter 게이트 # ═════════════════════════════════════════════════════════════════════════════ class _Adp: fallback_used = False daytrading_enabled = True trading_mode = "normal" async def _run_scan(fake_try_enter, *, adaptive=None, regime=None, cash=90_000_000.0): fake_now = datetime.now(ens._KST).replace(hour=11, minute=0, second=0) class _DT(datetime): @classmethod def now(cls, tz=None): return fake_now if tz else fake_now.replace(tzinfo=None) _adp_patch = ( patch("app.services.adaptive_strategy.get_current_adaptive_params", side_effect=adaptive) if isinstance(adaptive, BaseException) or adaptive is RuntimeError else patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=adaptive or _Adp()) ) _regime = regime if regime is not None else AsyncMock(return_value={"regime": "neutral"}) with patch.object(ens, "datetime", _DT), \ 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)), \ _adp_patch, \ patch.object(ens, "is_daytrading_allowed_by_playbook", return_value=True), \ patch.object(ens, "check_market_regime", _regime), \ patch.object(ens.korea_investment_api, "get_balance", AsyncMock( return_value={"output2": [{"tot_evlu_amt": "100000000", "prvs_rcdl_excc_amt": str(int(cash))}]})), \ 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): return await ens.scan_and_enter() @pytest.fixture def _watch3(): ens.set_watchlist([{"code": c, "name": c} for c in ("000001", "000002", "000003")]) ens._scan_offset = 0 yield ens.set_watchlist([]) @pytest.mark.asyncio async def test_m6_adaptive_lookup_failure_blocks_entry(_watch3): assert await _run_scan(_boom("_try_enter"), adaptive=RuntimeError("adaptive down")) == [] @pytest.mark.asyncio async def test_m6_regime_lookup_failure_blocks_entry(_watch3): out = await _run_scan( _boom("_try_enter"), regime=AsyncMock(side_effect=RuntimeError("regime down")), ) assert out == [] @pytest.mark.asyncio async def test_m8_available_cash_is_decremented_within_cycle(_watch3): seen = [] async def _enter(code, name, total_assets, available_cash, is_lunch_break=False): seen.append(available_cash) return {"stock_code": code, "price": 10_000.0, "quantity": 4_000} # 4천만원 await _run_scan(_enter, cash=90_000_000.0) assert seen == [90_000_000.0, 50_000_000.0, 10_000_000.0] @pytest.mark.asyncio async def test_m8_cycle_stops_when_cash_exhausted(_watch3): seen = [] async def _enter(code, name, total_assets, available_cash, is_lunch_break=False): seen.append(code) return {"stock_code": code, "price": 10_000.0, "quantity": 9_000} # 가용현금 전부 await _run_scan(_enter, cash=90_000_000.0) assert len(seen) == 1, "현금 소진 후에도 다음 종목을 시도했다" # ═════════════════════════════════════════════════════════════════════════════ # M7 — 매수 부분체결·미체결 # ═════════════════════════════════════════════════════════════════════════════ @pytest.fixture def fill_env(monkeypatch): monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True) monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0) monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0) cap = {"upserts": [], "closes": [], "updates": []} async def _ups(p): cap["upserts"].append(p) async def _close(code, strategy_name=None): cap["closes"].append((code, strategy_name)) async def _upd(oid, f): cap["updates"].append((oid, f)) monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups) monkeypatch.setattr("app.services.db_worker.enqueue_close_active_trade", _close) monkeypatch.setattr("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd) return cap async def _reconcile(rows, qty=10): with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): ens._schedule_buy_fill_reconcile("777777", "0000456", 10_000.0, qty) await asyncio.gather(*list(fr._pending_tasks)) @pytest.mark.asyncio async def test_m7_terminal_partial_fill_corrects_quantity(fill_env): day_trading_state.positions["777777"] = _pos(qty=10, order_id="0000456") await _reconcile([{"odno": "456", "tot_ccld_qty": "6", "avg_prvs": "10030", "ord_qty": "10", "rmn_qty": "0"}]) assert day_trading_state.positions["777777"]["quantity"] == 6 assert fill_env["upserts"][-1]["quantity"] == 6 assert fill_env["updates"][-1][1]["executed_quantity"] == 6 @pytest.mark.asyncio async def test_m7_terminal_zero_fill_removes_position(fill_env): day_trading_state.positions["777777"] = _pos(qty=10, order_id="0000456") await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0", "ord_qty": "10", "rmn_qty": "0"}]) assert "777777" not in day_trading_state.positions assert fill_env["closes"] == [("777777", "day_trade")] @pytest.mark.asyncio async def test_m7_non_terminal_partial_keeps_quantity(fill_env): day_trading_state.positions["777777"] = _pos(qty=10, order_id="0000456") await _reconcile([{"odno": "456", "tot_ccld_qty": "6", "avg_prvs": "10030", "ord_qty": "10", "rmn_qty": "4"}]) assert day_trading_state.positions["777777"]["quantity"] == 10 assert "quantity" not in fill_env["upserts"][-1] # ═════════════════════════════════════════════════════════════════════════════ # M3 / H1 / L1 — config 이관·플래그 # ═════════════════════════════════════════════════════════════════════════════ def test_flags_default_off_and_values_match_old_literals(): from app.core.config import Settings d = Settings.model_fields assert d["DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED"].default is False assert d["DAY_TRADE_ORB_FULL_RANGE"].default is False assert d["DAY_TRADE_ORB_MAX_GAP_PCT"].default == 0.04 assert d["DAY_TRADE_ORB_MIN_RANGE_PCT"].default == 0.003 assert d["DAY_TRADE_RSI_EXIT_OVERBOUGHT"].default == 70.0 assert d["DAY_TRADE_VWAP_TARGET_FALLBACK_MULT"].default == 1.03 def _mc(date, t, o, h, l, c, v=1000): return dts.MinuteCandle(date=date, time=t, open=o, high=h, low=l, close=c, volume=v, cum_volume=v, cum_amount=float(c * v)) def _gap_scenario(prev_close=10_000.0, open_=10_600.0): """전일 종가 10,000 → 당일 시가 10,600 (+6% 오버나이트 갭). 시가 이후 장중 이동은 작다.""" today = datetime.now().strftime("%Y%m%d") prev = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") candles = [_mc(prev, "152900", prev_close, prev_close, prev_close, prev_close)] candles += [_mc(today, f"0900{i:02d}", open_, open_ + 10, open_ - 10, open_) for i in range(0, 50, 10)] # 레인지 돌파 + 거래량 급증 + 양봉 candles += [_mc(today, "100000", open_, open_ + 80, open_, open_ + 70, v=50_000), _mc(today, "100100", open_ + 70, open_ + 90, open_ + 60, open_ + 85, v=50_000)] orb = dts.OpeningRange(high=open_ + 10, low=open_ - 50, total_volume=5000, avg_vol_per_min=1000, open_price=open_) return candles, orb, open_ + 85 def test_m3_overnight_gap_rejects_when_flag_on(monkeypatch): candles, orb, price = _gap_scenario() monkeypatch.setattr(settings, "DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED", True) assert dts.check_orb_signal("000001", "T", candles, orb, price) is None def test_m3_flag_off_keeps_intraday_gap_judgment(monkeypatch): """플래그 OFF 면 결과가 '오버나이트 갭을 모르는' 기존 판정과 같다(동작 불변).""" candles, orb, price = _gap_scenario() monkeypatch.setattr(settings, "DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED", False) with_prev = dts.check_orb_signal("000001", "T", candles, orb, price) today_only = dts.check_orb_signal("000001", "T", candles[1:], orb, price) assert (with_prev is None) == (today_only is None) @pytest.mark.asyncio async def test_h1_flag_off_keeps_first_range(monkeypatch): """FULL_RANGE OFF(기본): 이미 ORB 가 있으면 재수집하지 않는다(현행 레인지 불변).""" monkeypatch.setattr(settings, "DAY_TRADE_ORB_FULL_RANGE", False) ens.set_watchlist([{"code": "000001", "name": "T"}]) first = dts.OpeningRange(high=1, low=1, total_volume=1, avg_vol_per_min=1, open_price=1) day_trading_state.orb_cache["000001"] = first try: with patch.object(day_trading_state, "load_orb_cache", AsyncMock(return_value=0)), \ patch.object(ens.korea_investment_api, "get_minute_chart_full_day", _boom("get_minute_chart_full_day")): await ens.collect_opening_ranges() assert day_trading_state.orb_cache["000001"] is first finally: day_trading_state.orb_cache.pop("000001", None) ens.set_watchlist([]) # ═════════════════════════════════════════════════════════════════════════════ # 3회차 (2026-10-02) — H-A / H-B / M-A / Low # ═════════════════════════════════════════════════════════════════════════════ def _stale_env(order_fn, *, bal=None, finalize=None): """close_stale_positions 를 실제 _execute_exit_impl 까지 타게 하는 목 묶음.""" import app.services.trade_executor as te return [ patch.object(es, "is_market_open", return_value=True), patch("app.core.database.AsyncSessionLocal", lambda: _SeqSession([[], []])), patch.object(es.korea_investment_api, "get_balance", bal or _bal("064400", 4)), patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "9500"}})), patch.object(te, "safe_place_order", order_fn), patch.object(es, "_finalize_exit_stale", finalize or AsyncMock(return_value={"ok": 1})), ] async def _run_stale(n, patches): out = [] for p in patches: p.start() try: for _ in range(n): out.append(await es.close_stale_positions()) finally: for p in reversed(patches): p.stop() return out @pytest.mark.asyncio async def test_ha_transient_reject_is_retried_then_stops_after_success(monkeypatch): """EGW00201 같은 일시 거부 1회 → 백오프 뒤 재시도, 접수 성공 뒤에는 재주문 없음.""" monkeypatch.setattr(settings, "DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC", 0.0) day_trading_state.positions["064400"] = _stale_pos() calls = [] async def _order(**kw): calls.append(kw) if len(calls) == 1: return {"rt_cd": "1", "msg1": "EGW00201 초당 거래건수 초과"} return {"rt_cd": "0", "output": {"ODNO": "9"}} out = await _run_stale(3, _stale_env(_order)) assert len(calls) == 2, f"재시도 1회 후 성공, 이후 재주문 없음이어야 한다: {len(calls)}" assert out[1] == [{"ok": 1}] and out[2] == [] @pytest.mark.asyncio async def test_ha_retry_is_capped_by_config(monkeypatch): monkeypatch.setattr(settings, "DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC", 0.0) day_trading_state.positions["064400"] = _stale_pos() calls = [] async def _order(**kw): calls.append(kw) return {"rt_cd": "1", "msg1": "거부"} await _run_stale(int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES) + 3, _stale_env(_order)) assert len(calls) == int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES) @pytest.mark.asyncio async def test_ha_retry_respects_backoff(monkeypatch): monkeypatch.setattr(settings, "DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC", 3600.0) day_trading_state.positions["064400"] = _stale_pos() calls = [] async def _order(**kw): calls.append(kw) return {"rt_cd": "1", "msg1": "거부"} await _run_stale(3, _stale_env(_order)) assert len(calls) == 1 @pytest.mark.asyncio async def test_ha_unknown_result_checks_todays_orders_before_retry(monkeypatch): """응답 불명 뒤에는 당일 주문내역에 매도 접수가 있으면 재주문하지 않는다.""" monkeypatch.setattr(settings, "DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC", 0.0) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC", 0.0) day_trading_state.positions["064400"] = _stale_pos() calls = [] async def _order(**kw): # [4회차] 실제 반환 형태 — place_order 가 전송 후 예외를 msg_cd 로 감싸 돌려준다 # (예외를 던지는 목은 safe_place_order 가 절대 내지 않는 형태라 거짓 통과였다). calls.append(kw) return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout", "odno": None} rows = [{"pdno": "064400", "sll_buy_dvsn_cd": "01", "ord_qty": "4", "tot_ccld_qty": "0", "odno": "0000555"}] # 1차 사이클 보유 4/주문가능 4 → 매도 불명 → 이후 주문가능 0(매도 대기분이 빠짐 = 접수) with patch.object(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): await _run_stale(3, _stale_env(_order, bal=_bal_seq("064400", (4, 4), (4, 0)))) assert len(calls) == 1 assert ("%s" % es._kst_now().date().isoformat(), "064400") in es._stale_sell_attempted @pytest.mark.asyncio async def test_hb_timeout_in_stage1_does_not_double_sell(): """1단계 상한 초과로 판정 코루틴이 취소돼도 진행 중 주문이 있는 포지션은 다시 팔지 않는다. 3회차 재현: Redis 장애로 분산락이 무력화된 상태에서 4주 포지션에 [4, 4] 매도. """ import app.services.trade_executor as te pos = _pos(qty=4, stop=9_800.0) day_trading_state.positions["005930"] = pos orders = [] sem = asyncio.Semaphore(1) async def _order(**kw): async with sem: orders.append(kw["quantity"]) await asyncio.sleep(0.5) return {"rt_cd": "0", "output": {"ODNO": str(len(orders))}} async def _fin(stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit): async with day_trading_state.trade_lock: day_trading_state.positions.pop(stock_code, None) return {"stock_code": stock_code, "quantity": quantity} with patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "9500"}})), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(side_effect=RuntimeError("chart down"))), \ patch.object(te, "safe_place_order", _order), \ patch.object(es, "_finalize_exit", _fin): codes = list(day_trading_state.positions) fast = await es._run_exit_checks_bounded_impl(codes, es._fast_stop_check_impl, 0.2, "t") handled = {c for c, r in fast.items() if r and r[0]} remaining = [c for c in codes if c not in handled and c in day_trading_state.positions] await es._run_exit_checks_bounded_impl(remaining, es._check_single_position, 5, "t2") # 다음 사이클·EOD 도 진행 중이면 건너뛴다 assert await es.force_close_all("EOD") == [] await asyncio.sleep(1.0) assert orders == [4], f"같은 포지션 재매도: {orders}" assert not day_trading_state.exits_in_progress, "주문 완료 뒤 진행 중 표식이 해제돼야 한다" @pytest.mark.asyncio async def test_hb_unknown_result_is_held_then_released(monkeypatch): """주문 응답 시간초과(결과 불명) → 보류 시간 동안만 skip, 이후 해제(EOD 영구 skip 방지).""" import app.services.trade_executor as te monkeypatch.setattr(settings, "DAY_TRADE_EXIT_ORDER_TIMEOUT_SEC", 0.05) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC", 0.2) async def _hang(**kw): await asyncio.sleep(5) monkeypatch.setattr(te, "safe_place_order", _hang) day_trading_state.positions["005930"] = _p = _pos(qty=4) with patch.object(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])), \ patch.object(es.korea_investment_api, "get_balance", _bal_seq("005930", (4, 4))), \ patch("app.core.database.AsyncSessionLocal", _sess_factory([])): assert await es._execute_exit("005930", _p, 9_500.0, "손절") is None assert day_trading_state.is_exit_in_progress("005930") assert es._last_exit_order_outcome["005930"] == "unknown" await asyncio.sleep(0.35) assert not day_trading_state.is_exit_in_progress("005930") @pytest.mark.asyncio async def test_hb_rejected_order_releases_immediately(monkeypatch): import app.services.trade_executor as te monkeypatch.setattr(te, "safe_place_order", AsyncMock(return_value={"rt_cd": "1", "msg1": "거부"})) day_trading_state.positions["005930"] = _p = _pos(qty=4) assert await es._execute_exit("005930", _p, 9_500.0, "손절") is None assert not day_trading_state.is_exit_in_progress("005930") assert es._last_exit_order_outcome["005930"] == "rejected" @pytest.mark.asyncio async def test_ma_pending_swing_sell_not_double_subtracted(): """보유 9(DT 4 + 스윙 5), 스윙 매도 5주 대기 → 주문가능 4. DT 4주를 팔아야 한다.""" day_trading_state.positions["064400"] = _stale_pos(qty=4) swing_row = SimpleNamespace(stock_code="064400", quantity=5) bal = AsyncMock(return_value={"output1": [{"pdno": "064400", "hldg_qty": "9", "ord_psbl_qty": "4"}], "output2": [{}]}) exec_mock = AsyncMock(return_value={"stock_code": "064400"}) with patch.object(es, "is_market_open", return_value=True), \ patch("app.core.database.AsyncSessionLocal", _sess_factory([], [swing_row])), \ patch.object(es.korea_investment_api, "get_balance", bal), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "10000"}})), \ patch.object(es, "enqueue_close_active_trade", _boom("enqueue_close_active_trade")), \ patch.object(es, "_execute_exit_impl", exec_mock): await es.close_stale_positions() assert _impl_pos(exec_mock)["quantity"] == 4 @pytest.mark.asyncio async def test_ma_holding_present_but_no_dt_available_keeps_row(): """보유가 있는데 DT 가용 0 이면 행을 닫지 않는다(추적 유지) — 닫기는 보유 0 일 때만.""" day_trading_state.positions["064400"] = _stale_pos(qty=4) swing_row = SimpleNamespace(stock_code="064400", quantity=5) with patch.object(es, "is_market_open", return_value=True), \ patch("app.core.database.AsyncSessionLocal", _sess_factory([], [swing_row])), \ patch.object(es.korea_investment_api, "get_balance", _bal("064400", 5)), \ patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ patch.object(es, "enqueue_close_active_trade", _boom("enqueue_close_active_trade")), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): assert await es.close_stale_positions() == [] assert "064400" in day_trading_state.positions @pytest.mark.asyncio async def test_low_coexist_error_is_rate_limited(caplog): import logging from app.core import log_throttle log_throttle.reset_for_tests() today_pos = _pos(qty=2) day_trading_state.positions["064400"] = today_pos row = SimpleNamespace( stock_code="064400", stock_name="X", entry_price=10_000.0, quantity=4, highest_since_entry=10_000.0, created_at=datetime.now() - timedelta(days=1), order_id="1", ) with caplog.at_level(logging.ERROR, logger=es.logger.name): for _ in range(3): with patch.object(es, "is_market_open", return_value=True), \ patch("app.core.database.AsyncSessionLocal", _sess_factory([row], [])), \ patch.object(es.korea_investment_api, "get_balance", _bal("064400", 6)), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): await es.close_stale_positions() assert sum("공존" in r.getMessage() for r in caplog.records) == 1 @pytest.mark.asyncio async def test_low_m7_zero_fill_cancelled_without_rmn_qty_is_terminal(fill_env): day_trading_state.positions["777777"] = _pos(qty=10, order_id="0000456") await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0", "ord_qty": "10", "cncl_yn": "Y"}]) assert "777777" not in day_trading_state.positions def test_low_exit_constants_moved_to_config_with_same_values(): from app.core.config import Settings d = Settings.model_fields assert d["DAY_TRADE_EXIT_CHECK_CONCURRENCY"].default == 4 assert d["DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC"].default == 15.0 assert d["DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC"].default == 45.0 assert d["DAY_TRADE_TOTAL_ASSETS_REFRESH_SEC"].default == 300.0 for name in ("_EXIT_CHECK_CONCURRENCY", "_EXIT_FAST_STOP_TIMEOUT", "_EXIT_CHECK_TIMEOUT", "_TOTAL_ASSETS_REFRESH_SEC"): assert not hasattr(es, name), f"모듈 상수 잔재: {name}" @pytest.mark.asyncio async def test_hb_impl_guard_blocks_concurrent_same_stock_exit(monkeypatch): """[H-B 1층] 같은 종목 청산이 동시에 두 번 들어와도 주문은 1건(호출부 skip 과 독립).""" import app.services.trade_executor as te orders = [] async def _order(**kw): orders.append(kw["quantity"]) await asyncio.sleep(0.1) return {"rt_cd": "0", "output": {"ODNO": "1"}} monkeypatch.setattr(te, "safe_place_order", _order) monkeypatch.setattr(es, "_finalize_exit", AsyncMock(return_value={"ok": 1})) day_trading_state.positions["005930"] = _p = _pos(qty=4) await asyncio.gather( es._execute_exit("005930", _p, 9_500.0, "손절"), es._execute_exit("005930", _p, 9_500.0, "EOD"), ) assert orders == [4] @pytest.mark.asyncio async def test_hb_callers_skip_in_progress_positions(): """[H-B 2층] 진행 중 종목은 판정 경로 4곳이 시세·분봉 조회 전에 건너뛴다.""" day_trading_state.positions["005930"] = _pos(qty=4) day_trading_state.exits_in_progress.add("005930") with patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("get_minute_chart_full_day")), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): assert await es._fast_stop_check_impl("005930") == (True, None) assert await es._check_single_position("005930") is None assert await es._check_fallback_stop_loss("005930", _pos(qty=4), 9_700.0) is None assert await es.force_close_all("EOD") == [] # ═════════════════════════════════════════════════════════════════════════════ # 4회차 (2026-10-02) — H-1 / H-2 / M-1~M-4 / L-1·L-2·L-8 # ═════════════════════════════════════════════════════════════════════════════ def _bal_seq(code, *pairs): """get_balance 목 — 호출마다 (hldg, ord_psbl) 를 차례로 돌려준다(마지막 값 유지).""" seq = list(pairs) async def _f(): h, s_ = seq.pop(0) if len(seq) > 1 else seq[0] return {"rt_cd": "0", "output1": [{"pdno": code, "hldg_qty": str(h), "ord_psbl_qty": str(s_)}], "output2": [{}]} return _f def _fresh_kis_module(): """conftest 의 클래스 메서드 차단을 받지 않은 korea_investment_api 모듈 사본.""" import importlib.util import app.services.korea_investment_api as kmod spec = importlib.util.spec_from_file_location("kia_fresh_r4", kmod.__file__) fresh = importlib.util.module_from_spec(spec) spec.loader.exec_module(fresh) return fresh async def _real_place_order_with(exc): import httpx # noqa: F401 fresh = _fresh_kis_module() async def _mr(*a, **k): raise exc inst = SimpleNamespace(app_key="k", app_secret="s", account_number="1234567801", base_url="https://openapivts.koreainvestment.com", _make_request=_mr) return await fresh.KoreaInvestmentAPI.place_order( inst, stock_code="111111", order_type="SELL", quantity=10, price=None, ) @pytest.mark.asyncio async def test_h1_place_order_tags_post_send_loss_and_presend_failure(): import httpx after = await _real_place_order_with(httpx.ReadTimeout("read timeout after send")) assert after["rt_cd"] == "-1" and after["msg_cd"] == "KIS_TRANSPORT_UNKNOWN" before = await _real_place_order_with(httpx.ConnectError("refused")) assert before["msg_cd"] == "KIS_PRESEND_CONNECT" cb = await _real_place_order_with(Exception("Circuit breaker open: KIS API 일시 차단 (60s)")) assert cb["msg_cd"] == "KIS_PRESEND_CIRCUIT_OPEN" def test_h1_classification(): c = es._classify_order_response assert c({"rt_cd": "0"}) == "accepted" assert c({"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN"}) == "unknown" assert c({"rt_cd": "-1", "msg1": "wrapped exception"}) == "unknown" # msg_cd 없음 → 불명 assert c(None) == "unknown" assert c({"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE"}) == "rejected" assert c({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT"}) == "rejected" assert c({"rt_cd": "-1", "msg1": "order_lock_busy"}) == "rejected" assert c({"rt_cd": "1", "msg_cd": "APBK0013", "msg1": "주문가능수량 초과"}) == "rejected" @pytest.mark.asyncio async def test_h1_post_send_loss_is_not_treated_as_rejected(monkeypatch): """4회차 재현 A(뒤집음): 전송 후 응답 유실은 '거부'가 아니다 — 표식 유지·재매도 금지.""" import app.services.trade_executor as te resp = await _real_place_order_with(__import__("httpx").ReadTimeout("lost")) monkeypatch.setattr(te, "safe_place_order", AsyncMock(return_value=resp)) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC", 60.0) day_trading_state.positions["111111"] = _p = _pos(qty=10) with patch.object(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])), \ patch.object(es.korea_investment_api, "get_balance", _bal_seq("111111", (10, 10))), \ patch("app.core.database.AsyncSessionLocal", _sess_factory([])): assert await es._execute_exit("111111", _p, 9_600.0, "손절", 1.0) is None assert es._last_exit_order_outcome["111111"] == "unknown" assert "111111" in day_trading_state.exits_in_progress assert day_trading_state.positions["111111"]["quantity"] == 10 @pytest.mark.asyncio async def test_h2_unknown_is_resolved_from_daily_orders_without_resell(monkeypatch): """4회차 재현 B(뒤집음): 보류가 끝난 뒤 재시도 직전에 당일 주문내역을 확인 — 접수돼 있으면 그 ODNO 로 반영하고 다시 주문하지 않는다.""" import app.services.trade_executor as te monkeypatch.setattr(settings, "DAY_TRADE_EXIT_ORDER_TIMEOUT_SEC", 0.05) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC", 0.1) calls = [] async def _order(**kw): calls.append(kw["quantity"]) if len(calls) == 1: await asyncio.sleep(1) # 실제로는 접수됐지만 응답 유실 return {"rt_cd": "0", "output": {"ODNO": "2"}} fin = AsyncMock(return_value={"ok": 1}) monkeypatch.setattr(te, "safe_place_order", _order) monkeypatch.setattr(es, "_finalize_exit", fin) seen = {"n": 0} async def _orders(code): seen["n"] += 1 return [{"pdno": code, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10", "odno": "0000777", "ord_tmd": es._kst_now().strftime("%H%M%S")}] monkeypatch.setattr(es.korea_investment_api, "get_todays_orders", _orders) # 직후 잔고는 아직 미반영(주문가능 10), 재시도 직전에는 매도 대기분이 빠짐(주문가능 0) monkeypatch.setattr(es.korea_investment_api, "get_balance", _bal_seq("222222", (10, 10), (10, 0))) monkeypatch.setattr("app.core.database.AsyncSessionLocal", _sess_factory([])) day_trading_state.positions["222222"] = _p = _pos(qty=10) assert await es._execute_exit("222222", _p, 9_600.0, "손절") is None await asyncio.sleep(0.15) assert "222222" not in day_trading_state.exits_in_progress out = await es._execute_exit("222222", _p, 9_600.0, "손절") assert calls == [10], f"확인 없이 재매도: {calls}" assert out == {"ok": 1} assert fin.await_args.args[5]["output"]["ODNO"] == "0000777" @pytest.mark.asyncio async def test_h2_unresolved_unknown_caps_resell_by_sellable(monkeypatch): """접수 판정이 '미접수'면 재매도 수량 = DT 매도가능(min(주문가능,보유) − 비DT)으로 상한.""" import app.services.trade_executor as te es._unresolved_exit_unknown[(es._kst_now().date().isoformat(), "222222")] = { "qty": 10, "since": es._kst_now(), "total_qty": 10} sent = [] async def _order(**kw): sent.append(kw["quantity"]) return {"rt_cd": "0", "output": {"ODNO": "5"}} monkeypatch.setattr(te, "safe_place_order", _order) monkeypatch.setattr(es, "_finalize_exit", AsyncMock(return_value={"ok": 1})) monkeypatch.setattr(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])) # [C-1 r9] 재시도 판정은 get_todays_orders_v2(실패=None) + DT 원장 상한을 쓴다(목킹 보정) monkeypatch.setattr(es.korea_investment_api, "get_todays_orders_v2", AsyncMock(return_value=[])) monkeypatch.setattr(es, "_fetch_dt_ledger_net_qty_impl", AsyncMock(return_value=10)) # 보유 30, 주문가능 26, 비DT(스윙) 20 → DT 매도가능 6 (> 10−10=0 → 미접수) monkeypatch.setattr(es.korea_investment_api, "get_balance", _bal_seq("222222", (30, 26))) monkeypatch.setattr("app.core.database.AsyncSessionLocal", _sess_factory([SimpleNamespace(stock_code="222222", quantity=20)])) day_trading_state.positions["222222"] = _p = _pos(qty=10) await es._execute_exit("222222", _p, 9_600.0, "손절") assert sent == [6] @pytest.mark.asyncio async def test_m2_finalize_tail_hang_does_not_leave_in_progress(monkeypatch): """4회차 재현 C(뒤집음): Redis 저장이 멈춰도 인메모리 반영 직후 표식이 풀리고, 꼬리는 상한에서 끊긴다.""" import app.services.trade_executor as te import app.services.portfolio_cache_bridge as pcb monkeypatch.setattr(te, "safe_place_order", AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "3"}})) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_FINALIZE_STEP_TIMEOUT_SEC", 0.1) hang = asyncio.Event() async def _save(): await hang.wait() monkeypatch.setattr(day_trading_state, "save_daily_state", _save) monkeypatch.setattr(es, "_schedule_sell_fill_reconcile", lambda **k: None) monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock()) monkeypatch.setattr("app.services.db_worker.enqueue_active_trade_upsert", AsyncMock()) monkeypatch.setattr(pcb, "reflect_sell", AsyncMock()) day_trading_state.positions["333333"] = _p = _pos(qty=10) t = asyncio.create_task(es._execute_exit("333333", _p, 10_500.0, "목표", 0.5)) await asyncio.sleep(0.03) assert day_trading_state.positions["333333"]["quantity"] == 5 assert "333333" not in day_trading_state.exits_in_progress res = await asyncio.wait_for(t, timeout=2.0) assert res and res["quantity"] == 5 hang.set() @pytest.mark.asyncio async def test_m4_rejected_or_foreign_rows_are_not_acceptance(monkeypatch): """4회차 재현 D(뒤집음): 거부 행·수량 불일치·주문 시각 이전 행은 DT 접수로 보지 않는다.""" now = es._kst_now() old = (now - timedelta(hours=2)).strftime("%H%M%S") rows = [ {"pdno": "444444", "sll_buy_dvsn_cd": "01", "ord_qty": "4", "tot_ccld_qty": "0", "cncl_yn": "N", "rjct_qty": "4", "rmn_qty": "0"}, # 거부 {"pdno": "444444", "sll_buy_dvsn_cd": "01", "ord_qty": "7", "tot_ccld_qty": "7"}, # 수량 다름(스윙) {"pdno": "444444", "sll_buy_dvsn_cd": "01", "ord_qty": "4", "tot_ccld_qty": "4", "ord_tmd": old}, # 시각 이전 {"pdno": "444444", "sll_buy_dvsn_cd": "02", "ord_qty": "4", "tot_ccld_qty": "4"}, # 매수 ] monkeypatch.setattr(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)) assert await es._stale_sell_accepted_today_impl("444444", qty=4, since=now) is False ok = rows + [{"pdno": "444444", "sll_buy_dvsn_cd": "01", "ord_qty": "4", "tot_ccld_qty": "0", "rmn_qty": "4", "ord_tmd": now.strftime("%H%M%S")}] monkeypatch.setattr(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=ok)) assert await es._stale_sell_accepted_today_impl("444444", qty=4, since=now) is True @pytest.mark.asyncio async def test_m3_transient_failures_are_not_counted_and_exhaustion_keeps_retrying(monkeypatch): monkeypatch.setattr(settings, "DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC", 0.0) monkeypatch.setattr(settings, "DAY_TRADE_STALE_SELL_EXHAUSTED_RETRY_SEC", 0.0) day_trading_state.positions["064400"] = _stale_pos() calls = [] async def _order(**kw): calls.append(kw) return {"rt_cd": "1", "msg_cd": "EGW00201", "msg1": "초당 거래건수 초과"} await _run_stale(int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES) + 3, _stale_env(_order)) key = (es._kst_now().date().isoformat(), "064400") assert len(calls) == int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES) + 3 assert es._stale_sell_failures[key][0] == 0, "레이트리밋 거부를 횟수에 셌다" # 업무 거부가 한도에 닿아도 EXHAUSTED 간격으로 계속 시도한다 es._stale_sell_failures.clear() calls.clear() async def _biz(**kw): calls.append(kw) return {"rt_cd": "1", "msg_cd": "APBK0013", "msg1": "업무 거부"} await _run_stale(int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES) + 2, _stale_env(_biz)) assert len(calls) == int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES) + 2 @pytest.mark.asyncio async def test_m3_exhausted_retry_respects_long_interval(monkeypatch): monkeypatch.setattr(settings, "DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC", 0.0) monkeypatch.setattr(settings, "DAY_TRADE_STALE_SELL_EXHAUSTED_RETRY_SEC", 3600.0) day_trading_state.positions["064400"] = _stale_pos() calls = [] async def _biz(**kw): calls.append(kw) return {"rt_cd": "1", "msg_cd": "APBK0013", "msg1": "업무 거부"} await _run_stale(int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES) + 3, _stale_env(_biz)) assert len(calls) == int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES) @pytest.mark.asyncio async def test_l1_accepted_then_zero_holding_cleans_active_trade(): """접수 후 체결로 보유 0 이 되면, attempted 상태여도 ActiveTrade 를 정리한다.""" key = (es._kst_now().date().isoformat(), "064400") es._stale_sell_attempted.add(key) day_trading_state.positions["064400"] = _stale_pos() close_mock = AsyncMock() with patch.object(es, "is_market_open", return_value=True), \ patch("app.core.database.AsyncSessionLocal", _sess_factory([], [])), \ patch.object(es.korea_investment_api, "get_balance", AsyncMock(return_value={"output1": [], "output2": [{}]})), \ patch.object(es, "enqueue_close_active_trade", close_mock), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): await es.close_stale_positions() await es.close_stale_positions() close_mock.assert_awaited_once_with("064400", strategy_name="day_trade") @pytest.mark.asyncio async def test_l2_finalize_exception_applies_memory_and_holds(monkeypatch): import app.services.trade_executor as te monkeypatch.setattr(te, "safe_place_order", AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "9"}})) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC", 60.0) async def _bad(*a, **k): raise RuntimeError("finalize bug") monkeypatch.setattr(es, "_finalize_exit", _bad) day_trading_state.positions["555555"] = _p = _pos(qty=10) assert await es._execute_exit("555555", _p, 9_600.0, "손절") is None assert "555555" not in day_trading_state.positions, "체결된 매도가 인메모리에 남았다" assert "555555" in day_trading_state.exits_in_progress @pytest.mark.asyncio async def test_l8_replaced_position_is_not_sold(monkeypatch): import app.services.trade_executor as te monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order")) old = _pos(qty=10) day_trading_state.positions["666666"] = _pos(qty=3) # 판정 이후 교체됨 assert await es._execute_exit("666666", old, 9_600.0, "손절") is None assert "666666" not in day_trading_state.exits_in_progress @pytest.mark.asyncio async def test_hb_early_release_is_not_cleared_by_previous_finally(monkeypatch): """finalize 가 먼저 해제한 뒤 새 청산이 표식을 잡으면, 앞 작업의 finally 가 지우지 않는다.""" es._exit_tokens["777777"] = object() day_trading_state.exits_in_progress.add("777777") stale_token = object() es._release_exit_in_progress("777777", token=stale_token) assert "777777" in day_trading_state.exits_in_progress # ═════════════════════════════════════════════════════════════════════════════ # 5회차 (2026-10-02) — H-2 스윙 공존 / M-3 경보 / L-1 날짜 키·스윙 오인 / L-2 # ═════════════════════════════════════════════════════════════════════════════ async def _r5_setup(monkeypatch, bal, swing_qty=20, first_hangs=True): import app.services.trade_executor as te monkeypatch.setattr(settings, "DAY_TRADE_EXIT_ORDER_TIMEOUT_SEC", 0.05) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC", 0.05) sent = [] async def _order(**kw): sent.append(kw["quantity"]) if first_hangs and len(sent) == 1: await asyncio.sleep(1) # 접수됐으나 응답 유실 return {"rt_cd": "0", "output": {"ODNO": str(len(sent))}} async def _fin(stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit): # 실제 finalize 처럼 전량 매도면 인메모리 포지션을 제거한다 async with day_trading_state.trade_lock: if is_full_exit: day_trading_state.positions.pop(stock_code, None) return {"ok": 1} monkeypatch.setattr(te, "safe_place_order", _order) monkeypatch.setattr(es, "_finalize_exit", _fin) monkeypatch.setattr(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])) monkeypatch.setattr(es.korea_investment_api, "get_balance", bal) monkeypatch.setattr("app.core.database.AsyncSessionLocal", _sess_factory([SimpleNamespace(stock_code="333333", quantity=swing_qty)])) return sent @pytest.mark.asyncio async def test_r5_unknown_does_not_resell_swing_shares(monkeypatch): """5회차 재현(뒤집음): DT 10 + 스윙 20, 1차 매도 접수·응답 유실, 주문내역 조회 빈 결과. 주문가능 20(대기 10 차감) − 비DT 20 = DT 매도가능 0 → 접수로 판정, 재매도 없음.""" sent = await _r5_setup(monkeypatch, _bal_seq("333333", (30, 20))) day_trading_state.positions["333333"] = p = _pos(10) out1 = await es._execute_exit("333333", p, 9_600.0, "손절") await asyncio.sleep(0.1) await es._execute_exit("333333", p, 9_600.0, "손절") assert sent == [10], f"스윙 물량 재매도: {sent}" assert out1 == {"ok": 1} @pytest.mark.asyncio async def test_r5_balance_lag_then_reflected_no_resell(monkeypatch): """직후 잔고가 아직 미반영(주문가능 30)이어도 재시도 직전에 반영(20)되면 재매도하지 않는다.""" sent = await _r5_setup(monkeypatch, _bal_seq("333333", (30, 30), (30, 20))) day_trading_state.positions["333333"] = p = _pos(10) assert await es._execute_exit("333333", p, 9_600.0, "손절") is None assert "333333" in day_trading_state.positions await asyncio.sleep(0.1) assert await es._execute_exit("333333", p, 9_600.0, "손절") == {"ok": 1} assert sent == [10] assert "333333" not in day_trading_state.positions @pytest.mark.asyncio async def test_r5_lookup_failures_hold_and_alert_telegram(monkeypatch): """[M-3] 불명 이후 확인 조회가 계속 실패하면 재매도하지 않고, N회 연속이면 텔레그램 경보.""" sent = await _r5_setup(monkeypatch, AsyncMock(side_effect=RuntimeError("KIS down"))) alert = AsyncMock() from app.services import telegram_notifier as tn monkeypatch.setattr(tn.TelegramNotifier, "notify_system_warning", alert) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT", 3) day_trading_state.positions["333333"] = p = _pos(10) await es._execute_exit("333333", p, 9_600.0, "손절") for _ in range(4): await asyncio.sleep(0.08) await es._execute_exit("333333", p, 9_600.0, "손절") await asyncio.sleep(0.05) assert sent == [10], f"판단 불가 상태에서 재매도: {sent}" assert alert.await_count == 1, "N회 연속 실패 경보는 종목·일 1회" def test_r5_unknown_state_is_date_scoped(): yesterday = (es._kst_now() - timedelta(days=1)).date().isoformat() es._unresolved_exit_unknown[(yesterday, "333333")] = {"qty": 1, "since": es._kst_now()} es._finalized_sell_odnos.add((yesterday, "777")) es._unknown_lookup_failures[(yesterday, "333333")] = 5 es._prune_exit_state_for_today() assert not es._unresolved_exit_unknown and not es._finalized_sell_odnos assert not es._unknown_lookup_failures def test_r5_finalized_odno_excluded_only_today(): rows = [{"pdno": "333333", "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10", "odno": "0000777"}] es._finalized_sell_odnos.add((es._kst_now().date().isoformat(), "777")) assert es._find_accepted_sell_row(rows, "333333", 10, None) is None def test_r5_presend_codes_from_safe_place_order_are_rejected(): c = es._classify_order_response assert c({"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY", "msg1": "order_lock_busy"}) == "rejected" assert c({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_EXCEPTION", "msg1": "x"}) == "rejected" import app.services.trade_executor as te assert {te.SAFE_ORDER_MSG_LOCK_BUSY, te.SAFE_ORDER_MSG_PRESEND_EXCEPTION} <= es._PRESEND_MSG_CDS @pytest.mark.asyncio async def test_r5_stale_row_without_holdings_drop_is_not_acceptance(monkeypatch): """[L-1] 주문내역에 같은 수량 매도 행(스윙 등)이 있어도 잔고가 줄지 않았으면 DT 접수로 보지 않는다.""" monkeypatch.setattr(settings, "DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC", 0.0) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC", 0.0) day_trading_state.positions["064400"] = _stale_pos() calls = [] async def _order(**kw): calls.append(kw) if len(calls) == 1: return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout"} return {"rt_cd": "0", "output": {"ODNO": "9"}} rows = [{"pdno": "064400", "sll_buy_dvsn_cd": "01", "ord_qty": "4", "tot_ccld_qty": "4", "odno": "0000555"}] # [C-1 r9] 재시도 판정은 get_todays_orders_v2 + DT 원장 상한(목킹 보정). 행에 주문시각이 없어 # 등록 이후임을 입증하지 못하므로 v3 도 접수로 보지 않는다. with patch.object(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \ patch.object(es.korea_investment_api, "get_todays_orders_v2", AsyncMock(return_value=rows)), \ patch.object(es, "_fetch_dt_ledger_net_qty_impl", AsyncMock(return_value=4)): await _run_stale(2, _stale_env(_order, bal=_bal_seq("064400", (4, 4)))) assert len(calls) == 2, "잔고 감소 없는 주문내역 행을 접수로 오인해 재매도를 막았다" # ═════════════════════════════════════════════════════════════════════════════ # R7 — /positions 현재가 조회 실패 시 -100% 표시 금지 # ═════════════════════════════════════════════════════════════════════════════ def _boom_price(*a, **k): raise RuntimeError("KIS 현재가 장애(테스트)") @pytest.mark.asyncio async def test_r7_positions_price_failure_uses_engine_cached_price(monkeypatch, caplog): from app.services.korea_investment_api import KoreaInvestmentAPI from app.api.endpoints import day_trading as dt monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", AsyncMock(side_effect=_boom_price)) dt._price_fail_logged_at.clear() pos = _pos() entry = float(pos["entry_price"]) pos["current_price"] = entry * 1.01 day_trading_state.positions["666666"] = pos with caplog.at_level("WARNING"): res = await dt.get_positions() row = [r for r in res["positions"] if r["stock_code"] == "666666"][0] assert row["price_source"] == "engine_cached" assert row["current_price"] == pytest.approx(entry * 1.01) assert row["pnl_pct"] == pytest.approx(1.0, abs=0.01) assert any("현재가 조회 실패" in r.message for r in caplog.records) @pytest.mark.asyncio async def test_r7_positions_price_failure_without_cache_returns_none(monkeypatch, caplog): from fastapi.responses import JSONResponse from app.services.korea_investment_api import KoreaInvestmentAPI from app.api.endpoints import day_trading as dt monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", AsyncMock(side_effect=_boom_price)) dt._price_fail_logged_at.clear() pos = _pos() pos.pop("current_price", None) day_trading_state.positions["777777"] = pos with caplog.at_level("WARNING"): res = await dt.get_positions() await dt.get_positions() # 두 번째 실패는 스로틀로 경고 생략 JSONResponse(content=res) row = [r for r in res["positions"] if r["stock_code"] == "777777"][0] assert row["current_price"] is None assert row["pnl"] is None and row["pnl_pct"] is None assert row["price_source"] == "unavailable" warns = [r for r in caplog.records if "777777" in r.message and "현재가 조회 실패" in r.message] assert len(warns) == 1 @pytest.mark.asyncio async def test_r7_get_current_price_legacy_contract_and_zero_price(monkeypatch): from app.services.korea_investment_api import KoreaInvestmentAPI from app.api.endpoints import day_trading as dt monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "0"}})) dt._price_fail_logged_at.clear() assert await dt._get_current_price_v2("888888") is None assert await dt._get_current_price("888888") == 0.0