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 (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

View File

@@ -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"})