feat: 리스크 관리 강화 — 포트폴리오 락, MDD 단기전략 비활성화, 상관관계 필터
- portfolio.py: summary_lock으로 동시성 보호, apply_buy/apply_sell async화 - mdd_manager.py: MDD -10% 시 단기전략 비활성화 (is_short_term_strategy_paused) - loss_tracker.py: DB 직접 접근 → db_worker 큐 기반 전환 - db_worker.py: upsert_stock_ban 태스크 추가 - correlation_filter.py: 섹터 집중도 + 피어슨 상관관계 검증 (CLAUDE.md 5.4) - trade_executor.py: 상관관계 필터 통합, await 적용 - mean_reversion_strategy.py: config 값 타입 안전성 (float 변환) - main.py/day_trading.py: MDD 체크 후 자동매매/데이트레이딩 차단 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,7 @@ class PortfolioCache:
|
||||
self.last_synced: datetime | None = None
|
||||
self.change_flag = False # 외부 이벤트로 변경 감지 (매수/매도 등)
|
||||
self.sync_lock = asyncio.Lock()
|
||||
self.summary_lock = asyncio.Lock() # summary 수정 보호용 락
|
||||
self.min_interval = timedelta(seconds=15) # 최소 동기화 간격
|
||||
self.last_attempt: datetime | None = None
|
||||
self.last_error: str | None = None
|
||||
@@ -75,50 +76,59 @@ class PortfolioCache:
|
||||
def mark_changed(self):
|
||||
self.change_flag = True
|
||||
|
||||
def apply_buy(self, stock_code: str, stock_name: str, quantity: int, price: float):
|
||||
"""매수 체결 즉시 캐시 반영 — KIS 재동기화 전까지 summary 수치 정확하게 유지"""
|
||||
def get_summary_copy(self) -> dict | None:
|
||||
"""summary의 스냅샷 반환 (락 없이, 읽기 전용).
|
||||
summary가 수정 중일 수 있으므로 복사본을 반환."""
|
||||
if self.summary is None:
|
||||
self.mark_changed()
|
||||
return
|
||||
cost = quantity * price
|
||||
# 예수금 차감
|
||||
self.summary["available_cash"] = max(0.0, float(self.summary.get("available_cash", 0)) - cost)
|
||||
# 주식 평가금액 증가
|
||||
self.summary["total_value"] = float(self.summary.get("total_value", 0)) + cost
|
||||
# 매입금액 증가
|
||||
self.summary["total_investment"] = float(self.summary.get("total_investment", 0)) + cost
|
||||
# 총 자산 = 예수금 + 주식평가 (재계산)
|
||||
self.summary["total_assets"] = self.summary["available_cash"] + self.summary["total_value"]
|
||||
# 평가손익 재계산
|
||||
inv = self.summary["total_investment"]
|
||||
val = self.summary["total_value"]
|
||||
self.summary["total_profit_loss"] = val - inv
|
||||
self.summary["total_profit_loss_rate"] = ((val - inv) / inv * 100) if inv > 0 else 0
|
||||
self.summary["total_stocks"] = len([h for h in self.holdings if h.quantity > 0])
|
||||
self.mark_changed()
|
||||
return None
|
||||
return dict(self.summary)
|
||||
|
||||
def apply_sell(self, stock_code: str, quantity: int, price: float, avg_price: float):
|
||||
"""매도 체결 즉시 캐시 반영"""
|
||||
if self.summary is None:
|
||||
async def apply_buy(self, stock_code: str, stock_name: str, quantity: int, price: float):
|
||||
"""매수 체결 즉시 캐시 반영 — KIS 재동기화 전까지 summary 수치 정확하게 유지"""
|
||||
async with self.summary_lock:
|
||||
if self.summary is None:
|
||||
self.mark_changed()
|
||||
return
|
||||
cost = quantity * price
|
||||
# 예수금 차감
|
||||
self.summary["available_cash"] = max(0.0, float(self.summary.get("available_cash", 0)) - cost)
|
||||
# 주식 평가금액 증가
|
||||
self.summary["total_value"] = float(self.summary.get("total_value", 0)) + cost
|
||||
# 매입금액 증가
|
||||
self.summary["total_investment"] = float(self.summary.get("total_investment", 0)) + cost
|
||||
# 총 자산 = 예수금 + 주식평가 (재계산)
|
||||
self.summary["total_assets"] = self.summary["available_cash"] + self.summary["total_value"]
|
||||
# 평가손익 재계산
|
||||
inv = self.summary["total_investment"]
|
||||
val = self.summary["total_value"]
|
||||
self.summary["total_profit_loss"] = val - inv
|
||||
self.summary["total_profit_loss_rate"] = ((val - inv) / inv * 100) if inv > 0 else 0
|
||||
self.summary["total_stocks"] = len([h for h in self.holdings if h.quantity > 0])
|
||||
self.mark_changed()
|
||||
|
||||
async def apply_sell(self, stock_code: str, quantity: int, price: float, avg_price: float):
|
||||
"""매도 체결 즉시 캐시 반영"""
|
||||
async with self.summary_lock:
|
||||
if self.summary is None:
|
||||
self.mark_changed()
|
||||
return
|
||||
proceeds = quantity * price
|
||||
cost = quantity * avg_price
|
||||
# 예수금 증가
|
||||
self.summary["available_cash"] = float(self.summary.get("available_cash", 0)) + proceeds
|
||||
# 주식 평가금액 감소
|
||||
self.summary["total_value"] = max(0.0, float(self.summary.get("total_value", 0)) - proceeds)
|
||||
# 매입금액 감소
|
||||
self.summary["total_investment"] = max(0.0, float(self.summary.get("total_investment", 0)) - cost)
|
||||
# 총 자산 = 예수금 + 주식평가
|
||||
self.summary["total_assets"] = self.summary["available_cash"] + self.summary["total_value"]
|
||||
# 평가손익 재계산
|
||||
inv = self.summary["total_investment"]
|
||||
val = self.summary["total_value"]
|
||||
self.summary["total_profit_loss"] = val - inv
|
||||
self.summary["total_profit_loss_rate"] = ((val - inv) / inv * 100) if inv > 0 else 0
|
||||
self.summary["total_stocks"] = len([h for h in self.holdings if h.quantity > 0])
|
||||
self.mark_changed()
|
||||
return
|
||||
proceeds = quantity * price
|
||||
cost = quantity * avg_price
|
||||
# 예수금 증가
|
||||
self.summary["available_cash"] = float(self.summary.get("available_cash", 0)) + proceeds
|
||||
# 주식 평가금액 감소
|
||||
self.summary["total_value"] = max(0.0, float(self.summary.get("total_value", 0)) - proceeds)
|
||||
# 매입금액 감소
|
||||
self.summary["total_investment"] = max(0.0, float(self.summary.get("total_investment", 0)) - cost)
|
||||
# 총 자산 = 예수금 + 주식평가
|
||||
self.summary["total_assets"] = self.summary["available_cash"] + self.summary["total_value"]
|
||||
# 평가손익 재계산
|
||||
inv = self.summary["total_investment"]
|
||||
val = self.summary["total_value"]
|
||||
self.summary["total_profit_loss"] = val - inv
|
||||
self.summary["total_profit_loss_rate"] = ((val - inv) / inv * 100) if inv > 0 else 0
|
||||
self.summary["total_stocks"] = len([h for h in self.holdings if h.quantity > 0])
|
||||
self.mark_changed()
|
||||
|
||||
def should_sync(self) -> bool:
|
||||
now = datetime.now()
|
||||
@@ -156,7 +166,7 @@ class PortfolioCache:
|
||||
self.next_retry_at = None
|
||||
self.last_sync_success = True
|
||||
|
||||
def cache_summary(self, balance_data: dict = None, overseas_balance_data: dict = None, exchange_rate: float = 0.0):
|
||||
async def cache_summary(self, balance_data: dict = None, overseas_balance_data: dict = None, exchange_rate: float = 0.0):
|
||||
"""포트폴리오 요약 캐시 업데이트.
|
||||
|
||||
Args:
|
||||
@@ -269,7 +279,7 @@ class PortfolioCache:
|
||||
today_profit_loss = (total_assets - bfdy_total_assets) if bfdy_total_assets > 0 else 0.0
|
||||
today_profit_loss_rate = (today_profit_loss / bfdy_total_assets * 100) if bfdy_total_assets > 0 else 0.0
|
||||
|
||||
self.summary = {
|
||||
summary_data = {
|
||||
"total_stocks": total_stocks,
|
||||
"total_value": total_value,
|
||||
"total_investment": total_investment,
|
||||
@@ -288,6 +298,10 @@ class PortfolioCache:
|
||||
"exchange_rate": exchange_rate,
|
||||
}
|
||||
|
||||
# summary 수정을 락으로 보호
|
||||
async with self.summary_lock:
|
||||
self.summary = summary_data
|
||||
|
||||
portfolio_cache = PortfolioCache()
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -614,7 +628,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
await db.delete(h)
|
||||
await db.commit()
|
||||
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
|
||||
portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
|
||||
await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
|
||||
portfolio_cache.last_synced = datetime.now()
|
||||
portfolio_cache.change_flag = False
|
||||
portfolio_cache.last_trade_id = await _get_latest_executed_trade_id(db)
|
||||
@@ -640,13 +654,14 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
if portfolio_cache.summary and not portfolio_cache.should_sync():
|
||||
logger.debug("Portfolio cache hit (summary)")
|
||||
resp = dict(portfolio_cache.summary)
|
||||
resp = portfolio_cache.get_summary_copy() or {}
|
||||
else:
|
||||
try:
|
||||
await sync_portfolio(db, force=False)
|
||||
except HTTPException:
|
||||
logger.warning("get_portfolio_summary: sync failed, returning cached summary")
|
||||
resp = dict(portfolio_cache.summary or {
|
||||
summary_copy = portfolio_cache.get_summary_copy()
|
||||
resp = summary_copy or {
|
||||
"total_stocks": 0,
|
||||
"total_value": 0,
|
||||
"total_investment": 0,
|
||||
@@ -654,7 +669,7 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
|
||||
"total_profit_loss_rate": 0,
|
||||
"available_cash": 0,
|
||||
"total_assets": 0,
|
||||
})
|
||||
}
|
||||
|
||||
# 누적 실현 손익 (TradeHistory SELL 체결 기준)
|
||||
try:
|
||||
@@ -1249,3 +1264,97 @@ async def sell_stock(
|
||||
except Exception as e:
|
||||
logger.error(f"Manual sell error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"매도 처리 중 오류: {str(e)}")
|
||||
|
||||
|
||||
class ActiveTradeStatusUpdateRequest(BaseModel):
|
||||
"""활성 거래 상태 업데이트 요청"""
|
||||
status: str # 'active', 'pending_sell', 'closed'
|
||||
reason: Optional[str] = ""
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
@router.patch("/active-trades/{stock_code}/status")
|
||||
async def update_active_trade_status(
|
||||
stock_code: str,
|
||||
request: ActiveTradeStatusUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
활성 거래의 상태를 업데이트합니다.
|
||||
|
||||
CLAUDE.md Section 3.2: trade_monitor에서 직접 DB commit 대신 이 API를 호출
|
||||
- status: 'active' | 'pending_sell' | 'closed'
|
||||
- reason: 상태 변경 이유
|
||||
- updated_at: 업데이트 시각
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
request: 상태 업데이트 요청
|
||||
db: DB 세션
|
||||
|
||||
Returns:
|
||||
성공 결과 또는 에러
|
||||
"""
|
||||
try:
|
||||
# 활성 거래 조회
|
||||
result = await db.execute(
|
||||
select(ActiveTrade).where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
ActiveTrade.status != 'closed'
|
||||
)
|
||||
)
|
||||
active_trade = result.scalar_one_or_none()
|
||||
|
||||
if not active_trade:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"활성 거래를 찾을 수 없음: {stock_code}"
|
||||
)
|
||||
|
||||
# 상태 유효성 검증
|
||||
valid_statuses = {'active', 'pending_sell', 'closed'}
|
||||
if request.status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"유효하지 않은 상태: {request.status}. 허용값: {valid_statuses}"
|
||||
)
|
||||
|
||||
# 상태 변경 기록 (로깅)
|
||||
old_status = active_trade.status
|
||||
new_status = request.status
|
||||
reason = request.reason or ""
|
||||
|
||||
logger.info(
|
||||
f"[TradeMonitor API] 활성 거래 상태 변경: "
|
||||
f"{stock_code} {old_status} → {new_status} (이유: {reason})"
|
||||
)
|
||||
|
||||
# 상태 업데이트
|
||||
active_trade.status = new_status
|
||||
active_trade.updated_at = datetime.fromisoformat(request.updated_at) if request.updated_at else datetime.utcnow()
|
||||
|
||||
db.add(active_trade)
|
||||
await db.commit()
|
||||
await db.refresh(active_trade)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"stock_code": stock_code,
|
||||
"old_status": old_status,
|
||||
"new_status": new_status,
|
||||
"reason": reason,
|
||||
"updated_at": active_trade.updated_at.isoformat()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[TradeMonitor API] 상태 업데이트 실패 ({stock_code}): {e}",
|
||||
exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"상태 업데이트 중 오류: {str(e)}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user