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:
@@ -278,10 +278,15 @@ def manual_buy_env(monkeypatch):
|
||||
async def _noop(*a, **k):
|
||||
return None
|
||||
monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop)
|
||||
verifies = []
|
||||
|
||||
async def _verify(**kw):
|
||||
verifies.append(kw)
|
||||
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _verify)
|
||||
monkeypatch.setattr(dbw, "enqueue_trade_history", _noop)
|
||||
monkeypatch.setattr(dbw, "enqueue_portfolio_upsert", _noop)
|
||||
monkeypatch.setattr(sbr, "note_system_buy", lambda *a, **k: None)
|
||||
return SimpleNamespace(mb=mb, total=total, cash=cash, px=px, orders=orders, ad=ad, k=k, cf=cf)
|
||||
return SimpleNamespace(mb=mb, total=total, cash=cash, px=px, orders=orders, verifies=verifies, ad=ad, k=k, cf=cf)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -522,3 +527,123 @@ async def test_low_delete_pending_only_before_today():
|
||||
assert out["deleted_count"] == 0
|
||||
sql = str(db.stmts[0].compile(compile_kwargs={"literal_binds": False}))
|
||||
assert "created_at <" in sql, "당일 이전 조건이 없다 — 당일 PENDING(일일 한도 근거)까지 삭제"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r2_swing_manual_sell_loss_is_net_of_costs(swing_sell_env, monkeypatch):
|
||||
"""왕복 비용 이내 소폭 이익 수동 청산은 패(순손익 기준, §5.1), TradeHistory 는 순손익 저장."""
|
||||
import app.services.db_worker as dbw
|
||||
e = swing_sell_env
|
||||
hist = []
|
||||
|
||||
async def _h(d):
|
||||
hist.append(d)
|
||||
monkeypatch.setattr(dbw, "enqueue_trade_history", _h)
|
||||
# 현재가 10000, 진입가 settings 비용(약 0.21%)보다 작은 0.05% 이익
|
||||
entry = 10000 / 1.0005
|
||||
at = SimpleNamespace(entry_price=entry)
|
||||
monkeypatch.setattr("app.services.swing.position_manager.get_swing_active_trade",
|
||||
AsyncMock(return_value=at))
|
||||
monkeypatch.setattr("app.services.swing.exit_service._prior_position_realized_net_v2",
|
||||
AsyncMock(return_value=None))
|
||||
holding = SimpleNamespace(quantity=10, stock_name="삼성전자", average_price=entry, strategy_type="swing")
|
||||
from app.services.swing.exit_service import _swing_net_pnl_v2
|
||||
await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930"), db=_FakeDB([holding]))
|
||||
expected = _swing_net_pnl_v2(entry, 10000, 6, "KOSPI")["net_profit_loss"]
|
||||
assert expected < 0 < (10000 - entry) * 6, "전제: gross 이익 / net 손실"
|
||||
assert hist[0]["net_profit_loss"] == pytest.approx(expected)
|
||||
assert e.rec.await_args.kwargs["is_loss"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r2_swing_manual_sell_prior_leg_profit_makes_win(swing_sell_env, monkeypatch):
|
||||
"""앞 레그 실현이익이 마지막 소폭 손실 레그를 상쇄하면 승."""
|
||||
e = swing_sell_env
|
||||
entry = 10100.0 # 현재가 10000 → gross 손실
|
||||
monkeypatch.setattr("app.services.swing.position_manager.get_swing_active_trade",
|
||||
AsyncMock(return_value=SimpleNamespace(entry_price=entry)))
|
||||
monkeypatch.setattr("app.services.swing.exit_service._prior_position_realized_net_v2",
|
||||
AsyncMock(return_value=100000.0))
|
||||
holding = SimpleNamespace(quantity=10, stock_name="삼성전자", average_price=entry, strategy_type="swing")
|
||||
await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930"), db=_FakeDB([holding]))
|
||||
assert e.rec.await_args.kwargs["is_loss"] is False
|
||||
|
||||
|
||||
# ── R3 2026-10-09: 수동 매수 결과 불명 / 지정가 PENDING ──────────────────────
|
||||
@pytest.fixture
|
||||
def mb_capture(manual_buy_env, monkeypatch):
|
||||
import app.services.db_worker as dbw
|
||||
import app.services.trade_executor as te
|
||||
hist, ports = [], []
|
||||
|
||||
async def _h(d):
|
||||
hist.append(d)
|
||||
|
||||
async def _p(d):
|
||||
ports.append(d)
|
||||
monkeypatch.setattr(dbw, "enqueue_trade_history", _h)
|
||||
monkeypatch.setattr(dbw, "enqueue_portfolio_upsert", _p)
|
||||
e = manual_buy_env
|
||||
e.mb._last_done.clear()
|
||||
e.hist, e.ports, e.te = hist, ports, te
|
||||
monkeypatch.setattr(e.mb, "spawn_tracked_v2", lambda c: c.close())
|
||||
return e
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r3_manual_buy_transport_unknown_sets_cooldown(mb_capture, monkeypatch):
|
||||
e = mb_capture
|
||||
|
||||
async def _unk(**kw):
|
||||
return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "timeout"}
|
||||
monkeypatch.setattr(e.te, "safe_place_order", _unk)
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []]))
|
||||
assert ei.value.status_code == 504
|
||||
assert "005930" in e.mb._last_done
|
||||
assert e.hist and e.hist[0]["status"] == "PENDING" and not e.ports
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r3_manual_buy_presend_failure_no_cooldown(mb_capture, monkeypatch):
|
||||
e = mb_capture
|
||||
|
||||
async def _pre(**kw):
|
||||
return {"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT", "msg1": "x"}
|
||||
monkeypatch.setattr(e.te, "safe_place_order", _pre)
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []]))
|
||||
assert ei.value.status_code == 500
|
||||
assert "005930" not in e.mb._last_done and not e.hist
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r3_manual_buy_limit_order_pending_no_portfolio_row(mb_capture):
|
||||
e = mb_capture
|
||||
px = int(e.px * 0.9)
|
||||
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930", price=px), "005930", _FakeDB([None, 0, []]))
|
||||
assert e.hist[0]["status"] == "PENDING" and e.hist[0]["executed_quantity"] == 0
|
||||
assert not e.ports
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r3_manual_buy_market_order_unchanged(mb_capture):
|
||||
e = mb_capture
|
||||
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []]))
|
||||
assert e.hist[0]["status"] == "COMPLETED" and e.hist[0]["executed_quantity"] == e.orders[0]
|
||||
assert len(e.ports) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r7_manual_limit_buy_spawns_fill_verification(manual_buy_env):
|
||||
"""지정가 수동 매수의 PENDING 행을 확정할 체결 확인 태스크가 ODNO 로 연결돼야 한다."""
|
||||
import asyncio
|
||||
e = manual_buy_env
|
||||
req = e.mb.BuyRequest(stock_code="005930", price=int(e.px))
|
||||
await e.mb._buy_stock_impl(req, "005930", _FakeDB([None, 0, []]))
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
assert len(e.verifies) == 1
|
||||
v = e.verifies[0]
|
||||
assert v["order_id"] == "1" and v["order_type"] == "BUY" and v["is_domestic"] is True
|
||||
assert v["sell_pnl_basis"] == {"strategy_type": "manual"}
|
||||
|
||||
Reference in New Issue
Block a user