Initial commit

This commit is contained in:
2025-11-14 11:05:31 +09:00
commit b147498c31
124 changed files with 17983 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.endpoints import trading_rules, trades, portfolio, market_data, mcp, recommendations, monitoring
api_router = APIRouter()
api_router.include_router(trading_rules.router, prefix="/rules", tags=["trading_rules"])
api_router.include_router(trades.router, prefix="/trades", tags=["trades"])
api_router.include_router(portfolio.router, prefix="/portfolio", tags=["portfolio"])
api_router.include_router(market_data.router, prefix="/market-data", tags=["market_data"])
api_router.include_router(mcp.router, prefix="/mcp", tags=["mcp"])
api_router.include_router(recommendations.router, prefix="/recommendations", tags=["recommendations"])
api_router.include_router(monitoring.router, prefix="/monitoring", tags=["monitoring"])

View File

@@ -0,0 +1,272 @@
from datetime import datetime, timedelta
import random
import logging
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from app.core.config import settings
from app.services.korea_investment_api import korea_investment_api
router = APIRouter()
logger = logging.getLogger(__name__)
class StockPriceResponse(BaseModel):
stock_code: str
current_price: float
change_rate: float
volume: int
high_price: float
low_price: float
open_price: float
@router.get("/price/{stock_code}")
async def get_stock_price(stock_code: str):
"""주식 현재가 조회"""
try:
data = await korea_investment_api.get_current_price(stock_code)
if "output" not in data:
raise HTTPException(status_code=404, detail="Stock data not found")
output = data["output"]
return {
"stock_code": stock_code,
"current_price": float(output.get("stck_prpr", 0)),
"change_rate": float(output.get("prdy_ctrt", 0)),
"volume": int(output.get("acml_vol", 0)),
"high_price": float(output.get("stck_hgpr", 0)),
"low_price": float(output.get("stck_lwpr", 0)),
"open_price": float(output.get("stck_oprc", 0))
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch stock price: {str(e)}")
def _generate_sample_chart(stock_code: str, period: str) -> dict:
"""로컬 개발 환경을 위한 샘플 차트 데이터를 생성"""
if period not in {"D", "W", "M"}:
period = "D"
if period == "D":
points = 60
step = 1
elif period == "W":
points = 52
step = 7
else: # "M"
points = 36
step = 30
today = datetime.now()
start_date = today - timedelta(days=(points - 1) * step)
rng = random.Random(f"{stock_code}:{period}")
base_price = rng.randint(30000, 90000)
drift = rng.uniform(-0.04, 0.06)
volatility = rng.uniform(0.004, 0.018)
volume_base = rng.randint(80000, 180000)
def round_price(value: float) -> int:
return max(1000, int(round(value / 10)) * 10)
price = float(base_price)
chart_data = []
for idx in range(points):
current_date = start_date + timedelta(days=idx * step)
daily_change = drift / points + rng.uniform(-volatility, volatility)
open_price = price
close_price = max(1000.0, open_price * (1 + daily_change))
high_price = max(open_price, close_price) * (1 + rng.uniform(0, volatility * 1.8))
low_price = min(open_price, close_price) * (1 - rng.uniform(0, volatility * 1.8))
if low_price <= 0:
low_price = min(open_price, close_price) * 0.98
volume = int(volume_base * (1 + rng.uniform(-0.35, 0.35)))
open_value = round_price(open_price)
close_value = round_price(close_price)
high_value = max(round_price(high_price), open_value, close_value)
low_value = min(round_price(low_price), open_value, close_value)
chart_data.append({
"date": current_date.strftime("%Y%m%d"),
"open": float(open_value),
"high": float(high_value),
"low": float(low_value),
"close": float(close_value),
"volume": max(1000, volume),
})
price = close_price
return {
"stock_code": stock_code,
"period": period,
"data": chart_data,
"is_sample": True,
}
@router.get("/chart/{stock_code}")
async def get_stock_chart(
stock_code: str,
period: str = "D",
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환")
):
"""주식 차트 데이터 조회
Args:
stock_code: 종목 코드
period: D(일봉), W(주봉), M(월봉)
"""
try:
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
return _generate_sample_chart(stock_code, period)
data = await korea_investment_api.get_daily_chart(stock_code, period)
if "output" not in data:
raise HTTPException(status_code=404, detail="Chart data not found")
chart_data = []
for item in data["output"]:
# 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"))
# 소수점이 포함되어 있으면 제거하고 정수로 변환
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
# KIS API는 최신 데이터가 가장 먼저 올 수 있음 → 그대로 두고 프론트에서 reverse 처리
latest_candle = None
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"]
# 현재가를 마지막(최신) 캔들로 보정 (지표 계산 시 영향 최소화 위해 기존 첫 캔들과 가격 다르면 prepend)
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
}
except Exception as e:
# 외부 API 실패 시 개발 편의를 위해 샘플 데이터 제공 (이유 포함)
sample = _generate_sample_chart(stock_code, period)
sample["error"] = str(e)
sample["is_sample"] = True
sample["realtime"] = False
sample["latest"] = None
return sample
@router.get("/news")
async def get_market_news(limit: int = 10):
"""시장 뉴스 조회 (네이버 금융 RSS)"""
import feedparser
from datetime import datetime
try:
# 네이버 금융 증권 뉴스 RSS
feed_url = "https://news.naver.com/main/list.naver?mode=LSD&mid=sec&sid1=101&listType=title"
rss_url = "https://news.google.com/rss/search?q=한국+증시+주식&hl=ko&gl=KR&ceid=KR:ko"
# RSS 피드 파싱
feed = feedparser.parse(rss_url)
news_list = []
for idx, entry in enumerate(feed.entries[:limit]):
news_list.append({
"id": idx + 1,
"title": entry.get("title", "제목 없음"),
"summary": entry.get("summary", entry.get("description", ""))[:200] + "..." if entry.get("summary", entry.get("description", "")) else "",
"source": entry.get("source", {}).get("title", "Google News") if hasattr(entry.get("source", {}), "get") else "Google News",
"published_at": entry.get("published", datetime.now().isoformat()),
"url": entry.get("link", "#")
})
return news_list
except Exception as e:
# 에러 발생 시 샘플 데이터 반환
return [
{
"id": 1,
"title": "코스피, 외국인 매수세에 상승 마감",
"summary": "외국인 투자자들의 순매수세가 이어지면서 코스피가 상승 마감했습니다.",
"source": "한국경제",
"published_at": datetime.now().isoformat(),
"url": "#"
},
{
"id": 2,
"title": "반도체 업종 강세, 삼성전자 상승",
"summary": "반도체 업종이 강세를 보이며 삼성전자 주가가 상승했습니다.",
"source": "매일경제",
"published_at": datetime.now().isoformat(),
"url": "#"
},
{
"id": 3,
"title": "배터리 3사 동반 상승",
"summary": "LG에너지솔루션, 삼성SDI, SK온이 동반 상승했습니다.",
"source": "이데일리",
"published_at": datetime.now().isoformat(),
"url": "#"
}
][:limit]

View File

@@ -0,0 +1,118 @@
from fastapi import APIRouter, HTTPException
from typing import Dict, Any
from pydantic import BaseModel
import httpx
from app.core.config import settings
router = APIRouter()
class MCPToolRequest(BaseModel):
tool_name: str
parameters: Dict[str, Any]
class MCPAnalysisRequest(BaseModel):
analysis_type: str # "RULE_VALIDATION", "MARKET_ANALYSIS", "RISK_ASSESSMENT"
data: Dict[str, Any]
@router.get("/status")
async def get_mcp_status():
"""MCP 서버 상태 확인"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{settings.MCP_SERVER_URL}/health", timeout=5.0)
if response.status_code == 200:
return {
"status": "connected",
"mcp_server_url": settings.MCP_SERVER_URL,
"details": response.json()
}
else:
return {
"status": "error",
"message": "MCP server responded with error"
}
except Exception as e:
return {
"status": "disconnected",
"message": str(e)
}
@router.post("/tools/call")
async def call_mcp_tool(request: MCPToolRequest):
"""MCP 도구 호출"""
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{settings.MCP_SERVER_URL}/tools/call",
json={
"tool_name": request.tool_name,
"parameters": request.parameters
},
timeout=30.0
)
if response.status_code == 200:
return response.json()
else:
raise HTTPException(
status_code=response.status_code,
detail=f"MCP tool call failed: {response.text}"
)
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="MCP server timeout")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to call MCP tool: {str(e)}")
@router.post("/analyze")
async def analyze_with_claude(request: MCPAnalysisRequest):
"""Claude AI를 통한 분석 요청"""
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{settings.MCP_SERVER_URL}/analyze",
json={
"analysis_type": request.analysis_type,
"data": request.data
},
timeout=60.0
)
if response.status_code == 200:
return response.json()
else:
raise HTTPException(
status_code=response.status_code,
detail=f"Claude analysis failed: {response.text}"
)
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="Analysis timeout")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to analyze: {str(e)}")
@router.get("/tools")
async def list_mcp_tools():
"""사용 가능한 MCP 도구 목록"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{settings.MCP_SERVER_URL}/tools", timeout=10.0)
if response.status_code == 200:
return response.json()
else:
raise HTTPException(
status_code=response.status_code,
detail="Failed to fetch tools list"
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to list tools: {str(e)}")

View File

@@ -0,0 +1,69 @@
"""
API 모니터링 엔드포인트
KIS API 호출 통계 및 상태 확인
"""
from fastapi import APIRouter
from typing import Dict, Any
import logging
from app.services.korea_investment_api import korea_investment_api
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/api-stats")
async def get_api_stats() -> Dict[str, Any]:
"""
KIS API 호출 통계 조회
Returns:
- total_calls: 총 API 호출 수
- total_errors: 총 오류 수
- total_retries: 총 재시도 수
- error_rate: 오류율 (0.0 ~ 1.0)
- avg_duration: 평균 응답 시간 (초)
- recent_calls: 최근 모니터링 윈도우 내 호출 수
"""
stats = korea_investment_api.get_monitor_stats()
# 상태 평가
status = "healthy"
if stats["error_rate"] > 0.5:
status = "critical"
elif stats["error_rate"] > 0.3:
status = "warning"
return {
"status": status,
"stats": stats,
"message": f"오류율: {stats['error_rate']:.1%}, 평균 응답: {stats['avg_duration']:.2f}초"
}
@router.post("/log-stats")
async def log_api_stats() -> Dict[str, str]:
"""
현재 API 통계를 로그에 기록
"""
korea_investment_api.log_monitor_stats()
return {"message": "통계가 로그에 기록되었습니다"}
@router.get("/health")
async def health_check() -> Dict[str, Any]:
"""
API 헬스체크
"""
stats = korea_investment_api.get_monitor_stats()
is_healthy = stats["error_rate"] < 0.5
return {
"status": "healthy" if is_healthy else "unhealthy",
"error_rate": stats["error_rate"],
"total_calls": stats["total_calls"],
"timestamp": __import__("datetime").datetime.now().isoformat()
}

View File

@@ -0,0 +1,228 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from decimal import Decimal
from app.core.database import get_db
from app.models.models import Portfolio
from app.services.korea_investment_api import korea_investment_api
import asyncio
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요.
class PortfolioCache:
def __init__(self):
self.holdings = [] # List[Portfolio]
self.summary = None # Dict
self.last_synced: datetime | None = None
self.change_flag = False # 외부 이벤트로 변경 감지 (매수/매도 등)
self.sync_lock = asyncio.Lock()
self.min_interval = timedelta(seconds=15) # 최소 동기화 간격
self.last_attempt: datetime | None = None
self.last_error: str | None = None
# 실패 관리 (백오프)
self.failure_count: int = 0
self.next_retry_at: datetime | None = None
self.last_sync_success: bool = False
def mark_changed(self):
self.change_flag = True
def should_sync(self) -> bool:
now = datetime.now()
# 최초 또는 변경 플래그 또는 최소간격 경과 후 오류 상태 재시도
if self.last_synced is None:
return True
if self.change_flag:
return True
# 오류 발생 시 백오프 기간에는 재시도 금지
if self.last_error:
if self.next_retry_at and now < self.next_retry_at:
return False
# 백오프가 지났으면 재시도 허용 (최소 간격과 무관)
return True
if (now - self.last_synced) > timedelta(minutes=5): # 주기적 신선도 보장
return True
return False
def _compute_backoff(self) -> timedelta:
# 30s, 60s, 120s, max 5min
base = 30
delay = base * (2 ** max(self.failure_count - 1, 0))
delay = min(delay, 300)
return timedelta(seconds=delay)
def record_failure(self, err: Exception | str):
self.last_error = str(err)
self.failure_count += 1
self.next_retry_at = datetime.now() + self._compute_backoff()
self.last_sync_success = False
def record_success(self):
self.last_error = None
self.failure_count = 0
self.next_retry_at = None
self.last_sync_success = True
def cache_summary(self):
holdings = self.holdings
total_stocks = len(holdings)
total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings)
total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings)
total_profit_loss = sum(float(h.profit_loss or 0) for h in holdings)
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0
self.summary = {
"total_stocks": total_stocks,
"total_value": total_value,
"total_investment": total_investment,
"total_profit_loss": total_profit_loss,
"total_profit_loss_rate": total_profit_loss_rate
}
portfolio_cache = PortfolioCache()
from pydantic import BaseModel
router = APIRouter()
class PortfolioResponse(BaseModel):
id: int
stock_code: str
stock_name: str = None
quantity: int
average_price: Decimal = None
current_price: Decimal = None
profit_loss: Decimal = None
profit_loss_rate: Decimal = None
class Config:
from_attributes = True
@router.get("", response_model=List[PortfolioResponse])
async def get_portfolio(db: AsyncSession = Depends(get_db)):
"""포트폴리오 조회 (캐시 활용)"""
if portfolio_cache.holdings and not portfolio_cache.should_sync():
logger.debug("Portfolio cache hit (holdings)")
return portfolio_cache.holdings
# 필요하면 동기화 시도
try:
await sync_portfolio(db, force=False)
except HTTPException:
# 강제 동기화가 아닌 경우에는 오류를 사용자에게 전파하지 않고 캐시/빈 목록 반환
logger.warning("get_portfolio: sync failed, returning cached holdings")
return portfolio_cache.holdings
@router.get("/sync")
async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True):
"""실제 계좌와 포트폴리오 동기화 (캐시 반영)
force=False이면 캐시 정책에 따라 생략될 수 있음.
"""
if not force and not portfolio_cache.should_sync():
logger.debug("Skip sync: cache fresh & no change flag")
return {"message": "Skipped (cache fresh)", "cached": True}
async with portfolio_cache.sync_lock:
portfolio_cache.last_attempt = datetime.now()
try:
balance_data = await korea_investment_api.get_balance()
portfolio_cache.record_success()
# 기존 레코드 로드 (삭제 대신 업데이트)
result = await db.execute(select(Portfolio))
existing_all = {p.stock_code: p for p in result.scalars().all()}
processed_codes = set()
new_holdings = []
if "output1" in balance_data:
for item in balance_data["output1"]:
stock_code = item.get("pdno")
stock_name = item.get("prdt_name")
quantity = int(item.get("hldg_qty", 0))
if quantity <= 0:
continue
average_price = Decimal(item.get("pchs_avg_pric", 0))
current_price = Decimal(item.get("prpr", 0))
profit_loss = Decimal(item.get("evlu_pfls_amt", 0))
profit_loss_rate = Decimal(item.get("evlu_pfls_rt", 0))
processed_codes.add(stock_code)
if stock_code in existing_all:
h = existing_all[stock_code]
h.stock_name = stock_name
h.quantity = quantity
h.average_price = average_price
h.current_price = current_price
h.profit_loss = profit_loss
h.profit_loss_rate = profit_loss_rate
new_holdings.append(h)
else:
new_holding = Portfolio(
stock_code=stock_code,
stock_name=stock_name,
quantity=quantity,
average_price=average_price,
current_price=current_price,
profit_loss=profit_loss,
profit_loss_rate=profit_loss_rate
)
db.add(new_holding)
new_holdings.append(new_holding)
# 제거된 종목 반영 (수량 0 처리)
for code, h in existing_all.items():
if code not in processed_codes:
h.quantity = 0
await db.commit()
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
portfolio_cache.cache_summary()
portfolio_cache.last_synced = datetime.now()
portfolio_cache.change_flag = False
logger.info(f"Portfolio synced: {len(portfolio_cache.holdings)} holdings")
return {"message": "Portfolio synchronized", "count": len(portfolio_cache.holdings), "cached": False}
except Exception as e:
portfolio_cache.record_failure(e)
logger.error(f"Portfolio sync failed: {e}")
if force:
# 명시적 강제 동기화 요청 시에는 오류 전파
raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}")
# 비강제(백그라운드) 동기화 실패 시에는 캐시/빈 값 사용
return {"message": "Sync failed (using cache)", "cached": True, "error": str(e)}
@router.get("/summary")
async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
"""포트폴리오 요약 (캐시 활용)"""
if portfolio_cache.summary and not portfolio_cache.should_sync():
logger.debug("Portfolio cache hit (summary)")
resp = dict(portfolio_cache.summary)
resp.update({
"sync_status": "ok" if portfolio_cache.last_error is None else "error",
"last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None,
"last_error": portfolio_cache.last_error,
})
return resp
try:
await sync_portfolio(db, force=False)
except HTTPException:
logger.warning("get_portfolio_summary: sync failed, returning cached summary")
base = portfolio_cache.summary or {
"total_stocks": 0,
"total_value": 0,
"total_investment": 0,
"total_profit_loss": 0,
"total_profit_loss_rate": 0
}
resp = dict(base)
resp.update({
"sync_status": "ok" if portfolio_cache.last_error is None else "error",
"last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None,
"last_error": portfolio_cache.last_error,
})
return resp
@router.post("/refresh")
async def refresh_portfolio(db: AsyncSession = Depends(get_db)):
"""강제 캐시 무효화 및 재동기화"""
portfolio_cache.change_flag = True
return await sync_portfolio(db, force=True)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List, Optional
from datetime import datetime, timedelta
from decimal import Decimal
from app.core.database import get_db
from app.models.models import TradeHistory
from pydantic import BaseModel
router = APIRouter()
class TradeHistoryResponse(BaseModel):
id: int
rule_id: Optional[int]
stock_code: str
stock_name: Optional[str]
order_type: str
order_price: Optional[Decimal]
order_quantity: int
executed_price: Optional[Decimal]
executed_quantity: Optional[int]
status: str
order_id: Optional[str]
message: Optional[str]
created_at: datetime
executed_at: Optional[datetime]
class Config:
from_attributes = True
@router.get("", response_model=List[TradeHistoryResponse])
async def get_trade_history(
skip: int = 0,
limit: int = 100,
stock_code: Optional[str] = None,
status: Optional[str] = None,
order_type: Optional[str] = None,
days: int = 30,
db: AsyncSession = Depends(get_db)
):
"""거래 이력 조회"""
query = select(TradeHistory)
# 날짜 필터
since_date = datetime.utcnow() - timedelta(days=days)
query = query.where(TradeHistory.created_at >= since_date)
# 종목 코드 필터
if stock_code:
query = query.where(TradeHistory.stock_code == stock_code)
# 상태 필터
if status:
query = query.where(TradeHistory.status == status)
# 주문 유형 필터
if order_type:
query = query.where(TradeHistory.order_type == order_type)
query = query.order_by(TradeHistory.created_at.desc())
query = query.offset(skip).limit(limit)
result = await db.execute(query)
trades = result.scalars().all()
return trades
@router.get("/{trade_id}", response_model=TradeHistoryResponse)
async def get_trade(trade_id: int, db: AsyncSession = Depends(get_db)):
"""특정 거래 조회"""
result = await db.execute(select(TradeHistory).where(TradeHistory.id == trade_id))
trade = result.scalar_one_or_none()
if not trade:
raise HTTPException(status_code=404, detail="Trade not found")
return trade
@router.get("/stats/summary")
async def get_trade_stats(
days: int = 30,
db: AsyncSession = Depends(get_db)
):
"""거래 통계 조회"""
since_date = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(TradeHistory).where(TradeHistory.created_at >= since_date)
)
trades = result.scalars().all()
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"])
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"
)
return {
"period_days": days,
"total_trades": total_trades,
"buy_trades": buy_trades,
"sell_trades": sell_trades,
"executed_trades": executed_trades,
"failed_trades": failed_trades,
"success_rate": executed_trades / total_trades if total_trades > 0 else 0,
"total_amount": total_amount
}

View File

@@ -0,0 +1,143 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from typing import List
from datetime import datetime
from app.core.database import get_db
from app.models.models import TradingRule
from pydantic import BaseModel
router = APIRouter()
class TradingRuleCreate(BaseModel):
name: str
description: str = None
rule_type: str
conditions: dict
actions: dict
is_active: bool = True
priority: int = 0
class TradingRuleUpdate(BaseModel):
name: str = None
description: str = None
rule_type: str = None
conditions: dict = None
actions: dict = None
is_active: bool = None
priority: int = None
class TradingRuleResponse(BaseModel):
id: int
name: str
description: str = None
rule_type: str
conditions: dict
actions: dict
is_active: bool
priority: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
@router.get("", response_model=List[TradingRuleResponse])
async def get_trading_rules(
skip: int = 0,
limit: int = 100,
is_active: bool = None,
db: AsyncSession = Depends(get_db)
):
"""트레이딩 규칙 목록 조회"""
query = select(TradingRule)
if is_active is not None:
query = query.where(TradingRule.is_active == is_active)
query = query.order_by(TradingRule.priority.desc(), TradingRule.created_at.desc())
query = query.offset(skip).limit(limit)
result = await db.execute(query)
rules = result.scalars().all()
return rules
@router.get("/{rule_id}", response_model=TradingRuleResponse)
async def get_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
"""특정 트레이딩 규칙 조회"""
result = await db.execute(select(TradingRule).where(TradingRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="Trading rule not found")
return rule
@router.post("", response_model=TradingRuleResponse)
async def create_trading_rule(rule: TradingRuleCreate, db: AsyncSession = Depends(get_db)):
"""새 트레이딩 규칙 생성"""
db_rule = TradingRule(**rule.model_dump())
db.add(db_rule)
await db.commit()
await db.refresh(db_rule)
return db_rule
@router.put("/{rule_id}", response_model=TradingRuleResponse)
async def update_trading_rule(
rule_id: int,
rule: TradingRuleUpdate,
db: AsyncSession = Depends(get_db)
):
"""트레이딩 규칙 수정"""
result = await db.execute(select(TradingRule).where(TradingRule.id == rule_id))
db_rule = result.scalar_one_or_none()
if not db_rule:
raise HTTPException(status_code=404, detail="Trading rule not found")
update_data = rule.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_rule, field, value)
db_rule.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_rule)
return db_rule
@router.delete("/{rule_id}")
async def delete_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
"""트레이딩 규칙 삭제"""
result = await db.execute(select(TradingRule).where(TradingRule.id == rule_id))
db_rule = result.scalar_one_or_none()
if not db_rule:
raise HTTPException(status_code=404, detail="Trading rule not found")
await db.delete(db_rule)
await db.commit()
return {"message": "Trading rule deleted successfully"}
@router.post("/{rule_id}/toggle")
async def toggle_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
"""트레이딩 규칙 활성화/비활성화 토글"""
result = await db.execute(select(TradingRule).where(TradingRule.id == rule_id))
db_rule = result.scalar_one_or_none()
if not db_rule:
raise HTTPException(status_code=404, detail="Trading rule not found")
db_rule.is_active = not db_rule.is_active
db_rule.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_rule)
return db_rule

View File

@@ -0,0 +1,97 @@
import os
from pathlib import Path
from pydantic_settings import BaseSettings
from functools import lru_cache
# 프로젝트 루트 디렉토리 (backend의 상위 디렉토리)
BASE_DIR = Path(__file__).resolve().parent.parent.parent
BACKEND_ENV_FILE = BASE_DIR / ".env"
class Settings(BaseSettings):
# Application
APP_NAME: str = "Auto Stock Trading System"
ENVIRONMENT: str = "development"
DEBUG: bool = True
SECRET_KEY: str = "change-this-secret-key"
# Server
BACKEND_HOST: str = "0.0.0.0"
BACKEND_PORT: int = 8000
# Database
DATABASE_URL: str = "postgresql://autostock:autostock_password@localhost:5432/autostock_db"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
# RabbitMQ
RABBITMQ_URL: str = "amqp://autostock:autostock_password@localhost:5672/"
# Korea Investment API
KOREA_INVESTMENT_APP_KEY: str = ""
KOREA_INVESTMENT_APP_SECRET: str = ""
KOREA_INVESTMENT_ACCOUNT_NUMBER: str = ""
KOREA_INVESTMENT_API_BASE_URL: str = "https://openapi.koreainvestment.com:9443"
# Alias for backward compatibility with .env file
KIS_APP_KEY: str = ""
KIS_APP_SECRET: str = ""
KIS_ACCOUNT_NO: str = ""
KIS_BASE_URL: str = ""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Use KIS_* values if KOREA_INVESTMENT_* are empty
if not self.KOREA_INVESTMENT_APP_KEY and self.KIS_APP_KEY:
self.KOREA_INVESTMENT_APP_KEY = self.KIS_APP_KEY
if not self.KOREA_INVESTMENT_APP_SECRET and self.KIS_APP_SECRET:
self.KOREA_INVESTMENT_APP_SECRET = self.KIS_APP_SECRET
if not self.KOREA_INVESTMENT_ACCOUNT_NUMBER and self.KIS_ACCOUNT_NO:
self.KOREA_INVESTMENT_ACCOUNT_NUMBER = self.KIS_ACCOUNT_NO
if self.KIS_BASE_URL:
self.KOREA_INVESTMENT_API_BASE_URL = self.KIS_BASE_URL
# Claude AI
ANTHROPIC_API_KEY: str = ""
# MCP Server
MCP_SERVER_URL: str = "http://localhost:8001"
# News API
NEWS_API_KEY: str = ""
# Logging
LOG_LEVEL: str = "INFO"
# Trading Settings
MAX_POSITION_SIZE: float = 0.1 # 10% of portfolio
MAX_DAILY_TRADES: int = 10
STOP_LOSS_PERCENT: float = -5.0
TAKE_PROFIT_PERCENT: float = 15.0
# API Rate Limiting & Retry Settings (모의투자 API 안전 운영)
API_MAX_RETRIES: int = 3 # 최대 재시도 횟수
API_RETRY_DELAY: float = 1.5 # 초기 재시도 대기 시간 (초)
API_RETRY_BACKOFF: float = 2.0 # 지수 백오프 배율
API_MIN_REQUEST_INTERVAL: float = 0.5 # 최소 요청 간격 (초) - 초당 2회 권장
API_MAX_CONCURRENT_REQUESTS: int = 2 # 동시 최대 요청 수
API_TIMEOUT: float = 30.0 # API 타임아웃 (초)
# Monitoring & Alerting
API_ERROR_THRESHOLD: float = 0.3 # 오류율 임계값 (30%)
API_MONITORING_WINDOW: int = 100 # 모니터링 윈도우 (최근 N개 요청)
class Config:
env_file = str(BACKEND_ENV_FILE) if BACKEND_ENV_FILE.exists() else ".env"
case_sensitive = True
extra = "ignore" # .env 파일의 추가 필드 무시
env_file_encoding = "utf-8"
@lru_cache()
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

@@ -0,0 +1,38 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# PostgreSQL 연결 URL 변환 (asyncpg 사용)
DATABASE_URL = settings.DATABASE_URL
if DATABASE_URL.startswith("postgresql://"):
DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(
DATABASE_URL,
echo=settings.DEBUG,
future=True,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
connect_args={
"timeout": 60,
"command_timeout": 60,
}
)
AsyncSessionLocal = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()

124
backend/app/main.py Normal file
View File

@@ -0,0 +1,124 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import logging
import time
from app.core.config import settings
from app.api import api_router
from app.services.websocket_manager import websocket_manager
from app.core.database import engine, Base
from app.models import models # 모델 임포트
# 로깅 설정
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""애플리케이션 시작/종료 시 실행될 코드"""
logger.info("Starting Auto Stock Trading System...")
# DB 테이블 자동 생성
try:
logger.info(f"Connecting to database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'unknown'}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("✅ Database tables created successfully")
except ConnectionRefusedError as e:
logger.error(f"❌ Database connection refused: {e}")
logger.error("Please check if PostgreSQL server is running and accessible")
raise
except Exception as e:
logger.error(f"❌ Failed to create database tables: {e}")
import traceback
logger.error(traceback.format_exc())
raise
yield
# 종료 시 정리 작업
logger.info("Shutting down Auto Stock Trading System...")
await engine.dispose()
app = FastAPI(
title=settings.APP_NAME,
description="Claude AI와 MCP를 활용한 자동 주식 매매 시스템",
version="1.0.0",
lifespan=lifespan,
debug=settings.DEBUG,
)
# CORS 설정 (반드시 라우터 등록 전에)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
logger.info("CORS middleware configured")
# 요청 로깅 미들웨어
@app.middleware("http")
async def log_requests(request: Request, call_next):
start_time = time.time()
logger.info(f"🔵 Request: {request.method} {request.url}")
response = await call_next(request)
process_time = time.time() - start_time
logger.info(f"🟢 Response: {request.method} {request.url} - Status: {response.status_code} - Time: {process_time:.3f}s")
return response
# API 라우터 등록
app.include_router(api_router, prefix="/api/v1")
@app.get("/")
async def root():
return {
"message": "Auto Stock Trading System API",
"version": "1.0.0",
"status": "running"
}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.websocket("/ws/{client_id}")
async def websocket_endpoint(websocket: WebSocket, client_id: str):
"""WebSocket 연결 엔드포인트"""
await websocket_manager.connect(websocket, client_id)
try:
while True:
data = await websocket.receive_text()
# 메시지 처리
await websocket_manager.send_personal_message(
f"Echo: {data}", client_id
)
except WebSocketDisconnect:
websocket_manager.disconnect(client_id)
logger.info(f"Client {client_id} disconnected")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.BACKEND_HOST,
port=settings.BACKEND_PORT,
reload=settings.DEBUG,
)

View File

@@ -0,0 +1,141 @@
from datetime import datetime
from typing import Optional, List
from sqlalchemy import Column, Integer, String, Numeric, Boolean, DateTime, Text, ARRAY
from sqlalchemy.dialects.postgresql import JSONB
from app.core.database import Base
class TradingRule(Base):
__tablename__ = "trading_rules"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text)
rule_type = Column(String(50), nullable=False)
conditions = Column(JSONB, nullable=False)
actions = Column(JSONB, nullable=False)
is_active = Column(Boolean, default=True)
priority = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class TradeHistory(Base):
__tablename__ = "trade_history"
id = Column(Integer, primary_key=True, index=True)
rule_id = Column(Integer)
stock_code = Column(String(20), nullable=False, index=True)
stock_name = Column(String(100))
order_type = Column(String(20), nullable=False)
order_price = Column(Numeric(15, 2))
order_quantity = Column(Integer, nullable=False)
executed_price = Column(Numeric(15, 2))
executed_quantity = Column(Integer)
status = Column(String(50), nullable=False)
order_id = Column(String(100))
message = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
executed_at = Column(DateTime)
class Portfolio(Base):
__tablename__ = "portfolio"
id = Column(Integer, primary_key=True, index=True)
stock_code = Column(String(20), nullable=False, unique=True)
stock_name = Column(String(100))
quantity = Column(Integer, nullable=False, default=0)
average_price = Column(Numeric(15, 2))
current_price = Column(Numeric(15, 2))
profit_loss = Column(Numeric(15, 2))
profit_loss_rate = Column(Numeric(10, 4))
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class MarketData(Base):
__tablename__ = "market_data"
id = Column(Integer, primary_key=True, index=True)
stock_code = Column(String(20), nullable=False, index=True)
price = Column(Numeric(15, 2), nullable=False)
change_rate = Column(Numeric(10, 4))
volume = Column(Integer)
high_price = Column(Numeric(15, 2))
low_price = Column(Numeric(15, 2))
open_price = Column(Numeric(15, 2))
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
class News(Base):
__tablename__ = "news"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(500), nullable=False)
content = Column(Text)
source = Column(String(100))
url = Column(Text)
stock_codes = Column(ARRAY(String))
sentiment = Column(String(20))
published_at = Column(DateTime, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
class ClaudeAnalysisLog(Base):
__tablename__ = "claude_analysis_log"
id = Column(Integer, primary_key=True, index=True)
rule_id = Column(Integer)
stock_code = Column(String(20))
analysis_type = Column(String(50))
input_data = Column(JSONB)
analysis_result = Column(JSONB)
decision = Column(String(50))
confidence = Column(Numeric(5, 4))
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class MCPToolCall(Base):
__tablename__ = "mcp_tool_calls"
id = Column(Integer, primary_key=True, index=True)
tool_name = Column(String(100), nullable=False)
parameters = Column(JSONB)
result = Column(JSONB)
status = Column(String(50))
execution_time_ms = Column(Integer)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class StockAnalysis(Base):
"""매일 배치로 저장되는 종목 분석 데이터"""
__tablename__ = "stock_analysis"
id = Column(Integer, primary_key=True, index=True)
stock_code = Column(String(20), nullable=False, index=True)
stock_name = Column(String(100), nullable=False)
analysis_date = Column(DateTime, nullable=False, index=True)
# 가격 정보
current_price = Column(Integer, nullable=False)
change_rate = Column(Numeric(10, 4))
volume = Column(Integer)
# 기술적 지표
rsi = Column(Numeric(10, 4))
ma5 = Column(Numeric(15, 2))
ma20 = Column(Numeric(15, 2))
ma60 = Column(Numeric(15, 2))
stochastic_k = Column(Numeric(10, 4))
stochastic_d = Column(Numeric(10, 4))
bollinger_upper = Column(Numeric(15, 2))
bollinger_lower = Column(Numeric(15, 2))
bollinger_middle = Column(Numeric(15, 2))
# 분석 결과
signals = Column(JSONB) # ["RSI 과매도", "골든크로스" 등]
score = Column(Integer, default=0)
# 메타데이터
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -0,0 +1,456 @@
import httpx
from typing import Dict, Any, Optional, Deque
from datetime import datetime
import logging
import asyncio
import time
from collections import deque
from app.core.config import settings
logger = logging.getLogger(__name__)
class APICallMonitor:
"""API 호출 모니터링 및 통계"""
def __init__(self, window_size: int = 100):
self.window_size = window_size
self.call_history: Deque[Dict[str, Any]] = deque(maxlen=window_size)
self.total_calls = 0
self.total_errors = 0
self.total_retries = 0
def record_call(self, success: bool, duration: float, retries: int = 0):
"""API 호출 기록"""
self.total_calls += 1
if not success:
self.total_errors += 1
self.total_retries += retries
self.call_history.append({
"timestamp": time.time(),
"success": success,
"duration": duration,
"retries": retries
})
def get_error_rate(self) -> float:
"""최근 윈도우 내 오류율 계산"""
if not self.call_history:
return 0.0
errors = sum(1 for call in self.call_history if not call["success"])
return errors / len(self.call_history)
def get_avg_duration(self) -> float:
"""평균 응답 시간"""
if not self.call_history:
return 0.0
return sum(call["duration"] for call in self.call_history) / len(self.call_history)
def get_stats(self) -> Dict[str, Any]:
"""통계 정보 반환"""
return {
"total_calls": self.total_calls,
"total_errors": self.total_errors,
"total_retries": self.total_retries,
"error_rate": self.get_error_rate(),
"avg_duration": self.get_avg_duration(),
"recent_calls": len(self.call_history)
}
def should_alert(self, threshold: float = 0.3) -> bool:
"""오류율 임계값 초과 여부"""
return self.get_error_rate() > threshold
class KoreaInvestmentAPI:
"""한국투자증권 API 클라이언트 (안전한 재시도 및 쓰로틀링 적용)"""
def __init__(self):
self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL
self.app_key = settings.KOREA_INVESTMENT_APP_KEY
self.app_secret = settings.KOREA_INVESTMENT_APP_SECRET
self.account_number = settings.KOREA_INVESTMENT_ACCOUNT_NUMBER
self.access_token: Optional[str] = None
self.token_expires_at: Optional[datetime] = None
self.last_request_time: Optional[float] = None
# 설정 기반 쓰로틀링 파라미터
self.min_request_interval = settings.API_MIN_REQUEST_INTERVAL # 기본 0.5초 (초당 2회)
self.max_retries = settings.API_MAX_RETRIES # 기본 3회
self.retry_delay = settings.API_RETRY_DELAY # 기본 1.5초
self.retry_backoff = settings.API_RETRY_BACKOFF # 기본 2.0배
self.timeout = settings.API_TIMEOUT # 기본 30초
# 동시 요청 제한 (세마포어)
self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
# 모니터링
self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
async def _get_access_token(self) -> str:
"""접근 토큰 발급"""
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
return self.access_token
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/oauth2/tokenP",
json={
"grant_type": "client_credentials",
"appkey": self.app_key,
"appsecret": self.app_secret
}
)
if response.status_code == 200:
data = response.json()
self.access_token = data["access_token"]
# 토큰 만료 시간 설정 (일반적으로 24시간)
from datetime import timedelta
self.token_expires_at = datetime.now() + timedelta(hours=23)
return self.access_token
else:
raise Exception(f"Failed to get access token: {response.text}")
async def _make_request(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]:
"""API 요청 실행 (안전한 재시도 + 쓰로틀링 + 모니터링)"""
# 동시 요청 수 제한 (세마포어)
async with self.semaphore:
return await self._make_request_with_retry(method, endpoint, tr_id, **kwargs)
async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]:
"""재시도 로직을 포함한 실제 요청 실행"""
start_time = time.time()
retry_count = 0
last_exception = None
for attempt in range(self.max_retries + 1):
try:
# 쓰로틀링: 최소 요청 간격 보장
if self.last_request_time:
elapsed = time.time() - self.last_request_time
if elapsed < self.min_request_interval:
await asyncio.sleep(self.min_request_interval - elapsed)
self.last_request_time = time.time()
# 토큰 갱신
token = await self._get_access_token()
headers = {
"content-type": "application/json; charset=utf-8",
"authorization": f"Bearer {token}",
"appkey": self.app_key,
"appsecret": self.app_secret,
"tr_id": tr_id,
}
# 타임아웃 설정
timeout = httpx.Timeout(self.timeout, connect=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.request(
method,
f"{self.base_url}{endpoint}",
headers=headers,
**kwargs
)
# 성공
if response.status_code == 200:
duration = time.time() - start_time
self.monitor.record_call(success=True, duration=duration, retries=retry_count)
# 오류율 경고
if self.monitor.should_alert(threshold=settings.API_ERROR_THRESHOLD):
logger.warning(
f"⚠️ API 오류율 높음: {self.monitor.get_error_rate():.1%} "
f"(임계값: {settings.API_ERROR_THRESHOLD:.1%})"
)
return response.json()
# API 호출 제한 초과 (429) 또는 서버 오류 (5xx)
elif response.status_code in [429, 500, 502, 503, 504]:
retry_count += 1
last_exception = Exception(
f"API rate limit or server error: {response.status_code} - {response.text}"
)
if attempt < self.max_retries:
# 지수 백오프 적용
delay = self.retry_delay * (self.retry_backoff ** attempt)
logger.warning(
f"⏳ API 재시도 {attempt + 1}/{self.max_retries}: "
f"상태코드 {response.status_code}, {delay:.1f}초 후 재시도"
)
await asyncio.sleep(delay)
continue
else:
logger.error(f"❌ API 최대 재시도 횟수 초과: {response.status_code}")
raise last_exception
# 인증 오류 또는 잘못된 요청 (4xx) - 재시도 불필요
elif 400 <= response.status_code < 500:
duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
error_msg = f"API 클라이언트 오류 (재시도 안함): {response.status_code} - {response.text}"
logger.error(f"❌ {error_msg}")
raise Exception(error_msg)
# 기타 오류
else:
duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
error_msg = f"API 요청 실패: {response.status_code} - {response.text}"
logger.error(f"❌ {error_msg}")
raise Exception(error_msg)
except httpx.TimeoutException as e:
retry_count += 1
last_exception = e
if attempt < self.max_retries:
delay = self.retry_delay * (self.retry_backoff ** attempt)
logger.warning(f"⏳ API 타임아웃, {delay:.1f}초 후 재시도 ({attempt + 1}/{self.max_retries})")
await asyncio.sleep(delay)
continue
else:
duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
logger.error(f"❌ API 타임아웃 최대 재시도 초과")
raise
except Exception as e:
# 예상치 못한 오류
duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
logger.error(f"❌ API 요청 중 예외 발생: {str(e)}")
raise
# 여기까지 왔다면 모든 재시도 실패
duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
if last_exception:
raise last_exception
raise Exception("API 요청 실패 (알 수 없는 오류)")
async def get_current_price(self, stock_code: str) -> Dict[str, Any]:
"""현재가 조회"""
try:
result = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-price",
"FHKST01010100",
params={
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code
}
)
return result
except Exception as e:
logger.error(f"Error fetching price for {stock_code}: {e}")
raise
async def get_daily_chart(self, stock_code: str, period: str = "D") -> Dict[str, Any]:
"""일봉/주봉/월봉 조회
KIS API는 기본적으로 최근 30~100개 데이터를 반환
더 많은 데이터가 필요하면 반복 호출 필요 (페이지네이션 없음)
"""
try:
result = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
"FHKST01010400",
params={
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
"FID_PERIOD_DIV_CODE": period,
"FID_ORG_ADJ_PRC": "0" # 0: 수정주가, 1: 원주가
}
)
return result
except Exception as e:
logger.error(f"Error fetching chart for {stock_code}: {e}")
raise
async def get_balance(self) -> Dict[str, Any]:
"""계좌 잔고 조회"""
try:
account_number_prefix = self.account_number[:8]
account_number_suffix = self.account_number[8:]
result = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/trading/inquire-balance",
"TTTC8434R",
params={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
"AFHR_FLPR_YN": "N",
"OFL_YN": "",
"INQR_DVSN": "02",
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"FNCG_AMT_AUTO_RDPT_YN": "N",
"PRCS_DVSN": "01",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": ""
}
)
return result
except Exception as e:
logger.error(f"Error fetching balance: {e}")
raise
async def place_order(
self,
stock_code: str,
order_type: str, # "BUY" or "SELL"
quantity: int,
price: Optional[float] = None # None for market order
) -> Dict[str, Any]:
"""주문 실행"""
try:
account_number_prefix = self.account_number[:8]
account_number_suffix = self.account_number[8:]
# 매수/매도 구분
if order_type == "BUY":
tr_id = "TTTC0802U" # 현금 매수
else:
tr_id = "TTTC0801U" # 현금 매도
# 주문 유형 (00: 지정가, 01: 시장가)
order_dvsn = "01" if price is None else "00"
order_price = "0" if price is None else str(int(price))
result = await self._make_request(
"POST",
"/uapi/domestic-stock/v1/trading/order-cash",
tr_id,
json={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
"PDNO": stock_code,
"ORD_DVSN": order_dvsn,
"ORD_QTY": str(quantity),
"ORD_UNPR": order_price
}
)
return result
except Exception as e:
logger.error(f"Error placing order: {e}")
raise
async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]:
"""주문 취소"""
try:
account_number_prefix = self.account_number[:8]
account_number_suffix = self.account_number[8:]
result = await self._make_request(
"POST",
"/uapi/domestic-stock/v1/trading/order-rvsecncl",
"TTTC0803U",
json={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
"KRX_FWDG_ORD_ORGNO": "",
"ORGN_ODNO": order_id,
"ORD_DVSN": "00",
"RVSE_CNCL_DVSN_CD": "02", # 취소
"ORD_QTY": "0",
"ORD_UNPR": "0",
"QTY_ALL_ORD_YN": "Y"
}
)
return result
except Exception as e:
logger.error(f"Error canceling order: {e}")
raise
async def search_stocks_by_condition(
self,
market: str = "J", # J: 코스피, Q: 코스닥
condition_name: str = "",
max_results: int = 100
) -> Dict[str, Any]:
"""조건 검색으로 종목 조회"""
try:
result = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-search",
"HHKST03900300",
params={
"FID_COND_MRKT_DIV_CODE": market,
"FID_COND_SCR_DIV_CODE": "20171", # 조건검색
"FID_INPUT_ISCD": "0000",
"FID_DIV_CLS_CODE": "0",
"FID_BLNG_CLS_CODE": "0",
"FID_TRGT_CLS_CODE": "111111111",
"FID_TRGT_EXLS_CLS_CODE": "000000",
"FID_INPUT_PRICE_1": "",
"FID_INPUT_PRICE_2": "",
"FID_VOL_CNT": "",
"FID_INPUT_DATE_1": ""
}
)
return result
except Exception as e:
logger.error(f"Error searching stocks: {e}")
raise
async def get_stock_list(self, market: str = "J") -> Dict[str, Any]:
"""전체 종목 리스트 조회"""
try:
# 거래대금 상위 종목 조회
result = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/volume-rank",
"FHPST01710000",
params={
"FID_COND_MRKT_DIV_CODE": market,
"FID_COND_SCR_DIV_CODE": "20171",
"FID_INPUT_ISCD": "0000",
"FID_DIV_CLS_CODE": "0",
"FID_BLNG_CLS_CODE": "0",
"FID_TRGT_CLS_CODE": "111111111",
"FID_TRGT_EXLS_CLS_CODE": "000000",
"FID_INPUT_PRICE_1": "",
"FID_INPUT_PRICE_2": "",
"FID_VOL_CNT": "",
"FID_INPUT_DATE_1": ""
}
)
return result
except Exception as e:
logger.error(f"Error getting stock list: {e}")
raise
async def get_volume_rank(self, market: str = "J") -> Dict[str, Any]:
"""거래량 순위 조회 (get_stock_list와 동일하지만 명시적 이름)"""
return await self.get_stock_list(market)
def get_monitor_stats(self) -> Dict[str, Any]:
"""API 호출 모니터링 통계 반환"""
return self.monitor.get_stats()
def log_monitor_stats(self):
"""모니터링 통계 로깅"""
stats = self.get_monitor_stats()
logger.info(
f"📊 API 통계: 총 {stats['total_calls']}건, "
f"오류 {stats['total_errors']}건 ({stats['error_rate']:.1%}), "
f"재시도 {stats['total_retries']}건, "
f"평균 응답 {stats['avg_duration']:.2f}초"
)
# 싱글톤 인스턴스
korea_investment_api = KoreaInvestmentAPI()

View File

@@ -0,0 +1,79 @@
from typing import Dict, List
from fastapi import WebSocket
import json
import logging
logger = logging.getLogger(__name__)
class WebSocketManager:
def __init__(self):
self.active_connections: Dict[str, WebSocket] = {}
async def connect(self, websocket: WebSocket, client_id: str):
"""클라이언트 연결"""
await websocket.accept()
self.active_connections[client_id] = websocket
logger.info(f"Client {client_id} connected. Total connections: {len(self.active_connections)}")
def disconnect(self, client_id: str):
"""클라이언트 연결 해제"""
if client_id in self.active_connections:
del self.active_connections[client_id]
logger.info(f"Client {client_id} disconnected. Total connections: {len(self.active_connections)}")
async def send_personal_message(self, message: str, client_id: str):
"""특정 클라이언트에게 메시지 전송"""
if client_id in self.active_connections:
try:
await self.active_connections[client_id].send_text(message)
except Exception as e:
logger.error(f"Error sending message to {client_id}: {e}")
self.disconnect(client_id)
async def broadcast(self, message: str):
"""모든 연결된 클라이언트에게 메시지 브로드캐스트"""
disconnected_clients = []
for client_id, connection in self.active_connections.items():
try:
await connection.send_text(message)
except Exception as e:
logger.error(f"Error broadcasting to {client_id}: {e}")
disconnected_clients.append(client_id)
# 연결 실패한 클라이언트 제거
for client_id in disconnected_clients:
self.disconnect(client_id)
async def broadcast_json(self, data: dict):
"""JSON 데이터를 모든 클라이언트에게 브로드캐스트"""
message = json.dumps(data)
await self.broadcast(message)
async def send_market_update(self, stock_code: str, data: dict):
"""시장 데이터 업데이트 전송"""
message = {
"type": "market_update",
"stock_code": stock_code,
"data": data
}
await self.broadcast_json(message)
async def send_trade_notification(self, trade_data: dict):
"""거래 알림 전송"""
message = {
"type": "trade_notification",
"data": trade_data
}
await self.broadcast_json(message)
async def send_mcp_status(self, status: dict):
"""MCP 상태 업데이트 전송"""
message = {
"type": "mcp_status",
"data": status
}
await self.broadcast_json(message)
websocket_manager = WebSocketManager()