Initial commit

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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