Files
auto-stock/backend/app/api/endpoints/swing_portfolio.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

229 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""스윙 트레이딩 전용 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),
}