Files
auto-stock/backend/tests/test_exec_r9_20261012.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 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>
2026-10-12 15:37:16 +09:00

217 lines
9.6 KiB
Python

"""하드닝 R9 그룹 A — 집행·스케줄러 회귀 테스트 (2026-10-12).
A-1 늦게 확정된 0체결·부분체결 스윙 BUY 의 ActiveTrade 원복(fill_sweep) + 직후 확인의 레지스트리 재등록
A-2 는 test_swing_scheduler_r9_20261012.py
목: KIS 체결조회는 테스트별 지정 응답만 가진 SimpleNamespace(다른 속성 접근은 AttributeError),
db_worker 큐 함수는 호출 기록용 AsyncMock. 실 KIS·Redis·DB 호출 없음.
"""
import asyncio
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
import app.services.trade_executor as te
import app.services.swing.fill_sweep as fs
import app.services.db_worker as dbw
import app.services.korea_investment_api as kia
_real_sleep = asyncio.sleep
@pytest.fixture(autouse=True)
def _queue(monkeypatch):
rec = SimpleNamespace(
close=AsyncMock(), upsert=AsyncMock(), th=AsyncMock(), th_oid=AsyncMock(),
)
monkeypatch.setattr(dbw, "enqueue_close_active_trade", rec.close)
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", rec.upsert)
monkeypatch.setattr(dbw, "enqueue_update_trade_history", rec.th)
monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", rec.th_oid)
monkeypatch.setattr(te, "_buy_entry_rollbacks", {})
monkeypatch.setattr(te, "_entry_fill_adjusted_orders", {})
monkeypatch.setattr(fs, "_queued_ids", set())
monkeypatch.setattr(fs, "_attempts", {})
monkeypatch.setattr(fs, "_auction_fill_pending_v2", lambda *a, **k: False)
yield rec
def _th_row(**kw):
base = dict(
id=101, stock_code="005930", order_id="0000012345", order_type="BUY",
order_quantity=10, order_price=70000.0, total_amount=700000.0,
strategy_type="swing", entry_reason="평균회귀 RSI 반등", status="COMPLETED",
created_at=datetime.now() - timedelta(minutes=10), net_profit_loss=None, commission=None,
)
base.update(kw)
return SimpleNamespace(**base)
def _at_row(**kw):
base = dict(id=7, strategy_name="mean_reversion", entry_reason="평균회귀 RSI 반등",
quantity=10, status="active")
base.update(kw)
return SimpleNamespace(**base)
def _db_with(rows):
res = MagicMock()
res.scalars.return_value.all.return_value = rows
db = MagicMock()
db.execute = AsyncMock(return_value=res)
return db
def _kis(monkeypatch, info):
api = SimpleNamespace(get_order_execution=AsyncMock(return_value=info))
monkeypatch.setattr(kia, "korea_investment_api", api)
return api
def _active(monkeypatch, rows):
monkeypatch.setattr(fs, "_load_active_rows_for_fill_v2", AsyncMock(return_value=rows))
# ═══════════════════════ A-1 fill_sweep ════════════════════════════════
@pytest.mark.asyncio
async def test_late_zero_fill_cancels_and_closes_active_trade_from_registry(monkeypatch, _queue):
te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False)
_kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0})
_active(monkeypatch, [_at_row()])
n = await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()]))
assert n == 1
_queue.th.assert_awaited_once_with(101, {"status": "CANCELLED", "executed_quantity": 0})
_queue.close.assert_awaited_once_with("005930", "mean_reversion")
assert te._buy_entry_rollbacks == {}
@pytest.mark.asyncio
async def test_late_zero_fill_after_restart_reconstructs_from_trade_history(monkeypatch, _queue):
# 레지스트리 비어 있음(재기동) — TH entry_reason 으로 active 행 확정
_kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0,
"remaining_qty": None, "cancelled": True})
_active(monkeypatch, [_at_row(), _at_row(id=3, strategy_name="synced", entry_reason="동기화 유입")])
await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()]))
_queue.close.assert_awaited_once_with("005930", "mean_reversion")
@pytest.mark.asyncio
async def test_zero_fill_with_live_remainder_waits(monkeypatch, _queue):
_kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 10})
_active(monkeypatch, [_at_row()])
assert await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) == 0
_queue.th.assert_not_awaited()
_queue.close.assert_not_awaited()
assert 101 not in fs._queued_ids
@pytest.mark.asyncio
async def test_late_partial_fill_resizes_before_entry_adjust(monkeypatch, _queue):
te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False)
_kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 4, "filled_price": 69900.0, "remaining_qty": 0})
_active(monkeypatch, [_at_row()])
await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()]))
payloads = [c.args[0] for c in _queue.upsert.await_args_list]
assert payloads[0] == {"stock_code": "005930", "strategy_name": "mean_reversion", "quantity": 4}
assert "entry_fill_adjust" in payloads[1] and payloads[1]["strategy_name"] == "mean_reversion"
_queue.close.assert_not_awaited()
@pytest.mark.asyncio
async def test_late_partial_fill_after_restart_reconstructs(monkeypatch, _queue):
_kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 6, "filled_price": 70000.0, "remaining_qty": 0})
_active(monkeypatch, [_at_row()])
await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()]))
assert _queue.upsert.await_args_list[0].args[0]["quantity"] == 6
@pytest.mark.asyncio
async def test_rollback_is_idempotent_across_sweeps(monkeypatch, _queue):
te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False)
_kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0})
_active(monkeypatch, [_at_row()])
db = _db_with([_th_row()])
await fs.sweep_unconfirmed_fills_v2(db)
await fs.sweep_unconfirmed_fills_v2(db) # 워커 반영 전 같은 행이 다시 조회돼도
assert _queue.close.await_count == 1
assert _queue.th.await_count == 1
def test_reconstruct_already_resized_is_noop():
rb, why = fs.reconstruct_buy_rollback_v2(_th_row(), [_at_row(quantity=4)], 4)
assert rb is None and why == "already"
def test_reconstruct_accumulate_and_ambiguous():
rb, _ = fs.reconstruct_buy_rollback_v2(_th_row(), [_at_row(quantity=25)], 4)
assert rb["accumulate"] is True
rb, why = fs.reconstruct_buy_rollback_v2(_th_row(), [_at_row(quantity=7)], 4)
assert rb is None and "역산" in why
rb, _ = fs.reconstruct_buy_rollback_v2(_th_row(), [], 0)
assert rb is None
@pytest.mark.asyncio
async def test_accumulate_partial_requests_manual_not_resize(monkeypatch, _queue):
monkeypatch.setattr(te, "_notify_buy_ledger_v2", lambda m: None)
_kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 4, "filled_price": 70000.0, "remaining_qty": 0})
_active(monkeypatch, [_at_row(quantity=25)])
await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()]))
assert all("quantity" not in c.args[0] for c in _queue.upsert.await_args_list)
@pytest.mark.asyncio
async def test_dt_or_manual_buy_rows_not_rolled_back(monkeypatch, _queue):
_kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0})
_active(monkeypatch, [_at_row()])
await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row(strategy_type="manual")]))
_queue.close.assert_not_awaited()
_queue.th.assert_not_awaited()
# ═══════════════════════ A-1 직후 확인 재등록 ═══════════════════════════
async def _fast_sleep(_s):
await _real_sleep(0)
@pytest.mark.asyncio
@pytest.mark.parametrize("info", [
{"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 10},
{"ok": True, "found": True, "filled_qty": 3, "filled_price": 70000.0, "remaining_qty": 7},
])
async def test_verify_restores_registry_when_not_terminal(monkeypatch, _queue, info):
monkeypatch.setattr(te.asyncio, "sleep", _fast_sleep)
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
monkeypatch.setattr(te, "korea_investment_api",
SimpleNamespace(get_order_execution=AsyncMock(return_value=info)))
te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False)
await te._verify_and_sync_execution_v2(
"0000012345", "005930", "삼성전자", 10, 70000.0, "BUY", True, "KRX", 0, delay=0,
)
rb = te._pop_buy_entry_rollback_v2("0000012345", "005930")
assert rb and rb["strategy_name"] == "mean_reversion" and rb["quantity"] == 10
_queue.close.assert_not_awaited()
@pytest.mark.asyncio
async def test_verify_terminal_zero_fill_consumes_registry(monkeypatch, _queue):
monkeypatch.setattr(te.asyncio, "sleep", _fast_sleep)
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
monkeypatch.setattr(te, "korea_investment_api", SimpleNamespace(get_order_execution=AsyncMock(
return_value={"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0})))
te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False)
await te._verify_and_sync_execution_v2(
"0000012345", "005930", "삼성전자", 10, 70000.0, "BUY", True, "KRX", 0, delay=0,
)
_queue.close.assert_awaited_once_with("005930", "mean_reversion")
assert te._buy_entry_rollbacks == {}
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)