- 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>
217 lines
9.6 KiB
Python
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 실접속 방지)
|