Files
auto-stock/backend/app/api/endpoints/swing_portfolio.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

283 lines
12 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 = list(await get_swing_holdings(db))
# [2026-10-08] 전략별 분해(swing_* / dt_*) — 기존 필드는 계좌 합산·혼합 평단 그대로
from app.api.endpoints.portfolio import _annotate_strategy_breakdown_v2, _BREAKDOWN_FIELDS
await _annotate_strategy_breakdown_v2(db, holdings)
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,
**{k: getattr(h, k, None) for k in _BREAKDOWN_FIELDS},
}
for h in holdings
],
}
@router.get("/portfolio/summary")
async def get_swing_summary(db: AsyncSession = Depends(get_db)):
"""스윙 포지션 수익률 요약."""
holdings = list(await get_swing_holdings(db))
# [2026-10-08] 스윙 보유분(swing_quantity)·스윙 진입가(swing_avg_price) 기준 — 혼합 평단 회피
from app.api.endpoints.portfolio import _annotate_strategy_breakdown_v2
await _annotate_strategy_breakdown_v2(db, holdings)
def _sq(h):
v = getattr(h, "swing_quantity", None)
return int(h.quantity or 0) if v is None else int(v)
def _sp(h):
v = getattr(h, "swing_avg_price", None)
return float(h.average_price or 0) if v is None else float(v)
total_investment = sum(_sp(h) * _sq(h) for h in holdings)
total_value = sum(float(h.current_price or 0) * _sq(h) 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 = list(await get_swing_holdings(db))
from app.api.endpoints.portfolio import _annotate_strategy_breakdown_v2
await _annotate_strategy_breakdown_v2(db, holdings)
candidates = []
for h in holdings:
# [2026-10-08] 스윙 진입가 기준(혼합 평단이면 손익률이 왜곡된다)
avg_price = float(getattr(h, "swing_avg_price", None) or 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
# [2026-10-08] 스윙 진입가(혼합 평단 회피) — 스윙 ActiveTrade 우선
from app.services.swing.position_manager import (
get_swing_active_trade, resolve_swing_entry_price_v2,
)
# 주문은 이미 전송됐다 — 진입가 조회 실패가 TradeHistory 기록을 막으면 원장이 유실되므로
# 실패 시 Portfolio 평단으로 폴백(기록 손익은 추정치, 체결 확인이 보정한다).
try:
_at = await get_swing_active_trade(db, req.stock_code)
except Exception as _at_err:
logger.warning(
f"[SwingManualSell] {req.stock_code} 스윙 진입가 조회 실패 — Portfolio 평단으로 기록: "
f"{type(_at_err).__name__}: {_at_err}"
)
_at = None
avg_price, _ = resolve_swing_entry_price_v2(
getattr(_at, "entry_price", None) if _at is not None else None,
float(holding.average_price or 0), req.stock_code,
)
pl = (price - avg_price) * quantity
pl_rate = (price - avg_price) / avg_price * 100 if avg_price > 0 else 0.0
# [하드닝 R2 2026-10-09] 순손익(비용 차감, §5.1) — 자동 경로(swing/exit_service)와 동일 정의.
# 시장 구분 정보가 Portfolio 에 없어 KOSPI 세율 적용(추정치, 체결 확인이 보정).
# TradeHistory enqueue 전에 이전 레그 누적을 조회해야 이번 레그가 이중 합산되지 않는다.
from app.services.swing.exit_service import (
_swing_net_pnl_v2, _position_is_loss_v2, _prior_position_realized_net_v2,
)
alloc = allocate_sell_v2(quantity, row_qty, dt_qty, swing_only=True)
_pnl = _swing_net_pnl_v2(avg_price, price, quantity, "KOSPI")
net_pl = float(_pnl["net_profit_loss"])
# fill_sweep.derive_sell_entry_price_v2 가 entry = p − (net + commission)/q 로 역산하므로
# 비용 차감 net 을 기록할 땐 commission 도 함께 기록해야 한다(자동 경로와 동일 규약).
total_cost = float(_pnl["total_cost"])
prior_net = None
if alloc.swing_left <= 0 and _at is not None:
prior_net = await _prior_position_realized_net_v2(db, req.stock_code, _at, "KOSPI")
await enqueue_trade_history({
"stock_code": req.stock_code,
"stock_name": holding.stock_name,
"order_type": "SELL",
"order_price": price,
"order_quantity": quantity,
# [2026-10-08] 시장가 — 체결가는 체결 확인(swing/fill_sweep)이 채운다. 금액·손익은 추정치.
"executed_price": None,
"executed_quantity": quantity,
"total_amount": price * quantity,
"status": "COMPLETED",
"order_id": order_id,
"exit_reason": "수동 청산",
"net_profit_loss": net_pl,
"commission": total_cost,
"profit_loss_rate": pl_rate,
"strategy_type": "swing",
})
# ActiveTrade(비-DT 행만) + Portfolio(행 − 매도분, 큐 경유·캐시 반영)
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:
_is_loss = _position_is_loss_v2(prior_net, net_pl) # try 밖: 판정 예외가 기록을 삼키지 않게
try:
from app.services.loss_tracker import loss_tracker
await loss_tracker.record_trade_result_safe(stock_code=req.stock_code, is_loss=_is_loss)
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),
}