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:
2026-04-03 01:12:31 +09:00
parent bc9ba3901e
commit ff975defbd
2 changed files with 87 additions and 27 deletions

View File

@@ -50,3 +50,12 @@ PGID=1000
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
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

View File

@@ -1,14 +1,18 @@
import os
import json
import logging
from datetime import date as date_type
from typing import Optional
from fastapi import FastAPI, Query
from fastapi import FastAPI, Query, Header, Depends, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
import requests
from apscheduler.schedulers.background import BackgroundScheduler
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 (
init_db, save_articles, get_latest_articles,
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 = 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")
try:
@@ -124,53 +141,87 @@ def trigger_scrap():
run_scraping_job()
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():
"""계좌 잔고 조회 (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:
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
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())
print("[Proxy] Balance Success")
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:
print(f"[ProxyError] Connection Failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
)
logger.error(f"Balance Connection Failed: {e}")
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
class OrderRequest(BaseModel):
ticker: str # 종목 코드 (예: "005930")
action: str # "BUY" or "SELL"
quantity: int # 주문 수량
price: int = 0 # 0이면 시장가
reason: Optional[str] = "Manual Order" # 주문 사유 (AI 기록용)
ticker: str
action: str
quantity: int
price: int = 0
reason: Optional[str] = "Manual Order"
@app.post("/api/trade/order")
@app.post("/api/trade/order", dependencies=[Depends(verify_admin)])
def order_stock(req: OrderRequest):
"""주식 매수/매도 주문 (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:
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10)
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 resp.json()
except requests.JSONDecodeError:
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
except Exception as e:
print(f"[ProxyError] Order Connection Failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
logger.error(f"Order Connection Failed: {e}")
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
# --- 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"})