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:
62
backend/app/api/endpoints/adaptive_strategy.py
Normal file
62
backend/app/api/endpoints/adaptive_strategy.py
Normal 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)}
|
||||
@@ -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",
|
||||
|
||||
104
backend/app/api/endpoints/daytrading_v2.py
Normal file
104
backend/app/api/endpoints/daytrading_v2.py
Normal 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()),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
280
backend/app/api/endpoints/kr_backtest.py
Normal file
280
backend/app/api/endpoints/kr_backtest.py
Normal 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)
|
||||
@@ -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)}")
|
||||
|
||||
423
backend/app/api/endpoints/market_intelligence.py
Normal file
423
backend/app/api/endpoints/market_intelligence.py
Normal 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,
|
||||
}
|
||||
97
backend/app/api/endpoints/market_playbook.py
Normal file
97
backend/app/api/endpoints/market_playbook.py
Normal 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}
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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="학습 종목 수"),
|
||||
|
||||
106
backend/app/api/endpoints/news.py
Normal file
106
backend/app/api/endpoints/news.py
Normal 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)}
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 초기화 완료 — 신규 진입 가능",
|
||||
|
||||
177
backend/app/api/endpoints/swing_portfolio.py
Normal file
177
backend/app/api/endpoints/swing_portfolio.py
Normal 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),
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
161
backend/app/api/endpoints/us_trading.py
Normal file
161
backend/app/api/endpoints/us_trading.py
Normal 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))
|
||||
Reference in New Issue
Block a user