AI Coach 백엔드 프록시 추가 및 trade 엔드포인트 인증 적용
- POST /api/stock/ai-coach: Anthropic API 프록시 (API 키 서버 보관) - trade/balance, trade/order: ADMIN_API_KEY 헤더 인증 추가 - print() → logging 모듈 전환 (stock-lab) - .env.example: ADMIN_API_KEY, ANTHROPIC_API_KEY, CORS_ALLOW_ORIGINS 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,3 +50,12 @@ PGID=1000
|
|||||||
|
|
||||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
|
|
||||||
|
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화)
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
|
||||||
|
# Anthropic API Key (AI Coach 프록시, 미설정 시 AI Coach 비활성화)
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# CORS 허용 도메인 (콤마 구분)
|
||||||
|
CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import FastAPI, Query
|
from fastapi import FastAPI, Query, Header, Depends, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
import requests
|
import requests
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger("stock-lab")
|
||||||
|
|
||||||
from .db import (
|
from .db import (
|
||||||
init_db, save_articles, get_latest_articles,
|
init_db, save_articles, get_latest_articles,
|
||||||
add_portfolio_item, get_all_portfolio, get_portfolio_item,
|
add_portfolio_item, get_all_portfolio, get_portfolio_item,
|
||||||
@@ -37,6 +41,19 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
|||||||
# Windows AI Server URL (NAS .env에서 설정)
|
# Windows AI Server URL (NAS .env에서 설정)
|
||||||
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
||||||
|
|
||||||
|
# Admin API Key 인증
|
||||||
|
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
|
||||||
|
|
||||||
|
def verify_admin(x_admin_key: str = Header(None)):
|
||||||
|
"""admin/trade 엔드포인트 보호용 API 키 검증"""
|
||||||
|
if not ADMIN_API_KEY:
|
||||||
|
return # 키 미설정 시 인증 비활성화 (개발 환경)
|
||||||
|
if x_admin_key != ADMIN_API_KEY:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
# Anthropic API 프록시용 키 (서버 측 보관)
|
||||||
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
# 공휴일 목록 로드
|
# 공휴일 목록 로드
|
||||||
_HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "holidays.json")
|
_HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "holidays.json")
|
||||||
try:
|
try:
|
||||||
@@ -124,53 +141,87 @@ def trigger_scrap():
|
|||||||
run_scraping_job()
|
run_scraping_job()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
# --- Trading API (Windows Proxy) ---
|
# --- Trading API (Windows Proxy, 인증 필요) ---
|
||||||
|
|
||||||
@app.get("/api/trade/balance")
|
@app.get("/api/trade/balance", dependencies=[Depends(verify_admin)])
|
||||||
def get_balance():
|
def get_balance():
|
||||||
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
|
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
|
||||||
print(f"[Proxy] Requesting Balance from {WINDOWS_AI_SERVER_URL}...")
|
logger.info(f"Requesting Balance from {WINDOWS_AI_SERVER_URL}")
|
||||||
try:
|
try:
|
||||||
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
|
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
print(f"[ProxyError] Balance Error: {resp.status_code} {resp.text}")
|
logger.error(f"Balance Error: {resp.status_code}")
|
||||||
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
|
|
||||||
print("[Proxy] Balance Success")
|
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
except requests.JSONDecodeError:
|
||||||
|
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxyError] Connection Failed: {e}")
|
logger.error(f"Balance Connection Failed: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
|
||||||
status_code=500,
|
|
||||||
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
|
|
||||||
)
|
|
||||||
|
|
||||||
class OrderRequest(BaseModel):
|
class OrderRequest(BaseModel):
|
||||||
ticker: str # 종목 코드 (예: "005930")
|
ticker: str
|
||||||
action: str # "BUY" or "SELL"
|
action: str
|
||||||
quantity: int # 주문 수량
|
quantity: int
|
||||||
price: int = 0 # 0이면 시장가
|
price: int = 0
|
||||||
reason: Optional[str] = "Manual Order" # 주문 사유 (AI 기록용)
|
reason: Optional[str] = "Manual Order"
|
||||||
|
|
||||||
@app.post("/api/trade/order")
|
@app.post("/api/trade/order", dependencies=[Depends(verify_admin)])
|
||||||
def order_stock(req: OrderRequest):
|
def order_stock(req: OrderRequest):
|
||||||
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
|
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
|
||||||
print(f"[Proxy] Order Request: {req.dict()} to {WINDOWS_AI_SERVER_URL}...")
|
logger.info(f"Order Request: {req.action} {req.ticker} x{req.quantity}")
|
||||||
try:
|
try:
|
||||||
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10)
|
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
print(f"[ProxyError] Order Error: {resp.status_code} {resp.text}")
|
logger.error(f"Order Error: {resp.status_code}")
|
||||||
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
|
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
except requests.JSONDecodeError:
|
||||||
|
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxyError] Order Connection Failed: {e}")
|
logger.error(f"Order Connection Failed: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
|
||||||
status_code=500,
|
|
||||||
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
|
# --- AI Coach 프록시 (API 키를 서버에 보관) ---
|
||||||
|
|
||||||
|
class AiCoachRequest(BaseModel):
|
||||||
|
model: str = "claude-haiku-4-5-20251001"
|
||||||
|
prompt: str
|
||||||
|
max_tokens: int = 1024
|
||||||
|
|
||||||
|
@app.post("/api/stock/ai-coach")
|
||||||
|
def ai_coach(req: AiCoachRequest):
|
||||||
|
"""AI 포트폴리오 코치 — Anthropic API 프록시 (API 키 서버 보관)"""
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise HTTPException(503, "AI Coach not configured (ANTHROPIC_API_KEY missing)")
|
||||||
|
|
||||||
|
allowed_models = {"claude-haiku-4-5-20251001", "claude-sonnet-4-6"}
|
||||||
|
model = req.model if req.model in allowed_models else "claude-haiku-4-5-20251001"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
"https://api.anthropic.com/v1/messages",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"max_tokens": req.max_tokens,
|
||||||
|
"messages": [{"role": "user", "content": req.prompt}],
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"Anthropic API error: {resp.status_code}")
|
||||||
|
return JSONResponse(status_code=resp.status_code, content={"error": "AI API error"})
|
||||||
|
return resp.json()
|
||||||
|
except requests.Timeout:
|
||||||
|
return JSONResponse(status_code=504, content={"error": "AI API timeout"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI Coach error: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"error": "AI Coach failed"})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user