주식 증권 api 연동 및 window pc AI 연동 기능 구현 시작
This commit is contained in:
@@ -28,6 +28,13 @@ services:
|
|||||||
- "18500:8000"
|
- "18500:8000"
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-Asia/Seoul}
|
- 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}
|
||||||
volumes:
|
volumes:
|
||||||
- ${STOCK_DATA_PATH:-./data/stock}:/app/data
|
- ${STOCK_DATA_PATH:-./data/stock}:/app/data
|
||||||
|
|
||||||
|
|||||||
75
stock-lab/OLLAMA_SETUP.md
Normal file
75
stock-lab/OLLAMA_SETUP.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 🦙 Windows PC Ollama 연동 가이드
|
||||||
|
|
||||||
|
NAS(Docker)에 있는 `stock-lab` 서비스가 고성능 Windows PC의 Ollama를 사용하여 AI 분석을 수행하도록 설정하는 방법입니다.
|
||||||
|
|
||||||
|
## 1. Windows PC 설정 (AI 서버)
|
||||||
|
|
||||||
|
고성능 PC(9800X3D + 3070 Ti)에서 수행합니다.
|
||||||
|
|
||||||
|
### 1-1. Ollama 설치 및 준비
|
||||||
|
1. [Ollama 공식 홈페이지](https://ollama.com/)에서 Windows용 Ollama를 다운로드하여 설치합니다.
|
||||||
|
2. 명령 프롬프트(CMD)나 PowerShell을 열고 모델을 다운로드합니다.
|
||||||
|
```powershell
|
||||||
|
ollama pull llama3
|
||||||
|
# 또는 가벼운 모델
|
||||||
|
ollama pull gemma:2b
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1-2. 외부 접속 허용 설정 (중요 ⭐️)
|
||||||
|
기본적으로 Ollama는 로컬(localhost)에서만 접속 가능합니다. NAS에서 접속하려면 이를 모든 IP(`0.0.0.0`)에서 접속 가능하게 변경해야 합니다.
|
||||||
|
|
||||||
|
1. **작업 관리자**를 열고 'Ollama' 프로세스가 있다면 **작업 끝내기**로 종료합니다. (트레이 아이콘 우클릭 -> Quit)
|
||||||
|
2. **시스템 환경 변수 편집**을 엽니다. (윈도우 키 누르고 "환경 변수" 검색)
|
||||||
|
3. **시스템 변수(S)** 섹션에서 `새로 만들기(W)...`를 클릭합니다.
|
||||||
|
* 변수 이름: `OLLAMA_HOST`
|
||||||
|
* 변수 값: `0.0.0.0`
|
||||||
|
4. 확인을 눌러 저장하고, Ollama를 다시 실행합니다.
|
||||||
|
|
||||||
|
### 1-3. 방화벽 포트 개방
|
||||||
|
Windows Defender 방화벽이 외부 접속을 막을 수 있습니다.
|
||||||
|
|
||||||
|
1. Powershell을 **관리자 권한**으로 실행합니다.
|
||||||
|
2. 아래 명령어를 입력하여 11434 포트를 엽니다.
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallRule -DisplayName "Ollama API" -Direction Inbound -LocalPort 11434 -Protocol TCP -Action Allow
|
||||||
|
```
|
||||||
|
(또는 `제어판 > Windows Defender 방화벽 > 고급 설정`에서 인바운드 규칙으로 TCP 11434 포트 허용을 수동으로 추가해도 됩니다.)
|
||||||
|
|
||||||
|
### 1-4. IP 주소 확인
|
||||||
|
CMD에서 `ipconfig`를 입력하여 Windows PC의 IP 주소를 확인합니다.
|
||||||
|
(예: `192.168.0.5`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. NAS 설정 (Client)
|
||||||
|
|
||||||
|
Synology NAS의 `web-page-backend` 프로젝트에서 설정합니다.
|
||||||
|
|
||||||
|
### 2-1. .env 파일 수정
|
||||||
|
`.env` 파일에 Windows PC의 주소를 입력합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env 파일
|
||||||
|
# ... 기존 설정들 ...
|
||||||
|
|
||||||
|
# 윈도우 PC의 IP로 변경하세요 (http:// 포함, 포트 11434 포함)
|
||||||
|
OLLAMA_URL=http://192.168.0.5:11434
|
||||||
|
OLLAMA_MODEL=llama3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-2. 컨테이너 재배포
|
||||||
|
변경된 설정을 적용하기 위해 `stock-lab` 컨테이너를 다시 시작합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NAS 터미널 (프로젝트 루트 경로)
|
||||||
|
docker-compose up -d --build stock-lab
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 테스트
|
||||||
|
|
||||||
|
브라우저나 API 도구를 사용하여 NAS의 주소로 분석 요청을 보냅니다.
|
||||||
|
|
||||||
|
* **요청**: `GET http://[NAS_IP]:18500/api/stock/analyze`
|
||||||
|
* **결과**: Windows PC의 GPU가 작동하며(팬이 돌거나 GPU 로드율 상승) 몇 초 뒤에 분석된 텍스트가 반환됩니다.
|
||||||
52
stock-lab/app/analysis.py
Normal file
52
stock-lab/app/analysis.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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)
|
||||||
182
stock-lab/app/kis_api.py
Normal file
182
stock-lab/app/kis_api.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
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)}
|
||||||
|
|
||||||
@@ -2,11 +2,17 @@ import os
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
|
from .db import init_db, save_articles, get_latest_articles
|
||||||
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 .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()
|
app = FastAPI()
|
||||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||||
|
kis = KisApi()
|
||||||
|
analyst = AIAnalyst()
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
def on_startup():
|
||||||
@@ -53,8 +59,37 @@ def get_indices():
|
|||||||
def trigger_scrap():
|
def trigger_scrap():
|
||||||
"""수동 스크랩 트리거"""
|
"""수동 스크랩 트리거"""
|
||||||
run_scraping_job()
|
run_scraping_job()
|
||||||
|
run_scraping_job()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
# --- Trading API ---
|
||||||
|
|
||||||
|
@app.get("/api/trade/balance")
|
||||||
|
def get_balance():
|
||||||
|
"""계좌 잔고 조회 (보유주식 + 예수금)"""
|
||||||
|
return kis.get_balance()
|
||||||
|
|
||||||
|
class OrderRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
qty: int
|
||||||
|
price: int = 0 # 0이면 시장가
|
||||||
|
type: str # 'buy' or 'sell'
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
@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}
|
||||||
|
|
||||||
@app.get("/api/version")
|
@app.get("/api/version")
|
||||||
def version():
|
def version():
|
||||||
return {"version": os.getenv("APP_VERSION", "dev")}
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
fastapi
|
# 주식 서비스용 라이브러리
|
||||||
uvicorn
|
requests==2.32.3
|
||||||
requests
|
beautifulsoup4==4.12.3
|
||||||
beautifulsoup4
|
fastapi==0.115.6
|
||||||
apscheduler
|
uvicorn[standard]==0.30.6
|
||||||
|
apscheduler==3.10.4
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user