## 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>
178 lines
6.2 KiB
Python
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),
|
|
}
|