feat: 데이트레이딩 포지션 화면 표시 + 모든 매도 즉시 화면 반영
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
111
backend/app/services/portfolio_cache_bridge.py
Normal file
111
backend/app/services/portfolio_cache_bridge.py
Normal file
@@ -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}")
|
||||
@@ -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:
|
||||
|
||||
48
backend/tests/test_portfolio_cache_bridge.py
Normal file
48
backend/tests/test_portfolio_cache_bridge.py
Normal file
@@ -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()
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user