"""미국 주식 매매 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))