diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 9c69eff..9166f48 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -603,6 +603,13 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) _realtime_price_map = {} # ────────────────────────────────────────────────────────────────── + # 데이트레이딩 엔진이 보유 중인 종목 → strategy_type='daytrading' 태깅용 + try: + from app.services.daytrading.position_manager import day_trading_state as _dts + _dt_codes = set(_dts.positions.keys()) + except Exception: + _dt_codes = set() + for item in balance_data["output1"]: stock_code = item.get("pdno") @@ -739,6 +746,8 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) h.profit_loss = profit_loss h.profit_loss_rate = profit_loss_rate h.market = exchange_code + if stock_code in _dt_codes: + h.strategy_type = "daytrading" new_holdings.append(h) else: new_holding = Portfolio( @@ -750,6 +759,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) profit_loss=profit_loss, profit_loss_rate=profit_loss_rate, market=exchange_code, + strategy_type="daytrading" if stock_code in _dt_codes else "swing", ) db.add(new_holding) new_holdings.append(new_holding) diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index 3ad4ee6..478c21b 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -498,6 +498,9 @@ async def _try_enter( "entry_reason": signal.reason, "strategy_type": "daytrading", }) + # 포트폴리오 화면 즉시 반영 (strategy_type='daytrading' 태깅 → 스윙 청산 로직 대상에서 제외) + from app.services.portfolio_cache_bridge import reflect_buy + await reflect_buy(stock_code, stock_name, quantity, current_price, strategy_type="daytrading") await enqueue_active_trade_upsert({ "stock_code": stock_code, "stock_name": stock_name, diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index a9c262b..507f14e 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -275,6 +275,9 @@ async def _execute_exit( }) if is_full_exit: await enqueue_close_active_trade(stock_code, strategy_name="day_trade") + # 포트폴리오 화면에서 즉시 제거/차감 + from app.services.portfolio_cache_bridge import reflect_sell + await reflect_sell(stock_code, quantity, price, remaining_qty=max(0, int(total_qty) - int(quantity))) sell_label = f"{int(sell_pct * 100)}%" if sell_pct < 1.0 else "전량" logger.info( diff --git a/backend/app/services/portfolio_cache_bridge.py b/backend/app/services/portfolio_cache_bridge.py new file mode 100644 index 0000000..a8f3189 --- /dev/null +++ b/backend/app/services/portfolio_cache_bridge.py @@ -0,0 +1,111 @@ +"""portfolio_cache_bridge — 매매 엔진(데이트레이딩/스윙)에서 포트폴리오 화면 상태를 즉시 갱신. + +역할: + - 매수 직후: Portfolio 행 upsert(db_worker 큐) + 인메모리 캐시에 즉시 추가 (strategy_type 태깅) + - 매도 직후: Portfolio 수량 차감/0 (db_worker 큐) + 캐시에서 즉시 제거/차감 + 최근매도 기록 + (KIS 잔고 반영 지연으로 동기화가 되살리는 것 방지) + +DB 쓰기는 전부 db_worker 큐 경유(CLAUDE.md §3.2). 캐시는 인메모리라 실시간 계층에서 호출 가능. +portfolio.py 는 지연 import (순환 import·기동 순서 회피). +""" +import logging +from decimal import Decimal + +logger = logging.getLogger(__name__) + + +def _cache(): + from app.api.endpoints.portfolio import portfolio_cache + return portfolio_cache + + +async def reflect_buy( + stock_code: str, + stock_name: str, + quantity: int, + price: float, + strategy_type: str = "swing", + market: str = "KRX", +) -> None: + """매수 체결 즉시 Portfolio(큐) + 캐시 반영.""" + try: + from app.services.db_worker import enqueue_portfolio_upsert + from app.models.models import Portfolio + + cache = _cache() + existing = next((h for h in cache.holdings if h.stock_code == stock_code), None) + if existing is not None: + old_qty = int(existing.quantity or 0) + old_avg = float(existing.average_price or price) + new_qty = old_qty + quantity + new_avg = (old_avg * old_qty + price * quantity) / new_qty if new_qty > 0 else price + existing.quantity = new_qty + existing.average_price = Decimal(str(round(new_avg, 2))) + existing.current_price = Decimal(str(price)) + if strategy_type == "daytrading": + existing.strategy_type = "daytrading" + avg_for_db, qty_for_db = new_avg, new_qty + else: + cache.holdings.append(Portfolio( + stock_code=stock_code, stock_name=stock_name, quantity=quantity, + average_price=Decimal(str(price)), current_price=Decimal(str(price)), + profit_loss=Decimal("0"), profit_loss_rate=Decimal("0"), + market=market, strategy_type=strategy_type, + currency_code="KRW" if market == "KRX" else "USD", + )) + avg_for_db, qty_for_db = price, quantity + + await enqueue_portfolio_upsert({ + "stock_code": stock_code, + "stock_name": stock_name, + "quantity": qty_for_db, + "average_price": round(avg_for_db, 2), + "current_price": price, + "market": market, + "strategy_type": strategy_type, + }) + await cache.apply_buy(stock_code, stock_name, quantity, price) + except Exception as e: + logger.warning(f"[cache_bridge] reflect_buy 실패 ({stock_code}): {e}") + + +async def reflect_sell( + stock_code: str, + quantity: int, + price: float, + remaining_qty: int | None = None, +) -> None: + """매도 체결 즉시 Portfolio(큐) 차감 + 캐시 제거/차감 + 최근매도 기록. + + remaining_qty: 호출측이 아는 매도 후 잔량(권장). None이면 캐시 보유량 기준으로 계산 + (캐시에 없으면 전량 매도로 간주하지 않고 DB 갱신을 건너뜀). + """ + try: + from app.services.db_worker import enqueue_portfolio_upsert + + cache = _cache() + existing = next((h for h in cache.holdings if h.stock_code == stock_code), None) + avg = float(existing.average_price or price) if existing is not None else price + if remaining_qty is not None: + remaining = max(0, int(remaining_qty)) + elif existing is not None: + remaining = max(0, int(existing.quantity or 0) - quantity) + else: + remaining = None # 알 수 없음 → DB 수량은 건드리지 않음 + if existing is not None and remaining is not None: + if remaining <= 0: + cache.holdings = [h for h in cache.holdings if h.stock_code != stock_code] + else: + existing.quantity = remaining + existing.current_price = Decimal(str(price)) + + if remaining is not None: + await enqueue_portfolio_upsert({ + "stock_code": stock_code, + "quantity": remaining, # 0 → 목록에서 제외, 다음 동기화에서 삭제 + "current_price": price, + }) + cache.note_sell(stock_code, quantity) + await cache.apply_sell(stock_code, quantity, price, avg) + except Exception as e: + logger.warning(f"[cache_bridge] reflect_sell 실패 ({stock_code}): {e}") diff --git a/backend/app/services/swing/exit_service.py b/backend/app/services/swing/exit_service.py index 2ee49d8..3a69a4f 100644 --- a/backend/app/services/swing/exit_service.py +++ b/backend/app/services/swing/exit_service.py @@ -335,15 +335,17 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: "strategy_type": "swing", }) + # 포트폴리오 화면 즉시 반영 (Portfolio 수량 차감 + 캐시) + from app.services.portfolio_cache_bridge import reflect_sell + await reflect_sell(stock_code, sell_qty, latest_price, remaining_qty=quantity - sell_qty) + # 전량 청산이면 ActiveTrade 종료, 분할 매도면 remaining_pct 갱신 if sell_qty >= quantity: await enqueue_close_active_trade(stock_code, strategy_name=None) else: # 분할 매도 후 remaining_pct 갱신 (반복 트리거 방지) new_remaining = max(0.0, remaining_pct - sell_pct) - from app.services.db_worker import ( - enqueue_active_trade_upsert, enqueue_portfolio_upsert, - ) + from app.services.db_worker import enqueue_active_trade_upsert _at_data = { "stock_code": stock_code, "remaining_pct": new_remaining, @@ -364,13 +366,6 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: "entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)", }) await enqueue_active_trade_upsert(_at_data) - # [FIX] 잔여 수량도 즉시 반영 — 다음 사이클이 동기화 전 원래 수량 - # 기준으로 매도 수량을 계산하지 않도록 (18주→9주 재매도 방지) - await enqueue_portfolio_upsert({ - "stock_code": stock_code, - "quantity": quantity - sell_qty, - "current_price": latest_price, - }) # LossTracker 업데이트 — 전량매도 시에만 (분할매도 이중 카운트 방지) if sell_qty >= quantity: diff --git a/backend/tests/test_portfolio_cache_bridge.py b/backend/tests/test_portfolio_cache_bridge.py new file mode 100644 index 0000000..499c688 --- /dev/null +++ b/backend/tests/test_portfolio_cache_bridge.py @@ -0,0 +1,48 @@ +"""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 + + +@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() diff --git a/frontend/src/hooks/useTradeActions.ts b/frontend/src/hooks/useTradeActions.ts index 14f9920..9bf0f02 100644 --- a/frontend/src/hooks/useTradeActions.ts +++ b/frontend/src/hooks/useTradeActions.ts @@ -68,6 +68,15 @@ export const useTradeActions = () => { : '' toast.success(`${d.message || `${stockName} ${quantity}주 매도 주문 완료`}${pl}`) + // 낙관적 갱신: 서버 재조회 전에 화면에서 즉시 제거/차감 + queryClient.setQueryData(['portfolio-combined', mode], (prev: any) => { + if (!prev?.holdings) return prev + const holdings = prev.holdings + .map((h: any) => (h.stock_code === stockCode ? { ...h, quantity: h.quantity - quantity } : h)) + .filter((h: any) => h.quantity > 0) + return { ...prev, holdings } + }) + // Invalidate queries to refresh portfolio queryClient.invalidateQueries({ queryKey: ['portfolio-combined'],