- 스윙 체결가 확인 전 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
90 lines
3.8 KiB
Python
90 lines
3.8 KiB
Python
"""portfolio_cache_bridge: 매매 엔진 체결 → 포트폴리오 캐시/Portfolio 즉시 반영."""
|
|
import asyncio
|
|
from decimal import Decimal
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.api.endpoints.portfolio import portfolio_cache
|
|
from app.models.models import Portfolio
|
|
from app.services import portfolio_cache_bridge as bridge
|
|
|
|
|
|
class _DBBlocked(BaseException):
|
|
"""실 DB 세션 시도 차단 (BaseException: 프로덕션 except Exception 에 삼켜지지 않게)."""
|
|
|
|
|
|
def _blocked_session(*a, **k):
|
|
raise _DBBlocked("테스트에서 app.core.database.AsyncSessionLocal 직접 사용 차단")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _block_real_db(monkeypatch):
|
|
monkeypatch.setattr("app.core.database.AsyncSessionLocal", _blocked_session)
|
|
# DB 수량은 명시 주입 (기본: Portfolio 행 없음)
|
|
monkeypatch.setattr(bridge, "_db_quantity", AsyncMock(return_value=None))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_db_session_is_blocked():
|
|
"""격리 회귀: 차단 픽스처 하에서 원본 조회 경로가 실 세션을 열면 즉시 예외."""
|
|
from app.core.database import AsyncSessionLocal
|
|
with pytest.raises(_DBBlocked):
|
|
AsyncSessionLocal()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_cache():
|
|
portfolio_cache.holdings = []
|
|
portfolio_cache.summary = {"available_cash": 1_000_000, "total_value": 0, "total_investment": 0,
|
|
"total_assets": 1_000_000, "total_profit_loss": 0, "total_profit_loss_rate": 0, "total_stocks": 0}
|
|
portfolio_cache.recent_sells = {}
|
|
yield
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_buy_then_sell_updates_cache_and_queues_db():
|
|
q = AsyncMock()
|
|
with patch("app.services.db_worker.enqueue_portfolio_upsert", q):
|
|
await bridge.reflect_buy("005930", "삼성전자", 3, 70000.0, strategy_type="daytrading")
|
|
h = portfolio_cache.holdings[0]
|
|
assert (h.stock_code, h.quantity, h.strategy_type) == ("005930", 3, "daytrading")
|
|
assert portfolio_cache.summary["available_cash"] == 1_000_000 - 210_000
|
|
assert q.await_args.args[0]["strategy_type"] == "daytrading"
|
|
|
|
# 부분 매도 → 잔량 반영, 전량 매도 → 목록에서 제거 + 최근매도 기록(동기화 되살림 방지)
|
|
with patch("app.services.db_worker.enqueue_portfolio_upsert", q):
|
|
await bridge.reflect_sell("005930", 1, 71000.0, remaining_qty=2)
|
|
assert portfolio_cache.holdings[0].quantity == 2
|
|
await bridge.reflect_sell("005930", 2, 71000.0, remaining_qty=0)
|
|
assert portfolio_cache.holdings == []
|
|
assert q.await_args.args[0]["quantity"] == 0
|
|
assert portfolio_cache.recently_sold_qty("005930") == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reflect_sell_unknown_holding_does_not_zero_db():
|
|
"""캐시에 없고 잔량도 모르면 DB 수량을 0으로 덮어쓰지 않는다."""
|
|
q = AsyncMock()
|
|
with patch("app.services.db_worker.enqueue_portfolio_upsert", q):
|
|
await bridge.reflect_sell("000660", 5, 100000.0)
|
|
q.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dt_and_swing_same_stock_quantities_are_separable():
|
|
"""같은 종목을 스윙+DT로 보유해도 화면상 수량이 분리 가능해야 한다.
|
|
|
|
Portfolio는 종목당 1행(stock_code unique)이므로 합계만 남는다 →
|
|
프론트는 엔진 포지션 수량을 빼서 스윙 수량을 계산한다. 여기서는 그 계산식을 검증.
|
|
"""
|
|
q = AsyncMock()
|
|
with patch("app.services.db_worker.enqueue_portfolio_upsert", q):
|
|
await bridge.reflect_buy("064400", "LG씨엔에스", 8, 70148.0, strategy_type="swing")
|
|
await bridge.reflect_buy("064400", "LG씨엔에스", 4, 70800.0, strategy_type="daytrading")
|
|
|
|
row = portfolio_cache.holdings[0]
|
|
assert row.quantity == 12
|
|
dt_qty = 4
|
|
assert row.quantity - dt_qty == 8 # 스윙 잔량
|