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
This commit is contained in:
265
backend/tests/test_dt_entry_hardening_r3_20261009.py
Normal file
265
backend/tests/test_dt_entry_hardening_r3_20261009.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""DT 진입 하드닝 라운드 3 회귀 테스트 (2026-10-09).
|
||||
|
||||
1. [LOW] DT ActiveTrade upsert payload 에 order_id 가 없어(운영 154/154 NULL) 재기동 복원
|
||||
포지션의 ODNO 가 빈 값이던 결함 → payload 에 order_id 를 싣는다.
|
||||
2. [LOW] 매수 0체결·부분체결 종결 보정이 인메모리·ActiveTrade 만 정리하고 Portfolio(reflect_buy)
|
||||
반영은 되돌리지 않아, sync 전까지 고아 감지가 수동확인 경보를 오발하던 결함
|
||||
→ 'daytrading' 태그 행에 한해 미체결분을 되돌린다(하한 = DT 인메모리 잔량, reflect_sell 미사용).
|
||||
|
||||
목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형. 주문 공통 종단(safe_place_order)은
|
||||
도달 시 예외. 기대값은 settings 에서 계산한다.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import ActiveTrade, Portfolio
|
||||
from app.services.daytrading import entry_service as ens
|
||||
from app.services.daytrading import fill_reconciler as fr
|
||||
from app.services.daytrading.position_manager import _active_trade_row_to_position, day_trading_state
|
||||
|
||||
|
||||
class MustNotBeCalled(BaseException):
|
||||
"""호출 금지 경로 도달 (except Exception 에 삼켜지지 않게 BaseException)."""
|
||||
|
||||
|
||||
def _boom(name):
|
||||
async def _f(*a, **k):
|
||||
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
|
||||
return _f
|
||||
|
||||
|
||||
CODE = "777777"
|
||||
ODNO = "0000456"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate(monkeypatch):
|
||||
import app.services.trade_executor as te
|
||||
saved = dict(day_trading_state.positions)
|
||||
saved_closed = set(day_trading_state.closed_today)
|
||||
day_trading_state.positions.clear()
|
||||
day_trading_state.closed_today.clear()
|
||||
ens._pending_entries.clear()
|
||||
monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order"))
|
||||
with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)):
|
||||
yield
|
||||
day_trading_state.positions.clear()
|
||||
day_trading_state.positions.update(saved)
|
||||
day_trading_state.closed_today.clear()
|
||||
day_trading_state.closed_today.update(saved_closed)
|
||||
ens._pending_entries.clear()
|
||||
|
||||
|
||||
def _pos(qty=10, order_id=ODNO):
|
||||
return {
|
||||
"stock_name": "테스트", "strategy": "VWAP_BOUNCE", "entry_price": 10_000.0, "quantity": qty,
|
||||
"stop_loss": 9_700.0, "target_price": 10_450.0, "highest_price": 10_000.0,
|
||||
"entry_time": datetime.now(), "reason": "", "order_id": order_id,
|
||||
}
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 1. ActiveTrade order_id 영속화
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accepted_entry_persists_order_id_to_active_trade(monkeypatch):
|
||||
import app.services.portfolio_cache_bridge as pcb
|
||||
upserts, scheduled = [], []
|
||||
|
||||
async def _ups(p):
|
||||
upserts.append(p)
|
||||
|
||||
monkeypatch.setattr(ens, "enqueue_trade_history", AsyncMock())
|
||||
monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups)
|
||||
monkeypatch.setattr(pcb, "reflect_buy", AsyncMock())
|
||||
monkeypatch.setattr(ens, "_schedule_buy_fill_reconcile", lambda *a, **k: scheduled.append(a))
|
||||
signal = SimpleNamespace(strategy="RSI_BB", stop_loss=9_700.0, target_price=10_450.0, reason="t")
|
||||
|
||||
await ens._record_accepted_entry_impl(CODE, "테스트", signal, 10, 10_000.0, ODNO)
|
||||
|
||||
assert len(upserts) == 1
|
||||
assert upserts[0]["order_id"] == ODNO
|
||||
assert upserts[0]["strategy_name"] == "day_trade"
|
||||
# db_worker 신규 생성 경로(ActiveTrade(**create_data))가 받아들이는 키인지 — ORM 컬럼 존재 확인
|
||||
row = ActiveTrade(**upserts[0])
|
||||
assert row.order_id == ODNO
|
||||
assert scheduled and scheduled[0][1] == ODNO
|
||||
|
||||
|
||||
def test_restore_round_trip_keeps_order_id():
|
||||
"""복원 변환이 영속화된 ODNO 를 그대로 인메모리 포지션에 싣는다(빈 값 아님)."""
|
||||
row = SimpleNamespace(
|
||||
stock_code=CODE, stock_name="테스트", strategy_name="day_trade", entry_price=10_000.0,
|
||||
stop_loss=9_700.0, take_profit=10_450.0, quantity=10, highest_since_entry=None,
|
||||
created_at=datetime.now(), entry_reason="r", order_id=ODNO, remaining_pct=1.0,
|
||||
)
|
||||
pos = _active_trade_row_to_position(row)
|
||||
assert pos["order_id"] == ODNO
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 2. 0체결·부분체결 종결 시 Portfolio 되돌림
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _FakeCache:
|
||||
def __init__(self, holdings):
|
||||
self.holdings = holdings
|
||||
self.apply_sell = AsyncMock()
|
||||
|
||||
def note_sell(self, *a, **k):
|
||||
raise MustNotBeCalled("note_sell — 매수 취소를 매도로 기록하면 sync 가 실보유 행을 지울 수 있다")
|
||||
|
||||
|
||||
def _row(qty, tag="daytrading"):
|
||||
return Portfolio(stock_code=CODE, stock_name="테스트", quantity=qty, strategy_type=tag,
|
||||
average_price=10_000, current_price=10_000)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fill_env(monkeypatch):
|
||||
import app.services.portfolio_cache_bridge as pcb
|
||||
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0)
|
||||
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0)
|
||||
cap = {"upserts": [], "closes": [], "updates": [], "portfolio": []}
|
||||
|
||||
async def _ups(p):
|
||||
cap["upserts"].append(p)
|
||||
|
||||
async def _close(code, strategy_name=None):
|
||||
cap["closes"].append((code, strategy_name))
|
||||
|
||||
async def _upd(oid, f):
|
||||
cap["updates"].append((oid, f))
|
||||
|
||||
async def _pf(p):
|
||||
cap["portfolio"].append(p)
|
||||
|
||||
monkeypatch.setattr(ens, "enqueue_active_trade_upsert", _ups)
|
||||
monkeypatch.setattr("app.services.db_worker.enqueue_close_active_trade", _close)
|
||||
monkeypatch.setattr("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd)
|
||||
monkeypatch.setattr("app.services.db_worker.enqueue_portfolio_upsert", _pf)
|
||||
# 매도 경로로 되돌리면 note_sell 이 sync 를 오도한다 — 도달 금지
|
||||
monkeypatch.setattr(pcb, "reflect_sell", _boom("reflect_sell"))
|
||||
return cap
|
||||
|
||||
|
||||
def _install_cache(monkeypatch, holdings, db_qty):
|
||||
import app.services.portfolio_cache_bridge as pcb
|
||||
cache = _FakeCache(holdings)
|
||||
monkeypatch.setattr(pcb, "_cache", lambda: cache)
|
||||
monkeypatch.setattr(pcb, "_db_quantity", AsyncMock(return_value=db_qty))
|
||||
return cache
|
||||
|
||||
|
||||
async def _reconcile(rows, qty=10):
|
||||
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
|
||||
ens._schedule_buy_fill_reconcile(CODE, ODNO, 10_000.0, qty)
|
||||
await asyncio.gather(*list(fr._pending_tasks))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_fill_terminal_reverts_portfolio_daytrading_row(fill_env, monkeypatch):
|
||||
day_trading_state.positions[CODE] = _pos(qty=10)
|
||||
cache = _install_cache(monkeypatch, [_row(10)], db_qty=10)
|
||||
await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0",
|
||||
"ord_qty": "10", "rmn_qty": "0"}])
|
||||
assert CODE not in day_trading_state.positions
|
||||
assert fill_env["portfolio"] == [{"stock_code": CODE, "quantity": 0, "current_price": 10_000.0}]
|
||||
assert cache.holdings == []
|
||||
cache.apply_sell.assert_awaited_once()
|
||||
assert cache.apply_sell.await_args.args[:2] == (CODE, 10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_fill_terminal_keeps_reentry_and_restore_block(fill_env, monkeypatch):
|
||||
"""[R2-2 2026-10-09] 매수 0체결 종결 정리 뒤 재진입 차단(closed_today)과 당일 active 행
|
||||
복원 차단(restore_block_reason_v2)이 둘 다 유지된다 — close 큐 지연 중 잔존 행이
|
||||
인메모리로 되살아나 매도되지 않게.
|
||||
"""
|
||||
saved_exited = set(day_trading_state.dt_exited_today)
|
||||
day_trading_state.dt_exited_today.discard(CODE)
|
||||
try:
|
||||
day_trading_state.positions[CODE] = _pos(qty=10)
|
||||
_install_cache(monkeypatch, [_row(10)], db_qty=10)
|
||||
await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0",
|
||||
"ord_qty": "10", "rmn_qty": "0"}])
|
||||
assert CODE not in day_trading_state.positions
|
||||
assert CODE in day_trading_state.closed_today
|
||||
assert day_trading_state.restore_block_reason_v2(CODE) is not None
|
||||
finally:
|
||||
day_trading_state.dt_exited_today.clear()
|
||||
day_trading_state.dt_exited_today.update(saved_exited)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_fill_terminal_reverts_only_unfilled(fill_env, monkeypatch):
|
||||
day_trading_state.positions[CODE] = _pos(qty=10)
|
||||
cache = _install_cache(monkeypatch, [_row(10)], db_qty=10)
|
||||
await _reconcile([{"odno": "456", "tot_ccld_qty": "6", "avg_prvs": "10030",
|
||||
"ord_qty": "10", "rmn_qty": "0"}])
|
||||
assert day_trading_state.positions[CODE]["quantity"] == 6
|
||||
assert fill_env["portfolio"][-1]["quantity"] == 6
|
||||
assert cache.holdings[0].quantity == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_revert_never_below_dt_inmem_when_sync_already_corrected(fill_env, monkeypatch):
|
||||
"""sync 가 이미 실잔고(6)로 맞춘 뒤면 다시 빼지 않는다(하한 = DT 인메모리 6주)."""
|
||||
day_trading_state.positions[CODE] = _pos(qty=10)
|
||||
_install_cache(monkeypatch, [_row(6)], db_qty=6)
|
||||
await _reconcile([{"odno": "456", "tot_ccld_qty": "6", "avg_prvs": "10030",
|
||||
"ord_qty": "10", "rmn_qty": "0"}])
|
||||
assert fill_env["portfolio"][-1]["quantity"] == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_strategy_row_is_not_touched(fill_env, monkeypatch):
|
||||
"""타 전략 태그 행은 스윙 보유분이 섞였을 수 있어 깎지 않는다(§3.5) — sync 에 맡긴다."""
|
||||
day_trading_state.positions[CODE] = _pos(qty=10)
|
||||
cache = _install_cache(monkeypatch, [_row(13, tag="swing")], db_qty=13)
|
||||
import app.services.portfolio_cache_bridge as pcb
|
||||
monkeypatch.setattr(pcb, "_db_quantity", _boom("_db_quantity"))
|
||||
await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0",
|
||||
"ord_qty": "10", "rmn_qty": "0"}])
|
||||
assert CODE not in day_trading_state.positions
|
||||
assert fill_env["portfolio"] == []
|
||||
assert cache.holdings[0].quantity == 13
|
||||
cache.apply_sell.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replaced_position_zero_fill_does_not_revert(fill_env, monkeypatch):
|
||||
"""다른 주문의 포지션이면(교체·이미 청산) 원장만 보정 — Portfolio 는 건드리지 않는다."""
|
||||
day_trading_state.positions[CODE] = _pos(qty=10, order_id="OTHER")
|
||||
_install_cache(monkeypatch, [_row(10)], db_qty=10)
|
||||
await _reconcile([{"odno": "456", "tot_ccld_qty": "0", "avg_prvs": "0",
|
||||
"ord_qty": "10", "rmn_qty": "0"}])
|
||||
assert day_trading_state.positions[CODE]["quantity"] == 10
|
||||
assert fill_env["portfolio"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revert_helper_floor_and_no_increase(monkeypatch):
|
||||
import app.services.portfolio_cache_bridge as pcb
|
||||
out = []
|
||||
|
||||
async def _pf(p):
|
||||
out.append(p)
|
||||
|
||||
monkeypatch.setattr("app.services.db_worker.enqueue_portfolio_upsert", _pf)
|
||||
_install_cache(monkeypatch, [_row(3)], db_qty=3)
|
||||
# 미체결 10 > 행 3, 하한 0 → 0 (음수 아님)
|
||||
assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 10, 10_000.0, 0) == 0
|
||||
_install_cache(monkeypatch, [_row(3)], db_qty=3)
|
||||
# 하한이 행보다 커도 행 수량을 늘리지 않는다
|
||||
assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 2, 10_000.0, 9) == 3
|
||||
# 미체결 0 이면 아무것도 하지 않는다
|
||||
monkeypatch.setattr(pcb, "_cache", _boom("_cache"))
|
||||
assert await ens._revert_unfilled_buy_portfolio_v2_impl(CODE, 0, 10_000.0, 0) is None
|
||||
Reference in New Issue
Block a user