Files
auto-stock/backend/tests/test_dt_exit_partial_fill_pnl_20261009.py
shuim a264561576 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
2026-10-10 12:17:49 +09:00

129 lines
5.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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