Files
auto-stock/backend/app/services/portfolio_cache_bridge.py
shuim f32bf412f3 fix: 로그 분석으로 찾은 버그 4건 (과다매도·수량 0 오염·LSTM 무력화·sync 충돌)
1) DT 분할매도가 ActiveTrade.quantity를 갱신하지 않아 재기동 복원 시 원래 수량이 되살아남
   → 잔량 4주인데 7주 매도되어 같은 종목 스윙 보유분 3주까지 청산됨(2026-09-23 LG씨엔에스).
2) reflect_sell이 '전략 포지션 잔량'을 Portfolio 행 잔량으로 사용 → DT 전량청산이 스윙
   보유분까지 0으로 만듦. 행 잔량은 항상 (행 수량 - 매도 수량)으로 계산.
3) LSTM 체크포인트에 numpy 배열이 들어가 weights_only=True 로드가 매번 실패 → 앙상블에서
   LSTM이 조용히 빠진 채 예측 중이었음. 저장을 list로 바꾸고, 기존 파일은 MODEL_DIR 내부일
   때만 완화 재시도.
4) 수동 매도(행 삭제)와 sync 동시 실행 시 StaleDataError로 sync 전체 실패 → 전량 매도는
   행 삭제 대신 수량 0, sync는 충돌 시 실패 기록 없이 다음 주기 재시도.
   추가로 비강제 sync는 락 점유 중이면 30초 대기 없이 캐시 반환.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 10:24:45 +09:00

114 lines
4.8 KiB
Python

"""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: 호출측(전략 엔진)이 아는 '그 전략 포지션'의 잔량. Portfolio 행은
같은 종목의 스윙+데이트레이딩 수량이 합쳐진 값이므로, 행 잔량은 항상
`행 수량 - 매도 수량`으로 계산한다. remaining_qty는 캐시에 행이 없을 때의 폴백으로만 쓴다.
(2026-09-23: DT 전량청산이 스윙 보유분까지 0으로 만들던 버그)
"""
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 existing is not None:
remaining = max(0, int(existing.quantity or 0) - quantity)
elif remaining_qty is not None:
remaining = max(0, int(remaining_qty))
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}")