- 스윙 체결가 확인 전 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
358 lines
17 KiB
Python
358 lines
17 KiB
Python
"""[5회차 2026-10-02] 최종 게이트 결함 회귀 — H-3 / M-1(MDD) / M-2(주문 락) / L-6.
|
|
|
|
(H-1 시장가 미취소·지정가 롤백, L-5 는 tests/test_round4_fixes_20261002.py)
|
|
재현: scratchpad/r5/test_r5_repro.py 의 MDD·스윙 재시도 시나리오를 뒤집어 고정한다.
|
|
"""
|
|
import json
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from app.core.config import settings
|
|
from app.services import mdd_manager as mm
|
|
from app.services import trade_executor as te
|
|
|
|
|
|
async def _nosleep(*a, **k):
|
|
return None
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# H-3: 전송 후 결과 불명은 재전송 금지
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
@pytest.mark.parametrize("resp,unknown,retry", [
|
|
({"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN"}, True, False),
|
|
({"rt_cd": "-1", "msg1": "x"}, True, False), # msg_cd 없음
|
|
({}, True, False), # 응답 없음
|
|
({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT"}, False, True),
|
|
({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CIRCUIT_OPEN"}, False, True),
|
|
({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_EXCEPTION"}, False, True),
|
|
({"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE"}, False, True),
|
|
({"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY"}, False, True),
|
|
({"rt_cd": "1", "msg_cd": "EGW00201"}, False, True),
|
|
({"rt_cd": "1", "msg_cd": "EGW00123"}, False, True),
|
|
({"rt_cd": "1", "msg_cd": "APBK0013"}, False, False), # 업무 거부
|
|
])
|
|
def test_h3_classification(resp, unknown, retry):
|
|
assert te._is_order_outcome_unknown_v2(resp) is unknown
|
|
assert te._is_retryable_order_failure(resp) is retry
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_h3_repro_transport_unknown_not_resent(monkeypatch):
|
|
"""재현 뒤집기: KIS_TRANSPORT_UNKNOWN + 주문내역 조회 [] → 재전송하지 않는다(sent == [10])."""
|
|
sent = []
|
|
|
|
async def _po(code, side, qty, price):
|
|
sent.append(qty)
|
|
if len(sent) == 1:
|
|
return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout"}
|
|
return {"rt_cd": "0", "output": {"ODNO": "9"}}
|
|
monkeypatch.setattr(te.korea_investment_api, "place_order", _po)
|
|
monkeypatch.setattr(te.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[]))
|
|
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
|
|
r = await te._place_order_with_retry(True, "005930", "BUY", 10, 70000.0)
|
|
assert sent == [10], sent
|
|
assert te._is_order_outcome_unknown_v2(r)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_h3_unknown_found_in_orders_returns_lookup_marked(monkeypatch):
|
|
async def _po(*a):
|
|
return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN"}
|
|
monkeypatch.setattr(te.korea_investment_api, "place_order", _po)
|
|
from datetime import datetime
|
|
now = datetime.now().strftime("%H%M%S")
|
|
monkeypatch.setattr(te.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[
|
|
{"pdno": "005930", "sll_buy_dvsn_cd": "01", "ord_qty": "3", "ord_tmd": now, "odno": "77"},
|
|
]))
|
|
r = await te._place_order_with_retry(True, "005930", "SELL", 3, None)
|
|
assert r["rt_cd"] == "0" and r["_matched_by_lookup"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_h3_sell_unknown_recorded_pending_not_failed(monkeypatch):
|
|
"""결과 불명 매도는 PENDING — 30초 중복매도 차단·당일 매도 판정이 이 주문을 센다."""
|
|
import app.services.db_worker as dbw
|
|
rows = []
|
|
|
|
async def _th(p):
|
|
rows.append(p)
|
|
|
|
async def _po(**kw):
|
|
return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "timeout"}
|
|
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())
|
|
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
|
|
monkeypatch.setattr(dbw, "enqueue_trade_history", _th)
|
|
monkeypatch.setattr(te.korea_investment_api, "get_current_price", AsyncMock(side_effect=AssertionError))
|
|
# [하드닝 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", {})
|
|
|
|
class _DB:
|
|
async def execute(self, stmt):
|
|
return SimpleNamespace(scalars=lambda: SimpleNamespace(first=lambda: None, all=lambda: []))
|
|
from app.services.daytrading.position_manager import day_trading_state
|
|
day_trading_state.positions.pop("005930", None)
|
|
h = SimpleNamespace(quantity=10, average_price=60000, profit_loss_rate=0, strategy_type="swing",
|
|
stock_name="삼성", current_price=70000)
|
|
r = await te._execute_sell({"stock_code": "005930", "strategy_type": "swing", "remaining_pct": 1.0},
|
|
_DB(), h, 70000, ["x"], True, "KRX", "005930", "삼성", sell_pct=1.0)
|
|
assert r["success"] is False
|
|
assert rows and rows[-1]["status"] == "PENDING"
|
|
|
|
|
|
def test_h3_buy_unknown_is_pending_and_counted():
|
|
import inspect
|
|
src = inspect.getsource(te._execute_buy)
|
|
assert 'status = "COMPLETED" if success else ("PENDING" if _outcome_unknown else "FAILED")' in src
|
|
i = src.index("elif _outcome_unknown:")
|
|
blk = src[i:i + 900]
|
|
assert "note_system_buy(stock_code, quantity)" in blk and "_note_cycle_buy_v2(" in blk
|
|
# 거부 쿨다운은 결과 불명에 걸지 않는다
|
|
assert "not _outcome_unknown and not _is_retryable_order_failure(order_result)" in src
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# M-1: MDD 축소 — 결과 불명은 잔고 확인 전 재매도 금지, 일시 실패는 횟수 미산정
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
class _Acct:
|
|
def __init__(self, qty, with_psbl=True):
|
|
self.qty = qty
|
|
self.open_sell = 0 # KIS 에 살아 있는 미체결 매도
|
|
self.with_psbl = with_psbl
|
|
self.sent = []
|
|
self.script = [] # safe_place_order 응답 순서
|
|
|
|
async def get_balance(self, *a, **k):
|
|
row = {"pdno": "005930", "prdt_name": "S", "hldg_qty": str(self.qty), "prpr": "70000",
|
|
"pchs_avg_pric": "80000"}
|
|
if self.with_psbl:
|
|
row["ord_psbl_qty"] = str(self.qty - self.open_sell)
|
|
return {"rt_cd": "0", "output1": [row]}
|
|
|
|
async def order(self, stock_code, order_type, quantity, price=None, **kw):
|
|
self.sent.append(quantity)
|
|
r = self.script.pop(0) if self.script else {"rt_cd": "0", "output": {"ODNO": "1"}}
|
|
if r.get("rt_cd") == "0":
|
|
self.qty -= quantity
|
|
return r
|
|
|
|
|
|
@pytest.fixture
|
|
def mdd_env(monkeypatch):
|
|
import app.services.db_worker as dbw
|
|
import app.core.market_schedule as ms
|
|
|
|
async def _noop(*a, **k):
|
|
return None
|
|
monkeypatch.setattr(mm, "_sync_active_trade_after_reduction", _noop)
|
|
monkeypatch.setattr(mm, "_reflect_reduction", _noop)
|
|
monkeypatch.setattr(dbw, "enqueue_trade_history", _noop)
|
|
monkeypatch.setattr(ms, "is_market_open", lambda: True)
|
|
monkeypatch.setattr(mm.asyncio, "sleep", _nosleep)
|
|
|
|
def _make(acct):
|
|
# mdd_manager 는 함수 안에서 모듈 싱글톤을 import 한다 — 모듈 재적재로 te 쪽 참조와
|
|
# 다를 수 있으므로 둘 다 덮는다.
|
|
import app.services.korea_investment_api as kmod
|
|
monkeypatch.setattr(kmod.korea_investment_api, "get_balance", acct.get_balance)
|
|
monkeypatch.setattr(te.korea_investment_api, "get_balance", acct.get_balance)
|
|
monkeypatch.setattr(te, "safe_place_order", acct.order)
|
|
m = mm.MDDManager()
|
|
m.save_to_redis = AsyncMock()
|
|
return m
|
|
return _make
|
|
|
|
|
|
_ACT = {"action": "REDUCE_AND_PAUSE", "reduce_pct": 0.3}
|
|
_UNKNOWN = {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout"}
|
|
|
|
|
|
async def _armed(m):
|
|
await m.update(100.0)
|
|
await m.update(89.0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_m1_repro_unknown_not_resold_without_confirmation(mdd_env):
|
|
"""재현 뒤집기: 잔고에 주문가능 수량이 없어 확인 불가 → 재매도하지 않는다(sent == [30])."""
|
|
a = _Acct(100, with_psbl=False)
|
|
a.script = [dict(_UNKNOWN)]
|
|
m = mdd_env(a)
|
|
await _armed(m)
|
|
await m.execute_reduction(dict(_ACT))
|
|
await m.execute_reduction(dict(_ACT))
|
|
assert a.sent == [30], a.sent
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_m1_unknown_but_sold_marks_done(mdd_env):
|
|
a = _Acct(100)
|
|
a.script = [dict(_UNKNOWN)]
|
|
m = mdd_env(a)
|
|
await _armed(m)
|
|
await m.execute_reduction(dict(_ACT))
|
|
a.qty = 70 # 실제로는 체결됨
|
|
await m.execute_reduction(dict(_ACT))
|
|
assert a.sent == [30]
|
|
assert "005930" in m._reduced_codes["10pct"] and m._reduction_executed_10pct is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_m1_unknown_open_order_waits_then_not_sent_reorders(mdd_env):
|
|
a = _Acct(100)
|
|
a.script = [dict(_UNKNOWN)]
|
|
m = mdd_env(a)
|
|
await _armed(m)
|
|
await m.execute_reduction(dict(_ACT))
|
|
a.open_sell = 30 # 미체결 매도가 살아 있음 → 대기
|
|
await m.execute_reduction(dict(_ACT))
|
|
assert a.sent == [30]
|
|
a.open_sell = 0 # 미접수로 판명(보유·주문가능 불변) → 정상 재주문
|
|
await m.execute_reduction(dict(_ACT))
|
|
assert a.sent == [30, 30] and a.qty == 70
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_m1_transient_failures_not_counted(mdd_env):
|
|
cap = settings.MDD_REDUCTION_MAX_RETRIES
|
|
a = _Acct(100)
|
|
a.script = [{"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE"}] * (cap + 2)
|
|
m = mdd_env(a)
|
|
await _armed(m)
|
|
for _ in range(cap + 2):
|
|
await m.execute_reduction(dict(_ACT))
|
|
assert "005930" not in m._reduction_gave_up.get("10pct", set()), "일시 장애로 당일 포기"
|
|
assert m._reduction_attempts.get("10pct", {}).get("005930", 0) == 0
|
|
await m.execute_reduction(dict(_ACT))
|
|
assert a.qty == 70
|
|
|
|
|
|
def test_m1_unconfirmed_persisted():
|
|
m = mm.MDDManager()
|
|
m._reduction_unconfirmed = {"10pct": {"005930": {"qty_before": 100, "sell_qty": 30, "cycles": 1}}}
|
|
st = json.loads(json.dumps(m._reduction_progress_state()))
|
|
m2 = mm.MDDManager()
|
|
m2._restore_reduction_progress(st)
|
|
assert m2._reduction_unconfirmed == m._reduction_unconfirmed
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# M-2: 프로세스 내 in-flight 주문 락
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("redis_mode", ["none", "error"])
|
|
async def test_m2_inflight_blocks_duplicate_when_redis_unavailable(monkeypatch, redis_mode):
|
|
import app.core.redis as r
|
|
|
|
def _gr():
|
|
if redis_mode == "error":
|
|
raise TimeoutError("redis timeout")
|
|
return None
|
|
monkeypatch.setattr(r, "get_redis", _gr)
|
|
monkeypatch.setattr(r, "delete_key", AsyncMock())
|
|
assert await te._acquire_order_lock("005930", "SELL") is True
|
|
assert await te._acquire_order_lock("005930", "SELL") is False, "같은 종목 SELL 중복 허용"
|
|
assert await te._acquire_order_lock("005930", "BUY") is True # 방향이 다르면 독립
|
|
await te._release_order_lock("005930", "SELL")
|
|
assert await te._acquire_order_lock("005930", "SELL") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_m2_redis_held_elsewhere_does_not_leave_local_entry(monkeypatch):
|
|
import app.core.redis as r
|
|
client = SimpleNamespace(set=AsyncMock(return_value=None))
|
|
monkeypatch.setattr(r, "get_redis", lambda: client)
|
|
assert await te._acquire_order_lock("005930", "SELL") is False
|
|
assert ("005930", "SELL") not in te._inflight_order_locks
|
|
|
|
|
|
def test_m2_local_entry_expires_after_ttl(monkeypatch):
|
|
te._inflight_order_locks[("005930", "SELL")] = te.time.monotonic() - te._ORDER_LOCK_TTL - 1
|
|
assert te._local_order_lock_held_v2(("005930", "SELL")) is False
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# L-6: MDD Redis 쓰기는 Lock 밖
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_l6_redis_write_outside_lock(monkeypatch):
|
|
import app.core.redis as r
|
|
m = mm.MDDManager()
|
|
held = []
|
|
|
|
async def _set(key, val, **kw):
|
|
held.append(m._lock.locked())
|
|
return True
|
|
monkeypatch.setattr(r, "get_redis", lambda: SimpleNamespace(set=_set))
|
|
await m.update(100.0)
|
|
await m.update(89.0) # -11% 전이(이전엔 락 안에서 save_to_redis)
|
|
await m.reset()
|
|
assert held and not any(held), held
|
|
|
|
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
# H-1 + 인프라 cancel_order_v2: 원주문 org_no 전달, ORGNO_NOT_FOUND 는 롤백 없음
|
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
@pytest.fixture
|
|
def cancel_env(monkeypatch):
|
|
import app.services.db_worker as dbw
|
|
marks, ats = [], []
|
|
|
|
async def _upd(order_id, fields):
|
|
marks.append((order_id, dict(fields)))
|
|
|
|
async def _u(p):
|
|
ats.append(dict(p))
|
|
monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd)
|
|
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _u)
|
|
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
|
|
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
|
|
monkeypatch.setattr(te.korea_investment_api, "get_order_execution", AsyncMock(return_value={
|
|
"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 5}))
|
|
return SimpleNamespace(marks=marks, ats=ats)
|
|
|
|
|
|
_SNAP5 = {"stock_code": "005930", "strategy_name": "swing", "remaining_pct": 1.0, "quantity": 10}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_h1_cancel_uses_v2_with_org_no(cancel_env, monkeypatch):
|
|
cancel = AsyncMock(return_value={"rt_cd": "0"})
|
|
monkeypatch.setattr(te.korea_investment_api, "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(_SNAP5), order_org_no="06010",
|
|
)
|
|
cancel.assert_awaited_once_with("0001", org_no="06010", quantity=5, stock_code="005930")
|
|
assert cancel_env.marks and cancel_env.ats == [_SNAP5]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_h1_orgno_not_found_no_rollback(cancel_env, monkeypatch, caplog):
|
|
monkeypatch.setattr(te.korea_investment_api, "cancel_order_v2", AsyncMock(
|
|
return_value={"rt_cd": "-1", "msg_cd": "ORGNO_NOT_FOUND", "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(_SNAP5),
|
|
)
|
|
assert cancel_env.marks == [] and cancel_env.ats == [], "취소 미확정인데 CANCELLED/롤백"
|
|
assert any("수동 취소" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_h1_execute_sell_passes_org_no_from_order_response():
|
|
import inspect
|
|
src = inspect.getsource(te._execute_sell)
|
|
assert "order_org_no=_extract_org_no_v2(order_result)" in src
|