Files
auto-stock/backend/app/api/endpoints/swing_portfolio.py
shuim 028a1c352b feat: 자동매매 신뢰성 개선 — 시세 실서버 라우팅·일일 한도 전역화·수동 매수 API
- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를
  실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응)
- recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 +
  MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지)
- manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker)
- mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시
  active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정
- config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL,
  SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가
- docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001)
- frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선
- CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:37:08 +09:00

177 lines
6.3 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=None, # 시장가 (order_dvsn="01"은 price=None일 때 자동 적용)
)
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),
}