하네스 규칙 검사 전역 위반 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>
251 lines
11 KiB
Python
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))
|