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

57
backend/.env.example Normal file
View File

@@ -0,0 +1,57 @@
# Database Configuration
DATABASE_URL=postgresql://user:password@localhost:5432/auto_stock
DB_POOL_SIZE=10
# Redis Configuration
REDIS_URL=redis://localhost:6379/0
# RabbitMQ Configuration
RABBITMQ_URL=amqp://guest:guest@localhost:5672/
# Korea Investment Securities API - Mode Selection
KIS_MODE=virtual # virtual (모의투자) 또는 real (실전투자)
# 모의투자 (Virtual Trading)
KIS_VIRTUAL_APP_KEY=your_virtual_app_key_here
KIS_VIRTUAL_APP_SECRET=your_virtual_app_secret_here
KIS_VIRTUAL_ACCOUNT_NO=your_virtual_account_number_here
KIS_VIRTUAL_URL=https://openapivts.koreainvestment.com:29443
# 실전투자 (Real Trading)
KIS_REAL_APP_KEY=your_real_app_key_here
KIS_REAL_APP_SECRET=your_real_app_secret_here
KIS_REAL_ACCOUNT_NO=your_real_account_number_here
KIS_REAL_URL=https://openapi.koreainvestment.com:9443
# 현재 활성 설정 (KIS_MODE에 따라 자동 선택됨)
KIS_APP_KEY=your_virtual_app_key_here
KIS_APP_SECRET=your_virtual_app_secret_here
KIS_ACCOUNT_NO=your_virtual_account_number_here
KIS_BASE_URL=https://openapivts.koreainvestment.com:29443
# MCP Server Configuration
MCP_SERVER_URL=http://localhost:8001
MCP_API_KEY=your_mcp_api_key_here
# Claude AI Configuration (for MCP Server)
ANTHROPIC_API_KEY=your_anthropic_api_key_here
CLAUDE_MODEL=claude-3-5-sonnet-20241022
CLAUDE_MAX_TOKENS=4096
# Server Configuration
SERVER_HOST=0.0.0.0
SERVER_PORT=8000
DEBUG=false
LOG_LEVEL=info
# Security
SECRET_KEY=your_secret_key_here_change_in_production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# WebSocket
WS_HEARTBEAT_INTERVAL=30
WS_MESSAGE_QUEUE_SIZE=1000

22
backend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
# 시스템 의존성 설치
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Python 의존성 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드 복사
COPY . .
# 포트 노출
EXPOSE 8000
# 애플리케이션 실행
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

42
backend/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Backend
## Batch daily analysis (DB cache)
Precompute technical indicators each morning and cache results for fast recommendations.
### Run once (today)
```zsh
cd /Users/commylife/myproject/java/workspace/auto-stock/backend
python3 batch_daily_analysis.py --test # quick: ~10 stocks
# or full run
python3 batch_daily_analysis.py
```
### Schedule (cron on macOS)
```zsh
crontab -e
# weekdays 08:30 run (adjust path)
30 8 * * 1-5 cd /Users/commylife/myproject/java/workspace/auto-stock/backend && /usr/bin/python3 batch_daily_analysis.py >> ../logs/batch.log 2>&1
```
### Use DB cache in API
- Endpoint: `/api/v1/recommendations/rule-based`
- Default: uses DB cache; if cache empty, it automatically falls back to realtime analysis.
- Force realtime:
```text
/api/v1/recommendations/rule-based?realtime=true
```
### Monitoring
- API safety stats: `/api/v1/monitoring/api-stats`
- Health: `/api/v1/monitoring/health`
### Troubleshooting
- No recommendations: ensure `stock_analysis` has rows for today (run batch).
- Slow responses: rely on DB cache or reduce realtime stock count via query params.

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()

View File

@@ -0,0 +1,238 @@
"""
매일 아침 장 시작 전(9시) 실행되는 배치 분석 스크립트
전체 거래 종목을 분석하여 DB에 저장
- 차트 데이터 조회
- 기술적 지표 계산
- 매매 신호 생성
- DB 저장
실행: python batch_daily_analysis.py
Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py
"""
import asyncio
import sys
from datetime import datetime, date
from pathlib import Path
# 프로젝트 루트를 Python 경로에 추가
sys.path.insert(0, str(Path(__file__).parent))
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
from app.models.models import StockAnalysis
from app.services.korea_investment_api import KoreaInvestmentAPI
from app.api.endpoints.recommendations import analyze_stock_with_indicators
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# 데이터베이스 연결
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_size=10,
max_overflow=20
)
AsyncSessionLocal = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_all_tradable_stocks() -> list[tuple[str, str]]:
"""거래 가능한 전체 종목 조회 (안전한 재시도 적용)"""
kis_api = KoreaInvestmentAPI()
logger.info("Fetching all tradable stocks from KIS API...")
stocks = []
# KOSPI 상위 종목 조회
try:
# 재시도 로직이 내장되어 있으므로 sleep 제거 (쓰로틀링은 자동 적용)
kospi_result = await kis_api.get_volume_rank("J")
if kospi_result and kospi_result.get("output"):
for item in kospi_result["output"]:
stocks.append((
item["mksc_shrn_iscd"],
item["hts_kor_isnm"]
))
logger.info(f"✅ Found {len(stocks)} KOSPI stocks")
except Exception as e:
logger.error(f"❌ Failed to fetch KOSPI stocks: {e}")
# KOSDAQ 상위 종목 조회
try:
kosdaq_result = await kis_api.get_volume_rank("Q")
if kosdaq_result and kosdaq_result.get("output"):
kospi_count = len(stocks)
for item in kosdaq_result["output"]:
stocks.append((
item["mksc_shrn_iscd"],
item["hts_kor_isnm"]
))
logger.info(f"✅ Found {len(stocks) - kospi_count} KOSDAQ stocks")
except Exception as e:
logger.error(f"❌ Failed to fetch KOSDAQ stocks: {e}")
logger.info(f"📊 Total tradable stocks: {len(stocks)}")
return stocks
async def analyze_and_save_stock(
session: AsyncSession,
stock_code: str,
stock_name: str,
analysis_date: datetime
) -> bool:
"""종목 분석 후 DB 저장"""
try:
# 종목 분석 (기존 함수 재사용)
result = await analyze_stock_with_indicators(stock_code, stock_name)
if not result:
logger.warning(f"No analysis result for {stock_code}")
return False
# DB에 저장
analysis = StockAnalysis(
stock_code=stock_code,
stock_name=stock_name,
analysis_date=analysis_date,
current_price=result.current_price,
change_rate=result.change_rate,
volume=result.volume,
rsi=result.rsi,
ma5=result.ma5,
ma20=result.ma20,
ma60=result.ma60,
stochastic_k=result.stochastic_k,
stochastic_d=result.stochastic_d,
bollinger_upper=result.bollinger_upper,
bollinger_lower=result.bollinger_lower,
bollinger_middle=result.bollinger_middle,
signals=result.signals,
score=result.score
)
session.add(analysis)
await session.commit()
logger.info(f"✅ Saved analysis for {stock_code} ({stock_name}): score={result.score}")
return True
except Exception as e:
logger.error(f"❌ Failed to analyze {stock_code}: {e}")
await session.rollback()
return False
async def batch_analyze_all_stocks(limit: int = None):
"""전체 종목 배치 분석 (안전한 재시도 및 쓰로틀링 적용)"""
start_time = datetime.now()
analysis_date = date.today()
logger.info(f"🚀 Starting daily batch analysis for {analysis_date}")
logger.info(f"⚙️ Rate Limiting: {settings.API_MIN_REQUEST_INTERVAL}초 간격, 최대 재시도 {settings.API_MAX_RETRIES}회")
# KIS API 인스턴스
kis_api = KoreaInvestmentAPI()
async with AsyncSessionLocal() as session:
# 1. 오늘 날짜 기존 데이터 삭제
logger.info("🗑️ Deleting old analysis data...")
await session.execute(
delete(StockAnalysis).where(
StockAnalysis.analysis_date >= analysis_date
)
)
await session.commit()
logger.info("✅ Old data deleted")
# 2. 전체 종목 조회
all_stocks = await get_all_tradable_stocks()
if limit:
all_stocks = all_stocks[:limit]
logger.info(f"🧪 Limited to {limit} stocks for testing")
# 3. 배치 처리 (쓰로틀링은 KIS API 클래스에서 자동 처리)
success_count = 0
fail_count = 0
for i, (stock_code, stock_name) in enumerate(all_stocks, 1):
logger.info(f"[{i}/{len(all_stocks)}] Analyzing {stock_code} ({stock_name})...")
success = await analyze_and_save_stock(
session,
stock_code,
stock_name,
analysis_date
)
if success:
success_count += 1
else:
fail_count += 1
# 30개마다 통계 출력
if i % 30 == 0:
kis_api.log_monitor_stats()
# 4. 결과 요약 및 최종 통계
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
logger.info("=" * 60)
logger.info(f"✅ Batch analysis completed!")
logger.info(f"📊 Success: {success_count} / Total: {len(all_stocks)}")
logger.info(f"❌ Failed: {fail_count}")
logger.info(f"⏱️ Duration: {duration:.1f} seconds ({duration/60:.1f} minutes)")
logger.info(f"📅 Analysis Date: {analysis_date}")
# API 통계 출력
stats = kis_api.get_monitor_stats()
logger.info(f"📡 API 통계:")
logger.info(f" - 총 호출: {stats['total_calls']}건")
logger.info(f" - 오류: {stats['total_errors']}건 ({stats['error_rate']:.1%})")
logger.info(f" - 재시도: {stats['total_retries']}건")
logger.info(f" - 평균 응답 시간: {stats['avg_duration']:.2f}초")
logger.info("=" * 60)
async def main():
"""메인 실행 함수"""
import argparse
parser = argparse.ArgumentParser(description='Daily Stock Analysis Batch')
parser.add_argument('--limit', type=int, help='Limit number of stocks (for testing)')
parser.add_argument('--test', action='store_true', help='Test mode (analyze 10 stocks)')
args = parser.parse_args()
if args.test:
logger.info("🧪 TEST MODE: Analyzing 10 stocks only")
await batch_analyze_all_stocks(limit=10)
elif args.limit:
logger.info(f"📊 Custom limit: {args.limit} stocks")
await batch_analyze_all_stocks(limit=args.limit)
else:
logger.info("📊 Full analysis: All stocks")
await batch_analyze_all_stocks()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,57 @@
"""
테스트용 샘플 분석 데이터 생성 스크립트
실제 배치 대신 테스트용 데이터를 DB에 넣습니다.
"""
import psycopg2
from datetime import date
# DB 연결
conn = psycopg2.connect(
host="shium.net",
port=55432,
database="stock",
user="stock_admin",
password="stock_pass"
)
cur = conn.cursor()
# 테스트 데이터
test_data = [
('005930', '삼성전자', 58100, -1.11, 20000000, 22.5, 61200, 65400, 68200, 14.5, 18.2, 72000, 58000, 64500, ['RSI 과매도', '스토캐스틱 과매도'], 3),
('122630', 'KODEX 레버리지', 41140, -3.52, 40000000, 25.9, 43200, 45100, 47800, 21.9, 24.1, 49000, 39000, 44000, ['RSI 과매도', '스토캐스틱 과매도'], 3),
('462330', 'KODEX 2차전지산업레버리지', 1690, -6.63, 50000000, 20.5, 1850, 1920, 2100, 29.4, 32.1, 2200, 1600, 1900, ['RSI 과매도', '볼린저밴드 하단'], 3),
('000660', 'SK하이닉스', 179000, -2.15, 15000000, 28.3, 185000, 192000, 198000, 18.5, 22.3, 205000, 172000, 188500, ['RSI 과매도'], 2),
('035720', '카카오', 42500, 1.43, 8000000, 52.1, 41800, 40200, 38500, 65.2, 68.1, 46000, 38000, 42000, ['RSI 중립-상승', '골든크로스'], 2),
]
today = date.today()
print(f"🚀 Inserting test data for {today}...")
for stock in test_data:
cur.execute("""
INSERT INTO stock_analysis (
stock_code, stock_name, analysis_date, current_price, change_rate, volume,
rsi, ma5, ma20, ma60, stochastic_k, stochastic_d,
bollinger_upper, bollinger_lower, bollinger_middle,
signals, score
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT DO NOTHING
""", (
stock[0], stock[1], today, stock[2], stock[3], stock[4],
stock[5], stock[6], stock[7], stock[8], stock[9], stock[10],
stock[11], stock[12], stock[13], stock[14], stock[15]
))
print(f" ✅ {stock[0]} ({stock[1]})")
conn.commit()
cur.close()
conn.close()
print(f"\n✅ Test data inserted successfully!")
print(f"📊 Total: {len(test_data)} stocks")
print(f"\nNow test the API:")
print(f" GET http://localhost:8000/api/v1/recommendations/rule-based")

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
매매 규칙 설정을 데이터베이스에 로드하는 스크립트
Usage:
python load_trading_rules.py
"""
import asyncio
import json
from pathlib import Path
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
from app.core.config import settings
from app.models.models import TradingRule
async def load_trading_rules():
"""trading_rules_config.json 파일을 읽어서 데이터베이스에 저장"""
# 설정 파일 읽기
config_path = Path(__file__).parent / "trading_rules_config.json"
if not config_path.exists():
print(f"❌ 설정 파일을 찾을 수 없습니다: {config_path}")
return
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
# 데이터베이스 연결
engine = create_async_engine(settings.DATABASE_URL, echo=True)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
print("\n📋 매매 규칙 로드 중...\n")
# 기존 규칙 삭제 (선택적)
result = await session.execute(select(TradingRule))
existing_rules = result.scalars().all()
if existing_rules:
print(f"⚠️ 기존 규칙 {len(existing_rules)}개 발견")
response = input("기존 규칙을 모두 삭제하고 새로 로드하시겠습니까? (y/N): ")
if response.lower() == 'y':
for rule in existing_rules:
await session.delete(rule)
await session.commit()
print("✅ 기존 규칙 삭제 완료\n")
# 새 규칙 추가
rules = config.get("rules", [])
for idx, rule_data in enumerate(rules, 1):
trading_rule = TradingRule(
name=rule_data["name"],
description=rule_data.get("description", ""),
rule_type=rule_data["rule_type"],
conditions=rule_data["conditions"],
actions=rule_data["actions"],
is_active=rule_data.get("is_active", True),
priority=rule_data.get("priority", idx)
)
session.add(trading_rule)
print(f"✅ [{idx}/{len(rules)}] {rule_data['name']} - {'활성화' if rule_data.get('is_active') else '비활성화'}")
await session.commit()
print(f"\n🎉 총 {len(rules)}개의 매매 규칙이 로드되었습니다!")
# 포트폴리오 제약 조건 및 리스크 관리 설정 출력
print("\n" + "="*60)
print("📊 포트폴리오 제약 조건:")
print("="*60)
for key, value in config.get("portfolio_constraints", {}).items():
if isinstance(value, float):
print(f" • {key}: {value*100:.1f}%" if value < 1 else f" • {key}: {value:,.0f}")
else:
print(f" • {key}: {value}")
print("\n" + "="*60)
print("⚠️ 리스크 관리:")
print("="*60)
for key, value in config.get("risk_management", {}).items():
if isinstance(value, float) and value < 1:
print(f" • {key}: {value*100:.1f}%")
else:
print(f" • {key}: {value}")
print("\n" + "="*60)
print("✅ 설정 로드 완료!")
print("="*60)
await engine.dispose()
if __name__ == "__main__":
asyncio.run(load_trading_rules())

81
backend/register_rules.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/bin/bash
# 매매 규칙을 API로 등록하는 스크립트
API_URL="http://localhost:8000/api/v1/rules"
# 1. RSI 과매도 반등 전략
curl -X POST "$API_URL" \
-H "Content-Type: application/json" \
-d '{
"name": "RSI 과매도 반등 전략",
"description": "RSI가 40 이하로 과매도 상태이고, 거래량이 평균의 150% 이상일 때 매수",
"rule_type": "BUY",
"is_active": true,
"priority": 1,
"conditions": {
"rsi_threshold": 40,
"volume_multiplier": 1.5,
"ma_proximity": 0.03
},
"actions": {
"action_type": "buy",
"position_ratio": 0.15,
"min_amount": 500000,
"take_profit": 5.0,
"stop_loss": 3.0
}
}'
echo -e "\n✅ RSI 전략 등록 완료\n"
# 2. 골든크로스 추세 추종
curl -X POST "$API_URL" \
-H "Content-Type: application/json" \
-d '{
"name": "골든크로스 추세 추종",
"description": "5일선이 20일선을 상향 돌파하고 상승 추세가 확인될 때 매수",
"rule_type": "BUY",
"is_active": true,
"priority": 2,
"conditions": {
"sma_short": 5,
"sma_long": 20,
"trend": "up"
},
"actions": {
"action_type": "buy",
"position_ratio": 0.2,
"min_amount": 1000000,
"take_profit": 10.0,
"stop_loss": 5.0
}
}'
echo -e "\n✅ 골든크로스 전략 등록 완료\n"
# 3. 이동평균 정배열
curl -X POST "$API_URL" \
-H "Content-Type: application/json" \
-d '{
"name": "이동평균 정배열 전략",
"description": "5일 > 20일 > 60일 이동평균 정배열 상태에서 매수",
"rule_type": "BUY",
"is_active": true,
"priority": 3,
"conditions": {
"ma5_above_ma20": true,
"ma20_above_ma60": true,
"price_above_ma5": true
},
"actions": {
"action_type": "buy",
"position_ratio": 0.15,
"min_amount": 500000,
"take_profit": 7.0,
"stop_loss": 4.0
}
}'
echo -e "\n✅ 이동평균 전략 등록 완료\n"
echo "🎉 모든 매매 규칙이 등록되었습니다!"

24
backend/requirements.txt Normal file
View File

@@ -0,0 +1,24 @@
fastapi>=0.104.1
uvicorn[standard]>=0.24.0
pydantic>=2.5.0
pydantic-settings>=2.1.0
sqlalchemy>=2.0.36
asyncpg>=0.30.0
alembic>=1.14.0
greenlet>=3.0.0
redis==5.0.1
aio-pika==9.3.1
websockets==12.0
httpx==0.25.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
anthropic==0.7.8
pandas>=2.2.0
numpy>=1.26.0
ta==0.11.0
python-dotenv==1.0.0
celery==5.3.4
pytest==7.4.3
pytest-asyncio==0.21.1
feedparser==6.0.11

160
backend/test_api_safety.py Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
API 모니터링 테스트 스크립트
KIS API의 재시도 및 쓰로틀링 기능을 테스트합니다.
"""
import asyncio
import sys
from pathlib import Path
# 프로젝트 루트 추가
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.services.korea_investment_api import KoreaInvestmentAPI
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
async def test_throttling():
"""쓰로틀링 테스트: 빠른 연속 호출"""
logger.info("=" * 60)
logger.info("쓰로틀링 테스트 시작: 5개 종목 연속 조회")
logger.info("=" * 60)
kis_api = KoreaInvestmentAPI()
test_stocks = [
"005930", # 삼성전자
"000660", # SK하이닉스
"035420", # 네이버
"051910", # LG화학
"005380", # 현대차
]
import time
start = time.time()
for stock_code in test_stocks:
logger.info(f"📊 Fetching {stock_code}...")
try:
result = await kis_api.get_current_price(stock_code)
if result and result.get("output"):
price = result["output"].get("stck_prpr", "N/A")
logger.info(f"✅ {stock_code}: {price}원")
except Exception as e:
logger.error(f"❌ {stock_code}: {e}")
elapsed = time.time() - start
logger.info(f"⏱️ Total time: {elapsed:.2f}초")
logger.info(f"📈 Expected: ~{len(test_stocks) * kis_api.min_request_interval:.1f}초 (쓰로틀링 적용)")
# 통계 출력
stats = kis_api.get_monitor_stats()
logger.info("=" * 60)
logger.info("📡 API 통계:")
logger.info(f" - 총 호출: {stats['total_calls']}건")
logger.info(f" - 오류: {stats['total_errors']}건")
logger.info(f" - 재시도: {stats['total_retries']}건")
logger.info(f" - 오류율: {stats['error_rate']:.1%}")
logger.info(f" - 평균 응답: {stats['avg_duration']:.2f}초")
logger.info("=" * 60)
async def test_retry():
"""재시도 테스트: 잘못된 종목 코드로 테스트"""
logger.info("=" * 60)
logger.info("재시도 테스트 시작: 오류 케이스")
logger.info("=" * 60)
kis_api = KoreaInvestmentAPI()
# 잘못된 종목 코드
invalid_code = "000000"
try:
logger.info(f"📊 Fetching invalid stock: {invalid_code}")
result = await kis_api.get_current_price(invalid_code)
logger.info(f"✅ Result: {result}")
except Exception as e:
logger.error(f"❌ Expected error: {e}")
# 통계 출력
stats = kis_api.get_monitor_stats()
logger.info("=" * 60)
logger.info("📡 API 통계:")
logger.info(f" - 총 호출: {stats['total_calls']}건")
logger.info(f" - 오류: {stats['total_errors']}건")
logger.info(f" - 재시도: {stats['total_retries']}건")
logger.info(f" - 오류율: {stats['error_rate']:.1%}")
logger.info("=" * 60)
async def test_concurrent():
"""동시 요청 테스트: 세마포어 확인"""
logger.info("=" * 60)
logger.info(f"동시 요청 테스트 시작 (최대 동시 요청: 2건)")
logger.info("=" * 60)
kis_api = KoreaInvestmentAPI()
test_stocks = [
"005930", # 삼성전자
"000660", # SK하이닉스
"035420", # 네이버
"051910", # LG화학
]
import time
start = time.time()
# 병렬 실행
tasks = [kis_api.get_current_price(code) for code in test_stocks]
results = await asyncio.gather(*tasks, return_exceptions=True)
elapsed = time.time() - start
success = sum(1 for r in results if not isinstance(r, Exception))
logger.info(f"✅ 성공: {success}/{len(test_stocks)}")
logger.info(f"⏱️ Total time: {elapsed:.2f}초")
# 통계 출력
stats = kis_api.get_monitor_stats()
logger.info("=" * 60)
logger.info("📡 API 통계:")
logger.info(f" - 총 호출: {stats['total_calls']}건")
logger.info(f" - 오류: {stats['total_errors']}건")
logger.info(f" - 재시도: {stats['total_retries']}건")
logger.info(f" - 오류율: {stats['error_rate']:.1%}")
logger.info(f" - 평균 응답: {stats['avg_duration']:.2f}초")
logger.info("=" * 60)
async def main():
"""메인 테스트 실행"""
logger.info("🚀 KIS API 안전 운영 테스트 시작")
logger.info("")
# 1. 쓰로틀링 테스트
await test_throttling()
await asyncio.sleep(2)
# 2. 재시도 테스트
await test_retry()
await asyncio.sleep(2)
# 3. 동시 요청 테스트
await test_concurrent()
logger.info("")
logger.info("✅ 모든 테스트 완료")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
DB 연결 테스트 스크립트
실행: python test_db_connection.py
"""
import asyncio
import asyncpg
async def test_connection():
print("🔵 Testing PostgreSQL connection...")
print("Host: shium.net")
print("Port: 55432")
print("Database: stock")
print("User: stock")
print()
try:
# 직접 asyncpg로 연결 시도
conn = await asyncpg.connect(
host='shium.net',
port=55432,
database='stock',
user='stock',
password='Tjduddn!!12',
timeout=10,
ssl=False,
)
print("✅ Connection successful!")
# 간단한 쿼리 테스트
version = await conn.fetchval('SELECT version()')
print(f"PostgreSQL version: {version}")
# 테이블 목록 조회
tables = await conn.fetch("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
""")
print(f"\nTables in database:")
for table in tables:
print(f" - {table['table_name']}")
await conn.close()
print("\n✅ Connection test completed successfully!")
except asyncpg.PostgresConnectionError as e:
print(f"❌ PostgreSQL connection error: {e}")
except ConnectionRefusedError as e:
print(f"❌ Connection refused: {e}")
print("Possible causes:")
print(" 1. PostgreSQL server is not running")
print(" 2. Firewall blocking port 55432")
print(" 3. PostgreSQL not configured to accept external connections")
print(" 4. Need SSH tunnel or VPN to access the server")
except TimeoutError as e:
print(f"❌ Connection timeout: {e}")
print("The server is not responding. Check network connectivity.")
except Exception as e:
print(f"❌ Unexpected error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(test_connection())

View File

@@ -0,0 +1,137 @@
{
"portfolio_constraints": {
"min_cash_reserve_ratio": 0.2,
"max_cash_reserve_ratio": 0.3,
"min_cash_reserve_amount": 1000000,
"max_holdings_count": 5,
"max_position_size_ratio": 0.25,
"min_position_size_ratio": 0.05,
"max_sector_concentration": 0.4,
"rebalance_threshold": 0.1
},
"risk_management": {
"max_daily_loss": 0.03,
"max_daily_trades": 10,
"max_loss_per_trade": 0.05,
"trailing_stop_loss": 0.02,
"daily_profit_target": 0.05
},
"rules": [
{
"name": "RSI 과매도 반등 전략",
"description": "RSI가 30 이하로 과매도 상태이고, 거래량이 평균의 150% 이상일 때 매수",
"rule_type": "buy",
"is_active": true,
"priority": 1,
"conditions": {
"rsi_threshold": 30,
"volume_multiplier": 1.5,
"ma_proximity": 0.03,
"trend": "down"
},
"actions": {
"action_type": "buy",
"position_sizing": {
"method": "fixed_ratio",
"target_ratio": 0.15,
"min_amount": 500000
},
"take_profit": 5.0,
"stop_loss": 3.0
}
},
{
"name": "골든크로스 추세 추종",
"description": "20일선이 60일선을 상향 돌파하고 상승 추세가 확인될 때 매수",
"rule_type": "buy",
"is_active": true,
"priority": 2,
"conditions": {
"sma_short": 20,
"sma_long": 60,
"macd_signal": "positive",
"volume_trend": "increasing"
},
"actions": {
"action_type": "buy",
"position_sizing": {
"method": "fixed_ratio",
"target_ratio": 0.2,
"min_amount": 1000000
},
"take_profit": 10.0,
"stop_loss": 5.0
}
},
{
"name": "지지선 반등 전략",
"description": "주요 지지선에서 반등하고 긍정적인 뉴스나 이벤트가 있을 때 매수",
"rule_type": "buy",
"is_active": true,
"priority": 3,
"conditions": {
"support_level": "recent_low",
"bb_position": "lower_20",
"rsi_trend": "rising",
"volume_increase": true
},
"actions": {
"action_type": "buy",
"position_sizing": {
"method": "fixed_ratio",
"target_ratio": 0.15,
"min_amount": 500000
},
"take_profit": 7.0,
"stop_loss": 4.0
}
},
{
"name": "박스권 돌파 전략",
"description": "박스권 상단을 강한 거래량과 함께 돌파할 때 매수",
"rule_type": "buy",
"is_active": true,
"priority": 4,
"conditions": {
"breakout_period": 20,
"volume_multiplier": 2.0,
"rsi_max": 70,
"trend": "up"
},
"actions": {
"action_type": "buy",
"position_sizing": {
"method": "fixed_ratio",
"target_ratio": 0.2,
"min_amount": 1000000
},
"take_profit": 15.0,
"stop_loss": 5.0
}
},
{
"name": "모멘텀 추세 전략",
"description": "강한 상승 모멘텀이 지속되고 있을 때 추가 매수",
"rule_type": "buy",
"is_active": false,
"priority": 5,
"conditions": {
"consecutive_up_days": 3,
"volume_trend": "increasing",
"macd_histogram": "increasing",
"rsi_min": 50,
"rsi_max": 70
},
"actions": {
"action_type": "buy",
"position_sizing": {
"method": "fixed_ratio",
"target_ratio": 0.15,
"min_amount": 500000
},
"take_profit": 12.0,
"stop_loss": 4.0
}
}
]
}