Files
auto-stock/backend/app/api/endpoints/swing_portfolio.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

178 lines
6.2 KiB
Python

"""스윙 트레이딩 전용 API 엔드포인트
prefix: /api/v1/swing
엔드포인트:
GET /portfolio — 스윙 포지션만 조회
GET /portfolio/summary — 스윙 수익률 요약
GET /exit-candidates — 청산 후보 종목 목록
POST /sell — 스윙 포지션 수동 청산
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.models import Portfolio, ActiveTrade
from app.services.korea_investment_api import korea_investment_api
from app.services.swing.position_manager import get_swing_holdings, count_swing_positions
from app.core.json_helpers import _dumps
router = APIRouter()
logger = logging.getLogger(__name__)
class SwingSellRequest(BaseModel):
stock_code: str
quantity: Optional[int] = None # None이면 전량
@router.get("/portfolio")
async def get_swing_portfolio(db: AsyncSession = Depends(get_db)):
"""스윙 전용 포지션 목록 (strategy_type='swing')."""
holdings = await get_swing_holdings(db)
return {
"count": len(holdings),
"positions": [
{
"stock_code": h.stock_code,
"stock_name": h.stock_name,
"quantity": h.quantity,
"average_price": float(h.average_price or 0),
"current_price": float(h.current_price or 0),
"profit_loss": float(h.profit_loss or 0),
"profit_loss_rate": float(h.profit_loss_rate or 0),
"strategy_type": h.strategy_type,
}
for h in holdings
],
}
@router.get("/portfolio/summary")
async def get_swing_summary(db: AsyncSession = Depends(get_db)):
"""스윙 포지션 수익률 요약."""
holdings = await get_swing_holdings(db)
total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings)
total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings)
total_pl = total_value - total_investment
total_pl_rate = (total_pl / total_investment * 100) if total_investment > 0 else 0.0
return {
"position_count": len(holdings),
"total_investment": total_investment,
"total_value": total_value,
"total_profit_loss": total_pl,
"total_profit_loss_rate": round(total_pl_rate, 2),
}
@router.get("/exit-candidates")
async def get_exit_candidates(db: AsyncSession = Depends(get_db)):
"""청산 조건에 근접한 스윙 후보 종목 조회 (읽기 전용, 실제 청산 없음)."""
holdings = await get_swing_holdings(db)
candidates = []
for h in holdings:
avg_price = float(h.average_price or 0)
current_price = float(h.current_price or 0)
if avg_price <= 0 or current_price <= 0:
continue
pl_rate = (current_price - avg_price) / avg_price * 100
# -5% 이하 손실 또는 +10% 이상 수익이면 후보
if pl_rate <= -5.0 or pl_rate >= 10.0:
candidates.append({
"stock_code": h.stock_code,
"stock_name": h.stock_name,
"profit_loss_rate": round(pl_rate, 2),
"signal": "STOP_LOSS" if pl_rate <= -5.0 else "TAKE_PROFIT",
})
return {"candidates": sorted(candidates, key=lambda x: x["profit_loss_rate"])}
@router.post("/sell")
async def manual_swing_sell(
req: SwingSellRequest,
db: AsyncSession = Depends(get_db),
):
"""스윙 포지션 수동 청산."""
result = await db.execute(
select(Portfolio).where(
Portfolio.stock_code == req.stock_code,
Portfolio.strategy_type == "swing",
Portfolio.quantity > 0,
)
)
holding = result.scalar_one_or_none()
if not holding:
raise HTTPException(status_code=404, detail="스윙 포지션을 찾을 수 없습니다")
quantity = req.quantity or holding.quantity
quantity = min(quantity, holding.quantity)
# 현재가 조회
price_resp = await korea_investment_api.get_current_price(req.stock_code)
if not price_resp or not price_resp.get("output"):
raise HTTPException(status_code=503, detail="현재가 조회 실패")
price = int(price_resp["output"].get("stck_prpr", 0))
if price <= 0:
raise HTTPException(status_code=503, detail="현재가 조회 실패")
order_resp = await korea_investment_api.place_order(
stock_code=req.stock_code,
order_type="SELL",
quantity=quantity,
price=0,
order_price_type="01",
)
if not order_resp or order_resp.get("rt_cd") != "0":
err = order_resp.get("msg1", "unknown") if order_resp else "no response"
raise HTTPException(status_code=503, detail=f"매도 주문 실패: {err}")
from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert
avg_price = float(holding.average_price or 0)
pl = (price - avg_price) * quantity
pl_rate = (price - avg_price) / avg_price * 100 if avg_price > 0 else 0.0
# 매도 후 Portfolio 수량 갱신 (db_worker 큐 경유)
new_quantity = holding.quantity - quantity
await enqueue_portfolio_upsert({
"stock_code": req.stock_code,
"stock_name": holding.stock_name,
"quantity": new_quantity,
"average_price": avg_price,
"current_price": float(price),
"strategy_type": "swing",
})
await enqueue_trade_history({
"stock_code": req.stock_code,
"stock_name": holding.stock_name,
"order_type": "SELL",
"order_price": price,
"order_quantity": quantity,
"executed_price": price,
"executed_quantity": quantity,
"total_amount": price * quantity,
"status": "COMPLETED",
"exit_reason": "수동 청산",
"net_profit_loss": pl,
"profit_loss_rate": pl_rate,
"strategy_type": "swing",
})
logger.info(f"[SwingAPI] 수동 청산: {req.stock_code} 수량={quantity} 가격={price:,}")
return {
"stock_code": req.stock_code,
"quantity": quantity,
"price": price,
"profit_loss": round(pl),
"profit_loss_rate": round(pl_rate, 2),
}