백엔드 전체 구현 완료: 내부 서비스(Auth, Client, Realtime), API 엔드포인트 및 스케줄러 구현

This commit is contained in:
2026-02-02 23:55:07 +09:00
parent 03027d2206
commit 4f0cc05f39
22 changed files with 1279 additions and 23 deletions

View File

@@ -0,0 +1,60 @@
from typing import Optional, Dict, Any
from abc import ABC, abstractmethod
import httpx
class BaseAIProvider(ABC):
def __init__(self, api_key: str, model_name: str, base_url: str = None):
self.api_key = api_key
self.model_name = model_name
self.base_url = base_url
@abstractmethod
async def generate_content(self, prompt: str, system_instruction: str = None) -> str:
pass
class GeminiProvider(BaseAIProvider):
async def generate_content(self, prompt: str, system_instruction: str = None) -> str:
# Placeholder for Gemini API Implementation
# https://generativelanguage.googleapis.com/v1beta/models/...
return f"Gemini Response to: {prompt}"
class OpenAIProvider(BaseAIProvider):
async def generate_content(self, prompt: str, system_instruction: str = None) -> str:
# Placeholder for OpenAI API
return f"OpenAI Response to: {prompt}"
class OllamaProvider(BaseAIProvider):
"""
Ollama (Local LLM), compatible with OpenAI client usually, or direct /api/generate
"""
async def generate_content(self, prompt: str, system_instruction: str = None) -> str:
# Placeholder for Ollama API
url = f"{self.base_url}/api/generate"
payload = {
"model": self.model_name,
"prompt": prompt,
"stream": False
}
if system_instruction:
payload["system"] = system_instruction
try:
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=payload, timeout=60.0)
resp.raise_for_status()
data = resp.json()
return data.get("response", "")
except Exception as e:
return f"Error: {e}"
class AIFactory:
@staticmethod
def get_provider(provider_type: str, api_key: str, model_name: str, base_url: str = None) -> BaseAIProvider:
if provider_type.lower() == "gemini":
return GeminiProvider(api_key, model_name, base_url)
elif provider_type.lower() == "openai":
return OpenAIProvider(api_key, model_name, base_url)
elif provider_type.lower() == "ollama":
return OllamaProvider(api_key, model_name, base_url)
else:
raise ValueError(f"Unknown Provider: {provider_type}")

View File

@@ -0,0 +1,55 @@
from sqlalchemy import select
from app.db.database import SessionLocal
from app.db.models import AiConfig, ApiSettings
from app.services.ai_factory import AIFactory, BaseAIProvider
class AIOrchestrator:
def __init__(self):
pass
async def _get_provider_by_id(self, config_id: str) -> BaseAIProvider:
async with SessionLocal() as session:
stmt = select(AiConfig).where(AiConfig.id == config_id)
result = await session.execute(stmt)
config = result.scalar_one_or_none()
if not config:
raise ValueError("AI Config not found")
# Note: API Keys might need to be stored securely or passed from ENV/Settings.
# For now assuming API Key is managed externally or stored in config (not implemented in DB schema for security).
# Or we look up ApiSettings or a secure vault.
# Simplified: Use a placeholder or ENV.
api_key = "place_holder"
return AIFactory.get_provider(config.providerType, api_key, config.modelName, config.baseUrl)
async def get_preferred_provider(self, purpose: str) -> BaseAIProvider:
"""
purpose: 'news', 'stock', 'judgement', 'buy', 'sell'
"""
async with SessionLocal() as session:
stmt = select(ApiSettings).where(ApiSettings.id == 1)
result = await session.execute(stmt)
settings = result.scalar_one_or_none()
if not settings:
raise ValueError("Settings not initialized")
config_id = None
if purpose == 'news': config_id = settings.preferredNewsAiId
elif purpose == 'stock': config_id = settings.preferredStockAiId
elif purpose == 'judgement': config_id = settings.preferredNewsJudgementAiId
elif purpose == 'buy': config_id = settings.preferredAutoBuyAiId
elif purpose == 'sell': config_id = settings.preferredAutoSellAiId
if not config_id:
raise ValueError(f"No preferred AI configured for {purpose}")
return await self._get_provider_by_id(config_id)
async def analyze_text(self, text: str, purpose="news") -> str:
provider = await self.get_preferred_provider(purpose)
return await provider.generate_content(text)
ai_orchestrator = AIOrchestrator()

View File

@@ -0,0 +1,117 @@
import httpx
from datetime import datetime, timedelta
from sqlalchemy import select
from app.db.database import SessionLocal
from app.db.models import ApiSettings
class KisAuth:
BASE_URL_REAL = "https://openapi.koreainvestment.com:9443"
# BASE_URL_VIRTUAL = "https://openapivts.koreainvestment.com:29443"
def __init__(self):
pass
async def get_access_token(self, db_session=None) -> str:
"""
Returns valid access token. Issues new one if expired or missing.
"""
local_session = False
if not db_session:
db_session = SessionLocal()
local_session = True
try:
# 1. Get Settings
stmt = select(ApiSettings).where(ApiSettings.id == 1)
result = await db_session.execute(stmt)
settings_obj = result.scalar_one_or_none()
if not settings_obj or not settings_obj.appKey or not settings_obj.appSecret:
raise ValueError("KIS API Credentials not configured.")
# 2. Check Expiry (Buffer 10 mins)
if settings_obj.accessToken and settings_obj.tokenExpiry:
if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10):
return settings_obj.accessToken
# 3. Issue New Token
token_data = await self._issue_token(settings_obj.appKey, settings_obj.appSecret)
# 4. Save to DB
settings_obj.accessToken = token_data['access_token']
# expires_in is seconds (usually 86400)
settings_obj.tokenExpiry = datetime.now() + timedelta(seconds=int(token_data['expires_in']))
await db_session.commit()
return settings_obj.accessToken
except Exception as e:
await db_session.rollback()
raise e
finally:
if local_session:
await db_session.close()
async def _issue_token(self, app_key: str, app_secret: str) -> dict:
url = f"{self.BASE_URL_REAL}/oauth2/tokenP"
payload = {
"grant_type": "client_credentials",
"appkey": app_key,
"appsecret": app_secret
}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=payload, headers={"Content-Type": "application/json"})
resp.raise_for_status()
return resp.json()
async def get_approval_key(self, db_session=None) -> str:
"""
Returns WebSocket Approval Key. Issues new one if missing.
"""
local_session = False
if not db_session:
db_session = SessionLocal()
local_session = True
try:
stmt = select(ApiSettings).where(ApiSettings.id == 1)
result = await db_session.execute(stmt)
settings_obj = result.scalar_one_or_none()
if not settings_obj or not settings_obj.appKey or not settings_obj.appSecret:
raise ValueError("KIS API Credentials not configured.")
if settings_obj.websocketApprovalKey:
return settings_obj.websocketApprovalKey
# Issue New Key
approval_key = await self._issue_approval_key(settings_obj.appKey, settings_obj.appSecret)
settings_obj.websocketApprovalKey = approval_key
await db_session.commit()
return approval_key
except Exception as e:
await db_session.rollback()
raise e
finally:
if local_session:
await db_session.close()
async def _issue_approval_key(self, app_key: str, app_secret: str) -> str:
url = f"{self.BASE_URL_REAL}/oauth2/Approval"
payload = {
"grant_type": "client_credentials",
"appkey": app_key,
"secretkey": app_secret # Note: Parameter name difference
}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=payload, headers={"Content-Type": "application/json"})
resp.raise_for_status()
data = resp.json()
return data['approval_key']
kis_auth = KisAuth()

View File

@@ -0,0 +1,197 @@
import httpx
from typing import Dict, Optional, Any
from app.services.kis_auth import kis_auth
from app.core.rate_limiter import global_rate_limiter
from app.db.database import SessionLocal
from app.db.models import ApiSettings
from sqlalchemy import select
class KisClient:
"""
Brokerage Service Interface for KIS API.
Implements Section 9 Integration Map.
"""
# Domestic URLs
URL_DOMESTIC_ORDER = "/uapi/domestic-stock/v1/trading/order-cash"
URL_DOMESTIC_PRICE = "/uapi/domestic-stock/v1/quotations/inquire-price"
URL_DOMESTIC_BALANCE = "/uapi/domestic-stock/v1/trading/inquire-balance"
# Overseas URLs
URL_OVERSEAS_ORDER = "/uapi/overseas-stock/v1/trading/order"
URL_OVERSEAS_PRICE = "/uapi/overseas-price/v1/quotations/price"
URL_OVERSEAS_BALANCE = "/uapi/overseas-stock/v1/trading/inquire-balance"
def __init__(self):
pass
async def _get_settings(self):
async with SessionLocal() as session:
stmt = select(ApiSettings).where(ApiSettings.id == 1)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def _call_api(self, method: str, url_path: str, tr_id: str, params: Dict = None, data: Dict = None) -> Dict:
"""
Common API Caller with Rate Limiting and Auth.
"""
# 1. Rate Limit
await global_rate_limiter.wait()
# 2. Get Token & Base URL
# Assuming Real Environment for now. TODO: Support Virtual
base_url = kis_auth.BASE_URL_REAL
token = await kis_auth.get_access_token()
settings = await self._get_settings()
if not settings:
raise ValueError("Settings not found")
# 3. Headers
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": settings.appKey,
"appsecret": settings.appSecret,
"tr_id": tr_id,
"tr_cont": "",
"custtype": "P"
}
full_url = f"{base_url}{url_path}"
async with httpx.AsyncClient() as client:
if method == "GET":
resp = await client.get(full_url, headers=headers, params=params)
else:
resp = await client.post(full_url, headers=headers, json=data)
# TODO: Handle 401 Re-Auth Logic here (catch, clear token, retry)
resp.raise_for_status()
return resp.json()
# -----------------------------
# 1. Current Price
# -----------------------------
async def get_current_price(self, market: str, code: str) -> float:
if market == "Domestic":
# TR_ID: FHKST01010100
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code
}
res = await self._call_api("GET", self.URL_DOMESTIC_PRICE, "FHKST01010100", params=params)
return float(res['output']['stck_prpr'])
elif market == "Overseas":
# TR_ID: HHDFS00000300
# Need Exchange Code (e.g., NASD). Assuming NASD for generic US or mapped.
# Ideally code should be 'NASD:AAPL' or separate excg param.
# For now assuming 'NASD' if not provided implicitly.
# Or code is just 'AAPL'.
excg = "NASD" # Default
params = {
"AUTH": "",
"EXCD": excg,
"SYMB": code
}
res = await self._call_api("GET", self.URL_OVERSEAS_PRICE, "HHDFS00000300", params=params)
return float(res['output']['last'])
return 0.0
# -----------------------------
# 2. Balance
# -----------------------------
async def get_balance(self, market: str) -> Dict:
settings = await self._get_settings()
acc_no = settings.accountNumber
# acc_no is 8 digits. Split? "500xxx-01" -> 500xxx, 01
if '-' in acc_no:
cano, prdt = acc_no.split('-')
else:
cano = acc_no[:8]
prdt = acc_no[8:]
if market == "Domestic":
# TR_ID: TTTC8434R (Real)
params = {
"CANO": cano,
"ACNT_PRDT_CD": prdt,
"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": "01",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": ""
}
res = await self._call_api("GET", self.URL_DOMESTIC_BALANCE, "TTTC8434R", params=params)
return res
elif market == "Overseas":
# TR_ID: TTTS3012R (Real)
params = {
"CANO": cano,
"ACNT_PRDT_CD": prdt,
"OVRS_EXCG_CD": "NASD", # Default
"TR_CRCY_CD": "USD",
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": ""
}
res = await self._call_api("GET", self.URL_OVERSEAS_BALANCE, "TTTS3012R", params=params)
return res
return {}
# -----------------------------
# 3. Order
# -----------------------------
async def place_order(self, market: str, side: str, code: str, quantity: int, price: float) -> Dict:
"""
side: 'buy' or 'sell'
price: 0 for Market? KIS logic varies.
"""
settings = await self._get_settings()
if '-' in settings.accountNumber:
cano, prdt = settings.accountNumber.split('-')
else:
cano = settings.accountNumber[:8]
prdt = settings.accountNumber[8:]
if market == "Domestic":
# TR_ID: TTT 0802U (Buy), 0801U (Sell) -> using sample 0012U/0011U
# 0012U (Buy), 0011U (Sell)
tr_id = "TTTC0012U" if side == "buy" else "TTTC0011U"
data = {
"CANO": cano,
"ACNT_PRDT_CD": prdt,
"PDNO": code,
"ORD_DVSN": "00", # Limit (00). 01=Market
"ORD_QTY": str(quantity),
"ORD_UNPR": str(int(price)), # Cash Order requires integer price string
}
return await self._call_api("POST", self.URL_DOMESTIC_ORDER, tr_id, data=data)
elif market == "Overseas":
# TR_ID: TTTT1002U (US Buy), TTTT1006U (US Sell)
# Assuming US (NASD)
tr_id = "TTTT1002U" if side == "buy" else "TTTT1006U"
data = {
"CANO": cano,
"ACNT_PRDT_CD": prdt,
"OVRS_EXCG_CD": "NASD",
"PDNO": code,
"ORD_QTY": str(quantity),
"OVRS_ORD_UNPR": str(price),
"ORD_SVR_DVSN_CD": "0",
"ORD_DVSN": "00" # Limit
}
return await self._call_api("POST", self.URL_OVERSEAS_ORDER, tr_id, data=data)
kis_client = KisClient()

View File

@@ -0,0 +1,154 @@
import asyncio
import json
import websockets
import logging
from typing import Dict, Set, Callable, Optional
from app.services.kis_auth import kis_auth
from app.core.crypto import aes_cbc_base64_dec
from app.db.database import SessionLocal
# from app.db.crud import update_stock_price # TODO: Implement CRUD
logger = logging.getLogger(__name__)
class RealtimeManager:
"""
Manages KIS WebSocket Connection.
Handles: Connection, Subscription, Decryption, PINGPONG.
"""
WS_URL_REAL = "ws://ops.koreainvestment.com:21000"
def __init__(self):
self.ws: Optional[websockets.WebSocketClientProtocol] = None
self.approval_key: Optional[str] = None
self.subscribed_codes: Set[str] = set()
self.running = False
self.data_map: Dict[str, Dict] = {} # Store IV/Key for encrypted TRs
async def start(self):
"""
Main loop: Connect -> Authenticate -> Listen
"""
self.running = True
while self.running:
try:
# 1. Get Approval Key
self.approval_key = await kis_auth.get_approval_key()
logger.info(f"Connecting to KIS WS: {self.WS_URL_REAL}")
async with websockets.connect(self.WS_URL_REAL, ping_interval=None) as websocket:
self.ws = websocket
logger.info("Connected.")
# 2. Resubscribe if recovering connection
if self.subscribed_codes:
await self._resubscribe_all()
# 3. Listen Loop
await self._listen()
except Exception as e:
logger.error(f"WS Connection Error: {e}. Retrying in 5s...")
await asyncio.sleep(5)
async def stop(self):
self.running = False
if self.ws:
await self.ws.close()
async def subscribe(self, stock_code: str, type="price"):
"""
Subscribe to a stock.
type: 'price' (H0STCNT0 - 체결가)
"""
if not self.ws or not self.approval_key:
logger.warning("WS not ready. Adding to pending list.")
self.subscribed_codes.add(stock_code)
return
# Domestic Realtime Price TR ID: H0STCNT0
tr_id = "H0STCNT0"
tr_key = stock_code
payload = {
"header": {
"approval_key": self.approval_key,
"custtype": "P",
"tr_type": "1", # 1=Register, 2=Unregister
"content-type": "utf-8"
},
"body": {
"input": {
"tr_id": tr_id,
"tr_key": tr_key
}
}
}
await self.ws.send(json.dumps(payload))
self.subscribed_codes.add(stock_code)
logger.info(f"Subscribed to {stock_code}")
async def _resubscribe_all(self):
for code in self.subscribed_codes:
await self.subscribe(code)
async def _listen(self):
async for message in self.ws:
try:
# Message can be plain text provided by library, or bytes
if isinstance(message, bytes):
message = message.decode('utf-8')
# KIS sends data in specific formats.
# 1. JSON (Control Messages, PINGPONG, Subscription Ack)
# 2. Text/Pipe separated (Real Data) - Usually starts with 0 or 1
first_char = message[0]
if first_char in ['{', '[']:
# JSON Message
data = json.loads(message)
header = data.get('header', {})
tr_id = header.get('tr_id')
if tr_id == "PINGPONG":
await self.ws.send(message) # Echo back
logger.debug("PINGPONG handled")
elif 'body' in data:
# Subscription Ack
# Store IV/Key if encryption is enabled (msg1 often contains 'ENCRYPT')
# But for Brokerage API, H0STCNT0 is usually plaintext unless configured otherwise.
# If encrypted, 'iv' and 'key' are in body['output']
pass
elif first_char in ['0', '1']:
# Real Data: 0|TR_ID|DATA_CNT|DATA...
parts = message.split('|')
if len(parts) < 4:
continue
tr_id = parts[1]
raw_data = parts[3]
# Decryption Check
# If this tr_id was registered as encrypted, decrypt it.
# For now assuming Plaintext for H0STCNT0 as per standard Personal API.
# Parse Data
if tr_id == "H0STCNT0": # Domestic Price
# Data format: TIME^PRICE^...
# We need to look up format spec.
# Simple implementation: just log or split
fields = raw_data.split('^')
if len(fields) > 2:
current_price = fields[2] # Example index
# TODO: Update DB
# print(f"Price Update: {current_price}")
pass
except Exception as e:
logger.error(f"Error processing WS message: {e}")
realtime_manager = RealtimeManager()