- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강 - trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성) - korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계) - swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소) - swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램 - daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거 - main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC) - 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경) - 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기 - CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기) - 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
363 lines
17 KiB
Python
363 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(kmod.korea_investment_api, "get_balance_all_v2", acct.get_balance) # [R9] 연속조회 목 보정 — 같은 목을 공유
|
|
monkeypatch.setattr(te.korea_investment_api, "get_balance", acct.get_balance)
|
|
monkeypatch.setattr(te.korea_investment_api, "get_balance_all_v2", acct.get_balance) # [R9] 연속조회 목 보정 — 같은 목을 공유
|
|
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
|
|
|
|
|
|
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: Redis 실접속 방지)
|