Initial commit
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user