diff --git a/.env.example b/.env.example index c65ca99..c9a95f8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/stock-lab/app/main.py b/stock-lab/app/main.py index 78f6bf4..52615b2 100644 --- a/stock-lab/app/main.py +++ b/stock-lab/app/main.py @@ -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"})