감사 CRITICAL 4건:
- /daytrading/{watchlist,execute,close-all,cycle} 에 require_api_key 누락 → 인증 없이
전량 청산 가능했음 (CLAUDE.md §4 위반)
- 레거시 day_trading._positions 가 엔진 day_trading_state 와 별도로 기동 시 복원돼
같은 포지션이 두 엔진에 존재 → /close-all 시 이중 매도. 복원 제거 + close-all/cycle 을
엔진에 위임, /execute 실매수는 410(엔진 미관리 포지션 방지)
- reflect_buy 가 기존 행을 daytrading 으로 재태깅 → 같은 종목 스윙 보유분이
get_swing_holdings 에서 빠져 손절·익절 정지. 신규 행일 때만 태깅
- close_stale_positions 가 인메모리만 순회 → 자정 reset 후 대상이 사라져 무력.
ActiveTrade(day_trade/active/전일) 조회 기반으로 변경하고 인메모리가 비어도 실행
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
119 lines
5.3 KiB
Python
119 lines
5.3 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))
|
|
# [2026-09-23] 기존 행의 strategy_type 은 변경하지 않는다.
|
|
# 스윙 8주 보유 중 DT 4주를 사면 행(12주)이 daytrading 이 되어
|
|
# get_swing_holdings 에서 빠지고 스윙 8주의 손절·익절이 완전히 멈춘다.
|
|
# 화면의 DT 그룹은 엔진 포지션(/daytrading/positions)으로 별도 표시한다.
|
|
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
|
|
|
|
_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,
|
|
}
|
|
if existing is None:
|
|
# 신규 행일 때만 전략 태깅 (기존 행은 다른 전략 보유분이 섞여 있을 수 있음)
|
|
_upsert["strategy_type"] = strategy_type
|
|
await enqueue_portfolio_upsert(_upsert)
|
|
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}")
|