Files
auto-stock/backend/tests/test_dt_safety_fixes_20261002.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

1596 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""데이트레이딩 안전·정합성 결함 회귀 테스트 (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=[]))
# 보유 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"}]
with patch.object(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
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