From a57c7205596c40d8ca571ce5bf5b1df163196d2d Mon Sep 17 00:00:00 2001 From: shuim Date: Tue, 22 Sep 2026 16:49:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=8A=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=94=A9=20=ED=8F=AC=EC=A7=80=EC=85=98=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=91=9C=EC=8B=9C=20+=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=EB=A7=A4=EB=8F=84=20=EC=A6=89=EC=8B=9C=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - portfolio_cache_bridge 신설: 매매 엔진 체결 시 Portfolio(db_worker 큐) + 인메모리 캐시 즉시 갱신. DT 매수는 strategy_type='daytrading'으로 태깅되어 보유 종목 패널의 '데이트레이딩' 그룹에 표시되고 스윙 청산 로직(strategy_type='swing' 대상)에서 제외됨. (기존엔 KIS 동기화로 'swing'으로 유입돼 DT 포지션이 안 보이고 스윙 엔진이 같이 관리하는 문제) - DT 매도·스윙 자동 매도도 캐시에서 즉시 제거/차감 + 최근매도 기록(동기화 되살림 방지). - 동기화 시 DT 엔진 보유 종목은 'daytrading'으로 태깅. - 프론트: 수동 매도 성공 시 낙관적 갱신으로 서버 재조회 전 즉시 목록에서 제거. Co-Authored-By: Claude Opus 5 (1M context) --- backend/app/api/endpoints/portfolio.py | 10 ++ .../app/services/daytrading/entry_service.py | 3 + .../app/services/daytrading/exit_service.py | 3 + .../app/services/portfolio_cache_bridge.py | 111 ++++++++++++++++++ backend/app/services/swing/exit_service.py | 15 +-- backend/tests/test_portfolio_cache_bridge.py | 48 ++++++++ frontend/src/hooks/useTradeActions.ts | 9 ++ 7 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 backend/app/services/portfolio_cache_bridge.py create mode 100644 backend/tests/test_portfolio_cache_bridge.py 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'],