diff --git a/.env.example b/.env.example index 0907e49..ada34f7 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,10 @@ STOCK_DATA_PATH=./data/stock # Local: 1000:1000 (Windows Docker Desktop의 경우 크게 중요하지 않음) PUID=1000 PGID=1000 + +# [STOCK LAB] +# NAS는 Windows AI Server로 요청을 중계(Proxy)하는 역할만 수행합니다. +# 실제 KIS API 호출 및 AI 분석은 Windows PC에서 수행됩니다. + +# Windows AI Server (NAS 입장에서 바라본 Windows PC IP) +WINDOWS_AI_SERVER_URL=http://192.168.0.5:8000 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1c972e1..74c1497 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,13 +28,7 @@ services: - "18500:8000" environment: - TZ=${TZ:-Asia/Seoul} - - KIS_APP_KEY=${KIS_APP_KEY} - - KIS_APP_SECRET=${KIS_APP_SECRET} - - KIS_ACCOUNT_NO=${KIS_ACCOUNT_NO} - - KIS_ACCOUNT_CODE=${KIS_ACCOUNT_CODE:-01} - - KIS_MODE=${KIS_MODE:-DEV} # DEV(모의투자)|PROD(실전) - - OLLAMA_URL=${OLLAMA_URL:-http://192.168.0.x:11434} # Windows PC IP 설정 필요 - - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3} + - WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000} volumes: - ${STOCK_DATA_PATH:-./data/stock}:/app/data diff --git a/stock-lab/app/analysis.py b/stock-lab/app/analysis.py deleted file mode 100644 index 9759ed0..0000000 --- a/stock-lab/app/analysis.py +++ /dev/null @@ -1,52 +0,0 @@ -import os -import requests -import json - -class AIAnalyst: - """Ollama API를 통한 주식 뉴스 분석""" - - def __init__(self): - # NAS 외부의 Windows PC IP 주소 - self.base_url = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434") - self.model = os.getenv("OLLAMA_MODEL", "llama3") - - def _call_ollama(self, prompt: str) -> str: - url = f"{self.base_url}/api/generate" - payload = { - "model": self.model, - "prompt": prompt, - "stream": False, - "options": { - "temperature": 0.2, # 분석용이므로 창의성 낮춤 - "num_ctx": 4096 - } - } - try: - # 타임아웃 3분 (PC 사양 좋으므로 금방 될 것) - resp = requests.post(url, json=payload, timeout=180) - resp.raise_for_status() - data = resp.json() - return data.get("response", "").strip() - except Exception as e: - return f"Error analyzing: {str(e)}" - - def analyze_market_summary(self, articles: list) -> str: - """뉴스 헤드라인들을 모아 시장 분위기 요약""" - if not articles: - return "분석할 뉴스가 없습니다." - - # 최신 10개만 추려서 전달 - targets = articles[:10] - titles = "\n".join([f"- {a.get('title')}" for a in targets]) - - prompt = f""" - 다음은 최근 한국 증시 주요 뉴스 헤드라인입니다: - - {titles} - - 위 뉴스들을 바탕으로 현재 시장의 주요 이슈와 분위기를 3줄로 요약해주고, - 전반적인 투자 심리가 '긍정/부정/중립' 중 어디에 가까운지 판단해주세요. - 한국어로 답변해주세요. - """ - - return self._call_ollama(prompt) diff --git a/stock-lab/app/kis_api.py b/stock-lab/app/kis_api.py deleted file mode 100644 index 445293c..0000000 --- a/stock-lab/app/kis_api.py +++ /dev/null @@ -1,182 +0,0 @@ -import os -import time -import requests -import json -from datetime import datetime - -class KisApi: - """한국투자증권 REST API 래퍼 (모의투자/실전투자 지원)""" - - def __init__(self): - self.app_key = os.getenv("KIS_APP_KEY", "") - self.app_secret = os.getenv("KIS_APP_SECRET", "") - self.account_no = os.getenv("KIS_ACCOUNT_NO", "") # 계좌번호 앞 8자리 - self.account_code = os.getenv("KIS_ACCOUNT_CODE", "01") # 계좌번호 뒤 2자리 (보통 01) - - # 모의투자 여부 (환경변수가 "PROD"가 아니면 기본 모의투자) - self.is_prod = os.getenv("KIS_MODE", "DEV") == "PROD" - - if self.is_prod: - self.base_url = "https://openapi.koreainvestment.com:9443" - else: - self.base_url = "https://openapivts.koreainvestment.com:29443" - - self.access_token = None - self.token_expired_at = None - - def _get_headers(self, tr_id=None): - """공통 헤더 생성""" - self._ensure_token() - headers = { - "Content-Type": "application/json; charset=utf-8", - "authorization": f"Bearer {self.access_token}", - "appkey": self.app_key, - "appsecret": self.app_secret, - } - if tr_id: - headers["tr_id"] = tr_id - return headers - - def _ensure_token(self): - """토큰 유효성 검사 및 재발급""" - now = time.time() - # 토큰이 없거나 만료 1분 전이면 재발급 - if not self.access_token or not self.token_expired_at or now >= (self.token_expired_at - 60): - self._issue_token() - - def _issue_token(self): - """접근 토큰 발급""" - url = f"{self.base_url}/oauth2/tokenP" - payload = { - "grant_type": "client_credentials", - "appkey": self.app_key, - "appsecret": self.app_secret - } - try: - res = requests.post(url, json=payload, timeout=10) - res.raise_for_status() - data = res.json() - - self.access_token = data["access_token"] - # 만료 시간 (보통 24시간인데 여유 있게 계산) - expires_in = int(data.get("expires_in", 86400)) - self.token_expired_at = time.time() + expires_in - print(f"[KisApi] Token issued. Expires in {expires_in} sec.") - - except Exception as e: - print(f"[KisApi] Token issue failed: {e}") - raise - - def get_balance(self): - """주식 잔고 조회""" - url = f"{self.base_url}/uapi/domestic-stock/v1/trading/inquire-balance" - - # 실전: TTTC8434R, 모의: VTTC8434R - tr_id = "TTTC8434R" if self.is_prod else "VTTC8434R" - - headers = self._get_headers(tr_id=tr_id) - - params = { - "CANO": self.account_no, - "ACNT_PRDT_CD": self.account_code, #보통 01 - "AFHR_FLPR_YN": "N", - "OFL_YN": "", - "INQR_DVSN": "02", - "UNPR_DVSN": "01", - "FUND_STTL_ICLD_YN": "N", - "FNCG_AMT_AUTO_RDPT_YN": "N", - "PRCS_DVSN": "00", # 전일매매포함 - "CTX_AREA_FK100": "", - "CTX_AREA_NK100": "" - } - - try: - res = requests.get(url, headers=headers, params=params, timeout=10) - res.raise_for_status() - data = res.json() - - # API 응답 코드가 0이 아니면 에러 - if data["rt_cd"] != "0": - return {"error": data["msg1"]} - - output1 = data.get("output1", []) # 종목별 잔고 - output2 = data.get("output2", []) # 계좌 총 평가 - - holdings = [] - for item in output1: - # 보유수량이 0인 것은 제외 - qty = int(item.get("hldg_qty", 0)) - if qty > 0: - holdings.append({ - "code": item.get("pdno"), - "name": item.get("prdt_name"), - "qty": qty, - "buy_price": float(item.get("pchs_avg_pric", 0)), # 매입단가 - "current_price": float(item.get("prpr", 0)), # 현재가 - "profit_rate": float(item.get("evlu_pfls_rt", 0)),# 수익률 - }) - - total_eval = 0 - deposit = 0 - if output2: - total_eval = float(output2[0].get("tot_evlu_amt", 0)) # 총 평가금액 - deposit = float(output2[0].get("dnca_tot_amt", 0)) # 예수금 - - return { - "holdings": holdings, - "summary": { - "total_eval": total_eval, - "deposit": deposit - } - } - - except Exception as e: - return {"error": str(e)} - - def order(self, stock_code: str, qty: int, price: int, buy_sell: str): - """ - 주문 실행 - buy_sell: 'buy' or 'sell' - price: 0이면 시장가(추후 구현), 양수면 지정가 - """ - url = f"{self.base_url}/uapi/domestic-stock/v1/trading/order-cash" - - # 매수/매도 tr_id 구분 - # 실전: 매수 TTTC0802U, 매도 TTTC0801U - # 모의: 매수 VTTC0802U, 매도 VTTC0801U - if self.is_prod: - tr_id = "TTTC0802U" if buy_sell == "buy" else "TTTC0801U" - else: - tr_id = "VTTC0802U" if buy_sell == "buy" else "VTTC0801U" - - headers = self._get_headers(tr_id=tr_id) - - # 가격이 0이면 시장가 "01", 아니면 지정가 "00" - ord_dvsn = "01" if price == 0 else "00" - - payload = { - "CANO": self.account_no, - "ACNT_PRDT_CD": self.account_code, - "PDNO": stock_code, # 종목코드 (6자리) - "ORD_DVSN": ord_dvsn, # 주문구분 - "ORD_QTY": str(qty), # 주문수량 (문자열) - "ORD_UNPR": str(price), # 주문단가 (문자열) - } - - try: - res = requests.post(url, headers=headers, json=payload, timeout=10) - res.raise_for_status() - data = res.json() - - if data["rt_cd"] != "0": - return {"success": False, "message": data["msg1"]} - - return { - "success": True, - "message": data["msg1"], - "order_no": data["output"]["ODNO"] # 주문번호 - } - - except Exception as e: - return {"success": False, "message": str(e)} - diff --git a/stock-lab/app/main.py b/stock-lab/app/main.py index bfaf271..4f2e986 100644 --- a/stock-lab/app/main.py +++ b/stock-lab/app/main.py @@ -1,24 +1,23 @@ import os from fastapi import FastAPI +import requests from apscheduler.schedulers.background import BackgroundScheduler +from pydantic import BaseModel -from .db import init_db, save_articles, get_latest_articles from .db import init_db, save_articles, get_latest_articles from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news -from .kis_api import KisApi -from .analysis import AIAnalyst -from pydantic import BaseModel app = FastAPI() scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul")) -kis = KisApi() -analyst = AIAnalyst() + +# Windows AI Server URL (NAS .env에서 설정) +WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000") @app.on_event("startup") def on_startup(): init_db() - # 매일 아침 8시 뉴스 스크랩 + # 매일 아침 8시 뉴스 스크랩 (NAS 자체 수행) scheduler.add_job(run_scraping_job, "cron", hour="8", minute="0") # 앱 시작 시에도 한 번 실행 (데이터 없으면) @@ -44,7 +43,6 @@ def run_scraping_job(): def health(): return {"ok": True} -@app.get("/api/stock/news") @app.get("/api/stock/news") def get_news(limit: int = 20, category: str = None): """최신 주식 뉴스 조회 (category: 'domestic' | 'overseas')""" @@ -59,15 +57,22 @@ def get_indices(): def trigger_scrap(): """수동 스크랩 트리거""" run_scraping_job() - run_scraping_job() return {"ok": True} -# --- Trading API --- +# --- Trading API (Windows Proxy) --- @app.get("/api/trade/balance") def get_balance(): - """계좌 잔고 조회 (보유주식 + 예수금)""" - return kis.get_balance() + """계좌 잔고 조회 (Windows AI Server Proxy)""" + try: + # Windows Server로 잔고 조회 요청 (Windows가 직접 KIS 호출) + resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5) + # Windows Server가 500 등을 내더라도 JSON 포맷이면 그대로 전달 + if resp.status_code != 200: + return {"error": "Windows AI Server returned error", "status": resp.status_code, "detail": resp.text} + return resp.json() + except Exception as e: + return {"error": "Failed to connect to Windows AI Server", "detail": str(e), "url": WINDOWS_AI_SERVER_URL} class OrderRequest(BaseModel): code: str @@ -77,19 +82,36 @@ class OrderRequest(BaseModel): @app.post("/api/trade/order") def order_stock(req: OrderRequest): - """주식 매수/매도 주문""" - if req.type not in ["buy", "sell"]: - return {"success": False, "message": "Invalid type (buy/sell)"} - - return kis.order(req.code, req.qty, req.price, req.type) + """주식 매수/매도 주문 (Windows AI Server Proxy)""" + try: + # Windows Server로 주문 요청 + resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10) + return resp.json() + except Exception as e: + return {"success": False, "message": f"Proxy Error: {str(e)}", "url": WINDOWS_AI_SERVER_URL} + +@app.post("/api/trade/auto") +def auto_trade(): + """AI 자동 매매 트리거 (Windows AI Server Proxy)""" + try: + # Windows Server로 AI 매매 요청 + resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/auto", timeout=120) + return resp.json() + except Exception as e: + return {"success": False, "message": f"Proxy Error: {str(e)}", "url": WINDOWS_AI_SERVER_URL} @app.get("/api/stock/analyze") def analyze_market(): - """최신 뉴스를 기반으로 AI 시장 요약""" - articles = get_latest_articles(20) - result = analyst.analyze_market_summary(articles) - return {"analysis": result, "model": analyst.model} + """Windows PC를 통한 AI 시장 분석""" + try: + # Windows AI Server의 API 호출 + resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/analyze/portfolio", timeout=120) + return resp.json() + except Exception as e: + return {"error": "Failed to connect to Windows AI Server", "detail": str(e), "url": WINDOWS_AI_SERVER_URL} @app.get("/api/version") def version(): return {"version": os.getenv("APP_VERSION", "dev")} + +