"""스윙 트레이딩 전용 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), }