"""스윙 트레이딩 전용 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, Field 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 from app.services.manual_sell_service import ( allocate_sell_v2, dt_inmemory_qty_v2, sync_active_trade_after_manual_sell_v2, ) router = APIRouter() logger = logging.getLogger(__name__) class SwingSellRequest(BaseModel): stock_code: str # None이면 스윙 보유분 전량. 0/음수는 422 (M-5 2026-10-02) quantity: Optional[int] = Field(None, gt=0) @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), ): """스윙 포지션 수동 청산. [M-5 2026-10-02] 기존 결함: `place_order` 직접 호출(종목 세마포어·Redis 락 없음 → 자동 청산과 동시 매도), DT 보유분 미차감(합산 행 전량을 팔아 DT 분까지 매도, §3.5), ActiveTrade close·loss_tracker 누락, 음수 수량 미검증. 수정: 매도 가능 = 행 수량 − DT 인메모리 보유분, 주문은 safe_place_order(락 경유), ActiveTrade 는 비-DT 필터로 갱신, 스윙분 전량 청산 시 loss_tracker record+save_to_db, DB 쓰기는 db_worker 큐 경유. """ if req.quantity is not None and req.quantity <= 0: raise HTTPException(status_code=400, detail="매도 수량은 1 이상이어야 합니다") # DB-READ: 보유 행 SELECT 전용(commit 없음) 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="스윙 포지션을 찾을 수 없습니다") row_qty = int(holding.quantity or 0) dt_qty = dt_inmemory_qty_v2(req.stock_code) if dt_qty is None: # DT 보유분을 모르면 스윙 매도 가능 수량을 확정할 수 없다 → DT 분 오매도 방지 raise HTTPException( status_code=503, detail="데이트레이딩 보유분 확인 실패 — 스윙 매도 가능 수량을 확정할 수 없습니다", ) swing_qty = max(0, row_qty - min(dt_qty, row_qty)) if swing_qty <= 0: raise HTTPException( status_code=400, detail=f"스윙 보유분이 없습니다 (행 {row_qty}주 전부 데이트레이딩 보유분 {dt_qty}주)", ) quantity = req.quantity if req.quantity is not None else swing_qty if quantity > swing_qty: raise HTTPException( status_code=400, detail=( f"매도 요청 {quantity}주 > 스윙 보유분 {swing_qty}주 " f"(행 {row_qty}주 − 데이트레이딩 {dt_qty}주)" ), ) # 현재가 조회 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="현재가 조회 실패") from app.services.trade_executor import safe_place_order order_resp = await safe_place_order( stock_code=req.stock_code, order_type="SELL", quantity=quantity, price=None, # 시장가 is_domestic=True, ) if not order_resp or order_resp.get("rt_cd") != "0": err = order_resp.get("msg1", "unknown") if order_resp else "no response" status = 409 if err == "order_lock_busy" else 503 raise HTTPException(status_code=status, detail=f"매도 주문 실패: {err}") order_id = (order_resp.get("output") or {}).get("ODNO") from app.services.db_worker import enqueue_trade_history 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 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", "order_id": order_id, "exit_reason": "수동 청산", "net_profit_loss": pl, "profit_loss_rate": pl_rate, "strategy_type": "swing", }) # ActiveTrade(비-DT 행만) + Portfolio(행 − 매도분, 큐 경유·캐시 반영) alloc = allocate_sell_v2(quantity, row_qty, dt_qty, swing_only=True) await sync_active_trade_after_manual_sell_v2(req.stock_code, alloc, price=float(price)) from app.services.portfolio_cache_bridge import reflect_sell await reflect_sell(req.stock_code, quantity, float(price), remaining_qty=row_qty - quantity) # LossTracker — 스윙분 전량 청산 시에만(분할 매도 이중 카운트 방지, swing/exit_service 와 동일) if alloc.swing_left <= 0: try: from app.services.loss_tracker import loss_tracker await loss_tracker.record_trade_result_safe(stock_code=req.stock_code, is_loss=pl < 0) await loss_tracker.save_to_db(stock_code=req.stock_code) except Exception as e: logger.warning(f"[SwingAPI] LossTracker 기록 실패 {req.stock_code}: {e}") logger.info( f"[SwingAPI] 수동 청산: {req.stock_code} 수량={quantity} 가격={price:,} " f"(스윙 잔량 {alloc.swing_left}주, DT {dt_qty}주 유지)" ) return { "stock_code": req.stock_code, "quantity": quantity, "price": price, "order_id": order_id, "profit_loss": round(pl), "profit_loss_rate": round(pl_rate, 2), }