- 스윙 체결가 확인 전 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
129 lines
5.8 KiB
Python
129 lines
5.8 KiB
Python
"""DT 매도 미종결 부분체결 손익 계상 회귀 테스트 (2026-10-09, 하드닝 루프 R1 — exit_service.py).
|
||
|
||
결함: fill 확인이 terminal=False 이고 0 < filled < quantity 일 때 `_on_fill` 이
|
||
`dt_net_pnl(entry, avg, quantity)` 와 `total_amount = avg × quantity` 로 **미체결분까지 체결가로**
|
||
계상했다(장부·daily_pnl 오염).
|
||
수정: `_sell_fill_pnl_impl` — 체결분은 체결가, 미체결분은 신호가 추정, 거래대금·체결수량은 체결분만.
|
||
|
||
목킹 원칙(CLAUDE.md §4.1): 주문 공통 종단(`safe_place_order`)과 체결 조회 종단
|
||
(`fill_reconciler.korea_investment_api.get_todays_orders`)은 BaseException 발생형으로 막는다.
|
||
기대값은 dt_net_pnl(settings 기반 비용) 로 계산한다 — 비용률 하드코딩 없음.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from app.services.daytrading import exit_service as es
|
||
from app.services.daytrading import fill_reconciler
|
||
from app.services.daytrading.position_manager import day_trading_state
|
||
|
||
|
||
class MustNotBeCalled(BaseException):
|
||
"""호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException)."""
|
||
|
||
|
||
def _boom(name):
|
||
async def _f(*a, **k):
|
||
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
|
||
return _f
|
||
|
||
|
||
CODE = "333333"
|
||
ENTRY = 10_000.0
|
||
SIGNAL = 9_800.0
|
||
QTY = 4
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _env():
|
||
saved_pnl = day_trading_state.daily_pnl
|
||
saved_pos = dict(day_trading_state.positions)
|
||
day_trading_state.positions.clear()
|
||
with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \
|
||
patch("app.services.trade_executor.safe_place_order", _boom("safe_place_order")), \
|
||
patch.object(fill_reconciler.korea_investment_api, "get_todays_orders",
|
||
_boom("get_todays_orders")), \
|
||
patch.object(es, "_alert_once_v2", MagicMock()):
|
||
yield
|
||
day_trading_state.daily_pnl = saved_pnl
|
||
day_trading_state.positions.clear()
|
||
day_trading_state.positions.update(saved_pos)
|
||
|
||
|
||
def _pos():
|
||
return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": ENTRY, "quantity": QTY,
|
||
"stop_loss": 9_700.0, "highest_price": ENTRY, "reason": "VWAP 반등"}
|
||
|
||
|
||
async def _run_with(info, count_daily_pnl=True):
|
||
pos = _pos()
|
||
recorded = es.dt_net_pnl(ENTRY, SIGNAL, QTY, pos)["net_profit_loss"]
|
||
day_trading_state.daily_pnl = recorded if count_daily_pnl else 0.0
|
||
captured = {}
|
||
|
||
def _sched(code, order_id, side, qty, cb):
|
||
captured["cb"] = cb
|
||
return object()
|
||
|
||
upd = AsyncMock()
|
||
with patch.object(es.fill_reconciler, "schedule_v2", _sched), \
|
||
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", upd):
|
||
es._schedule_sell_fill_reconcile_v2(
|
||
stock_code=CODE, pos=pos, order_id="0000555", signal_price=SIGNAL, quantity=QTY,
|
||
entry_price=ENTRY, recorded_net=recorded, count_daily_pnl=count_daily_pnl,
|
||
)
|
||
await captured["cb"](info)
|
||
return pos, recorded, upd.await_args.args[1]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_non_terminal_partial_fill_books_only_filled_at_avg():
|
||
avg, filled = 9_500.0, 1
|
||
pos, recorded, payload = await _run_with({"terminal": False, "filled_qty": filled, "avg_price": avg})
|
||
blended = (avg * filled + SIGNAL * (QTY - filled)) / QTY # 체결분 체결가 + 미체결분 신호가
|
||
expect = es.dt_net_pnl(ENTRY, blended, QTY, pos)["net_profit_loss"]
|
||
wrong = es.dt_net_pnl(ENTRY, avg, QTY, pos)["net_profit_loss"] # 옛 계산(전량을 체결가로)
|
||
assert float(payload["net_profit_loss"]) == pytest.approx(round(expect, 2), abs=0.01)
|
||
assert float(payload["net_profit_loss"]) != pytest.approx(round(wrong, 2), abs=0.01)
|
||
assert float(payload["total_amount"]) == pytest.approx(avg * filled)
|
||
assert payload["executed_quantity"] == filled
|
||
assert day_trading_state.daily_pnl == pytest.approx(expect)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_full_fill_unchanged():
|
||
avg = 9_750.0
|
||
pos, recorded, payload = await _run_with({"terminal": True, "filled_qty": QTY, "avg_price": avg})
|
||
expect = es.dt_net_pnl(ENTRY, avg, QTY, pos)
|
||
assert float(payload["net_profit_loss"]) == pytest.approx(round(expect["net_profit_loss"], 2), abs=0.01)
|
||
assert float(payload["total_amount"]) == pytest.approx(avg * QTY)
|
||
assert float(payload["commission"]) == pytest.approx(expect["total_cost"])
|
||
assert "executed_quantity" not in payload
|
||
assert day_trading_state.daily_pnl == pytest.approx(expect["net_profit_loss"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_non_terminal_partial_stale_goes_to_stale_pnl_not_daily():
|
||
before = es._stale_realized_pnl
|
||
try:
|
||
avg, filled = 9_500.0, 2
|
||
pos, recorded, _ = await _run_with(
|
||
{"terminal": False, "filled_qty": filled, "avg_price": avg}, count_daily_pnl=False)
|
||
blended = (avg * filled + SIGNAL * (QTY - filled)) / QTY
|
||
expect = es.dt_net_pnl(ENTRY, blended, QTY, pos)["net_profit_loss"]
|
||
assert es._stale_realized_pnl - before == pytest.approx(expect - recorded)
|
||
assert day_trading_state.daily_pnl == 0.0 # 전일·고아 손익은 당일 미반영(§7)
|
||
finally:
|
||
es._stale_realized_pnl = before
|
||
|
||
|
||
def test_sell_fill_pnl_impl_linear_identity():
|
||
"""전량 체결과 '체결분+미체결분(같은 가격)' 합이 같아야 한다(dt_net_pnl 선형성)."""
|
||
pos = _pos()
|
||
full = es._sell_fill_pnl_impl(ENTRY, SIGNAL, SIGNAL, QTY, QTY, pos)
|
||
split = es._sell_fill_pnl_impl(ENTRY, SIGNAL, SIGNAL, 1, QTY, pos)
|
||
assert split["net_profit_loss"] == pytest.approx(full["net_profit_loss"])
|
||
assert split["total_cost"] == pytest.approx(full["total_cost"])
|
||
assert split["executed_quantity"] == 1 and split["is_partial"] is True
|
||
assert full["is_partial"] is False
|