Initial commit
This commit is contained in:
57
backend/.env.example
Normal file
57
backend/.env.example
Normal 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
22
backend/Dockerfile
Normal 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
42
backend/README.md
Normal 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.
|
||||
12
backend/app/api/__init__.py
Normal file
12
backend/app/api/__init__.py
Normal 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"])
|
||||
272
backend/app/api/endpoints/market_data.py
Normal file
272
backend/app/api/endpoints/market_data.py
Normal 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]
|
||||
118
backend/app/api/endpoints/mcp.py
Normal file
118
backend/app/api/endpoints/mcp.py
Normal 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)}")
|
||||
69
backend/app/api/endpoints/monitoring.py
Normal file
69
backend/app/api/endpoints/monitoring.py
Normal 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()
|
||||
}
|
||||
228
backend/app/api/endpoints/portfolio.py
Normal file
228
backend/app/api/endpoints/portfolio.py
Normal 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)
|
||||
1378
backend/app/api/endpoints/recommendations.py
Normal file
1378
backend/app/api/endpoints/recommendations.py
Normal file
File diff suppressed because it is too large
Load Diff
118
backend/app/api/endpoints/trades.py
Normal file
118
backend/app/api/endpoints/trades.py
Normal 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
|
||||
}
|
||||
143
backend/app/api/endpoints/trading_rules.py
Normal file
143
backend/app/api/endpoints/trading_rules.py
Normal 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
|
||||
97
backend/app/core/config.py
Normal file
97
backend/app/core/config.py
Normal 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()
|
||||
38
backend/app/core/database.py
Normal file
38
backend/app/core/database.py
Normal 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
124
backend/app/main.py
Normal 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,
|
||||
)
|
||||
141
backend/app/models/models.py
Normal file
141
backend/app/models/models.py
Normal 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)
|
||||
456
backend/app/services/korea_investment_api.py
Normal file
456
backend/app/services/korea_investment_api.py
Normal 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()
|
||||
79
backend/app/services/websocket_manager.py
Normal file
79
backend/app/services/websocket_manager.py
Normal 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()
|
||||
238
backend/batch_daily_analysis.py
Normal file
238
backend/batch_daily_analysis.py
Normal 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())
|
||||
57
backend/insert_test_data.py
Normal file
57
backend/insert_test_data.py
Normal 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")
|
||||
101
backend/load_trading_rules.py
Normal file
101
backend/load_trading_rules.py
Normal 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
81
backend/register_rules.sh
Executable 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
24
backend/requirements.txt
Normal 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
160
backend/test_api_safety.py
Executable 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())
|
||||
69
backend/test_db_connection.py
Normal file
69
backend/test_db_connection.py
Normal 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())
|
||||
137
backend/trading_rules_config.json
Normal file
137
backend/trading_rules_config.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user