fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리

## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View File

@@ -0,0 +1,62 @@
"""
적응형 전략 엔진 API 엔드포인트
현재 적응형 파라미터 조회, 수동 갱신, 모드 변경 이력 조회.
"""
import logging
from fastapi import APIRouter, Depends
from app.core.security import require_api_key
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/adaptive-strategy", tags=["adaptive-strategy"])
@router.get("/status")
async def get_adaptive_status():
"""현재 적응형 전략 상태 조회.
Returns:
trading_mode, params, condition 포함 딕셔너리
"""
try:
from app.services.adaptive_strategy import get_adaptive_status as _get_status
return _get_status()
except Exception as e:
logger.error("[Adaptive API] 상태 조회 실패: %s", e)
return {"error": str(e), "trading_mode": "normal", "fallback_used": True}
@router.post("/refresh", dependencies=[Depends(require_api_key)])
async def refresh_adaptive_params():
"""적응형 파라미터 수동 갱신.
KODEX200 데이터를 다시 조회하여 파라미터를 재계산.
"""
try:
from app.services.adaptive_strategy import (
refresh_adaptive_params as _refresh,
get_adaptive_status as _get_status,
)
await _refresh()
return {"result": "refreshed", **_get_status()}
except Exception as e:
logger.error("[Adaptive API] 수동 갱신 실패: %s", e)
return {"error": str(e)}
@router.get("/history")
async def get_mode_history(days: int = 30):
"""모드 변경 이력 조회.
Args:
days: 조회 기간 (일, 기본 30일)
"""
try:
from app.services.adaptive_strategy import get_strategy_history
history = await get_strategy_history(days=days)
return {"history": history, "count": len(history)}
except Exception as e:
logger.error("[Adaptive API] 이력 조회 실패: %s", e)
return {"history": [], "count": 0, "error": str(e)}

View File

@@ -44,10 +44,11 @@ from app.core.market_schedule import (
from app.core.config import settings
from app.core.json_helpers import _dumps
from app.services.websocket_manager import websocket_manager
from app.services.loss_tracker import loss_tracker
from app.core.database import get_db, AsyncSessionLocal
from app.models.models import TradeHistory, ActiveTrade
from app.models.models import ActiveTrade
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update as sa_update
from sqlalchemy import select
# MCP risk assessment — 모듈 최상단에서 import (순환 참조 시 별도 util 모듈 분리 고려)
from app.api.endpoints.mcp import request_mcp_risk_assessment
@@ -240,29 +241,31 @@ async def _record_trade(
profit_loss: float = 0.0,
order_price: Optional[float] = None, # 주문가 (슬리피지 분석용)
) -> None:
"""TradeHistory DB 기록"""
"""TradeHistory DB 기록 (db_worker 큐 경유 -- CLAUDE.md 3.2 준수).
db 파라미터는 시그니처 호환을 위해 유지하되 사용하지 않음 (읽기 전용).
"""
try:
from app.services.db_worker import enqueue_trade_history
total = price * quantity
slippage = (price - order_price) if order_price is not None else None
trade = TradeHistory(
stock_code=stock_code,
stock_name=stock_name,
order_type=order_type,
order_price=order_price,
order_quantity=quantity,
executed_price=price,
executed_quantity=quantity,
total_amount=total,
net_profit_loss=profit_loss,
profit_loss_rate=(profit_loss / total * 100) if total > 0 else 0,
slippage=slippage,
status="COMPLETED",
entry_reason=reason if order_type == "BUY" else None,
exit_reason=reason if order_type == "SELL" else None,
executed_at=_kst_now(),
)
db.add(trade)
await db.commit()
await enqueue_trade_history({
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": order_type,
"order_price": order_price,
"order_quantity": quantity,
"executed_price": price,
"executed_quantity": quantity,
"total_amount": total,
"net_profit_loss": profit_loss,
"profit_loss_rate": (profit_loss / total * 100) if total > 0 else 0,
"slippage": slippage,
"status": "COMPLETED",
"entry_reason": reason if order_type == "BUY" else None,
"exit_reason": reason if order_type == "SELL" else None,
"executed_at": _kst_now(),
})
except Exception as e:
logger.error(f"TradeHistory 기록 실패: {e}")
@@ -413,7 +416,7 @@ async def _check_exits(
Args:
candle_cache: 사이클 단위 분봉 캐시 {stock_code: candles}. None이면 직접 fetch.
"""
global _daily_pnl
global _daily_pnl, _portfolio_cache_dirty
exits = []
now_time = _kst_time()
@@ -456,21 +459,18 @@ async def _check_exits(
_positions.pop(code, None)
_closed_today.add(code) # 당일 재진입 차단
async with AsyncSessionLocal() as db:
await _record_trade(
db, code, pos["stock_name"], "SELL",
current_price, quantity, exit_signal.reason, pnl,
)
await db.execute(
sa_update(ActiveTrade)
.where(
ActiveTrade.stock_code == code,
ActiveTrade.strategy_name == "day_trade",
ActiveTrade.status == "active",
)
.values(status="closed")
)
await db.commit()
# db_worker 큐 경유 — CLAUDE.md 3.2 준수
from app.services.db_worker import enqueue_close_active_trade
await _record_trade(
None, code, pos["stock_name"], "SELL",
current_price, quantity, exit_signal.reason, pnl,
)
await enqueue_close_active_trade(code, strategy_name="day_trade")
# loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수
_profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0
await loss_tracker.record_trade_result_safe(code, _profit_rate < 0)
await loss_tracker.save_to_db(db=None, stock_code=code)
exits.append({
"stock_code": code,
@@ -634,27 +634,26 @@ async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict
"reason": signal.reason,
}
async with AsyncSessionLocal() as db:
await _record_trade(
db, signal.stock_code, signal.stock_name, "BUY",
signal.entry_price, quantity, signal.reason,
order_price=float(int(signal.entry_price)), # 주문가 = 지정가
)
# ActiveTrade 저장 — 재시작 시 포지션 복구를 위해
active = ActiveTrade(
stock_code=signal.stock_code,
stock_name=signal.stock_name,
quantity=quantity,
entry_price=signal.entry_price,
stop_loss=signal.stop_loss,
take_profit=signal.target_price,
highest_since_entry=signal.entry_price,
strategy_name="day_trade",
entry_reason=signal.strategy,
status="active",
)
db.add(active)
await db.commit()
# db_worker 큐 경유 — CLAUDE.md 3.2 준수
from app.services.db_worker import enqueue_active_trade_upsert
await _record_trade(
None, signal.stock_code, signal.stock_name, "BUY",
signal.entry_price, quantity, signal.reason,
order_price=float(int(signal.entry_price)), # 주문가 = 지정가
)
# ActiveTrade 저장 — 재시작 시 포지션 복구를 위해
await enqueue_active_trade_upsert({
"stock_code": signal.stock_code,
"stock_name": signal.stock_name,
"quantity": quantity,
"entry_price": signal.entry_price,
"stop_loss": signal.stop_loss,
"take_profit": signal.target_price,
"highest_since_entry": signal.entry_price,
"strategy_name": "day_trade",
"entry_reason": signal.strategy,
"status": "active",
})
logger.info(
f"🛒 데이트레이딩 진입 [{signal.stock_code}] "
@@ -678,7 +677,7 @@ async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict
async def _execute_eod_flat() -> None:
"""EOD 전체 포지션 강제 청산"""
global _daily_pnl
global _daily_pnl, _portfolio_cache_dirty
for code, pos in list(_positions.items()):
try:
current_price = await _get_current_price(code)
@@ -692,21 +691,18 @@ async def _execute_eod_flat() -> None:
_closed_today.add(code) # 당일 재진입 차단
logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원")
async with AsyncSessionLocal() as db:
await _record_trade(
db, code, pos["stock_name"], "SELL",
current_price, pos["quantity"], "EOD_flat", pnl,
)
await db.execute(
sa_update(ActiveTrade)
.where(
ActiveTrade.stock_code == code,
ActiveTrade.strategy_name == "day_trade",
ActiveTrade.status == "active",
)
.values(status="closed")
)
await db.commit()
# db_worker 큐 경유 — CLAUDE.md 3.2 준수
from app.services.db_worker import enqueue_close_active_trade
await _record_trade(
None, code, pos["stock_name"], "SELL",
current_price, pos["quantity"], "EOD_flat", pnl,
)
await enqueue_close_active_trade(code, strategy_name="day_trade")
# loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수
_profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0
await loss_tracker.record_trade_result_safe(code, _profit_rate < 0)
await loss_tracker.save_to_db(db=None, stock_code=code)
await websocket_manager.broadcast(_dumps({
"type": "day_trade_exit",

View File

@@ -0,0 +1,104 @@
"""데이트레이딩 전용 API v2 엔드포인트
prefix: /api/v1/daytrading-v2
기존 day_trading.py는 유지하며, 새 아키텍처 기반의 엔드포인트를 추가.
엔드포인트:
GET /watchlist — 감시 종목 조회
POST /watchlist — 감시 종목 설정
GET /positions — 활성 포지션 조회
GET /pnl — 당일 실현 손익
POST /close-all — 전체 강제 청산
GET /state — 전체 상태 스냅샷 (디버그용)
"""
import logging
from typing import List, Dict
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.services.daytrading.position_manager import day_trading_state
from app.services.daytrading.entry_service import set_watchlist, get_watchlist
from app.services.daytrading.exit_service import force_close_all
router = APIRouter()
logger = logging.getLogger(__name__)
class WatchlistRequest(BaseModel):
stocks: List[Dict[str, str]] # [{"code": "005930", "name": "삼성전자"}, ...]
@router.get("/watchlist")
async def get_watchlist_v2():
"""감시 종목 목록 조회."""
wl = get_watchlist()
return {
"count": len(wl),
"stocks": [{"code": k, "name": v} for k, v in wl.items()],
}
@router.post("/watchlist")
async def set_watchlist_v2(req: WatchlistRequest):
"""감시 종목 설정 (기존 목록 교체)."""
set_watchlist(req.stocks)
return {"message": f"{len(req.stocks)}개 감시 종목 설정 완료"}
@router.get("/positions")
async def get_positions_v2():
"""활성 데이트레이딩 포지션 조회 (인메모리)."""
day_trading_state.reset_if_new_day()
positions = []
for code, pos in day_trading_state.positions.items():
entry_price = pos.get("entry_price", 0)
highest = pos.get("highest_price", entry_price)
positions.append({
"stock_code": code,
"stock_name": pos.get("stock_name", code),
"quantity": pos.get("quantity", 0),
"entry_price": entry_price,
"highest_price": highest,
"strategy": pos.get("strategy", ""),
"reason": pos.get("reason", ""),
"entry_time": str(pos.get("entry_time", "")),
})
return {"count": len(positions), "positions": positions}
@router.get("/pnl")
async def get_daily_pnl_v2():
"""당일 실현 손익 조회."""
day_trading_state.reset_if_new_day()
return {
"daily_pnl": round(day_trading_state.daily_pnl),
"closed_today_count": len(day_trading_state.closed_today),
"active_count": day_trading_state.position_count(),
}
@router.post("/close-all")
async def close_all_positions_v2(reason: str = "수동 강제 청산"):
"""전체 데이트레이딩 포지션 강제 청산."""
if not day_trading_state.positions:
return {"message": "활성 포지션 없음", "exits": []}
results = await force_close_all(reason=reason)
logger.info(f"[DayTradingAPI] 강제 청산: {len(results)}건")
return {"exits": results, "count": len(results)}
@router.get("/state")
async def get_state_snapshot():
"""전체 상태 스냅샷 (디버그용)."""
day_trading_state.reset_if_new_day()
return {
"position_count": day_trading_state.position_count(),
"orb_cache_count": len(day_trading_state.orb_cache),
"daily_pnl": round(day_trading_state.daily_pnl),
"closed_today": list(day_trading_state.closed_today),
"last_reset_date": str(day_trading_state.last_reset_date),
"watchlist_count": len(get_watchlist()),
}

View File

@@ -117,7 +117,7 @@ async def generate_journal_for_trade_id(
trade = trade_result.scalar_one_or_none()
if not trade:
raise HTTPException(status_code=404, detail="거래 이력을 찾을 수 없습니다.")
if trade.order_type != "SELL" or trade.status != "FILLED":
if trade.order_type != "SELL" or trade.status not in ("COMPLETED", "FILLED"):
raise HTTPException(status_code=400, detail="완료된 매도 거래에 대해서만 일지를 생성할 수 있습니다.")
perf = await analyze_performance(db, days=30)
@@ -146,7 +146,6 @@ async def get_journal(journal_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(TradeJournal).where(TradeJournal.id == journal_id))
journal = result.scalar_one_or_none()
if not journal:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Journal not found")
return {
"id": journal.id,

View File

@@ -0,0 +1,280 @@
"""
국내 주식 백테스트 API v1 엔드포인트
POST /api/v1/kr-backtest/run -- 백테스트 실행
GET /api/v1/kr-backtest/strategies -- 사용 가능한 전략 목록
POST /api/v1/kr-backtest/walk-forward -- Walk-Forward 최적화 실행
"""
import logging
from dataclasses import asdict
from datetime import datetime
from typing import Dict, List, Any, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.services.kr_backtest_engine import (
KrBacktestEngine,
get_available_strategies,
)
from app.services.walk_forward import (
WalkForwardOptimizer,
WalkForwardConfig,
)
router = APIRouter()
logger = logging.getLogger(__name__)
# ── Pydantic 스키마 ──────────────────────────────────────────────────────
class KrBacktestRunRequest(BaseModel):
stock_codes: List[str] = Field(
..., description="대상 종목 코드 목록 (예: ['005930', '035720'])"
)
start_date: str = Field(..., description="시작일 (YYYY-MM-DD)")
end_date: str = Field(..., description="종료일 (YYYY-MM-DD)")
strategy: str = Field(
"mean_reversion", description="전략 키 (mean_reversion 등)"
)
params: Optional[Dict[str, Any]] = Field(
None, description="전략 파라미터 (None이면 기본값)"
)
initial_capital: float = Field(
10_000_000, ge=100_000, description="초기 자본금 (원)"
)
market: str = Field(
"KOSPI", description="시장 구분 (KOSPI/KOSDAQ) — 세금 계산용"
)
random_seed: Optional[int] = Field(
42, description="슬리피지 재현성 시드 (None이면 매번 다름)"
)
# ── 백테스트 실행 / 전략 목록 API ──────────────────────────────────────
@router.get("/strategies")
async def api_get_strategies() -> List[Dict[str, Any]]:
"""사용 가능한 백테스트 전략 목록 반환"""
return get_available_strategies()
@router.post("/run")
async def api_run_kr_backtest(req: KrBacktestRunRequest) -> Dict[str, Any]:
"""한국 주식 백테스트 실행
stock_daily_prices 테이블의 일봉 데이터로 전략 시뮬레이션을 수행합니다.
수수료 모델: 매수 0.015%, 매도 0.015% + 증권거래세 (KOSPI 0.18% / KOSDAQ 0.23%)
슬리피지 모델: 시장가 기준 +/-0.1% 랜덤
"""
# 날짜 파싱
try:
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="날짜 형식 오류 (YYYY-MM-DD)")
if start_date >= end_date:
raise HTTPException(
status_code=400,
detail="start_date는 end_date보다 이전이어야 합니다",
)
if not req.stock_codes:
raise HTTPException(status_code=400, detail="stock_codes를 1개 이상 지정하세요")
if len(req.stock_codes) > 50:
raise HTTPException(status_code=400, detail="종목 수 최대 50개 제한")
# 전략 유효성 검사
available = [s["key"] for s in get_available_strategies()]
if req.strategy not in available:
raise HTTPException(
status_code=400,
detail=f"알 수 없는 전략: {req.strategy}. 사용 가능: {available}",
)
# 백테스트 실행
engine = KrBacktestEngine()
try:
result = await engine.run(
stock_codes=req.stock_codes,
start_date=start_date,
end_date=end_date,
strategy_name=req.strategy,
params=req.params,
initial_capital=req.initial_capital,
market=req.market,
random_seed=req.random_seed,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("백테스트 실행 오류: %s", e, exc_info=True)
raise HTTPException(
status_code=500,
detail=f"백테스트 실행 중 오류: {str(e)}",
)
# dataclass -> dict 변환
metrics_dict = asdict(result.metrics)
return {
"metrics": metrics_dict,
"equity_curve": result.equity_curve,
"trades": result.trades,
"drawdown_curve": result.drawdown_curve,
"config": result.config,
"summary": {
"total_return": f"{metrics_dict['total_return_pct']:.2f}%",
"cagr": f"{metrics_dict['cagr_pct']:.2f}%",
"sharpe": f"{metrics_dict['sharpe_ratio']:.4f}",
"sortino": f"{metrics_dict['sortino_ratio']:.4f}",
"max_drawdown": f"{metrics_dict['max_drawdown_pct']:.2f}%",
"win_rate": f"{metrics_dict['win_rate']:.1f}%",
"profit_factor": f"{metrics_dict['profit_factor']:.2f}",
"total_trades": metrics_dict["total_trades"],
},
}
# ── Walk-Forward 최적화 ─────────────────────────────────────────────────
class WalkForwardRequest(BaseModel):
stock_codes: List[str] = Field(
..., description="대상 종목 코드 목록"
)
start_date: str = Field(
..., description="시작일 (YYYY-MM-DD), 최소 8개월 이상의 기간 필요"
)
end_date: str = Field(
..., description="종료일 (YYYY-MM-DD)"
)
initial_capital: float = Field(
10_000_000, description="초기 자본금"
)
max_positions: int = Field(
5, description="최대 동시 보유 종목 수"
)
position_size: float = Field(
0.2, description="종목당 포지션 비율 (0.2 = 20%)"
)
# 롤링 윈도우 설정
is_months: int = Field(
6, ge=2, le=24, description="In-Sample 기간 (월, 기본 6)"
)
oos_months: int = Field(
2, ge=1, le=12, description="Out-of-Sample 기간 (월, 기본 2)"
)
step_months: int = Field(
1, ge=1, le=6, description="슬라이딩 간격 (월, 기본 1)"
)
# Optuna 설정
n_trials: int = Field(
50, ge=5, le=200, description="윈도우당 최대 Optuna trial 수"
)
optuna_timeout: int = Field(
300, ge=30, le=1800, description="윈도우당 최대 시간 (초)"
)
# ── API 엔드포인트 ───────────────────────────────────────────────────────
@router.post("/walk-forward")
async def api_walk_forward(req: WalkForwardRequest) -> Dict[str, Any]:
"""
Walk-Forward 최적화 실행
전체 기간을 In-Sample/Out-of-Sample 윈도우로 분할하여
Optuna 기반 파라미터 최적화와 과적합 탐지를 수행합니다.
탐색 대상 파라미터:
- RSI 임계값 (25~45)
- 볼린저 밴드 기간 (15~25)
- ATR 손절 배수 (1.5~3.5)
- 손절 비율 (4%~10%)
목적함수: In-Sample Sharpe Ratio 최대화
과적합 탐지: IS vs OOS Sharpe 비교 (50% 이상 감소 시 경고)
응답에 포함되는 항목:
- 각 윈도우별 최적 파라미터, IS/OOS 성과
- 종합 OOS 성과 (평균 Sharpe, 수익률, MDD, 승률)
- 과적합 분석 (경고 윈도우 수, 감소율)
- 최종 추천 파라미터 (OOS Sharpe 가중평균)
"""
# 날짜 파싱
try:
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(
status_code=400,
detail="날짜 형식 오류. YYYY-MM-DD 형식을 사용하세요.",
)
if start_date >= end_date:
raise HTTPException(
status_code=400,
detail="start_date는 end_date보다 이전이어야 합니다.",
)
# 최소 기간 검증 (IS + OOS 이상)
min_months = req.is_months + req.oos_months
diff_days = (end_date - start_date).days
if diff_days < min_months * 28:
raise HTTPException(
status_code=400,
detail=(
f"기간이 너무 짧습니다. IS({req.is_months}개월) + "
f"OOS({req.oos_months}개월) = 최소 {min_months}개월이 "
f"필요하지만, 입력 기간은 약 {diff_days // 30}개월입니다."
),
)
if not req.stock_codes:
raise HTTPException(
status_code=400,
detail="stock_codes가 비어 있습니다.",
)
# WalkForwardConfig 생성
wf_config = WalkForwardConfig(
stock_codes=req.stock_codes,
start_date=start_date,
end_date=end_date,
initial_capital=req.initial_capital,
max_positions=req.max_positions,
position_size=req.position_size,
is_months=req.is_months,
oos_months=req.oos_months,
step_months=req.step_months,
n_trials=req.n_trials,
optuna_timeout=req.optuna_timeout,
)
logger.info(
f"Walk-Forward API 호출: {len(req.stock_codes)}종목, "
f"{req.start_date}~{req.end_date}, "
f"IS={req.is_months}m, OOS={req.oos_months}m, trials={req.n_trials}"
)
try:
optimizer = WalkForwardOptimizer()
result = await optimizer.run(wf_config)
except ImportError as e:
raise HTTPException(
status_code=500,
detail=f"필요한 패키지가 설치되지 않았습니다: {e}",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e))
# WalkForwardResult -> dict (dataclass asdict는 이미 dict 필드)
return asdict(result)

View File

@@ -69,7 +69,11 @@ async def get_stock_price(stock_code: str):
except Exception as e:
# 예외 발생 시 500 에러와 함께 상세 내용 반환
raise HTTPException(status_code=500, detail=f"Failed to fetch stock price: {str(e)}")
logger.warning(f"Price unavailable for stock: {e}")
return {"stock_code": stock_code, "current_price": 0, "error": "temporarily_unavailable"}
_PRICES_MAX_CODES = 50 # /prices 엔드포인트 최대 종목 수 (KIS API 레이트 리밋 방어)
@router.get("/prices")
@@ -78,14 +82,18 @@ async def get_stock_prices(codes: str):
Query:
- codes: 콤마로 구분된 종목코드들 (예: 005930,000660,035420)
- 최대 50개 종목 허용. 초과 시 앞의 50개만 조회.
"""
try:
# codes 파라미터가 없는 경우 빈 데이터 반환
if not codes:
return {"prices": {}, "count": 0, "timestamp": datetime.now().isoformat()}
# 콤마로 구분된 종목 코드 문자열을 리스트로 변환
# 콤마로 구분된 종목 코드 문자열을 리스트로 변환 (최대 50개 제한)
code_list: List[str] = [c.strip() for c in codes.split(",") if c.strip()]
if len(code_list) > _PRICES_MAX_CODES:
logger.warning(f"/prices 종목 수 초과: 요청={len(code_list)}, 최대={_PRICES_MAX_CODES}개로 제한")
code_list = code_list[:_PRICES_MAX_CODES]
prices: Dict[str, float] = {}
# 병렬로 현재가 일괄 조회 (asyncio.gather)
@@ -200,51 +208,152 @@ def _generate_sample_chart(stock_code: str, period: str, limit: int = 100) -> di
}
# 차트 데이터를 위한 간단한 인메모리 캐시 딕셔너리
# 차트 데이터를 위한 간단한 인메모리 캐시 딕셔너리 (최대 _CHART_CACHE_MAX_SIZE 개)
chart_cache = {}
_CHART_CACHE_MAX_SIZE = 100
_CHART_CACHE_TTL = 600 # 10분
def _aggregate_weekly_monthly(daily_data: list, period: str) -> list:
"""일봉 데이터를 주봉(W) 또는 월봉(M)으로 집계."""
if not daily_data:
return []
from collections import OrderedDict
buckets: OrderedDict = OrderedDict()
for d in daily_data:
dt = datetime.strptime(d["date"], "%Y%m%d")
if period == "W":
# ISO 주의 월요일을 키로 사용
monday = dt - timedelta(days=dt.weekday())
key = monday.strftime("%Y%m%d")
else: # "M"
key = dt.strftime("%Y%m01")
if key not in buckets:
buckets[key] = {
"date": d["date"], # 해당 기간의 마지막 거래일
"open": d["open"],
"high": d["high"],
"low": d["low"],
"close": d["close"],
"volume": d["volume"],
}
else:
b = buckets[key]
b["date"] = d["date"] # 마지막 거래일로 갱신
b["high"] = max(b["high"], d["high"])
b["low"] = min(b["low"], d["low"])
b["close"] = d["close"]
b["volume"] += d["volume"]
return list(buckets.values())
async def _fetch_chart_from_db(stock_code: str, limit: int) -> list:
"""stock_daily_prices DB에서 일봉 차트 데이터 조회 (oldest first).
배치(collect_krx_data.py / batch_daily_analysis.py)가 사전 수집한 데이터를 사용.
DB에 데이터가 없으면 빈 리스트 반환.
[READ-ONLY 함수 — DB 쓰기 없음]
SELECT만 수행하므로 CLAUDE.md 3.1 "실시간 계층 DB I/O 금지" 규칙의 예외 대상.
단, WebSocket 경로에서 이 함수를 직접 호출하면 안 됩니다.
WebSocket 핸들러는 get_chart_data_cached()를 통해 인메모리 캐시(1차)를 우선 사용하고,
캐시 미스 시에도 이 함수를 호출하지 말고 캐시된 값(빈 리스트 포함)을 반환해야 합니다.
REST 엔드포인트에서만 DB 폴백이 허용됩니다.
"""
try:
from app.core.database import AsyncSessionLocal
from app.models.models import StockDailyPrice
async with AsyncSessionLocal() as db:
result = await db.execute(
select(StockDailyPrice)
.where(StockDailyPrice.stock_code == stock_code)
.order_by(StockDailyPrice.trade_date.desc())
.limit(limit)
)
rows = result.scalars().all()
if not rows:
return []
# DB 결과는 newest first → oldest first로 역순 변환
chart_data = []
for row in reversed(rows):
chart_data.append({
"date": row.trade_date.strftime("%Y%m%d"),
"open": float(row.open_price or 0),
"high": float(row.high_price or 0),
"low": float(row.low_price or 0),
"close": float(row.close_price or 0),
"volume": int(row.volume or 0),
})
return chart_data
except Exception as e:
logger.warning(f"DB 차트 조회 실패 ({stock_code}): {e}")
return []
async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> list:
"""캐시된 차트 데이터 반환 — REST endpoint와 WebSocket 핸들러가 공유.
캐시 미스 시 KIS API 호출 후 chart_cache에 저장.
조회 우선순위:
1. 인메모리 캐시 (10분 TTL)
2. DB (stock_daily_prices) — 일봉 전용, 배치가 사전 수집한 데이터
3. KIS API 폴백 (DB에 데이터 부족 시)
realtime 보정 없음 (현재가는 WebSocket tick이 처리).
"""
if period not in ("D", "W", "M"):
period = "D"
limit = min(limit, 240)
cache_key = f"{stock_code}__{period}"
cache_key = f"{stock_code}__{period}__{limit}"
current_time = datetime.now()
# 1차: 인메모리 캐시 (TTL 만료 항목 삭제 + 크기 제한)
cached = chart_cache.get(cache_key)
if cached and (current_time - cached["timestamp"]).total_seconds() < 600:
return list(cached["data"])
if cached:
if (current_time - cached["timestamp"]).total_seconds() < _CHART_CACHE_TTL:
return list(cached["data"])
else:
# 만료 항목 즉시 삭제
del chart_cache[cache_key]
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
# 2차: DB 조회 (KIS API 사용 안 함 — 배치가 수집한 일봉 데이터만 사용)
# 주/월봉은 일봉 데이터에서 집계
db_limit = limit if period == "D" else limit * 7 if period == "W" else limit * 31
db_data = await _fetch_chart_from_db(stock_code, min(db_limit, 1200))
if not db_data:
# DB에 데이터 없으면 샘플 반환
return _generate_sample_chart(stock_code, period, limit).get("data", [])
data = await korea_investment_api.get_daily_chart(stock_code, period, limit=limit)
if "output2" not in data:
return []
# 주/월봉 집계
if period in ("W", "M"):
db_data = _aggregate_weekly_monthly(db_data, period)
db_data = db_data[-limit:] # 최신 N개만
chart_data = []
for item in data["output2"]:
try:
chart_data.append({
"date": item.get("stck_bsop_date"),
"open": float(str(item.get("stck_oprc", "0")).replace(",", "")),
"high": float(str(item.get("stck_hgpr", "0")).replace(",", "")),
"low": float(str(item.get("stck_lwpr", "0")).replace(",", "")),
"close": float(str(item.get("stck_clpr", "0")).replace(",", "")),
"volume": int(item.get("acml_vol", 0)),
})
except (ValueError, TypeError) as e:
logger.error(f"가격 파싱 오류: {item} - {e}")
continue
# 캐시 크기 제한: 초과 시 가장 오래된 항목 제거
if len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
# 만료 항목 우선 정리
expired_keys = [
k for k, v in chart_cache.items()
if (current_time - v["timestamp"]).total_seconds() >= _CHART_CACHE_TTL
]
for k in expired_keys:
del chart_cache[k]
# 그래도 초과면 가장 오래된 항목 제거
while len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
oldest_key = min(chart_cache, key=lambda k: chart_cache[k]["timestamp"])
del chart_cache[oldest_key]
chart_cache[cache_key] = {"timestamp": current_time, "data": chart_data}
return chart_data
chart_cache[cache_key] = {"timestamp": current_time, "data": db_data}
logger.debug(f"차트 DB 히트: {stock_code} period={period} ({len(db_data)}행)")
return db_data
@router.get("/chart/{stock_code}")
@@ -254,7 +363,7 @@ async def get_stock_chart(
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환"),
limit: int = Query(100, description="조회할 데이터 개수 (기본: 100, 최대: 240)")
):
"""주식 차트 데이터 조회 엔드포인트 (캐싱 적용)
"""주식 차트 데이터 조회 엔드포인트 (DB 전용 — KIS API 미사용)
Args:
stock_code: 종목 코드
@@ -262,119 +371,20 @@ async def get_stock_chart(
limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터)
"""
try:
# 캐시 키 생성 (예: "005930__D")
cache_key = f"{stock_code}__{period}"
current_time = datetime.now()
# 캐시에서 데이터 조회
cached_data = chart_cache.get(cache_key)
use_cache = False
chart_data = []
# 캐시 데이터가 있고, 10분(600초) 이내에 생성된 경우 캐시 사용
if cached_data:
last_updated = cached_data.get("timestamp")
if last_updated and (current_time - last_updated).total_seconds() < 600:
chart_data = list(cached_data.get("data", [])) # 원본 수정을 방지하기 위해 데이터 복사
use_cache = True
# logger.debug(f"Chart cache hit for {stock_code}")
chart_data = await get_chart_data_cached(stock_code, period, limit)
# 캐시를 사용하지 않는 경우 (캐시가 없거나 만료됨)
if not use_cache:
# API 키가 설정되지 않은 경우 샘플 데이터 생성
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
return _generate_sample_chart(stock_code, period, limit)
# 한국투자증권 API를 통해 차트 데이터 조회
data = await korea_investment_api.get_daily_chart(stock_code, period, limit=limit)
if "output2" not in data:
raise HTTPException(status_code=404, detail="Chart data not found")
chart_data = [] # 데이터 리스트 초기화
for item in data["output2"]:
# KIS API 응답에서 가격 데이터를 안전하게 파싱
try:
# 콤마가 포함될 수 있는 문자열 형태의 가격을 가져옴
close_str = str(item.get("stck_clpr", "0"))
open_str = str(item.get("stck_oprc", "0"))
high_str = str(item.get("stck_hgpr", "0"))
low_str = str(item.get("stck_lwpr", "0"))
# 콤마를 제거하고 float으로 변환
close_val = float(close_str.replace(",", ""))
open_val = float(open_str.replace(",", ""))
high_val = float(high_str.replace(",", ""))
low_val = float(low_str.replace(",", ""))
# 파싱된 데이터를 딕셔너리 형태로 리스트에 추가
chart_data.append({
"date": item.get("stck_bsop_date"),
"open": open_val,
"high": high_val,
"low": low_val,
"close": close_val,
"volume": int(item.get("acml_vol", 0))
})
except (ValueError, TypeError) as e:
# 가격 파싱 중 오류 발생 시 로그 기록하고 해당 데이터는 건너뜀
logger.error(f"가격 파싱 오류: {item} - {e}")
continue
# 조회한 데이터를 캐시에 저장
chart_cache[cache_key] = {
"timestamp": current_time,
"data": chart_data
}
# KIS API는 최신 데이터가 가장 먼저 오는 경향이 있으므로, 프론트엔드에서 날짜순으로 정렬(reverse)하여 사용
latest_candle = None
# 'realtime' 쿼리 파라미터가 True인 경우, 현재가를 반영한 실시간 캔들 생성
if realtime:
try:
price_data = await korea_investment_api.get_current_price(stock_code)
if price_data and price_data.get("output"):
o = price_data["output"]
# 현재가, 고가, 저가, 시가 등의 정보를 파싱하여 실시간 캔들 데이터 구성
current_price = float(str(o.get("stck_prpr", "0")).replace(",", ""))
high_price = float(str(o.get("stck_hgpr", "0")).replace(",", ""))
low_price = float(str(o.get("stck_lwpr", "0")).replace(",", ""))
open_price = float(str(o.get("stck_oprc", current_price)).replace(",", ""))
volume = int(o.get("acml_vol", 0))
today_str = o.get("stck_bsop_date") or datetime.now().strftime("%Y%m%d")
latest_candle = {
"date": today_str,
"open": open_price,
"high": high_price,
"low": low_price,
"close": current_price,
"volume": volume,
"realtime": True
}
# 기존 차트 데이터의 첫 번째(가장 최신) 캔들이 오늘 날짜와 같으면 교체, 아니면 맨 앞에 추가
if chart_data and chart_data[0]["date"] == latest_candle["date"]:
chart_data[0] = latest_candle
else:
chart_data.insert(0, latest_candle)
except Exception as e:
# 실시간 캔들 생성 실패 시 경고 로그 기록
logger.warning(f"실시간 현재가 캔들 생성 실패: {e}")
# 최종 차트 데이터를 딕셔너리 형태로 반환
return {
"stock_code": stock_code,
"period": period,
"data": chart_data,
"is_sample": False,
"realtime": realtime,
"latest": latest_candle
"is_sample": not chart_data,
"realtime": False,
"latest": None,
}
except Exception as e:
# API 호출 등에서 예외 발생 시, 개발 편의를 위해 샘플 데이터를 반환
sample = _generate_sample_chart(stock_code, period)
sample["error"] = str(e) # 에러 메시지 포함
sample["error"] = str(e)
sample["is_sample"] = True
sample["realtime"] = False
sample["latest"] = None
@@ -442,7 +452,7 @@ async def get_market_news(limit: int = 10):
async def get_orderbook(stock_code: str):
"""호가 정보 조회 엔드포인트 (매수/매도 10단계 호가)"""
try:
logger.info(f"🔍 Fetching orderbook for stock: {stock_code}")
logger.debug(f"Fetching orderbook for stock: {stock_code}")
# 한국투자증권 API를 통해 호가 데이터 조회
data = await korea_investment_api.get_orderbook(stock_code)
@@ -478,18 +488,18 @@ async def get_orderbook(stock_code: str):
}
# 디버깅을 위한 원본 매도호가 데이터 로그
logger.info(f"📊 [{stock_code}] 매도호가 원본 데이터:")
logger.debug(f"[{stock_code}] 매도호가 원본 데이터:")
for i in range(1, 11):
price = output1.get(f"askp{i}", 0)
volume = output1.get(f"askp_rsqn{i}", 0)
logger.info(f" askp{i}: {price} ({volume}주)")
logger.debug(f" askp{i}: {price} ({volume}주)")
# 디버깅을 위한 원본 매수호가 데이터 로그
logger.info(f"📊 [{stock_code}] 매수호가 원본 데이터:")
logger.debug(f"[{stock_code}] 매수호가 원본 데이터:")
for i in range(1, 11):
price = output1.get(f"bidp{i}", 0)
volume = output1.get(f"bidp_rsqn{i}", 0)
logger.info(f" bidp{i}: {price} ({volume}주)")
logger.debug(f" bidp{i}: {price} ({volume}주)")
# 매도호가 10단계 파싱 (askp1 ~ askp10)
temp_asks = []
@@ -512,13 +522,13 @@ async def get_orderbook(stock_code: str):
orderbook["total_bid_volume"] += volume
# 최종 파싱 결과 로그
logger.info(f"✅ Orderbook {stock_code}: current={current_price}, asks={len(orderbook['asks'])}, bids={len(orderbook['bids'])}")
logger.info(f" 파싱된 매도호가: {[ask['price'] for ask in orderbook['asks']]}")
logger.info(f" 파싱된 매수호가: {[bid['price'] for bid in orderbook['bids']]}")
logger.debug(f"Orderbook {stock_code}: current={current_price}, asks={len(orderbook['asks'])}, bids={len(orderbook['bids'])}")
logger.debug(f" 파싱된 매도호가: {[ask['price'] for ask in orderbook['asks']]}")
logger.debug(f" 파싱된 매수호가: {[bid['price'] for bid in orderbook['bids']]}")
if orderbook['asks']:
logger.info(f" Best ask: {orderbook['asks'][-1]['price']}") # 가장 낮은 매도호가
logger.debug(f" Best ask: {orderbook['asks'][-1]['price']}") # 가장 낮은 매도호가
if orderbook['bids']:
logger.info(f" Best bid: {orderbook['bids'][0]['price']}") # 가장 높은 매수호가
logger.debug(f" Best bid: {orderbook['bids'][0]['price']}") # 가장 높은 매수호가
return orderbook
@@ -526,9 +536,9 @@ async def get_orderbook(stock_code: str):
# HTTPException은 그대로 다시 발생시킴
raise
except Exception as e:
# 그 외 예외는 500 에러로 처리
logger.error(f"Error fetching orderbook for {stock_code}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch orderbook: {str(e)}")
# KIS API 장애 시 빈 호가 반환 (프론트 500 폭탄 방지)
logger.warning(f"Orderbook unavailable for {stock_code}: {e}")
return {"stock_code": stock_code, "asks": [], "bids": [], "error": "temporarily_unavailable"}
@router.get("/ai-analysis/{stock_code}")
@@ -575,15 +585,14 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
"bollinger_lower": float(analysis_record.bollinger_lower) if analysis_record.bollinger_lower else None,
}
else:
# DB에 분석 데이터 없으면 KIS 일봉 차트로 직접 계산 (fallback)
# DB에 분석 데이터 없으면 DB 일봉으로 직접 계산 (fallback, KIS API 미사용)
try:
from app.services.mean_reversion_strategy import calculate_rsi, calculate_bollinger
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=60)
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 20:
closes = [float(c.get("stck_clpr", 0)) for c in reversed(chart_data)]
highs = [float(c.get("stck_hgpr", 0)) for c in reversed(chart_data)]
lows = [float(c.get("stck_lwpr", 0)) for c in reversed(chart_data)]
db_chart = await _fetch_chart_from_db(stock_code, 60)
if len(db_chart) >= 20:
closes = [c["close"] for c in db_chart]
highs = [c["high"] for c in db_chart]
lows = [c["low"] for c in db_chart]
rsi_val = calculate_rsi(closes)
bb = calculate_bollinger(closes)
ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else None
@@ -753,3 +762,40 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
# 그 외 예외는 500 에러로 처리
logger.error(f"Error in AI analysis for {stock_code}: {e}")
raise HTTPException(status_code=500, detail=f"AI 분석 실패: {str(e)}")
# ── 해외 주식 엔드포인트 ──────────────────────────────────────────
@router.get("/overseas/price/{ticker}")
async def get_overseas_price(ticker: str):
"""미국 주식 현재가 조회"""
from app.services.exchange_detector import detect_exchange
exchange = detect_exchange(ticker)
result = await korea_investment_api.get_overseas_price(ticker, exchange)
if not result:
raise HTTPException(status_code=404, detail=f"해외 시세 조회 실패: {ticker}")
return result
@router.get("/overseas/chart/{ticker}")
async def get_overseas_chart(ticker: str, period: str = "D", count: int = 100):
"""미국 주식 차트 데이터 조회
period: D(일), W(주), M(월)
"""
from app.services.exchange_detector import detect_exchange
exchange = detect_exchange(ticker)
result = await korea_investment_api.get_overseas_daily_chart(ticker, exchange, period)
if not result:
raise HTTPException(status_code=404, detail=f"해외 차트 조회 실패: {ticker}")
# Limit results
if isinstance(result, list):
result = result[:count]
return result
@router.get("/overseas/exchange-rate")
async def get_exchange_rate():
"""현재 USD/KRW 환율 조회"""
try:
rate = await korea_investment_api.get_exchange_rate()
return {"currency_pair": "USD/KRW", "rate": rate}
except Exception as e:
raise HTTPException(status_code=500, detail=f"환율 조회 실패: {str(e)}")

View File

@@ -0,0 +1,423 @@
"""
종합 시장 상황 대시보드 API
- GET /dashboard: 시장 국면, 리스크 상태, 포트폴리오 요약 통합 조회
- GET /events: 향후 7일 이벤트 캘린더
- GET /news: 종목별 뉴스 + 감성 점수
"""
import logging
import pytz
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Query
from app.core.config import settings
from app.core.json_helpers import _dumps
logger = logging.getLogger(__name__)
router = APIRouter()
KST = pytz.timezone("Asia/Seoul")
def _get_kst_now() -> datetime:
return datetime.now(KST)
# ---------------------------------------------------------------------------
# Helpers: 안전하게 외부 모듈 호출 (미설치/미구현 시 빈 결과 반환)
# ---------------------------------------------------------------------------
async def _get_market_regime() -> dict:
"""market_analyzer.check_market_regime() 호출. 실패 시 빈 결과."""
try:
from app.services.market_analyzer import check_market_regime
raw = await check_market_regime()
regime = raw.get("regime", "unknown")
hmm_ensemble = raw.get("hmm_ensemble") or {}
return {
"rule_based": regime,
"hmm": hmm_ensemble.get("hmm_regime"),
"ensemble": hmm_ensemble.get("regime"),
"confidence": hmm_ensemble.get("confidence"),
"position_multiplier": raw.get("position_multiplier", 0.5),
"max_trades": raw.get("max_trades"),
"message": raw.get("message", ""),
"consecutive_down": raw.get("consecutive_down"),
"drop_from_ma20": raw.get("drop_from_ma20"),
}
except Exception as e:
logger.warning("market_regime 조회 실패: %s", e)
return {
"rule_based": "unknown",
"hmm": None,
"ensemble": None,
"confidence": None,
"position_multiplier": 0.5,
"max_trades": None,
"message": f"조회 실패: {e}",
}
async def _get_playbook() -> dict:
"""market_playbook.get_today_playbook() 호출. 파일 없으면 빈 결과."""
try:
from app.services.market_playbook import get_today_playbook # type: ignore
return await get_today_playbook()
except ImportError:
return {
"events_today": [],
"position_multiplier": None,
"entry_allowed": None,
"daytrading_allowed": None,
"reason": "market_playbook 모듈 미설치",
}
except Exception as e:
logger.warning("playbook 조회 실패: %s", e)
return {
"events_today": [],
"position_multiplier": None,
"entry_allowed": None,
"daytrading_allowed": None,
"reason": f"조회 실패: {e}",
}
async def _get_news_sentiment(stock_code: Optional[str] = None) -> dict:
"""news_sentiment.get_market_news_score() 호출. 파일 없으면 빈 결과."""
try:
from app.services.news_sentiment import get_market_news_score # type: ignore
return await get_market_news_score(stock_code=stock_code)
except ImportError:
return {
"market_score": None,
"top_positive": [],
"top_negative": [],
"reason": "news_sentiment 모듈 미설치",
}
except Exception as e:
logger.warning("news_sentiment 조회 실패: %s", e)
return {
"market_score": None,
"top_positive": [],
"top_negative": [],
"reason": f"조회 실패: {e}",
}
async def _get_risk_status() -> dict:
"""mdd_manager + loss_tracker 에서 리스크 상태 수집."""
from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker
# 현재 MDD 계산
peak = mdd_manager.peak_balance
if peak > 0:
# 마지막으로 알려진 잔고가 없으므로 peak 기반 drawdown만 표시
# portfolio_summary에서 total_assets가 있으면 호출측에서 보정 가능
mdd_current = None # 호출측에서 portfolio 데이터로 계산
else:
mdd_current = 0.0
# [BUG FIX] _tracker 직접 접근 -> Lock 보호된 get_all_bans_safe() 사용
banned_stocks = await loss_tracker.get_all_bans_safe()
return {
"mdd_peak": peak,
"mdd_current_pct": mdd_current,
"is_paused": mdd_manager.is_paused,
"is_short_term_paused": mdd_manager.is_short_term_strategy_paused,
"reduction_applied": mdd_manager.reduction_applied,
"banned_stocks": banned_stocks,
"banned_count": len(banned_stocks),
}
def _get_portfolio_summary() -> dict:
"""portfolio_cache에서 요약 정보 수집."""
try:
from app.api.endpoints.portfolio import portfolio_cache
summary = portfolio_cache.get_summary_copy()
if summary is None:
return {
"total_assets": None,
"available_cash": None,
"total_value": None,
"total_investment": None,
"total_profit_loss": None,
"total_profit_loss_rate": None,
"holdings_count": 0,
"last_synced": None,
"reason": "포트폴리오 미동기화",
}
last_synced = portfolio_cache.last_synced
return {
"total_assets": summary.get("total_assets"),
"available_cash": summary.get("available_cash"),
"total_value": summary.get("total_value"),
"total_investment": summary.get("total_investment"),
"total_profit_loss": summary.get("total_profit_loss"),
"total_profit_loss_rate": summary.get("total_profit_loss_rate"),
"holdings_count": summary.get("total_stocks", 0),
"last_synced": last_synced.isoformat() if last_synced else None,
}
except Exception as e:
logger.warning("portfolio_summary 조회 실패: %s", e)
return {
"total_assets": None,
"available_cash": None,
"holdings_count": 0,
"reason": f"조회 실패: {e}",
}
def _get_active_strategies(market_regime: dict, risk_status: dict) -> dict:
"""현재 활성화된 전략 상태 판별."""
reasons = []
# 기본 활성화 상태
swing_enabled = settings.AUTO_TRADE_ENABLED
daytrading_enabled = settings.DAY_TRADE_ENABLED
short_term_enabled = settings.AUTO_TRADE_ENABLED
us_trading_enabled = settings.US_TRADING_ENABLED
# TRADING_HALTED 전역 정지
if settings.TRADING_HALTED:
swing_enabled = False
daytrading_enabled = False
short_term_enabled = False
reasons.append("TRADING_HALTED=true (수동 비상 정지)")
# 시장 국면 필터: SIDEWAYS/crash/halted 시 진입 차단
regime = market_regime.get("rule_based", "unknown")
if regime in ("sideways", "crash", "halted"):
swing_enabled = False
daytrading_enabled = False
short_term_enabled = False
reasons.append(f"시장 국면 '{regime}' - 전체 진입 차단")
# MDD 기반 차단
if risk_status.get("is_paused"):
swing_enabled = False
daytrading_enabled = False
short_term_enabled = False
reasons.append("MDD 한도 초과 - 신규 진입 중단")
if risk_status.get("is_short_term_paused"):
daytrading_enabled = False
short_term_enabled = False
if "MDD" not in " ".join(reasons):
reasons.append("MDD -10% 초과 - 단기/데이트레이딩 중단")
return {
"swing": swing_enabled,
"daytrading": daytrading_enabled,
"short_term": short_term_enabled,
"us_trading": us_trading_enabled,
"reasons": reasons if reasons else ["정상 운영 중"],
}
# ---------------------------------------------------------------------------
# 1) GET /dashboard -- 종합 대시보드
# ---------------------------------------------------------------------------
@router.get("/dashboard")
async def get_market_intelligence_dashboard():
"""종합 시장 상황 대시보드.
시장 국면, 플레이북, 뉴스 감성, 리스크 상태, 포트폴리오 요약,
활성 전략 정보를 한 번에 반환합니다.
"""
# 병렬로 가져올 수 있는 항목은 동시 실행
import asyncio
market_regime_task = asyncio.create_task(_get_market_regime())
playbook_task = asyncio.create_task(_get_playbook())
news_task = asyncio.create_task(_get_news_sentiment())
market_regime = await market_regime_task
playbook = await playbook_task
news_sentiment = await news_task
risk_status = await _get_risk_status()
portfolio_summary = _get_portfolio_summary()
# MDD 현재 비율 계산 (portfolio 데이터가 있을 때)
total_assets = portfolio_summary.get("total_assets")
peak = risk_status.get("mdd_peak", 0)
if total_assets and peak and peak > 0:
risk_status["mdd_current_pct"] = round(
((total_assets - peak) / peak) * 100, 2
)
# 활성 전략 판별
active_strategies = _get_active_strategies(market_regime, risk_status)
now = _get_kst_now()
return {
"timestamp": now.isoformat(),
"market_regime": market_regime,
"playbook": playbook,
"news_sentiment": news_sentiment,
"risk_status": risk_status,
"portfolio_summary": portfolio_summary,
"active_strategies": active_strategies,
"config_snapshot": {
"kis_mode": settings.KIS_MODE,
"auto_trade_enabled": settings.AUTO_TRADE_ENABLED,
"day_trade_enabled": settings.DAY_TRADE_ENABLED,
"us_trading_enabled": settings.US_TRADING_ENABLED,
"trading_halted": settings.TRADING_HALTED,
"max_position_size": settings.MAX_POSITION_SIZE,
"max_holdings_count": settings.MAX_HOLDINGS_COUNT,
},
}
# ---------------------------------------------------------------------------
# 2) GET /events -- 향후 7일 이벤트 캘린더
# ---------------------------------------------------------------------------
# 한국 주식시장 정기 이벤트 (요일 기반 + 특정일)
_KNOWN_EVENTS = {
# 매월 둘째 목요일: 옵션 만기일 (간소화 -- 실제로는 달력 계산 필요)
"options_expiry_weekday": 3, # Thursday = 3
"options_expiry_week": 2, # 둘째 주
}
def _get_options_expiry_date(year: int, month: int) -> Optional[datetime]:
"""해당 월의 옵션 만기일 (둘째 목요일) 계산."""
from calendar import monthcalendar
cal = monthcalendar(year, month)
thursdays = [week[3] for week in cal if week[3] != 0]
if len(thursdays) >= 2:
return datetime(year, month, thursdays[1], tzinfo=KST)
return None
def _get_events_for_range(start: datetime, days: int = 7) -> list:
"""start부터 days일간 이벤트 목록 반환."""
events = []
for offset in range(days):
d = start + timedelta(days=offset)
date_str = d.strftime("%Y-%m-%d")
weekday = d.weekday()
# 주말
if weekday >= 5:
events.append({
"date": date_str,
"event": "weekend",
"description": "주말 (장 휴무)",
"impact": "none",
})
continue
day_events = []
# 옵션 만기일 체크
expiry = _get_options_expiry_date(d.year, d.month)
if expiry and expiry.date() == d.date():
day_events.append({
"event": "kr_options_expiry",
"description": "옵션 만기일 -- 변동성 확대 주의, 데이트레이딩 차단 권장",
"impact": "high",
})
# 월말 (리밸런싱 수요)
next_day = d + timedelta(days=1)
if next_day.month != d.month:
day_events.append({
"event": "month_end",
"description": "월말 -- 기관/펀드 리밸런싱 수요",
"impact": "medium",
})
# 월초 (1~3일)
if d.day <= 3:
day_events.append({
"event": "month_start",
"description": "월초 -- 자금 유입 기대",
"impact": "low",
})
if day_events:
for evt in day_events:
events.append({"date": date_str, **evt})
else:
events.append({
"date": date_str,
"event": "normal",
"description": "정상 거래일",
"impact": "none",
})
return events
@router.get("/events")
async def get_market_events(
days: int = Query(default=7, ge=1, le=30, description="조회 기간 (일)"),
):
"""향후 N일간 시장 이벤트 캘린더.
옵션 만기일, 월말/월초 등 정기 이벤트를 반환합니다.
market_playbook 모듈이 설치되어 있으면 추가 이벤트를 병합합니다.
"""
now = _get_kst_now()
base_events = _get_events_for_range(now, days)
# market_playbook이 있으면 추가 이벤트 병합
try:
from app.services.market_playbook import get_events_calendar # type: ignore
extra = await get_events_calendar(days=days)
if extra:
# 날짜 기준으로 병합
existing_dates = {e["date"] for e in base_events if e.get("event") != "normal"}
for evt in extra:
if evt.get("date") not in existing_dates:
base_events.append(evt)
base_events.sort(key=lambda e: e["date"])
except ImportError:
pass
except Exception as e:
logger.warning("playbook events 조회 실패: %s", e)
return {
"timestamp": now.isoformat(),
"range_start": now.strftime("%Y-%m-%d"),
"range_end": (now + timedelta(days=days - 1)).strftime("%Y-%m-%d"),
"events": base_events,
}
# ---------------------------------------------------------------------------
# 3) GET /news -- 종목별 뉴스 + 감성 점수
# ---------------------------------------------------------------------------
@router.get("/news")
async def get_market_news(
stock_code: Optional[str] = Query(
default=None,
description="종목코드 (없으면 전체 시장 뉴스)",
),
):
"""종목별 또는 전체 시장 뉴스 + 감성 점수.
news_sentiment 모듈이 설치되어 있지 않으면 빈 결과를 반환합니다.
"""
now = _get_kst_now()
result = await _get_news_sentiment(stock_code=stock_code)
return {
"timestamp": now.isoformat(),
"stock_code": stock_code,
**result,
}

View File

@@ -0,0 +1,97 @@
"""시장 이벤트 플레이북 API
읽기 전용 엔드포인트:
GET /api/playbook/today — 오늘 적용 중인 플레이북
GET /api/playbook/upcoming — 향후 N일 이벤트 목록
GET /api/playbook/realtime — 실시간 이벤트 상태 (서킷브레이커, VI)
쓰기 엔드포인트 (require_api_key):
POST /api/playbook/circuit-breaker — 서킷브레이커 수동 발동
DELETE /api/playbook/circuit-breaker — 서킷브레이커 수동 해제
POST /api/playbook/vi/{stock_code} — VI 수동 발동
"""
import logging
from fastapi import APIRouter, Query
from app.services.market_playbook import (
get_today_playbook,
get_upcoming_events,
check_realtime_events,
trigger_circuit_breaker,
clear_circuit_breaker,
trigger_vi,
get_realtime_state_summary,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/today")
async def get_today_playbook_endpoint():
"""오늘 적용 중인 플레이북 행동 지침."""
playbook = get_today_playbook()
return playbook.to_dict()
@router.get("/upcoming")
async def get_upcoming_events_endpoint(days: int = Query(default=7, ge=1, le=30)):
"""향후 N일 이내 예정된 시장 이벤트 목록."""
events = get_upcoming_events(days=days)
return {
"days": days,
"count": len(events),
"events": [
{
"type": e.event_type.value,
"date": e.event_date.isoformat(),
"description": e.description,
"phase": e.phase.value,
"days_until": e.days_until,
}
for e in events
],
}
@router.get("/realtime")
async def get_realtime_events_endpoint():
"""실시간 이벤트 상태 (서킷브레이커, VI)."""
alerts = check_realtime_events()
state = get_realtime_state_summary()
return {
"alerts": [
{
"type": a.event_type.value,
"description": a.description,
"severity": a.severity,
}
for a in alerts
],
"state": state,
}
@router.post("/circuit-breaker")
async def trigger_circuit_breaker_endpoint(description: str = "수동 서킷브레이커 발동"):
"""서킷브레이커 수동 발동 (긴급 매매 중단)."""
trigger_circuit_breaker(description)
logger.warning(f"[PlaybookAPI] 서킷브레이커 수동 발동: {description}")
return {"status": "activated", "description": description}
@router.delete("/circuit-breaker")
async def clear_circuit_breaker_endpoint():
"""서킷브레이커 수동 해제."""
clear_circuit_breaker()
logger.info("[PlaybookAPI] 서킷브레이커 수동 해제")
return {"status": "cleared"}
@router.post("/vi/{stock_code}")
async def trigger_vi_endpoint(stock_code: str, description: str = ""):
"""특정 종목 VI 수동 발동."""
trigger_vi(stock_code, description or f"수동 VI 발동: {stock_code}")
return {"status": "triggered", "stock_code": stock_code}

View File

@@ -25,14 +25,23 @@ router = APIRouter()
_http_client: Optional[httpx.AsyncClient] = None
def _mcp_default_headers() -> dict:
"""MCP 서버 인증용 기본 헤더. MCP_API_KEY가 설정되어 있으면 X-API-Key 포함."""
headers: dict = {}
if settings.MCP_API_KEY:
headers["X-API-Key"] = settings.MCP_API_KEY
return headers
async def init_http_client() -> None:
"""lifespan 시작 시 한 번만 호출. keepalive 연결 풀 설정."""
global _http_client
_http_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0),
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
headers=_mcp_default_headers(),
)
logger.info("✅ MCP httpx.AsyncClient initialized")
logger.info("MCP httpx.AsyncClient initialized (api_key=%s)", "set" if settings.MCP_API_KEY else "none")
async def close_http_client() -> None:
@@ -53,6 +62,11 @@ def get_http_client() -> httpx.AsyncClient:
return _http_client
# ─── fail-open 카운터 ─────────────────────────────────────────────────
_fail_open_count: int = 0
_FAIL_OPEN_WARN_THRESHOLD: int = 5 # 연속 N회 초과 시 경고 로그
# ─── 공통 리스크 평가 함수 ────────────────────────────────────────────
async def request_mcp_risk_assessment(
trade_plan: Dict[str, Any],
@@ -72,13 +86,23 @@ async def request_mcp_risk_assessment(
- approve=False → 주문 스킵
- MCP 장애 / 타임아웃 → fail-open (approve=True, 매매 중단 방지)
"""
global _fail_open_count
if not settings.MCP_VALIDATION_ENABLED:
return True, "mcp_disabled"
try:
client = get_http_client()
except RuntimeError:
logger.warning("⚠️ http_client 미초기화 — fail-open")
_fail_open_count += 1
logger.warning(
"MCP http_client 미초기화 — fail-open (누적 %d회)", _fail_open_count
)
if _fail_open_count > _FAIL_OPEN_WARN_THRESHOLD:
logger.error(
"MCP fail-open 연속 %d회 초과: MCP 서버 연결 상태를 점검하세요",
_fail_open_count,
)
return True, "client_not_initialized_fail_open"
try:
@@ -101,16 +125,42 @@ async def request_mcp_risk_assessment(
decision = str(payload.get("decision", "approve")).lower()
reason = str(payload.get("reason", ""))
if "deny" in decision or "reject" in decision:
_fail_open_count = 0 # 정상 응답 수신 시 카운터 리셋
return False, reason
_fail_open_count = 0 # 정상 응답 수신 시 카운터 리셋
return True, reason
else:
logger.warning(f"⚠️ MCP RISK_ASSESSMENT HTTP {resp.status_code} — fail-open")
_fail_open_count += 1
logger.warning(
"MCP RISK_ASSESSMENT HTTP %d — fail-open (누적 %d회)",
resp.status_code, _fail_open_count,
)
if _fail_open_count > _FAIL_OPEN_WARN_THRESHOLD:
logger.error(
"MCP fail-open 연속 %d회 초과: MCP 서버 응답 오류가 지속됩니다",
_fail_open_count,
)
return True, f"mcp_http_{resp.status_code}_fail_open"
except httpx.TimeoutException:
logger.warning("⚠️ MCP RISK_ASSESSMENT 타임아웃 — fail-open")
_fail_open_count += 1
logger.warning(
"MCP RISK_ASSESSMENT 타임아웃 — fail-open (누적 %d회)", _fail_open_count
)
if _fail_open_count > _FAIL_OPEN_WARN_THRESHOLD:
logger.error(
"MCP fail-open 연속 %d회 초과: MCP 서버 타임아웃이 지속됩니다",
_fail_open_count,
)
return True, "mcp_timeout_fail_open"
except Exception as e:
logger.warning(f"⚠️ MCP RISK_ASSESSMENT 오류: {e} — fail-open")
_fail_open_count += 1
logger.warning(
"MCP RISK_ASSESSMENT 오류: %s — fail-open (누적 %d회)", e, _fail_open_count
)
if _fail_open_count > _FAIL_OPEN_WARN_THRESHOLD:
logger.error(
"MCP fail-open 연속 %d회 초과: 오류=%s", _fail_open_count, e
)
return True, f"mcp_error_fail_open: {e}"

View File

@@ -7,7 +7,8 @@ ML 앙상블 API 엔드포인트
- GET /backtest: 백테스트 결과
"""
from fastapi import APIRouter, HTTPException, Query, BackgroundTasks
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from app.core.security import require_api_key
import logging
import asyncio
@@ -139,7 +140,7 @@ async def _train_task(limit: int):
logger.info(f"학습 완료: {metrics}")
@router.post("/train")
@router.post("/train", dependencies=[Depends(require_api_key)])
async def train_model(
background_tasks: BackgroundTasks,
limit: int = Query(50, description="학습 종목 수"),

View File

@@ -0,0 +1,106 @@
"""뉴스 감성 분석 API 엔드포인트
- GET /api/v1/news/score/{stock_code} : 종목별 뉴스 감성 점수
- GET /api/v1/news/score/market : 시장 전체 뉴스 감성 점수
- GET /api/v1/news/summary/{stock_code} : 종목 뉴스 요약 (점수 + 기사 목록)
- GET /api/v1/news/recent : 최근 수집된 뉴스 목록 (DB 조회)
"""
import logging
from typing import Optional
from fastapi import APIRouter, Query
from sqlalchemy import select, desc
from app.core.database import AsyncSessionLocal
from app.models.models import News
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/score/market")
async def get_market_score():
"""시장 전체 뉴스 감성 점수 조회.
Returns:
{"score": float, "source": "market"}
"""
from app.services.news_sentiment import get_market_news_score
score = await get_market_news_score()
return {"score": score, "source": "market"}
@router.get("/score/{stock_code}")
async def get_stock_score(stock_code: str):
"""종목별 뉴스 감성 점수 조회.
Args:
stock_code: 종목 코드 (예: 005930)
Returns:
{"stock_code": str, "score": float}
"""
from app.services.news_sentiment import get_stock_news_score
score = await get_stock_news_score(stock_code)
return {"stock_code": stock_code, "score": score}
@router.get("/summary/{stock_code}")
async def get_stock_summary(stock_code: str):
"""종목 뉴스 요약 (점수 + 기사 목록 + 감성 분포).
Args:
stock_code: 종목 코드
Returns:
{"stock_code", "score", "article_count", "latest_articles", "sentiment_distribution"}
"""
from app.services.news_sentiment import get_stock_news_summary
return await get_stock_news_summary(stock_code)
@router.get("/recent")
async def get_recent_news(
stock_code: Optional[str] = Query(None, description="종목 코드 필터 (선택)"),
limit: int = Query(20, ge=1, le=100, description="조회 개수"),
):
"""DB에 저장된 최근 뉴스 목록 조회.
Args:
stock_code: 특정 종목 필터 (선택)
limit: 조회 개수 (기본 20, 최대 100)
Returns:
{"news": [...], "count": int}
"""
try:
async with AsyncSessionLocal() as session:
query = select(News).order_by(desc(News.published_at))
if stock_code:
query = query.where(News.stock_codes.any(stock_code))
query = query.limit(limit)
result = await session.execute(query)
rows = result.scalars().all()
news_list = [
{
"id": row.id,
"title": row.title,
"source": row.source,
"url": row.url,
"stock_codes": row.stock_codes,
"sentiment": row.sentiment,
"sentiment_score": float(row.sentiment_score) if row.sentiment_score else None,
"published_at": row.published_at.isoformat() if row.published_at else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
for row in rows
]
return {"news": news_list, "count": len(news_list)}
except Exception as e:
logger.error(f"[NEWS_API] 최근 뉴스 조회 오류: {e}")
return {"news": [], "count": 0, "error": str(e)}

View File

@@ -24,6 +24,7 @@ from app.services.mean_reversion_strategy import (
from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker
from app.services.websocket_manager import websocket_manager
from app.services.utils import _is_domestic
import asyncio
import logging
from datetime import datetime, timedelta
@@ -33,18 +34,6 @@ logger = logging.getLogger(__name__)
# 한국 시간대
KST = pytz.timezone('Asia/Seoul')
# 해외 거래소 코드 목록
OVERSEAS_MARKETS = {"NASD", "NYSE", "AMEX", "TSEX", "SEHK", "SHAA", "SZAA", "HASE", "VNSE"}
def _is_domestic(stock_code: str, market: str = None) -> bool:
"""국내 종목 여부 판별.
market 컬럼이 있으면 우선 사용, 없으면 종목코드 패턴으로 폴백.
"""
if market:
return market not in OVERSEAS_MARKETS
# 폴백: 6자리 숫자이면 국내(KRX)
return stock_code.isdigit() and len(stock_code) == 6
# Request/Response Models
class SellRequest(BaseModel):
stock_code: str
@@ -55,6 +44,157 @@ def _get_kst_now():
"""현재 한국 시간 반환 (timezone-naive datetime)"""
return datetime.now(KST).replace(tzinfo=None)
def _pick_float(d: dict, keys: list) -> float:
"""Helper to extract float value from dict by key priority."""
for k in keys:
if k in d and d.get(k) not in (None, ""):
try:
return float(d[k])
except Exception:
continue
return 0.0
def _calculate_portfolio_values(holdings: list) -> dict:
"""Calculate total value, investment, and profit/loss from holdings.
Returns dict with total_stocks, total_value, total_investment, total_profit_loss,
total_profit_loss_rate, domestic_value, overseas_value_usd.
"""
total_stocks = len(holdings)
# 국내/해외 분리하여 평가금액 계산
domestic_value = sum(
float(h.current_price or 0) * h.quantity
for h in holdings if _is_domestic(h.stock_code, getattr(h, "market", None))
)
overseas_value_usd = sum(
float(h.current_price or 0) * h.quantity
for h in holdings if not _is_domestic(h.stock_code, getattr(h, "market", None))
)
# 국내/해외 투자금을 분리 계산 (통화 혼합 방지)
domestic_investment = sum(
float(h.average_price or 0) * h.quantity
for h in holdings if _is_domestic(h.stock_code, getattr(h, "market", None))
)
overseas_investment_usd = sum(
float(h.average_price or 0) * h.quantity
for h in holdings if not _is_domestic(h.stock_code, getattr(h, "market", None))
)
# 국내 손익 (KRW 단위)
domestic_profit_loss = domestic_value - domestic_investment
# 해외 손익 (USD 단위) — 원화 환산은 cache_summary()에서 환율 적용 시 수행
overseas_profit_loss_usd = overseas_value_usd - overseas_investment_usd
# 하위 호환: total_investment 은 국내만 (기존 호출처 동작 유지)
total_investment = domestic_investment
total_profit_loss = domestic_profit_loss
return {
"total_stocks": total_stocks,
"domestic_value": domestic_value,
"overseas_value_usd": overseas_value_usd,
"domestic_investment": domestic_investment,
"overseas_investment_usd": overseas_investment_usd,
"domestic_profit_loss": domestic_profit_loss,
"overseas_profit_loss_usd": overseas_profit_loss_usd,
"total_investment": total_investment,
"total_profit_loss": total_profit_loss,
}
def _extract_balance_data(balance_data: dict) -> dict:
"""Extract cash and total assets from KIS API balance response.
Returns dict with available_cash, total_assets, kis_total_assets, output2.
"""
available_cash = 0.0
total_assets = 0.0
kis_total_assets = 0.0
output2 = {}
if balance_data:
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys()}")
if "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
logger.info(f"📊 output2 keys: {output2.keys()}")
logger.info(f"💰 output2 data: {output2}")
else:
logger.warning("⚠️ No 'output2' in balance_data!")
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
available_cash = _pick_float(output2, [
"dnca_tot_amt",
"d2_depo_amt",
"d2_deposit",
"nass_amt"
])
kis_total_assets = _pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"])
if kis_total_assets > 0:
total_assets = kis_total_assets
try:
logger.debug(
"Portfolio balance extract - cash=%s, total_assets=%s, keys=%s",
f"{available_cash:,.0f}",
f"{total_assets:,.0f}",
{k: output2.get(k) for k in [
"d2_depo_amt", "d2_deposit", "dnca_tot_amt", "nass_amt", "tot_evlu_amt"
] if k in output2}
)
except Exception:
pass
return {
"available_cash": available_cash,
"total_assets": total_assets,
"kis_total_assets": kis_total_assets,
"output2": output2,
}
def _extract_detailed_balance(balance_data: dict) -> dict:
"""Extract D+2 settlement and stock value details from KIS API response."""
d2_settlement = 0.0
stock_value_from_api = 0.0
bfdy_total_assets = 0.0
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
d2_settlement = _pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"])
stock_value_from_api = _pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"])
bfdy_total_assets = _pick_float(output2, ["bfdy_tot_asst_evlu_amt"])
return {
"d2_settlement": d2_settlement,
"stock_value_from_api": stock_value_from_api,
"bfdy_total_assets": bfdy_total_assets,
}
def _extract_overseas_balance(overseas_balance_data: dict) -> dict:
"""Extract overseas holdings total from KIS API response."""
overseas_total_krw = 0.0
if overseas_balance_data and "output2" in overseas_balance_data:
ovrs_out2 = overseas_balance_data["output2"]
if isinstance(ovrs_out2, list):
ovrs_out2 = ovrs_out2[0] if ovrs_out2 else {}
# tot_evlu_amt: 해외주식 평가금액 합계 (원화)
overseas_total_krw = _pick_float(ovrs_out2, ["tot_evlu_amt", "frcr_evlu_tota"])
if overseas_total_krw > 0:
logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원")
return {"overseas_total_krw": overseas_total_krw}
# 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요.
class PortfolioCache:
def __init__(self):
@@ -174,113 +314,66 @@ class PortfolioCache:
overseas_balance_data: 해외 잔고 KIS API 응답
exchange_rate: USD/KRW 환율 (해외 종목 원화 환산용, 0이면 미제공)
"""
# 1. Calculate portfolio values from holdings
holdings = self.holdings
portfolio_values = _calculate_portfolio_values(holdings)
def pick_float(d: dict, keys: list) -> float:
for k in keys:
if k in d and d.get(k) not in (None, ""):
try:
return float(d[k])
except Exception:
continue
return 0.0
domestic_value = portfolio_values["domestic_value"]
overseas_value_usd = portfolio_values["overseas_value_usd"]
domestic_investment = portfolio_values["domestic_investment"]
overseas_investment_usd = portfolio_values["overseas_investment_usd"]
total_stocks = len(holdings)
# 국내/해외 분리하여 평가금액 계산
domestic_value = sum(
float(h.current_price or 0) * h.quantity
for h in holdings if _is_domestic(h.stock_code, getattr(h, "market", None))
)
overseas_value_usd = sum(
float(h.current_price or 0) * h.quantity
for h in holdings if not _is_domestic(h.stock_code, getattr(h, "market", None))
)
# 해외 원화 환산 (환율 미제공 시 overseas_balance_data output2의 tot_evlu_amt 사용)
overseas_value_krw = overseas_value_usd * exchange_rate if exchange_rate > 0 else 0.0
overseas_investment_krw = overseas_investment_usd * exchange_rate if exchange_rate > 0 else 0.0
total_value = domestic_value + overseas_value_krw
total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings)
total_profit_loss = total_value - total_investment
# 손익 계산: 국내(KRW) + 해외(환율 적용 KRW) 분리 후 합산
domestic_profit_loss = domestic_value - domestic_investment
overseas_profit_loss_krw = overseas_value_krw - overseas_investment_krw
total_investment = domestic_investment + overseas_investment_krw
total_profit_loss = domestic_profit_loss + overseas_profit_loss_krw
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0
# KIS API output2에서 예수금 및 총자산 정보 추출
available_cash = 0.0
total_assets = 0.0
if balance_data:
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys()}")
if "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
logger.info(f"📊 output2 keys: {output2.keys()}")
logger.info(f"💰 output2 data: {output2}")
else:
logger.warning("⚠️ No 'output2' in balance_data!")
# 2. Extract balance data from KIS API
balance_info = _extract_balance_data(balance_data)
available_cash = balance_info["available_cash"]
total_assets = balance_info["total_assets"]
kis_total_assets = balance_info["kis_total_assets"]
output2 = balance_info["output2"]
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
available_cash = pick_float(output2, [
"dnca_tot_amt",
"d2_depo_amt",
"d2_deposit",
"nass_amt"
])
kis_total_assets = pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"])
if kis_total_assets > 0:
total_assets = kis_total_assets
calculated_assets = total_value + available_cash
if abs(kis_total_assets - calculated_assets) > 1000:
logger.debug(
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
f"단순계산(현금+주식): {calculated_assets:,.0f}원, "
f"차이: {(kis_total_assets - calculated_assets):,.0f}원 (D+2정산액 포함)"
)
else:
total_assets = total_value + available_cash
try:
# Log discrepancies
if kis_total_assets > 0:
calculated_assets = total_value + available_cash
if abs(kis_total_assets - calculated_assets) > 1000:
logger.debug(
"Portfolio summary calc - cash=%s, total_assets=%s, keys=%s",
f"{available_cash:,.0f}",
f"{total_assets:,.0f}",
{k: output2.get(k) for k in [
"d2_depo_amt", "d2_deposit", "dnca_tot_amt", "nass_amt", "tot_evlu_amt"
] if k in output2}
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
f"단순계산(현금+주식): {calculated_assets:,.0f}원, "
f"차이: {(kis_total_assets - calculated_assets):,.0f}원 (D+2정산액 포함)"
)
except Exception:
pass
else:
total_assets = total_value + available_cash
# D+2 정산액 및 기타 상세 정보 추출
d2_settlement = 0.0
stock_value_from_api = 0.0
bfdy_total_assets = 0.0
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
d2_settlement = pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"])
stock_value_from_api = pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"])
bfdy_total_assets = pick_float(output2, ["bfdy_tot_asst_evlu_amt"])
# 3. Extract detailed balance info (D+2, stock value, previous day total)
detail_info = _extract_detailed_balance(balance_data)
d2_settlement = detail_info["d2_settlement"]
stock_value_from_api = detail_info["stock_value_from_api"]
bfdy_total_assets = detail_info["bfdy_total_assets"]
# 해외 잔고 합산 (원화 환산 총자산에 포함)
overseas_total_krw = 0.0
if overseas_balance_data and "output2" in overseas_balance_data:
ovrs_out2 = overseas_balance_data["output2"]
if isinstance(ovrs_out2, list):
ovrs_out2 = ovrs_out2[0] if ovrs_out2 else {}
# tot_evlu_amt: 해외주식 평가금액 합계 (원화)
overseas_total_krw = pick_float(ovrs_out2, ["tot_evlu_amt", "frcr_evlu_tota"])
if overseas_total_krw > 0:
total_assets += overseas_total_krw
logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원")
# 4. Extract overseas balance
overseas_info = _extract_overseas_balance(overseas_balance_data)
overseas_total_krw = overseas_info["overseas_total_krw"]
# 금일 평가손익 (전일 총자산 대비 오늘 변화)
if overseas_total_krw > 0:
total_assets += overseas_total_krw
# 5. Calculate daily profit/loss
today_profit_loss = (total_assets - bfdy_total_assets) if bfdy_total_assets > 0 else 0.0
today_profit_loss_rate = (today_profit_loss / bfdy_total_assets * 100) if bfdy_total_assets > 0 else 0.0
# 6. Build and store summary
summary_data = {
"total_stocks": total_stocks,
"total_stocks": portfolio_values["total_stocks"],
"total_value": total_value,
"total_investment": total_investment,
"total_profit_loss": total_profit_loss,
@@ -318,6 +411,7 @@ class PortfolioResponse(BaseModel):
profit_loss: Decimal = None
profit_loss_rate: Decimal = None
market: str = "KRX"
strategy_type: str = "swing"
class Config:
from_attributes = True
@@ -626,6 +720,10 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
logger.info(f"🗑️ Deleting {h.stock_name}({code}) from portfolio DB (not in KIS API response)")
await db.delete(h)
# TODO: db_worker 마이그레이션 필요 — 읽기+업데이트가 같은 트랜잭션에서
# 이뤄져야 하므로(Portfolio SELECT → UPDATE/DELETE → commit) 큐로 분리가 어려움.
# sync_portfolio()는 API 엔드포인트(get_db 의존성) 경로에서만 호출되므로
# 실시간 콜백/스케줄러 직접 호출과는 성격이 다름.
await db.commit()
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
@@ -694,7 +792,16 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
return resp
async def _sync_portfolio_background():
"""백그라운드 포트폴리오 KIS 동기화 (비차단). 이미 동기화 중이면 건너뜀."""
"""백그라운드 포트폴리오 KIS 동기화 (비차단). 이미 동기화 중이면 건너뜀.
[DB 직접 접근 예외 사유]
sync_portfolio()는 KIS API → Portfolio READ/UPSERT → ActiveTrade UPSERT의
복합 트랜잭션이 한 세션 내에서 원자적으로 실행되어야 합니다.
db_worker 큐는 단순 INSERT/UPDATE 태스크 단위를 처리하므로
이 복합 조회+쓰기 흐름을 분리하면 일관성이 깨집니다.
따라서 이 함수에 한해 AsyncSessionLocal 직접 사용을 허용합니다.
(실시간 WebSocket 콜백이 아닌 asyncio.create_task 백그라운드 태스크에서 호출)
"""
if portfolio_cache.sync_lock.locked():
return
try:
@@ -754,7 +861,7 @@ async def get_portfolio_combined(db: AsyncSession = Depends(get_db)):
db_holdings = list(result.scalars().all())
if db_holdings:
portfolio_cache.holdings = db_holdings
portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 표시
await portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 표시
logger.info(f"Portfolio cold-start: DB {len(db_holdings)}종목 즉시 반환, KIS 갱신 백그라운드 시작")
asyncio.create_task(_sync_portfolio_background())
return _build_combined_resp()
@@ -776,15 +883,27 @@ async def refresh_portfolio(db: AsyncSession = Depends(get_db)):
async def check_and_execute_exit_trades(db: AsyncSession):
"""
보유 종목의 손절/익절 조건을 체크하고 자동 매도 실행
"""[DEPRECATED] 스윙 포지션 손절/익절 체크.
.. deprecated::
services/swing/exit_service.py의 check_and_execute_exit_v2()로 대체됨.
swing_scheduler가 호출. 이 함수는 더 이상 활성 스케줄러에서 호출되지 않음.
하위 호환을 위해 함수는 유지.
CLAUDE.md 섹션 6.2 + 7.3 + 7.4 기반:
- ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -6%) ← -8% → -6%
- ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -6%)
- 1차 익절(30%/50%): RSI 55~65 + BB 중앙선 상회
- 2차 익절(50%): RSI ≥ 65 + BB 상단선 도달
- 트레일링 스탑: 최고가 대비 -3% 하락 시 잔여 전량 청산
- 폴백: 고정 비율 손절 (STOP_LOSS_PERCENT)
"""
import warnings
warnings.warn(
"check_and_execute_exit_trades is deprecated. "
"Use services/swing/exit_service.check_and_execute_exit_v2() instead.",
DeprecationWarning,
stacklevel=2,
)
from app.core.config import settings
from datetime import datetime
@@ -803,6 +922,13 @@ async def check_and_execute_exit_trades(db: AsyncSession):
mdd_action = await mdd_manager.update(total_assets)
if mdd_action["action"] != "NONE":
logger.warning(f"MDD 조치: {mdd_action}")
# CRITICAL: MDD 액션에 따른 실제 포지션 축소 매도 실행
try:
reduction_results = await mdd_manager.execute_reduction(mdd_action)
if reduction_results:
logger.warning(f"MDD 강제 축소 완료: {len(reduction_results)}종목 매도")
except Exception as e:
logger.error(f"MDD 강제 축소 실행 실패: {e}", exc_info=True)
result = await db.execute(
select(Portfolio).where(Portfolio.quantity > 0)
@@ -933,9 +1059,10 @@ async def check_and_execute_exit_trades(db: AsyncSession):
if exit_signal.action == "SELL_PARTIAL":
sell_quantity = max(1, int(quantity * exit_signal.sell_pct))
logger.info(f"ATR 청산: {stock_name} - {exit_reason} (매도 {sell_quantity}주)")
elif profit_loss_rate <= settings.STOP_LOSS_PERCENT:
# [이슈9 연동] STOP_LOSS_PERCENT는 양수(6.0)로 저장됨 — 손절 비교 시 음수 변환
elif profit_loss_rate <= -abs(settings.STOP_LOSS_PERCENT):
should_exit = True
exit_reason = f"고정 손절 ({profit_loss_rate:.2f}% <= {settings.STOP_LOSS_PERCENT}%)"
exit_reason = f"고정 손절 ({profit_loss_rate:.2f}% <= -{abs(settings.STOP_LOSS_PERCENT):.1f}%)"
elif profit_loss_rate >= settings.TAKE_PROFIT_PERCENT:
should_exit = True
exit_reason = f"고정 익절 ({profit_loss_rate:.2f}% >= {settings.TAKE_PROFIT_PERCENT}%)"
@@ -975,32 +1102,34 @@ async def check_and_execute_exit_trades(db: AsyncSession):
total_amount = Decimal(str(executed_price)) * sell_quantity
profit_loss = (float(executed_price) - avg_price) * sell_quantity
trade = TradeHistory(
stock_code=stock_code,
stock_name=stock_name,
order_type="SELL",
order_price=Decimal(str(order_price)),
order_quantity=sell_quantity,
executed_price=executed_price,
executed_quantity=sell_quantity,
total_amount=total_amount,
status="COMPLETED" if order_result.get("rt_cd") == "0" else "FAILED",
order_id=order_result.get("odno"),
message=f"자동 청산: {profit_loss_rate:.2f}%",
exit_reason=f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원",
created_at=_get_kst_now(),
executed_at=_get_kst_now() if order_result.get("rt_cd") == "0" else None
)
db.add(trade)
await db.commit()
# TradeHistory DB 기록 (db_worker 큐 경유 — CLAUDE.md 3.2 준수)
from app.services.db_worker import enqueue_trade_history
_trade_status = "COMPLETED" if order_result.get("rt_cd") == "0" else "FAILED"
await enqueue_trade_history({
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": Decimal(str(order_price)),
"order_quantity": sell_quantity,
"executed_price": executed_price,
"executed_quantity": sell_quantity,
"total_amount": total_amount,
"status": _trade_status,
"order_id": order_result.get("odno"),
"message": f"자동 청산: {profit_loss_rate:.2f}%",
"exit_reason": f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원",
"created_at": _get_kst_now(),
"executed_at": _get_kst_now() if order_result.get("rt_cd") == "0" else None,
})
if order_result.get("rt_cd") == "0":
# 연속 손실 추적 (섹션 7.4) — 인메모리 + DB 영속화
is_loss = profit_loss < 0
loss_tracker.record_trade_result(stock_code, is_loss)
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
await loss_tracker.save_to_db(db, stock_code)
# 분할 청산 시 ActiveTrade 잔여 비율 업데이트
# TODO: db_worker 마이그레이션 필요 — 읽기+업데이트가 같은 트랜잭션이라 큐 분리 어려움
if exit_signal and exit_signal.action == "SELL_PARTIAL" and active_trade:
new_remaining = remaining_pct * (1.0 - exit_signal.sell_pct)
active_trade.remaining_pct = Decimal(str(round(new_remaining, 4)))
@@ -1022,9 +1151,9 @@ async def check_and_execute_exit_trades(db: AsyncSession):
})
logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원")
# 매매일지 백그라운드 생성
# 매매일지 백그라운드 생성 — trade_id 미확보이므로 0 전달
asyncio.create_task(_create_journal_background(
trade_id=trade.id,
trade_id=0,
stock_code=stock_code,
stock_name=stock_name,
order_type="SELL",
@@ -1080,8 +1209,9 @@ async def _create_journal_background(
tags = []
# MCP 서버로 AI 일지 생성 요청 (실패 시 규칙 기반 폴백)
_mcp_headers = {"X-API-Key": settings.MCP_API_KEY} if settings.MCP_API_KEY else {}
try:
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(headers=_mcp_headers) as client:
resp = await client.post(
f"{settings.MCP_SERVER_URL}/analyze",
json={"analysis_type": "GENERATE_JOURNAL", "data": trade_info},
@@ -1108,21 +1238,19 @@ async def _create_journal_background(
mood = "좋음" if profit_loss > 0 else "나쁨"
tags = [direction, stock_code]
# DB 저장 (별도 세션 사용 — 호출 시점의 세션은 이미 닫혔을 수 있음)
async with AsyncSessionLocal() as session:
journal = TradeJournal(
trade_id=trade_id,
stock_code=stock_code,
stock_name=stock_name,
journal_date=datetime.utcnow(),
content=content,
strategy_name="AUTO_TRADE",
mood=mood,
tags=tags if tags else None,
)
session.add(journal)
await session.commit()
logger.info(f"매매일지 생성 완료: {stock_name} (trade_id={trade_id})")
# DB 저장 — CLAUDE.md 3.2: db_worker 큐 경유
from app.services.db_worker import enqueue_trade_journal_raw
await enqueue_trade_journal_raw({
"trade_id": trade_id,
"stock_code": stock_code,
"stock_name": stock_name,
"journal_date": datetime.utcnow(),
"content": content,
"strategy_name": "AUTO_TRADE",
"mood": mood,
"tags": tags if tags else None,
})
logger.info(f"매매일지 큐 등록 완료: {stock_name} (trade_id={trade_id})")
# WebSocket으로 프론트엔드에 즉시 알림
await websocket_manager.broadcast_json({
@@ -1194,23 +1322,27 @@ async def sell_stock(
profit_loss = (executed_price - holding.average_price) * quantity
profit_loss_rate = ((executed_price / holding.average_price) - 1) * 100
trade = TradeHistory(
stock_code=stock_code,
stock_name=stock_name,
order_type='SELL',
order_price=Decimal(str(price)) if price else None,
order_quantity=quantity,
executed_price=Decimal(str(executed_price)),
executed_quantity=quantity,
total_amount=Decimal(str(executed_price * quantity)),
status='EXECUTED',
order_id=order_result.get('output', {}).get('ODNO'),
message=f"수동 청산: 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)",
exit_reason=f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)",
created_at=_get_kst_now(),
executed_at=_get_kst_now()
)
db.add(trade)
# CLAUDE.md 3.2: db_worker 큐 경유
from app.services.db_worker import enqueue_trade_history
await enqueue_trade_history({
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": float(price) if price else None,
"order_quantity": quantity,
"executed_price": float(executed_price),
"executed_quantity": quantity,
"total_amount": float(executed_price * quantity),
"status": "COMPLETED",
"order_id": order_result.get('output', {}).get('ODNO'),
"message": f"수동 청산: 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)",
"exit_reason": f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)",
"net_profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"strategy_type": "manual",
"created_at": _get_kst_now(),
"executed_at": _get_kst_now(),
})
# ✅ Portfolio DB 즉시 반영 (매도 후 보유종목에서 즉시 제거)
if quantity >= original_holding_qty:

View File

@@ -99,7 +99,7 @@ async def get_recommendations(
use_volatility_breakout: bool = Query(False, description="변동성 돌파 전략 사용"),
min_volume_ratio: float = Query(1.0, description="최소 거래량 비율 (평균 대비)"),
max_disparity: float = Query(200.0, description="최대 이격도 (20일선 대비 %)"),
max_stocks: int = Query(100, description="분석할 최대 종목 수 (기본 100개)"),
max_stocks: int = Query(100, ge=1, le=200, description="분석할 최대 종목 수 (기본 100개, 최대 200개)"),
):
market_regime = await check_market_regime()
logger.info(f"📊 시장 상태: {market_regime['message']}")
@@ -154,7 +154,7 @@ async def get_recommendations(
r for r in all_results
if r is not None
and not isinstance(r, Exception)
and r.current_price >= 3000
and r.current_price > 5000
and r.score >= regime_min_score
]
recommendations.sort(key=lambda x: x.score, reverse=True)
@@ -181,8 +181,11 @@ async def get_rule_based_recommendations(
if auto_trade and result.get("recommendations_by_rule"):
rec_snapshot = result
async def _bg_trade():
async with AsyncSessionLocal() as bg_db:
await execute_auto_trade_background(rec_snapshot, bg_db)
try:
async with AsyncSessionLocal() as bg_db:
await execute_auto_trade_background(rec_snapshot, bg_db)
except Exception as e:
logger.error(f"[_bg_trade] 백그라운드 자동매매 실행 중 예외: {e}", exc_info=True)
asyncio.create_task(_bg_trade())
return result
@@ -228,7 +231,7 @@ async def _load_active_rules_from_db(db: AsyncSession) -> list:
Rule(
id=1, name="RSI+볼린저 평균회귀", description="기본 평균회귀 규칙",
rule_type="MEAN_REVERSION", priority=1,
conditions={"rsi_bull_max": 25, "rsi_bear_max": 35,
conditions={"rsi_bull_max": 40, "rsi_bear_max": 30,
"bb_lower_pct": 3.0, "bb_upper_pct": 1.5},
actions={"take_profit": 3.0, "stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
@@ -252,21 +255,43 @@ def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str)
regime = MarketRegime.BULL if market_regime_str == "bull" else MarketRegime.BEAR
adx_val = float(stock.adx_14) if getattr(stock, "adx_14", None) else None
# ADX는 0~100 범위. 100 초과 시 계산 오류이므로 무시
if adx_val is not None and adx_val > 100:
adx_val = None
adx_strong_trend = adx_val is not None and adx_val > 30
# %B 계산: 밴드 내 상대 위치 (0=하단, 1=상단)
bb_width = bb_upper - bb_lower
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
if regime == MarketRegime.BULL:
rsi_threshold = conditions.get("rsi_bull_max", 25)
bb_lower_pct = conditions.get("bb_lower_pct", 3.0) / 100
bb_upper_pct = conditions.get("bb_upper_pct", 1.5) / 100
in_bb_zone = bb_lower * (1 - bb_lower_pct) <= close <= bb_lower * (1 + bb_upper_pct)
rsi_threshold = conditions.get("rsi_bull_max", 40)
if adx_strong_trend:
return False
return rsi_val <= rsi_threshold and in_bb_zone
if rsi_val > rsi_threshold:
return False
# RSI 깊이에 따른 동적 %B 임계값
if rsi_val < 25:
pct_b_max = 0.35 # 깊은 과매도 → BB 조건 완화
elif rsi_val < 30:
pct_b_max = 0.30
else:
pct_b_max = 0.20 # RSI 30~40 → BB 하단 근처 요구
return pct_b <= pct_b_max
else:
rsi_threshold = conditions.get("rsi_bear_max", 35)
rsi_threshold = conditions.get("rsi_bear_max", 30)
if adx_strong_trend:
return False
return rsi_val <= rsi_threshold and close <= bb_lower
if rsi_val > rsi_threshold:
return False
# BEAR: RSI 깊이에 따른 동적 %B (BULL보다 엄격)
if rsi_val < 20:
pct_b_max = 0.30
elif rsi_val < 25:
pct_b_max = 0.25
else:
pct_b_max = 0.20
return pct_b <= pct_b_max
async def get_rule_based_recommendations_from_db(db: AsyncSession):
active_rules = await _load_active_rules_from_db(db)
@@ -283,8 +308,8 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
all_analyzed_stocks = []
for s in raw_stocks:
if s.stock_code not in seen:
# 전일 거래량 필터: 100만주 미만 종목 제외
if s.volume is not None and s.volume < 1_000_000:
# 전일 거래량 필터: 20만주 미만 종목 제외 (유동성 확보)
if s.volume is not None and s.volume < 200_000:
continue
seen.add(s.stock_code)
all_analyzed_stocks.append(s)
@@ -398,7 +423,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"recommendations_by_rule": recommendations_by_rule,
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules],
"batch_time": (lambda d: d.date() if isinstance(d, datetime) else d)(
max((s.analysis_date for s in all_analyzed_stocks), default=date.today())
max((s.analysis_date for s in all_analyzed_stocks), default=_today_kst())
).isoformat(),
}
@@ -449,7 +474,7 @@ async def get_rule_based_recommendations_realtime():
"rule_priority": 1,
"position_ratio": 0.15,
"take_profit": 5.0,
"stop_loss": 3.0
"stop_loss": -6.0
}
default_stocks.append(stock_dict)
@@ -500,16 +525,16 @@ async def get_rule_based_recommendations_realtime():
min_volume_ratio=0, # intraday acml_vol << avg_vol_20 → 장중엔 항상 0으로 고정
)
batch_size = 1
delay_between_batches = 1.5
batch_size = 5 # 네이버 일봉 사용으로 병렬 처리 가능
delay_between_batches = 0.3 # 네이버 서버 부하 방지 최소 딜레이
all_analyzed_stocks = []
for i in range(0, len(stocks_to_analyze), batch_size):
batch = stocks_to_analyze[i:i + batch_size]
tasks = [analyze_stock(code, name, criteria) for code, name in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_analyzed_stocks.extend(results)
if i + batch_size < len(stocks_to_analyze):
await asyncio.sleep(delay_between_batches)
@@ -578,33 +603,15 @@ async def get_rule_based_recommendations_realtime():
rule_matches.sort(key=lambda x: (x["rule_priority"], -x["score"]))
from app.services.korea_investment_api import korea_investment_api as kis_api
# 현재가는 DB 분석 데이터 그대로 사용 (KIS API 미호출)
top_recommendations = rule_matches[:20]
for rec in top_recommendations:
try:
await asyncio.sleep(0.2)
price_data = await kis_api.get_current_price(rec["stock_code"])
if price_data and price_data.get("output"):
output = price_data["output"]
rec["current_price"] = int(output.get("stck_prpr", rec["current_price"]))
rec["change_rate"] = float(output.get("prdy_ctrt", rec.get("change_rate", 0)))
except Exception as e:
logger.warning(f"Failed to get real-time price for {rec['stock_code']}: {e}")
for rule_group in recommendations_by_rule:
for stock in rule_group["stocks"]:
updated = next((r for r in top_recommendations if r["stock_code"] == stock["stock_code"]), None)
if updated:
stock["current_price"] = updated["current_price"]
stock["change_rate"] = updated["change_rate"]
from app.services.analysis_db_service import bulk_upsert_analysis as _bulk_upsert
# KST 기준 오늘 날짜 사용 (UTC 기준 date.today() 사용 시 KST와 날짜 불일치 → UPSERT 대신 INSERT 반복 버그 수정)
# db_worker 큐 경유 — 실시간 분석 경로에서 AsyncSessionLocal 직접 사용 금지 (CLAUDE.md 3.2)
from app.services.analysis_db_service import queue_bulk_upsert_analysis as _queue_bulk
_today = _today_kst()
_items = [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks]
async with AsyncSessionLocal() as _db:
saved = await _bulk_upsert(_db, _items, _today)
await _db.commit()
await _queue_bulk(_items, _today)
return {
"total": len(rule_matches),

View File

@@ -11,20 +11,29 @@
from fastapi import APIRouter, HTTPException, Query, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func, desc
from datetime import datetime, timedelta, date
from datetime import datetime, timezone, timedelta, date
from decimal import Decimal
from typing import List, Optional
import logging
import numpy as np
from app.core.database import get_db
_KST = timezone(timedelta(hours=9))
def _today_kst() -> date:
"""KST 기준 오늘 날짜 반환 (UTC 기준 date.today() 대신 항상 이 함수 사용)"""
return datetime.now(_KST).date()
from app.core.config import settings
from app.core.security import require_api_key
from app.models.models import TradeHistory, TradingSession, Portfolio
router = APIRouter()
logger = logging.getLogger(__name__)
# 🔥 리스크 관리 설정
MAX_POSITION_SIZE_RATIO = 0.20 # 계좌의 20%까지만
# 🔥 리스크 관리 설정 — config.py 참조
MAX_POSITION_SIZE_RATIO = settings.MAX_POSITION_SIZE # config.py: 0.06 (6%)
MAX_DAILY_LOSS_RATIO = 0.03 # 일일 3% 손실 시 중단
MAX_CONSECUTIVE_LOSSES = 3 # 3연속 손실 시 중단
COMMISSION_RATE = 0.00015 # 수수료 0.015%
@@ -52,7 +61,7 @@ async def get_risk_metrics(
select(TradeHistory)
.where(TradeHistory.created_at >= since_date)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "FILLED"]))
.where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"]))
.order_by(TradeHistory.executed_at)
)
trades = result.scalars().all()
@@ -146,7 +155,7 @@ async def get_sharpe_ratio(
select(TradeHistory)
.where(TradeHistory.created_at >= since_date)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "FILLED"]))
.where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"]))
.where(TradeHistory.profit_loss_rate.isnot(None))
)
trades = result.scalars().all()
@@ -171,11 +180,19 @@ async def get_sharpe_ratio(
avg_return = np.mean(returns)
std_return = np.std(returns)
# Sharpe Ratio 계산 (연간화: 252 거래일 기준)
# Sharpe Ratio 계산 — 거래 빈도 기반 연간화
# 거래가 일 1회가 아닌 경우 sqrt(252)로 연간화하면 부풀려짐.
# 실제 거래 기간(calendar days) 대비 거래 횟수로 연간 거래 빈도를 추정한다.
# trades_per_year: 관측 기간 내 거래 빈도를 252 거래일 기준으로 환산
first_trade_date = trades[0].executed_at or trades[0].created_at
last_trade_date = trades[-1].executed_at or trades[-1].created_at
span_days = max((last_trade_date - first_trade_date).days, 1)
trades_per_year = len(returns) / span_days * 252
# 무위험 수익률은 0으로 가정
sharpe = (avg_return * np.sqrt(252)) / std_return if std_return > 0 else 0
sharpe = (avg_return * np.sqrt(trades_per_year)) / std_return if std_return > 0 else 0
# 평가
if sharpe > 2:
interpretation = "Excellent (매우 우수)"
@@ -185,12 +202,13 @@ async def get_sharpe_ratio(
interpretation = "Fair (보통)"
else:
interpretation = "Poor (개선 필요)"
return {
"sharpe_ratio": round(sharpe, 3),
"avg_return": round(avg_return * 100, 2), # %
"std_return": round(std_return * 100, 2), # %
"annualized_return": round(avg_return * 252 * 100, 2), # %
"annualized_return": round(avg_return * trades_per_year * 100, 2), # %
"trades_per_year": round(trades_per_year, 1),
"interpretation": interpretation,
"trades_count": len(returns)
}
@@ -210,7 +228,7 @@ async def get_today_session(db: AsyncSession = Depends(get_db)):
- 거래 중지 상태
"""
try:
today = date.today()
today = _today_kst()
result = await db.execute(
select(TradingSession)
@@ -250,7 +268,7 @@ async def get_today_session(db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=500, detail=str(e))
@router.post("/check-risk-limits")
@router.post("/check-risk-limits", dependencies=[Depends(require_api_key)])
async def check_risk_limits(
order_value: float = Query(..., description="주문 금액"),
account_balance: float = Query(..., description="계좌 잔고"),
@@ -265,7 +283,7 @@ async def check_risk_limits(
"""
try:
# 1. 오늘 세션 확인
today = date.today()
today = _today_kst()
result = await db.execute(
select(TradingSession)
.where(TradingSession.session_date == today)
@@ -347,7 +365,7 @@ async def check_risk_limits(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/update-session")
@router.post("/update-session", dependencies=[Depends(require_api_key)])
async def update_trading_session(
trade_result: str = Query(..., description="win 또는 loss"),
profit_loss: float = Query(..., description="거래 손익"),
@@ -360,7 +378,7 @@ async def update_trading_session(
- 일일 손익 누적
"""
try:
today = date.today()
today = _today_kst()
result = await db.execute(
select(TradingSession)
.where(TradingSession.session_date == today)
@@ -416,7 +434,7 @@ async def get_win_rate(
select(TradeHistory)
.where(TradeHistory.created_at >= since_date)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "FILLED"]))
.where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"]))
)
trades = result.scalars().all()
@@ -473,7 +491,7 @@ async def get_win_rate(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/mdd/reset")
@router.post("/mdd/reset", dependencies=[Depends(require_api_key)])
async def reset_mdd():
"""MDD 상태 수동 초기화 (신규 진입 차단 해제)"""
from app.services.mdd_manager import mdd_manager
@@ -482,7 +500,7 @@ async def reset_mdd():
"peak_balance": mdd_manager.peak_balance,
"reduction_applied": mdd_manager.reduction_applied,
}
mdd_manager.reset()
await mdd_manager.reset()
logger.info("✅ MDD 수동 초기화 완료")
return {
"message": "MDD 초기화 완료 — 신규 진입 가능",

View File

@@ -0,0 +1,177 @@
"""스윙 트레이딩 전용 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
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
router = APIRouter()
logger = logging.getLogger(__name__)
class SwingSellRequest(BaseModel):
stock_code: str
quantity: Optional[int] = None # None이면 전량
@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),
):
"""스윙 포지션 수동 청산."""
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="스윙 포지션을 찾을 수 없습니다")
quantity = req.quantity or holding.quantity
quantity = min(quantity, holding.quantity)
# 현재가 조회
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="현재가 조회 실패")
order_resp = await korea_investment_api.place_order(
stock_code=req.stock_code,
order_type="SELL",
quantity=quantity,
price=0,
order_price_type="01",
)
if not order_resp or order_resp.get("rt_cd") != "0":
err = order_resp.get("msg1", "unknown") if order_resp else "no response"
raise HTTPException(status_code=503, detail=f"매도 주문 실패: {err}")
from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert
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
# 매도 후 Portfolio 수량 갱신 (db_worker 큐 경유)
new_quantity = holding.quantity - quantity
await enqueue_portfolio_upsert({
"stock_code": req.stock_code,
"stock_name": holding.stock_name,
"quantity": new_quantity,
"average_price": avg_price,
"current_price": float(price),
"strategy_type": "swing",
})
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",
"exit_reason": "수동 청산",
"net_profit_loss": pl,
"profit_loss_rate": pl_rate,
"strategy_type": "swing",
})
logger.info(f"[SwingAPI] 수동 청산: {req.stock_code} 수량={quantity} 가격={price:,}")
return {
"stock_code": req.stock_code,
"quantity": quantity,
"price": price,
"profit_loss": round(pl),
"profit_loss_rate": round(pl_rate, 2),
}

View File

@@ -12,6 +12,7 @@ from typing import List, Optional
from datetime import datetime, timedelta
from decimal import Decimal
import logging
import re
from app.core.database import get_db
from app.models.models import TradeHistory
@@ -90,9 +91,7 @@ async def get_trade_history(
response = TradeHistoryResponse.model_validate(trade)
# 매도 시 손익 정보 설정
if trade.order_type == "SELL" and trade.status == "EXECUTED":
import re
if trade.order_type == "SELL" and trade.status in ("EXECUTED", "COMPLETED"):
# 1. DB에 저장된 값 먼저 사용
if hasattr(trade, 'net_profit_loss') and trade.net_profit_loss is not None:
response.profit_loss = trade.net_profit_loss
@@ -169,13 +168,13 @@ async def get_trade_stats(
total_trades = len(trades)
buy_trades = len([t for t in trades if t.order_type == "BUY"])
sell_trades = len([t for t in trades if t.order_type == "SELL"])
executed_trades = len([t for t in trades if t.status == "EXECUTED"])
executed_trades = len([t for t in trades if t.status in ("EXECUTED", "COMPLETED")])
failed_trades = len([t for t in trades if t.status == "FAILED"])
# 총 거래 금액 계산
total_amount = sum(
float(t.executed_price or 0) * (t.executed_quantity or 0)
for t in trades if t.status == "EXECUTED"
for t in trades if t.status in ("EXECUTED", "COMPLETED")
)
return {

View File

@@ -15,6 +15,7 @@ from typing import List
from datetime import datetime
from app.core.database import get_db
from app.core.security import require_api_key
from app.models.models import TradingRule
from pydantic import BaseModel
@@ -90,7 +91,7 @@ async def get_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
return rule
@router.post("", response_model=TradingRuleResponse)
@router.post("", response_model=TradingRuleResponse, dependencies=[Depends(require_api_key)])
async def create_trading_rule(rule: TradingRuleCreate, db: AsyncSession = Depends(get_db)):
"""새 트레이딩 규칙 생성"""
db_rule = TradingRule(**rule.model_dump())
@@ -100,7 +101,7 @@ async def create_trading_rule(rule: TradingRuleCreate, db: AsyncSession = Depend
return db_rule
@router.put("/{rule_id}", response_model=TradingRuleResponse)
@router.put("/{rule_id}", response_model=TradingRuleResponse, dependencies=[Depends(require_api_key)])
async def update_trading_rule(
rule_id: int,
rule: TradingRuleUpdate,
@@ -123,7 +124,7 @@ async def update_trading_rule(
return db_rule
@router.delete("/{rule_id}")
@router.delete("/{rule_id}", dependencies=[Depends(require_api_key)])
async def delete_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
"""트레이딩 규칙 삭제"""
result = await db.execute(select(TradingRule).where(TradingRule.id == rule_id))
@@ -137,7 +138,7 @@ async def delete_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
return {"message": "Trading rule deleted successfully"}
@router.post("/{rule_id}/toggle")
@router.post("/{rule_id}/toggle", dependencies=[Depends(require_api_key)])
async def toggle_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
"""트레이딩 규칙 활성화/비활성화 토글"""
result = await db.execute(select(TradingRule).where(TradingRule.id == rule_id))

View File

@@ -0,0 +1,161 @@
"""미국 주식 매매 API 엔드포인트
- GET /us-trading/watchlist: 미국 주식 관심 종목 목록
- GET /us-trading/signals: 매매 신호 스캔 결과
- GET /us-trading/status: 미국장 상태 (개장/폐장/프리마켓)
- POST /us-trading/execute: 미국 주식 매수/매도 실행
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, List
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/us-trading", tags=["US Trading"])
class UsOrderRequest(BaseModel):
ticker: str
order_type: str # "buy" or "sell"
quantity: int
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 Exception as e:
logger.error(f"US signal scan failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/execute")
async def execute_us_trade(req: UsOrderRequest):
"""미국 주식 매수/매도 실행"""
from app.services.exchange_detector import detect_exchange
from app.services.korea_investment_api import korea_investment_api
exchange = detect_exchange(req.ticker)
try:
if req.order_type.lower() == "buy":
if not req.price:
# Get current price for limit order
price_data = await korea_investment_api.get_overseas_price(req.ticker, exchange)
req.price = float(price_data.get("last", 0))
if not req.price or req.price <= 0:
raise HTTPException(
status_code=400,
detail=f"유효하지 않은 주문 가격: {req.ticker} 현재가 조회 실패 또는 0달러"
)
result = await korea_investment_api.place_overseas_order(
req.ticker, "buy", req.quantity, req.price, exchange
)
elif req.order_type.lower() == "sell":
if not req.price:
price_data = await korea_investment_api.get_overseas_price(req.ticker, exchange)
req.price = float(price_data.get("last", 0))
if not req.price or req.price <= 0:
raise HTTPException(
status_code=400,
detail=f"유효하지 않은 주문 가격: {req.ticker} 현재가 조회 실패 또는 0달러"
)
result = await korea_investment_api.place_overseas_order(
req.ticker, "sell", req.quantity, req.price, exchange
)
else:
raise HTTPException(status_code=400, detail="order_type must be 'buy' or 'sell'")
return result
except Exception as e:
logger.error(f"US trade execution failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/cycle")
async def trigger_us_trade_cycle():
"""미국 매매 사이클 수동 즉시 실행 (테스트/긴급 용도)"""
from app.services.us_market_scheduler import us_trade_cycle
try:
result = await us_trade_cycle()
return result
except Exception as e:
logger.error(f"US trade cycle failed: {e}")
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))