Files
auto-stock/backend/tests/test_round4_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

459 lines
21 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.
"""[4회차 2026-10-02] 검증 결함 회귀 — H-1 / M-2 / M-3 / L-2 / L-3 / L-4.
(M-1 reset 경합·L-1 장외 축소는 tests/test_mdd_reduction_retry_20261002.py)
주문·취소 종단은 trade_executor 가 실제로 참조하는 `te.korea_investment_api` 에 건다.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.core.config import settings
from app.services import trade_executor as te
class _MustNotCall(BaseException):
pass
def _boom(name):
async def _f(*a, **k):
raise _MustNotCall(f"{name} 가 호출되면 안 된다")
return _f
async def _nosleep(*a, **k):
return None
# ═════════════════════════════════════════════════════════════════════════════
# H-1 (a)(b): 매도 가격 — 낡은 배치가 대신 시장가 / 주문 직전 재산정
# ═════════════════════════════════════════════════════════════════════════════
def test_h1_price_policy_pure():
# 전량 청산 → 시장가
assert te._resolve_sell_order_price_v2("005930", 70000, True, True, 5.0)[1:] == (None, "market")
# 손실 청산(부분이어도) → 시장가
assert te._resolve_sell_order_price_v2("005930", 70000, True, False, -3.0)[1:] == (None, "market")
# 부분 익절 → 재산정
assert te._resolve_sell_order_price_v2("005930", 70000, True, False, 4.0)[2] == "limit_refresh"
# 해외 → 기존 지정가 유지
ref, px, kind = te._resolve_sell_order_price_v2("AAPL", 100.0, False, True, -5.0)
assert kind == "limit_overseas" and px == te._adjust_order_price(100.0, "SELL", False)
class _Scal:
def first(self):
return None
def all(self):
return []
class _SellDB:
async def execute(self, stmt):
return SimpleNamespace(scalars=lambda: _Scal(), scalar_one_or_none=lambda: None)
@pytest.fixture
def sell_env(monkeypatch):
import app.services.db_worker as dbw
from app.services.loss_tracker import loss_tracker
class _L(list):
pass
orders = _L()
async def _po(is_domestic, stock_code, order_type, quantity, price, exchange_code="NASD", max_retries=3):
orders.append({"price": price, "quantity": quantity})
return {"rt_cd": "0", "output": {"ODNO": "0001", "ORD_TMD": "100000"}}
monkeypatch.setattr(te, "_place_order_with_retry", _po)
monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True))
monkeypatch.setattr(te, "_release_order_lock", AsyncMock(return_value=None))
# [하드닝 R1] 매도 직전 KIS 실보유 상한·공용 청산 쿨다운 — 상한 넉넉히, 쿨다운 상태 격리
import app.services.swing.exit_service as _es
monkeypatch.setattr(te, "_recommendation_sell_kis_cap_v2", AsyncMock(return_value=(10**6, 10**6)))
monkeypatch.setattr(_es, "_recent_exits", {})
async def _noop(*a, **k):
return None
verifies = []
async def _cap_verify(*a, **k):
verifies.append(k)
monkeypatch.setattr(te, "_verify_and_sync_execution", _cap_verify)
monkeypatch.setattr(te, "_verify_and_sync_execution_v2", _cap_verify)
# [2026-10-08] 매수·매도 경로가 체결 기준 금액·손익 갱신용 v3 를 직접 호출한다(공통 종단)
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _cap_verify)
monkeypatch.setattr(dbw, "enqueue_trade_history", _noop)
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _noop)
monkeypatch.setattr(dbw, "enqueue_close_active_trade", _noop)
monkeypatch.setattr(dbw, "enqueue_db_write", _noop)
monkeypatch.setattr(loss_tracker, "record_trade_result_safe", AsyncMock())
monkeypatch.setattr(loss_tracker, "save_to_db", AsyncMock())
from app.services.daytrading.position_manager import day_trading_state
day_trading_state.positions.pop("005930", None)
orders.verifies = verifies # type: ignore[attr-defined]
return orders
def _holding(qty=10):
return SimpleNamespace(quantity=qty, average_price=60000, profit_loss_rate=0,
strategy_type="swing", stock_name="삼성", current_price=70000)
async def _sell(sell_pct, current_price=70000, avg=60000):
h = _holding()
h.average_price = avg
stock = {"stock_code": "005930", "strategy_type": "swing", "strategy_name": "swing",
"remaining_pct": 1.0}
return await te._execute_sell(
stock, _SellDB(), h, current_price, ["x"], True, "KRX", "005930", "삼성", sell_pct=sell_pct,
)
@pytest.mark.asyncio
async def test_h1_full_exit_is_market_order(sell_env, monkeypatch):
monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price"))
await _sell(1.0)
assert sell_env[-1]["price"] is None, "전량 청산이 낡은 배치가 지정가로 나갔다"
@pytest.mark.asyncio
async def test_h1_partial_take_profit_reprices_before_order(sell_env, monkeypatch):
monkeypatch.setattr(te.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "65000"}}))
await _sell(0.5)
assert sell_env[-1]["price"] == te._adjust_order_price(65000, "SELL", True), \
"부분 익절 지정가가 주문 직전 현재가로 재산정되지 않았다(배치가 70000 사용)"
@pytest.mark.asyncio
async def test_h1_partial_reprice_failure_falls_back_to_market(sell_env, monkeypatch):
async def _fail(*a, **k):
raise RuntimeError("시세 실패")
monkeypatch.setattr(te.korea_investment_api, "get_current_price", _fail)
await _sell(0.5)
assert sell_env[-1]["price"] is None
# ═════════════════════════════════════════════════════════════════════════════
# H-1 (c) / L-4: 체결 확인 — 미체결 매도 취소, 불성립 매수 CANCELLED
# ═════════════════════════════════════════════════════════════════════════════
@pytest.fixture
def verify_env(monkeypatch):
import app.services.db_worker as dbw
updates = []
async def _upd(order_id, fields):
updates.append((order_id, dict(fields)))
monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd)
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
async def _noop(*a, **k):
return None
monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop)
return updates
def _exec(filled, remaining, found=True):
return AsyncMock(return_value={"ok": True, "found": found, "filled_qty": filled,
"filled_price": 70000, "remaining_qty": remaining})
async def _flush_tasks():
import asyncio as _aio
await _aio.sleep(0)
@pytest.mark.asyncio
async def test_h1r5_market_full_exit_never_cancelled(sell_env, monkeypatch):
"""[5회차] 시장가(전량·손절) 매도는 미체결 취소 대상이 아니다(종가 단일가·VI·하한가)."""
monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price"))
await _sell(1.0)
await _flush_tasks()
assert sell_env.verifies and sell_env.verifies[-1]["cancel_unfilled_limit"] is False
assert sell_env.verifies[-1]["rollback_active_trade"] is None
@pytest.mark.asyncio
async def test_h1r5_limit_partial_passes_cancel_and_rollback_snapshot(sell_env, monkeypatch):
monkeypatch.setattr(te.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "65000"}}))
await _sell(0.5)
await _flush_tasks()
k = sell_env.verifies[-1]
assert k["cancel_unfilled_limit"] is True
snap = k["rollback_active_trade"]
assert snap["quantity"] == 10 and snap["remaining_pct"] == 1.0
assert snap.get("strategy_name") == "swing", "롤백이 다른 전략 행을 덮으면 안 된다"
@pytest.mark.asyncio
async def test_h1r5_v1_verify_never_cancels(verify_env, monkeypatch):
"""기존 시그니처(_verify_and_sync_execution) = 시장가·종류 불명 경로 → 잔량이 살아 있어도 취소 금지."""
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order(시장가)"))
await te._verify_and_sync_execution("0001", "005930", "삼성", 5, 70000, "SELL", True, "KRX", 0, delay=0)
assert verify_env == []
_SNAP = {"stock_code": "005930", "strategy_name": "swing", "remaining_pct": 1.0,
"quantity": 10, "highest_since_entry": 72000.0}
@pytest.fixture
def at_capture(monkeypatch):
import app.services.db_worker as dbw
cap = []
async def _u(p):
cap.append(dict(p))
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _u)
return cap
@pytest.mark.asyncio
async def test_h1r5_limit_unfilled_cancel_rolls_back_active_trade(verify_env, at_capture, monkeypatch):
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
cancel = AsyncMock(return_value={"rt_cd": "0"})
monkeypatch.setattr(k, "cancel_order_v2", cancel)
await te._verify_and_sync_execution_v2(
"0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0,
cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP),
)
cancel.assert_awaited_once_with("0001", org_no=None, quantity=5, stock_code="005930")
assert verify_env == [("0001", {"status": "CANCELLED", "executed_quantity": 0})]
assert at_capture == [_SNAP], "0체결 취소인데 ActiveTrade 가 주문 전 상태로 원복되지 않았다"
@pytest.mark.asyncio
async def test_h1r5_limit_partial_fill_cancel_fixes_quantity(verify_env, at_capture, monkeypatch):
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(2, 3))
monkeypatch.setattr(k, "cancel_order_v2", AsyncMock(return_value={"rt_cd": "0"}))
await te._verify_and_sync_execution_v2(
"0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0,
cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP),
)
assert verify_env and verify_env[-1][1]["status"] == "COMPLETED"
assert verify_env[-1][1]["executed_quantity"] == 2
assert at_capture == [{"stock_code": "005930", "strategy_name": "swing", "quantity": 8}]
@pytest.mark.asyncio
async def test_h1r5_cancel_failure_logs_error_no_rollback(verify_env, at_capture, monkeypatch, caplog):
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
monkeypatch.setattr(k, "cancel_order_v2", AsyncMock(return_value={"rt_cd": "1", "msg1": "취소불가"}))
with caplog.at_level("ERROR"):
await te._verify_and_sync_execution_v2(
"0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0,
cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP),
)
assert verify_env == [] and at_capture == []
assert any("취소 거부" in r.message for r in caplog.records)
def test_l5_lookup_matched_order_is_never_cancel_target():
import inspect
src = inspect.getsource(te._execute_sell)
i = src.index("_cancel_unfilled_limit = (")
assert 'not order_result.get("_matched_by_lookup")' in src[i:i + 300]
assert "order_price is not None" in src[i:i + 300] and "not _full_exit" in src[i:i + 300]
@pytest.mark.asyncio
async def test_h1_buy_live_remainder_is_not_cancelled(verify_env, monkeypatch):
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order(BUY)"))
await te._verify_and_sync_execution("0001", "005930", "삼성", 5, 70000, "BUY", True, "KRX", 0, delay=0)
assert verify_env == []
@pytest.mark.asyncio
async def test_l4_dead_buy_marked_cancelled(verify_env, monkeypatch):
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(0, 0))
monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order"))
await te._verify_and_sync_execution("0002", "005930", "삼성", 5, 70000, "BUY", True, "KRX", 0, delay=0)
assert verify_env == [("0002", {"status": "CANCELLED", "executed_quantity": 0})]
# ═════════════════════════════════════════════════════════════════════════════
# M-3: 일일 스윙 한도에 반영 지연 중인 사이클 매수 합산
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_m3_daily_count_includes_inflight_swing_buys():
class _DB:
async def execute(self, stmt):
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: ["000001"]))
te._recent_cycle_buys.clear()
try:
for code, st in (("000001", "swing"), ("000002", "swing"), ("000003", "manual"), ("AAPL", "swing")):
te._note_cycle_buy_v2(code, code, 1, 1000)
te._recent_cycle_buys[code]["strategy_type"] = st
te._note_cycle_buy_v2("000004", "D", 1, 1000) # 태그 없음(레거시) = 스윙 소유로 셈
# DB 000001 ∪ 지연 중 스윙 000002·000004 (manual·해외 제외, 000001 중복 제외)
assert await te._count_swing_buys_today_v2(_DB()) == 3
finally:
te._recent_cycle_buys.clear()
# ═════════════════════════════════════════════════════════════════════════════
# M-2 / L-2 / L-3: 루프
# ═════════════════════════════════════════════════════════════════════════════
class _LoopDB:
def __init__(self, held):
self.held = held
async def execute(self, stmt):
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: list(self.held)))
@pytest.fixture
def loop_env(monkeypatch):
monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False)
monkeypatch.setattr(settings, "MAX_DAILY_TRADES", 2)
monkeypatch.setattr(te, "_regime_limits_v2", lambda _r: (20, 1.0))
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
te._order_reject_cooldown.clear()
te._account_reject_at = None
te._eval_rotation_offset = 0
seen = []
async def _eat(stock, db):
seen.append((stock["stock_code"], bool(stock.get("_exit_only"))))
return {"action": "HOLD", "reason": "x"}
monkeypatch.setattr(te, "execute_auto_trade", _eat)
yield seen
te._order_reject_cooldown.clear()
te._account_reject_at = None
te._eval_rotation_offset = 0
def _recs(codes):
return {"recommendations_by_rule": [{"rule_name": "r", "stocks": [
{"stock_code": c, "rule_priority": 1, "score": 100 - i} for i, c in enumerate(codes)
]}]}
@pytest.mark.asyncio
@pytest.mark.parametrize("count", [2, None])
async def test_m2_held_exit_eval_when_buys_blocked(loop_env, monkeypatch, count):
"""일일 한도 소진(2) / 건수 조회 실패(None) — 신규는 막고 보유는 _exit_only 로 평가."""
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=count))
await te._execute_auto_trade_background_locked(_recs(["HELD", "NEW"]), _LoopDB(["HELD"]))
assert loop_env == [("HELD", True)]
@pytest.mark.asyncio
async def test_m2_regime_gate_still_evaluates_held(loop_env, monkeypatch):
monkeypatch.setattr(te, "_regime_limits_v2", lambda _r: (0, 0.0))
monkeypatch.setattr(te, "_count_swing_buys_today_v2", _boom("count(국면 게이트 시 불필요)"))
await te._execute_auto_trade_background_locked(_recs(["HELD", "NEW"]), _LoopDB(["HELD"]))
assert loop_env == [("HELD", True)]
@pytest.mark.asyncio
async def test_l2_account_reject_pauses_all_new_buys(loop_env, monkeypatch):
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0))
te._note_order_reject_v2("X", {"rt_cd": "1", "msg_cd": settings.KIS_ACCOUNT_REJECT_MSG_CODES[0]})
assert te._account_reject_cooldown_remaining_v2() > 0
await te._execute_auto_trade_background_locked(_recs(["HELD", "N1", "N2"]), _LoopDB(["HELD"]))
assert loop_env == [("HELD", True)]
# 종목 단위 거부는 계좌 쿨다운을 걸지 않는다
te._account_reject_at = None
te._note_order_reject_v2("Y", {"rt_cd": "1", "msg_cd": "APBK0013"})
assert te._account_reject_cooldown_remaining_v2() == 0.0
@pytest.mark.asyncio
async def test_l3_rotation_offset_reaches_lower_candidates(loop_env, monkeypatch):
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0))
monkeypatch.setattr(settings, "AUTO_TRADE_MAX_EVALUATIONS_PER_CYCLE", 2)
cycles = []
for _ in range(3):
loop_env.clear()
await te._execute_auto_trade_background_locked(_recs(list("ABCDE")), _LoopDB([]))
cycles.append([c for c, _ in loop_env])
assert cycles == [["A", "B"], ["C", "D"], ["E", "A"]], cycles
# ═════════════════════════════════════════════════════════════════════════════
# safe_place_order(_v2) msg_cd — 전송 전/후 구분 (DT 재시도 판정용)
# ═════════════════════════════════════════════════════════════════════════════
# conftest 가 테스트마다 te.safe_place_order 를 차단 스텁으로 바꾸므로, 수집 시점의
# 원본을 잡아 둔다(KIS 클래스 메서드 차단은 그대로 유지된다).
from app.services.trade_executor import ( # noqa: E402
safe_place_order as _REAL_SAFE_PLACE_ORDER,
safe_place_order_v2 as _REAL_SAFE_PLACE_ORDER_V2,
)
@pytest.fixture
def safe_env(monkeypatch):
released = []
monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True))
async def _rel(code, typ):
released.append((code, typ))
monkeypatch.setattr(te, "_release_order_lock", _rel)
return released
def _ctx(kis):
return SimpleNamespace(is_legacy=True, user_id=None, kis=kis)
@pytest.mark.asyncio
@pytest.mark.parametrize("variant", ["v1", "v2"])
async def test_safe_order_lock_busy_has_msg_cd(monkeypatch, variant):
monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=False))
k = SimpleNamespace(place_order=_boom("place_order"))
monkeypatch.setattr(te.korea_investment_api, "place_order", _boom("place_order"))
r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1"
else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1))
assert r == {"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY", "msg1": "order_lock_busy"}
@pytest.mark.asyncio
@pytest.mark.parametrize("variant", ["v1", "v2"])
async def test_safe_order_presend_exception(safe_env, monkeypatch, variant):
def _bad_sem(key):
raise RuntimeError("semaphore broken")
monkeypatch.setattr(te, "_get_order_semaphore", _bad_sem)
k = SimpleNamespace(place_order=_boom("place_order"))
monkeypatch.setattr(te.korea_investment_api, "place_order", _boom("place_order"))
r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1"
else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1))
assert r["rt_cd"] == "-1" and r["msg_cd"] == "KIS_PRESEND_EXCEPTION"
assert safe_env, "락이 해제되지 않았다"
@pytest.mark.asyncio
@pytest.mark.parametrize("variant", ["v1", "v2"])
@pytest.mark.parametrize("mode", ["raise", "none"])
async def test_safe_order_post_send_unknown(safe_env, monkeypatch, variant, mode):
async def _po(**kw):
if mode == "raise":
raise RuntimeError("read timeout")
return None
k = SimpleNamespace(place_order=_po)
monkeypatch.setattr(te.korea_investment_api, "place_order", _po)
r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1"
else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1))
assert r["rt_cd"] == "-1" and r["msg_cd"] == "KIS_TRANSPORT_UNKNOWN"
@pytest.mark.asyncio
async def test_safe_order_success_passthrough(safe_env, monkeypatch):
ok = {"rt_cd": "0", "output": {"ODNO": "1"}}
monkeypatch.setattr(te.korea_investment_api, "place_order", AsyncMock(return_value=ok))
assert await _REAL_SAFE_PLACE_ORDER("005930", "BUY", 1) == ok