- 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>
177 lines
6.3 KiB
Python
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),
|
|
}
|