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:
@@ -26,6 +26,15 @@ UNKNOWN = {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeou
|
||||
OK = {"rt_cd": "0", "output": {"ODNO": "9"}}
|
||||
|
||||
|
||||
_FILL_CHECKS: list = []
|
||||
|
||||
|
||||
async def _record_fill_check(order_id, stock_code, stock_name, quantity, price, side, *args, **kwargs):
|
||||
"""체결 확인 종단 기록형 목 — 즉시 완료(대기·KIS 조회 없음)."""
|
||||
_FILL_CHECKS.append({"order_id": order_id, "stock_code": stock_code, "quantity": quantity,
|
||||
"side": side, "kwargs": kwargs})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset(monkeypatch):
|
||||
import app.services.swing.exit_service as es
|
||||
@@ -41,6 +50,13 @@ def _reset(monkeypatch):
|
||||
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
|
||||
import app.services.trade_executor as te
|
||||
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
|
||||
# [하드닝 R2 2026-10-09] 성공한 시장가 매도는 _schedule_swing_sell_fill_check_v2 →
|
||||
# spawn_tracked_task_v2(_verify_and_sync_execution_v3(..., delay=3)) 로 **실제** 체결 확인
|
||||
# 코루틴을 띄운다. 목이 없으면 테스트 종료 후 pending 으로 남고(“Task was destroyed but it is
|
||||
# pending”), 3초가 지나면 get_order_execution(KIS)·enqueue_update_trade_history 를 호출해
|
||||
# conftest 차단에만 의존했다(§4.1 위반). 공통 종단(_verify_and_sync_execution_v3)에 기록형 목.
|
||||
_FILL_CHECKS.clear()
|
||||
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _record_fill_check)
|
||||
|
||||
|
||||
class _Sim:
|
||||
@@ -338,3 +354,121 @@ async def test_m4_cancel_during_order_still_records(monkeypatch):
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
assert len(sim.history) == 1 and sim.closed == 1
|
||||
|
||||
|
||||
# ── [2026-10-09 R1] 연속손실 판정 = 비용 차감 순손익 (CLAUDE.md §5.1) ─────────
|
||||
|
||||
def _full_signal(remaining):
|
||||
return ExitSignal("SELL_ALL", "전량 익절", 1.0)
|
||||
|
||||
|
||||
def _expected_net(avg, price, qty):
|
||||
from app.services.trade_helpers import calculate_trade_costs
|
||||
costs = calculate_trade_costs(entry_price=avg, exit_price=price, quantity=qty,
|
||||
is_domestic=True, market="KOSPI")
|
||||
return (price - avg) * qty - costs["total_cost"], costs["total_cost"]
|
||||
|
||||
|
||||
def test_r1_swing_net_pnl_matches_trade_helpers_and_flips_small_gain():
|
||||
"""+0.1% 는 총손익 양수지만 왕복 비용(수수료×2+매도세) 차감 후 음수. 비용 정의는
|
||||
trade_helpers.calculate_trade_costs 와 동일(KOSPI/KOSDAQ 세율 모두)."""
|
||||
import app.services.swing.exit_service as es
|
||||
from app.services.trade_helpers import calculate_trade_costs
|
||||
for mkt, label in (("KRX", "KOSPI"), ("KOSDAQ", "KOSDAQ")):
|
||||
r = es._swing_net_pnl_v2(10000, 10010, 10, mkt, domestic=True)
|
||||
c = calculate_trade_costs(entry_price=10000, exit_price=10010, quantity=10,
|
||||
is_domestic=True, market=label)
|
||||
assert r["gross_profit_loss"] == 100
|
||||
assert r["total_cost"] == c["total_cost"]
|
||||
assert r["net_profit_loss"] == 100 - c["total_cost"]
|
||||
assert r["net_profit_loss"] < 0
|
||||
# 해외는 거래세 없음(trade_helpers 와 동일)
|
||||
r = es._swing_net_pnl_v2(100, 101, 10, "NASD", domestic=False)
|
||||
c = calculate_trade_costs(entry_price=100, exit_price=101, quantity=10, is_domestic=False)
|
||||
assert r["total_cost"] == c["total_cost"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r1_full_exit_within_cost_band_counts_as_loss(monkeypatch):
|
||||
"""[재현 반전] 스윙 10주 +0.1% 전량 청산. 이전: is_loss=False(총손익 +100원), net_profit_loss
|
||||
에 총손익 기록. 이제: 순손익 기준 is_loss=True, net_profit_loss·commission 은 비용 차감값."""
|
||||
avg, price = 10000, 10010
|
||||
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal)
|
||||
await sim.cycles(1)
|
||||
assert sim.sent == [10]
|
||||
net, cost = _expected_net(avg, price, 10)
|
||||
assert net < 0 < (price - avg) * 10
|
||||
sim.loss.assert_awaited_once()
|
||||
assert sim.loss.await_args.kwargs["is_loss"] is True
|
||||
sell = [h for h in sim.history if h["order_type"] == "SELL"]
|
||||
assert len(sell) == 1
|
||||
assert sell[0]["net_profit_loss"] == net
|
||||
assert sell[0]["commission"] == cost
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r1_full_exit_clear_gain_is_not_loss(monkeypatch):
|
||||
"""비용을 넘는 이익(+5%)은 여전히 승 — 판정이 과도하게 뒤집히지 않음."""
|
||||
avg, price = 10000, 10500
|
||||
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal)
|
||||
await sim.cycles(1)
|
||||
sim.loss.assert_awaited_once()
|
||||
assert sim.loss.await_args.kwargs["is_loss"] is False
|
||||
net, _ = _expected_net(avg, price, 10)
|
||||
assert [h for h in sim.history if h["order_type"] == "SELL"][0]["net_profit_loss"] == net
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r1_unknown_sell_reconcile_uses_net_pnl(monkeypatch):
|
||||
"""결과 불명 전량 매도가 KIS 잔고로 체결 확인되는 경로도 순손익 기준으로 판정·기록."""
|
||||
avg, price = 10000, 10010
|
||||
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal,
|
||||
responses=[UNKNOWN])
|
||||
await sim.cycles(3)
|
||||
assert sum(sim.sent) == 10
|
||||
sim.loss.assert_awaited_once()
|
||||
assert sim.loss.await_args.kwargs["is_loss"] is True
|
||||
done = [h for h in sim.history if "체결 확인" in (h.get("exit_reason") or "")]
|
||||
assert len(done) == 1
|
||||
net, cost = _expected_net(avg, price, 10)
|
||||
assert done[0]["net_profit_loss"] == net and done[0]["commission"] == cost
|
||||
|
||||
|
||||
# ── 하드닝 R2 (2026-10-09): 체결 확인 백그라운드 태스크가 테스트 밖으로 새지 않는다 ─────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r2_fill_check_terminal_is_mocked_and_task_completes(monkeypatch):
|
||||
"""성공 매도 → 체결 확인 예약은 기록형 목 종단으로 수렴하고, swing_sell_fill 태스크가
|
||||
테스트 종료 전에 끝난다(실 _verify_and_sync_execution_v3 미실행 → KIS 조회 0)."""
|
||||
import app.services.trade_executor as te
|
||||
assert te._verify_and_sync_execution_v3 is _record_fill_check
|
||||
sim = _Sim(monkeypatch, swing=10, dt=0)
|
||||
await sim.cycles(1)
|
||||
assert sim.sent == [10] and sim.closed == 1
|
||||
assert len(_FILL_CHECKS) == 1
|
||||
fc = _FILL_CHECKS[0]
|
||||
assert fc["stock_code"] == "005930" and fc["quantity"] == 10 and fc["side"] == "SELL"
|
||||
assert fc["kwargs"]["sell_pnl_basis"]["strategy_type"] == "swing"
|
||||
# 기록형 목은 즉시 완료 — 한 틱 양보 후 남은 swing_sell_fill 태스크가 없어야 한다
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
pending = [t for t in asyncio.all_tasks()
|
||||
if (t.get_name() or "").startswith("swing_sell_fill:") and not t.done()]
|
||||
assert pending == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r2_fill_check_real_coroutine_would_hit_kis_is_not_reached(monkeypatch):
|
||||
"""종단 목이 빠지면 실 코루틴이 get_order_execution 을 부른다는 전제를 예외 발생형 목으로
|
||||
고정 — 이 파일의 매도 경로가 그 메서드에 도달하지 않음을 증명한다."""
|
||||
sim = _Sim(monkeypatch, swing=10, dt=0)
|
||||
|
||||
async def _boom(*a, **k):
|
||||
raise AssertionError("get_order_execution 실호출 경로 도달")
|
||||
sim.api.get_order_execution = AsyncMock(side_effect=_boom)
|
||||
import app.services.trade_executor as te
|
||||
monkeypatch.setattr(te, "korea_investment_api", sim.api, raising=False)
|
||||
await sim.cycles(1)
|
||||
await asyncio.sleep(0)
|
||||
sim.api.get_order_execution.assert_not_called()
|
||||
assert len(_FILL_CHECKS) == 1
|
||||
|
||||
Reference in New Issue
Block a user