Files
auto-stock/backend/app/api/endpoints/us_trading.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

251 lines
11 KiB
Python

"""미국 주식 매매 API 엔드포인트
- GET /us-trading/watchlist: 미국 주식 관심 종목 목록
- GET /us-trading/signals: 매매 신호 스캔 결과
- GET /us-trading/status: 미국장 상태 (개장/폐장/프리마켓)
- POST /us-trading/execute: 매도(청산)만 허용 — 실보유 검증·주문락·부기. 매수는 410
- POST /us-trading/cycle: 미국 매매 사이클 수동 실행 (US_TRADING_ENABLED=False 면 423)
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from typing import Literal, Optional, List
import logging
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/us-trading", tags=["US Trading"])
class UsOrderRequest(BaseModel):
ticker: str
order_type: Literal["buy", "sell"]
quantity: int = Field(gt=0, description="주문 수량 (1 이상)")
price: Optional[float] = None # None = market order attempt (limit 근사)
@router.get("/status")
async def get_us_market_status():
"""미국장 현재 상태"""
from app.services.us_market_schedule import (
is_us_market_open, is_us_premarket, is_us_aftermarket, get_next_us_market_open
)
return {
"is_open": is_us_market_open(),
"is_premarket": is_us_premarket(),
"is_aftermarket": is_us_aftermarket(),
"next_open": get_next_us_market_open().isoformat() if not is_us_market_open() else None,
}
@router.get("/watchlist")
async def get_us_watchlist():
"""미국 주식 관심 종목 목록"""
from app.services.us_stock_strategy import get_us_watchlist
tickers = get_us_watchlist()
return {"tickers": tickers, "count": len(tickers)}
@router.get("/signals")
async def scan_us_signals():
"""미국 주식 매매 신호 스캔"""
from app.services.us_stock_strategy import scan_us_stocks
from app.services.korea_investment_api import korea_investment_api
try:
results = await scan_us_stocks(korea_investment_api)
return {"signals": results, "count": len(results)}
except HTTPException:
raise
except Exception as e:
logger.error(f"US signal scan failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
def _f(v) -> float:
try:
return float(str(v or 0).replace(",", ""))
except (TypeError, ValueError):
return 0.0
@router.post("/execute")
async def execute_us_trade(req: UsOrderRequest):
"""미국 주식 수동 주문 — **매도(청산)만 허용**, 매수는 410.
[H-5 2026-10-02] 매수: `place_overseas_order` 직접 호출로 US_TRADING_ENABLED·MDD·
종목당 상한·현금 유보·가격 필터·밴을 전부 우회했다 → 공통 미국 매수 가드가 생기기 전까지 410.
[H-1 3회차 2026-10-02] 매도는 청산 경로이므로 막지 않는다(§8.1-11, 플래그 OFF 여도 허용).
- 수량 ≤ KIS 해외 잔고 실보유(`ord_psbl_qty`) — 앱 장부가 아닌 계좌 기준
- 주문은 `safe_place_order(is_domestic=False)`(종목 세마포어 + Redis 락)
- TradeHistory·ActiveTrade(비-DT 필터)·Portfolio 는 db_worker 큐 경유
- 전량 청산 시 loss_tracker record + save_to_db
"""
from app.services.exchange_detector import detect_exchange
from app.services.korea_investment_api import korea_investment_api
ticker = req.ticker.strip().upper()
if req.order_type.lower() != "sell":
logger.warning(
f"[US] /us-trading/execute 매수 거부(410): {ticker} x{req.quantity} — 가드 없는 직접 매수 경로 폐지 "
f"(US_TRADING_ENABLED={settings.US_TRADING_ENABLED})"
)
raise HTTPException(
status_code=410,
detail="미국 수동 매수는 폐지되었습니다(리스크 가드 우회). 자동매매는 /us-trading/cycle 을 사용하십시오.",
)
# ── 실보유 확인 (KIS 해외 잔고) ─────────────────────────────────────────
try:
bal = await korea_investment_api.get_overseas_balance()
except Exception as e:
logger.warning(f"[US SELL] {ticker} 해외 잔고 조회 실패: {e}")
raise HTTPException(status_code=502, detail=f"해외 잔고 조회 실패: {e}")
if not isinstance(bal, dict) or bal.get("rt_cd") not in (None, "0") or not isinstance(bal.get("output1"), list):
raise HTTPException(status_code=502, detail="해외 잔고 조회 실패(오류 응답) — 보유 수량을 확인할 수 없습니다")
row = next((h for h in bal["output1"] if str(h.get("ovrs_pdno", "")).upper() == ticker), None)
sellable = int(_f((row or {}).get("ord_psbl_qty")))
held = max(sellable, int(_f((row or {}).get("ovrs_cblc_qty"))))
if row is None or sellable <= 0:
raise HTTPException(status_code=404, detail=f"매도 가능한 해외 보유가 없습니다: {ticker}")
if req.quantity > sellable:
raise HTTPException(
status_code=400, detail=f"매도 요청 {req.quantity}주 > 매도가능 {sellable}주 ({ticker})",
)
exchange = (row.get("ovrs_excg_cd") or "").strip() or detect_exchange(ticker)
price = float(req.price or 0) or _f(row.get("now_pric2"))
if price <= 0:
try:
price_data = await korea_investment_api.get_overseas_price(ticker, exchange)
price = _f((price_data or {}).get("output", {}).get("last"))
except Exception as e:
logger.warning(f"[US SELL] {ticker} 현재가 조회 실패: {e}")
if price <= 0:
raise HTTPException(status_code=400, detail=f"유효하지 않은 주문 가격: {ticker} 현재가 조회 실패 또는 0달러")
from app.services.trade_executor import safe_place_order
result = await safe_place_order(
stock_code=ticker, order_type="SELL", quantity=req.quantity, price=price,
is_domestic=False, exchange_code=exchange,
)
if not result or result.get("rt_cd") != "0":
msg = (result or {}).get("msg1", "no_response")
raise HTTPException(status_code=409 if msg == "order_lock_busy" else 502, detail=f"매도 주문 실패: {msg}")
order_id = (result.get("output") or {}).get("ODNO")
avg = _f(row.get("pchs_avg_pric"))
pnl_usd = (price - avg) * req.quantity if avg > 0 else 0.0
pnl_rate = (price / avg - 1) * 100 if avg > 0 else 0.0
from app.services.db_worker import enqueue_trade_history
from app.services.manual_sell_service import allocate_sell_v2, sync_active_trade_after_manual_sell_v2
from app.services.portfolio_cache_bridge import reflect_sell
await enqueue_trade_history({
"stock_code": ticker,
"stock_name": row.get("ovrs_item_name") or ticker,
"order_type": "SELL",
"order_price": price,
"order_quantity": req.quantity,
"executed_price": price,
"executed_quantity": req.quantity,
"total_amount": price * req.quantity,
"status": "COMPLETED",
"order_id": order_id,
"exit_reason": f"사용자 수동 청산 (손익 ${pnl_usd:+,.2f}, {pnl_rate:+.2f}%)",
"net_profit_loss": pnl_usd,
"profit_loss_rate": round(pnl_rate, 4),
"strategy_type": "us_swing",
"currency_code": "USD",
"exchange_code": exchange,
})
alloc = allocate_sell_v2(req.quantity, held, 0, swing_only=True)
await sync_active_trade_after_manual_sell_v2(ticker, alloc, price=price)
await reflect_sell(ticker, req.quantity, price, remaining_qty=alloc.swing_left)
if alloc.swing_left <= 0:
try:
from app.services.loss_tracker import loss_tracker
await loss_tracker.record_trade_result_safe(stock_code=ticker, is_loss=pnl_usd < 0)
await loss_tracker.save_to_db(stock_code=ticker)
except Exception as e:
logger.warning(f"[US SELL] LossTracker 기록 실패 {ticker}: {e}")
logger.warning(f"[US SELL] 수동 청산 {ticker} {req.quantity}주 @ ${price:,.2f} (잔량 {alloc.swing_left}주)")
return {
"success": True, "ticker": ticker, "quantity": req.quantity, "price": price,
"order_id": order_id, "remaining": alloc.swing_left,
"profit_loss_usd": round(pnl_usd, 2), "profit_loss_rate": round(pnl_rate, 2),
}
@router.post("/cycle")
async def trigger_us_trade_cycle():
"""미국 매매 사이클 수동 즉시 실행 (테스트/긴급 용도)
[H-5 2026-10-02] `US_TRADING_ENABLED=False` 면 423 — 기존에는 플래그를 우회해
정지된 미국 전략을 수동으로 돌릴 수 있었다. 판정은 try 밖(§4).
"""
if not settings.US_TRADING_ENABLED:
raise HTTPException(
status_code=423,
detail="US_TRADING_ENABLED=False — 미국 자동매매 비활성 상태",
)
from app.services.us_market_scheduler import us_trade_cycle
try:
result = await us_trade_cycle()
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"US trade cycle failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/positions")
async def get_us_positions():
"""현재 미국 주식 활성 포지션 조회"""
from app.core.database import AsyncSessionLocal
from app.models.models import ActiveTrade
from sqlalchemy import select
try:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(ActiveTrade).where(
ActiveTrade.status == "active",
ActiveTrade.currency_code == "USD",
)
)
positions = result.scalars().all()
return {
"positions": [
{
"ticker": p.stock_code,
"name": p.stock_name,
"quantity": p.quantity,
"entry_price": float(p.entry_price or 0),
"current_price": float(p.current_price or 0),
"stop_loss": float(p.stop_loss or 0),
"remaining_pct": float(p.remaining_pct or 1.0),
"exchange": p.exchange_code,
"entry_reason": p.entry_reason,
}
for p in positions
],
"count": len(positions),
}
except Exception as e:
logger.error(f"US positions fetch failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/report")
async def get_us_daily_report():
"""오늘 미국 매매 일일 리포트"""
from app.services.us_market_scheduler import us_post_market_report
try:
return await us_post_market_report()
except Exception as e:
logger.error(f"US report failed: {e}")
raise HTTPException(status_code=500, detail=str(e))