웹소켓 연결 완료
This commit is contained in:
2
Makefile
2
Makefile
@@ -71,7 +71,7 @@ shell-mcp: ## MCP Server 쉘 접속
|
|||||||
@docker exec -it autostock-mcp-server /bin/bash
|
@docker exec -it autostock-mcp-server /bin/bash
|
||||||
|
|
||||||
shell-db: ## PostgreSQL 쉘 접속
|
shell-db: ## PostgreSQL 쉘 접속
|
||||||
@docker exec -it autostock-postgres psql -U autostock -d autostock_db
|
@docker exec -it autostock-postgres psql -U autostock -d autostock
|
||||||
|
|
||||||
test-backend: ## Backend 테스트
|
test-backend: ## Backend 테스트
|
||||||
@cd backend && pytest
|
@cd backend && pytest
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ class Settings(BaseSettings):
|
|||||||
KIS_WS_APPROVAL_KEY: str = ""
|
KIS_WS_APPROVAL_KEY: str = ""
|
||||||
|
|
||||||
# KIS WebSocket URL (.env에서 오버라이드 가능)
|
# KIS WebSocket URL (.env에서 오버라이드 가능)
|
||||||
KIS_WS_REAL_URL: str = "wss://openapi.koreainvestment.com:21000/websocket/realtime"
|
# KIS 공식 실시간 시세 WebSocket 엔드포인트
|
||||||
KIS_WS_VIRTUAL_URL: str = "wss://openapiwss.koreainvestment.com:29443/websocket/realtime"
|
# 경로: /websocket (또는 /cgi-md5 in some cases)
|
||||||
|
KIS_WS_REAL_URL: str = "wss://openapi.koreainvestment.com:21000/websocket"
|
||||||
|
KIS_WS_VIRTUAL_URL: str = "wss://openapivts.koreainvestment.com:29443/websocket"
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
@@ -242,6 +244,7 @@ class Settings(BaseSettings):
|
|||||||
# ═════════════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
# KIS WebSocket & 실시간 스트림 (kis_realtime_stream.py)
|
# KIS WebSocket & 실시간 스트림 (kis_realtime_stream.py)
|
||||||
# ═════════════════════════════════════════════════════════════════════════════
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
KIS_WS_ENABLED: bool = True # WebSocket 실시간 스트림 활성화 (True) / 비활성화 (False)
|
||||||
KIS_SYNC_INTERVAL: int = 10 # 구독 목록 동기화 주기 (초)
|
KIS_SYNC_INTERVAL: int = 10 # 구독 목록 동기화 주기 (초)
|
||||||
KIS_RECONNECT_WAIT: int = 5 # 재연결 대기 시간 (초)
|
KIS_RECONNECT_WAIT: int = 5 # 재연결 대기 시간 (초)
|
||||||
KIS_APPROVAL_KEY_TTL: int = 82800 # approval_key 캐시 유효시간 (초, 23시간)
|
KIS_APPROVAL_KEY_TTL: int = 82800 # approval_key 캐시 유효시간 (초, 23시간)
|
||||||
|
|||||||
@@ -454,10 +454,14 @@ async def lifespan(app: FastAPI):
|
|||||||
app.state.batch_analysis_task = asyncio.create_task(batch_analysis_scheduler())
|
app.state.batch_analysis_task = asyncio.create_task(batch_analysis_scheduler())
|
||||||
app.state.day_trade_task = asyncio.create_task(day_trade_scheduler())
|
app.state.day_trade_task = asyncio.create_task(day_trade_scheduler())
|
||||||
|
|
||||||
# KIS 실시간 스트림 시작
|
# KIS 실시간 스트림 시작 (설정으로 제어 가능)
|
||||||
|
if settings.KIS_WS_ENABLED:
|
||||||
from app.services.kis_realtime_stream import run_kis_realtime_stream
|
from app.services.kis_realtime_stream import run_kis_realtime_stream
|
||||||
app.state.kis_stream_task = asyncio.create_task(run_kis_realtime_stream())
|
app.state.kis_stream_task = asyncio.create_task(run_kis_realtime_stream())
|
||||||
logger.info("📡 KIS 실시간 스트림 태스크 시작")
|
logger.info("📡 KIS 실시간 스트림 태스크 시작 (KIS_WS_ENABLED=True)")
|
||||||
|
else:
|
||||||
|
logger.info("⏭️ KIS 실시간 스트림 비활성화됨 (KIS_WS_ENABLED=False in .env)")
|
||||||
|
app.state.kis_stream_task = None
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ MARKET_CLOSE = time(15, 30)
|
|||||||
|
|
||||||
|
|
||||||
# ── 데이터 클래스 ─────────────────────────────────────────────────────────────
|
# ── 데이터 클래스 ─────────────────────────────────────────────────────────────
|
||||||
|
ENTRY_CUTOFF = _get_entry_cutoff()
|
||||||
|
EOD_FLAT = _get_eod_flat()
|
||||||
|
MAX_POSITIONS = settings.DAY_TRADE_MAX_POSITIONS
|
||||||
|
MAX_DAY_TRADE_RATIO = settings.DAY_TRADE_MAX_CAPITAL_RATIO
|
||||||
|
MAX_CAPITAL_RATIO = settings.DAY_TRADE_MAX_RATIO_DT
|
||||||
|
DAILY_LOSS_LIMIT = -settings.DAY_TRADE_DAILY_LOSS_LIMIT / 100
|
||||||
|
MARKET_CLOSE = time(15, 30)
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MinuteCandle:
|
class MinuteCandle:
|
||||||
@@ -491,3 +498,13 @@ def is_daily_loss_exceeded(
|
|||||||
if total_assets <= 0:
|
if total_assets <= 0:
|
||||||
return False
|
return False
|
||||||
return (realized_pnl_today / total_assets) <= DAILY_LOSS_LIMIT
|
return (realized_pnl_today / total_assets) <= DAILY_LOSS_LIMIT
|
||||||
|
|
||||||
|
|
||||||
|
# 동적 시간 설정 (settings에서 읽음)
|
||||||
|
def _get_entry_cutoff() -> time:
|
||||||
|
"""신규 진입 마감 시간 (.env: DAY_TRADE_ENTRY_CUTOFF_HOUR, DAY_TRADE_ENTRY_CUTOFF_MIN)"""
|
||||||
|
return time(settings.DAY_TRADE_ENTRY_CUTOFF_HOUR, settings.DAY_TRADE_ENTRY_CUTOFF_MIN)
|
||||||
|
|
||||||
|
def _get_eod_flat() -> time:
|
||||||
|
"""EOD 강제 청산 시작 시간 (.env: DAY_TRADE_EOD_HOUR, DAY_TRADE_EOD_MIN)"""
|
||||||
|
return time(settings.DAY_TRADE_EOD_HOUR, settings.DAY_TRADE_EOD_MIN)
|
||||||
@@ -17,6 +17,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import ssl
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -26,10 +27,11 @@ except ImportError:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import websockets
|
import websockets
|
||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed, InvalidStatusCode
|
||||||
except ImportError:
|
except ImportError:
|
||||||
websockets = None # type: ignore
|
websockets = None # type: ignore
|
||||||
ConnectionClosed = Exception # type: ignore
|
ConnectionClosed = Exception # type: ignore
|
||||||
|
InvalidStatusCode = Exception # type: ignore
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.redis import setex_json, TTL_TICK, TTL_QUOTE
|
from app.core.redis import setex_json, TTL_TICK, TTL_QUOTE
|
||||||
@@ -66,8 +68,16 @@ def _get_lock() -> asyncio.Lock:
|
|||||||
return _approval_key_lock
|
return _approval_key_lock
|
||||||
|
|
||||||
|
|
||||||
|
def _create_ssl_context() -> ssl.SSLContext:
|
||||||
|
"""KIS WebSocket 연결용 SSL 컨텍스트 (검증 비활성화)."""
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
# ctx.check_hostname = False
|
||||||
|
# ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
def _get_ws_url() -> str:
|
def _get_ws_url() -> str:
|
||||||
return _KIS_WS_VIRTUAL if getattr(settings, "is_virtual_trading", True) else _KIS_WS_REAL
|
return _KIS_WS_VIRTUAL if getattr(settings, "KIS_MODE", True) == "virtual" else _KIS_WS_REAL
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_approval_key() -> Optional[str]:
|
async def _fetch_approval_key() -> Optional[str]:
|
||||||
@@ -100,17 +110,24 @@ async def _fetch_approval_key() -> Optional[str]:
|
|||||||
logger.error("❌ httpx 패키지 미설치. pip install httpx")
|
logger.error("❌ httpx 패키지 미설치. pip install httpx")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
base_url = settings.KOREA_INVESTMENT_API_BASE_URL
|
# KIS_MODE에 따라 REST API 엔드포인트 선택 (WebSocket 승인도 같은 서버)
|
||||||
app_key = settings.KOREA_INVESTMENT_APP_KEY
|
is_real = getattr(settings, "KIS_MODE", "virtual").lower() == "real"
|
||||||
app_secret = settings.KOREA_INVESTMENT_APP_SECRET
|
if is_real:
|
||||||
|
base_url = "https://openapi.koreainvestment.com:9443"
|
||||||
|
app_key = settings.KIS_REAL_APP_KEY or settings.KOREA_INVESTMENT_APP_KEY
|
||||||
|
app_secret = settings.KIS_REAL_APP_SECRET or settings.KOREA_INVESTMENT_APP_SECRET
|
||||||
|
else:
|
||||||
|
base_url = "https://openapivts.koreainvestment.com:29443"
|
||||||
|
app_key = settings.KIS_VIRTUAL_APP_KEY or settings.KOREA_INVESTMENT_APP_KEY
|
||||||
|
app_secret = settings.KIS_VIRTUAL_APP_SECRET or settings.KOREA_INVESTMENT_APP_SECRET
|
||||||
|
|
||||||
if not app_key or not app_secret:
|
if not app_key or not app_secret:
|
||||||
logger.error("❌ KIS_APP_KEY / KIS_APP_SECRET 미설정. approval_key 발급 불가.")
|
logger.error("❌ KIS_APP_KEY / KIS_APP_SECRET 미설정. approval_key 발급 불가.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(f"🔑 KIS approval_key 자동 발급 요청: {base_url}/oauth2/Approval")
|
logger.info(f"🔑 KIS approval_key 자동 발급 요청: {base_url}/oauth2/Approval (모드={['virtual','real'][is_real]})")
|
||||||
try:
|
try:
|
||||||
async with _httpx.AsyncClient(timeout=30.0) as client:
|
async with _httpx.AsyncClient(timeout=30.0, verify=False) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{base_url}/oauth2/Approval",
|
f"{base_url}/oauth2/Approval",
|
||||||
json={
|
json={
|
||||||
@@ -127,7 +144,7 @@ async def _fetch_approval_key() -> Optional[str]:
|
|||||||
if key:
|
if key:
|
||||||
_cached_approval_key = key
|
_cached_approval_key = key
|
||||||
_cached_key_expires = time.monotonic() + _APPROVAL_KEY_TTL
|
_cached_key_expires = time.monotonic() + _APPROVAL_KEY_TTL
|
||||||
logger.info("✅ KIS WS approval_key 발급 완료 (유효: 23h)")
|
logger.info(f"✅ KIS WS approval_key 발급 완료 (유효: 23h, 길이={len(key)})")
|
||||||
return _cached_approval_key
|
return _cached_approval_key
|
||||||
logger.error(f"❌ approval_key 응답에 키 없음: {data}")
|
logger.error(f"❌ approval_key 응답에 키 없음: {data}")
|
||||||
else:
|
else:
|
||||||
@@ -316,6 +333,11 @@ class KisRealtimeStream:
|
|||||||
|
|
||||||
1단계 → 2단계 → 3단계 → 4단계 반복.
|
1단계 → 2단계 → 3단계 → 4단계 반복.
|
||||||
연결 끊김/오류 시 approval_key 재발급 후 재연결.
|
연결 끊김/오류 시 approval_key 재발급 후 재연결.
|
||||||
|
|
||||||
|
주의: KIS WebSocket이 응답하지 않으면 일부 설정을 확인하십시오:
|
||||||
|
- .env: KIS_WS_VIRTUAL_URL / KIS_WS_REAL_URL (포트, 경로)
|
||||||
|
- KIS 모드: KIS_MODE (virtual/real)
|
||||||
|
- 재시도: 최대 5회 후 경고만 표시 (무한 루프 방지)
|
||||||
"""
|
"""
|
||||||
if websockets is None:
|
if websockets is None:
|
||||||
logger.error("❌ websockets 패키지 미설치. pip install websockets")
|
logger.error("❌ websockets 패키지 미설치. pip install websockets")
|
||||||
@@ -325,6 +347,9 @@ class KisRealtimeStream:
|
|||||||
ws_url = _get_ws_url()
|
ws_url = _get_ws_url()
|
||||||
logger.info(f"🔌 KIS 실시간 스트림 시작 (URL: {ws_url})")
|
logger.info(f"🔌 KIS 실시간 스트림 시작 (URL: {ws_url})")
|
||||||
|
|
||||||
|
connection_attempts = 0
|
||||||
|
max_connection_attempts = 5 # 5회 실패 후 경고만
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
# ── 1단계: approval_key 발급 ──────────────────────────────────
|
# ── 1단계: approval_key 발급 ──────────────────────────────────
|
||||||
if not await self._ensure_approval_key():
|
if not await self._ensure_approval_key():
|
||||||
@@ -334,13 +359,68 @@ class KisRealtimeStream:
|
|||||||
|
|
||||||
# ── 2단계: WebSocket 연결 ────────────────────────────────────
|
# ── 2단계: WebSocket 연결 ────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(
|
ssl_context = _create_ssl_context()
|
||||||
ws_url,
|
|
||||||
|
if connection_attempts < max_connection_attempts:
|
||||||
|
logger.info(f"🚀 KIS WS 접속 시도 #{connection_attempts + 1}: {ws_url} (key_len={len(self._approval_key) if self._approval_key else 0})")
|
||||||
|
else:
|
||||||
|
if connection_attempts == max_connection_attempts:
|
||||||
|
logger.warning(
|
||||||
|
f"⚠️ KIS WS {max_connection_attempts}회 연결 실패. 앞으로 30초마다 시도합니다.\n"
|
||||||
|
f" 확인 사항:\n"
|
||||||
|
f" 1. .env에서 KIS_WS_VIRTUAL_URL / KIS_WS_REAL_URL 정확한가?\n"
|
||||||
|
f" 2. KIS_MODE가 'virtual' 또는 'real'로 설정되었나?\n"
|
||||||
|
f" 3. 네트워크에서 {ws_url}에 접근 가능한가?\n"
|
||||||
|
f" 4. KIS 서버가 WebSocket을 지원하나? (REST API만 지원할 수도 있음)\n"
|
||||||
|
)
|
||||||
|
logger.debug(f"🔄 KIS WS 재연결 시도 #{connection_attempts + 1}...")
|
||||||
|
|
||||||
|
# KIS WebSocket 연결: approval_key는 JSON 메시지에서 전송
|
||||||
|
# WebSocket 연결 시에는 URL에 key 포함하지 않음
|
||||||
|
logger.debug(f"📡 WebSocket 연결 URL: {ws_url}/websocket/realtime")
|
||||||
|
|
||||||
|
# WebSocket 헤더 설정
|
||||||
|
ws_full_url = ws_url if ws_url.endswith("/websocket/realtime") else f"{ws_url.rstrip('/')}/websocket/realtime"
|
||||||
|
# Allow optional override via settings, otherwise derive an Origin from the ws url
|
||||||
|
origin = getattr(settings, "KIS_WS_ORIGIN", None)
|
||||||
|
if not origin:
|
||||||
|
try:
|
||||||
|
if ws_full_url.startswith("wss://"):
|
||||||
|
origin = "https://" + ws_full_url[len("wss://"):].split("/")[0]
|
||||||
|
elif ws_full_url.startswith("ws://"):
|
||||||
|
origin = "http://" + ws_full_url[len("ws://"):].split("/")[0]
|
||||||
|
except Exception:
|
||||||
|
origin = None
|
||||||
|
|
||||||
|
# 헤더에 Origin 반드시 포함
|
||||||
|
extra_headers = {
|
||||||
|
"Origin": "https://securities.koreainvestment.com",
|
||||||
|
"User-Agent": "Mozilla/5.0"
|
||||||
|
}
|
||||||
|
if origin:
|
||||||
|
extra_headers["Origin"] = origin
|
||||||
|
logger.debug(f"📡 WebSocket extra headers: {extra_headers}")
|
||||||
|
try:
|
||||||
|
# Optional subprotocols can be set via settings.KIS_WS_SUBPROTOCOLS
|
||||||
|
subprotocols = getattr(settings, "KIS_WS_SUBPROTOCOLS", None)
|
||||||
|
if isinstance(subprotocols, str):
|
||||||
|
subprotocols = [s.strip() for s in subprotocols.split(",") if s.strip()]
|
||||||
|
if subprotocols:
|
||||||
|
logger.info(f"🧩 Attempting WebSocket subprotocols: {subprotocols}")
|
||||||
|
|
||||||
|
ws_connect_kwargs = dict(
|
||||||
ping_interval=30,
|
ping_interval=30,
|
||||||
ping_timeout=10,
|
ping_timeout=10,
|
||||||
close_timeout=5,
|
close_timeout=5,
|
||||||
) as ws:
|
#ssl=ssl_context,
|
||||||
|
extra_headers=extra_headers,
|
||||||
|
)
|
||||||
|
if subprotocols:
|
||||||
|
ws_connect_kwargs["subprotocols"] = subprotocols
|
||||||
|
|
||||||
|
async with websockets.connect(ws_full_url, **ws_connect_kwargs) as ws:
|
||||||
self._ws = ws
|
self._ws = ws
|
||||||
|
connection_attempts = 0 # Reset on success
|
||||||
logger.info("✅ KIS WebSocket 연결 완료")
|
logger.info("✅ KIS WebSocket 연결 완료")
|
||||||
|
|
||||||
# ── 3단계: 초기 구독 ──────────────────────────────────
|
# ── 3단계: 초기 구독 ──────────────────────────────────
|
||||||
@@ -381,8 +461,38 @@ class KisRealtimeStream:
|
|||||||
await _cache_realtime_data(msg_type, stock_code, parsed)
|
await _cache_realtime_data(msg_type, stock_code, parsed)
|
||||||
# 2) WebSocket 클라이언트 브로드캐스트
|
# 2) WebSocket 클라이언트 브로드캐스트
|
||||||
await websocket_manager.send_market_update(stock_code, parsed)
|
await websocket_manager.send_market_update(stock_code, parsed)
|
||||||
|
except InvalidStatusCode as isc:
|
||||||
|
# WebSocket handshake returned a non-101 HTTP response (often 200)
|
||||||
|
connection_attempts += 1
|
||||||
|
status = getattr(isc, 'status_code', None)
|
||||||
|
logger.error(f"❌ KIS WS InvalidStatusCode: HTTP {status}")
|
||||||
|
# Try HTTP GET diagnostic (replace wss:// -> https://)
|
||||||
|
if _httpx is not None:
|
||||||
|
try:
|
||||||
|
http_url = ws_full_url.replace('wss://', 'https://')
|
||||||
|
logger.info(f"🔍 Performing HTTP GET diagnostic to {http_url}")
|
||||||
|
async with _httpx.AsyncClient(timeout=10.0, verify=False) as client:
|
||||||
|
# include Origin in diagnostic GET if available to mirror websocket handshake
|
||||||
|
diag_headers = {"User-Agent": "Mozilla/5.0"}
|
||||||
|
if origin:
|
||||||
|
diag_headers["Origin"] = origin
|
||||||
|
resp = await client.get(http_url, headers=diag_headers)
|
||||||
|
headers_snippet = dict(list(resp.headers.items())[:10])
|
||||||
|
body_snippet = (resp.text or '')[:1000]
|
||||||
|
logger.error(f"🔍 Diagnostic HTTP {resp.status_code} headers={headers_snippet} body={body_snippet}")
|
||||||
|
except Exception as diag_exc:
|
||||||
|
logger.error(f"🔍 Diagnostic HTTP GET failed: {diag_exc}", exc_info=True)
|
||||||
|
else:
|
||||||
|
logger.error("🔍 httpx not installed — cannot perform HTTP diagnostic")
|
||||||
|
# reset ws state and approval_key to attempt re-issue on next loop
|
||||||
|
self._ws = None
|
||||||
|
self._subscribed_codes.clear()
|
||||||
|
self._approval_key = None
|
||||||
|
# continue to retry after outer sleep
|
||||||
|
continue
|
||||||
|
|
||||||
except ConnectionClosed as e:
|
except ConnectionClosed as e:
|
||||||
|
connection_attempts += 1
|
||||||
logger.warning(f"⚡ KIS WS 연결 끊김: {e}. {_RECONNECT_WAIT}초 후 재연결...")
|
logger.warning(f"⚡ KIS WS 연결 끊김: {e}. {_RECONNECT_WAIT}초 후 재연결...")
|
||||||
self._ws = None
|
self._ws = None
|
||||||
self._subscribed_codes.clear()
|
self._subscribed_codes.clear()
|
||||||
@@ -394,7 +504,10 @@ class KisRealtimeStream:
|
|||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ KIS WS 오류: {e}", exc_info=True)
|
connection_attempts += 1
|
||||||
|
logger.error(f"❌ KIS WS 오류 (시도 #{connection_attempts}): {type(e).__name__}: {e}")
|
||||||
|
if connection_attempts <= max_connection_attempts:
|
||||||
|
logger.debug(f" 예외 상세: ", exc_info=True)
|
||||||
self._ws = None
|
self._ws = None
|
||||||
self._subscribed_codes.clear()
|
self._subscribed_codes.clear()
|
||||||
self._approval_key = None
|
self._approval_key = None
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ class ShortTermStrategy:
|
|||||||
if rsi_15m is None or rsi_15m > self.M15_RSI_ENTRY:
|
if rsi_15m is None or rsi_15m > self.M15_RSI_ENTRY:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 조건 3: RSI 반등 방향 확인 — 바닥에서 회복 중인지 확인 (승률 햵심)
|
# 조건 3: RSI 반등 방향 확인 — 바닥에서 회복 중인지 확인 (승률 핵심)
|
||||||
rsi_prev = calculate_rsi(m15_closes[:-RSI_DIRECTION_LOOKBACK], 14)
|
rsi_prev = calculate_rsi(m15_closes[:-settings.SHORT_TERM_RSI_LOOKBACK], 14)
|
||||||
if rsi_prev is not None and rsi_15m <= rsi_prev:
|
if rsi_prev is not None and rsi_15m <= rsi_prev:
|
||||||
# RSI가 여전히 하락 중 → 변곡점 전이므로 진입 금지
|
# RSI가 여전히 하락 중 → 변곡점 전이므로 진입 금지
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
|
|
||||||
# RabbitMQ Message Queue
|
# RabbitMQ Message Queue
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3-management-alpine
|
image: rabbitmq:3-management
|
||||||
container_name: autostock-rabbitmq
|
container_name: autostock-rabbitmq
|
||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
|
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
|
||||||
@@ -48,6 +48,7 @@ services:
|
|||||||
- "15672:15672"
|
- "15672:15672"
|
||||||
volumes:
|
volumes:
|
||||||
- rabbitmq_data:/var/lib/rabbitmq
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
|
# - rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
|
||||||
networks:
|
networks:
|
||||||
- autostock-network
|
- autostock-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class GeminiClient:
|
class GeminiClient:
|
||||||
"""Gemini AI 클라이언트 (ClaudeClient와 동일한 인터페이스)"""
|
"""Gemini AI 클라이언트 (최신 google-genai SDK 버전)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
if not settings.GEMINI_API_KEY:
|
if not settings.GEMINI_API_KEY:
|
||||||
raise ValueError("GEMINI_API_KEY is not set")
|
raise ValueError("GEMINI_API_KEY is not set")
|
||||||
genai.configure(api_key=settings.GEMINI_API_KEY)
|
|
||||||
|
|
||||||
self.model = genai.GenerativeModel(settings.GEMINI_MODEL)
|
# 최신 SDK에서는 Client 객체를 생성하여 사용합니다.
|
||||||
self.generate_config = types.GenerateContentConfig(
|
self.client = genai.Client(api_key=settings.GEMINI_API_KEY)
|
||||||
|
self.model_id = settings.GEMINI_MODEL # 예: 'gemini-2.0-flash'
|
||||||
|
|
||||||
|
# GenerationConfig 설정
|
||||||
|
self.config = types.GenerateContentConfig(
|
||||||
max_output_tokens=settings.MAX_TOKENS,
|
max_output_tokens=settings.MAX_TOKENS,
|
||||||
temperature=settings.TEMPERATURE,
|
temperature=settings.TEMPERATURE,
|
||||||
)
|
)
|
||||||
@@ -31,16 +34,23 @@ class GeminiClient:
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Gemini를 통한 분석 실행"""
|
"""Gemini를 통한 분석 실행"""
|
||||||
try:
|
try:
|
||||||
model_with_system_prompt = genai.GenerativeModel(
|
# 최신 SDK에서는 별도의 모델 객체 생성 없이 client.models.generate_content를 사용합니다.
|
||||||
model_name=self.model.model_name,
|
# system_instruction을 config에 포함시켜 보냅니다.
|
||||||
system_instruction=system_prompt
|
config_with_system = types.GenerateContentConfig(
|
||||||
|
system_instruction=system_prompt,
|
||||||
|
max_output_tokens=self.config.max_output_tokens,
|
||||||
|
temperature=self.config.temperature,
|
||||||
)
|
)
|
||||||
response = await model_with_system_prompt.generate_content_async(
|
|
||||||
user_message,
|
# 비동기 호출
|
||||||
generation_config=self.generate_config,
|
response = await self.client.models.generate_content(
|
||||||
|
model=self.model_id,
|
||||||
|
contents=user_message,
|
||||||
|
config=config_with_system
|
||||||
)
|
)
|
||||||
|
|
||||||
text = response.text if response.text else ""
|
text = response.text if response.text else ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"content": [{"type": "text", "text": text}],
|
"content": [{"type": "text", "text": text}],
|
||||||
"stop_reason": "end_turn",
|
"stop_reason": "end_turn",
|
||||||
|
|||||||
94
scripts/ws_handshake_test.py
Normal file
94
scripts/ws_handshake_test.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""WebSocket handshake tester.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/ws_handshake_test.py --url wss://openapivts.koreainvestment.com:29443/websocket/realtime \
|
||||||
|
--origin https://securities.koreainvestment.com --subprotocols realtime
|
||||||
|
|
||||||
|
Performs an HTTP GET diagnostic (https) and then attempts a websocket.connect()
|
||||||
|
while printing responses and exceptions.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except Exception:
|
||||||
|
httpx = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
from websockets.exceptions import InvalidStatusCode, ConnectionClosed
|
||||||
|
except Exception:
|
||||||
|
websockets = None
|
||||||
|
InvalidStatusCode = Exception
|
||||||
|
ConnectionClosed = Exception
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--url", required=True)
|
||||||
|
p.add_argument("--origin", required=False)
|
||||||
|
p.add_argument("--subprotocols", required=False, help="comma separated")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
url = args.url
|
||||||
|
origin = args.origin
|
||||||
|
subs = None
|
||||||
|
if args.subprotocols:
|
||||||
|
subs = [s.strip() for s in args.subprotocols.split(",") if s.strip()]
|
||||||
|
|
||||||
|
print("-- HTTP GET diagnostic --")
|
||||||
|
if httpx is None:
|
||||||
|
print("httpx not installed. Skipping HTTP diagnostic.")
|
||||||
|
else:
|
||||||
|
http_url = url.replace("wss://", "https://").replace("ws://", "http://")
|
||||||
|
headers = {"User-Agent": "ws-handshake-test/1.0"}
|
||||||
|
if origin:
|
||||||
|
headers["Origin"] = origin
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(verify=True, timeout=10.0) as client:
|
||||||
|
resp = await client.get(http_url, headers=headers)
|
||||||
|
print(f"HTTP {resp.status_code}")
|
||||||
|
print("Headers:")
|
||||||
|
for k, v in list(resp.headers.items())[:10]:
|
||||||
|
print(f" {k}: {v}")
|
||||||
|
print("Body snippet:")
|
||||||
|
print(resp.text[:800])
|
||||||
|
except Exception as e:
|
||||||
|
print("HTTP diagnostic failed:", repr(e))
|
||||||
|
|
||||||
|
print("\n-- WebSocket handshake attempt --")
|
||||||
|
if websockets is None:
|
||||||
|
print("websockets package not installed. Install with: pip install websockets")
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = [("User-Agent", "ws-handshake-test/1.0")]
|
||||||
|
if origin:
|
||||||
|
headers.append(("Origin", origin))
|
||||||
|
|
||||||
|
kwargs = dict(extra_headers=dict(headers))
|
||||||
|
if subs:
|
||||||
|
kwargs["subprotocols"] = subs
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.connect(url, **kwargs) as ws:
|
||||||
|
print("Connected. Subprotocol:", ws.subprotocol)
|
||||||
|
await ws.send("ping")
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(ws.recv(), timeout=3.0)
|
||||||
|
print("Received:", msg)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print("No response received (timeout)")
|
||||||
|
except InvalidStatusCode as isc:
|
||||||
|
status = getattr(isc, "status_code", None)
|
||||||
|
print(f"InvalidStatusCode: HTTP {status}")
|
||||||
|
except ConnectionClosed as cc:
|
||||||
|
print("ConnectionClosed:", cc)
|
||||||
|
except Exception as e:
|
||||||
|
print("WebSocket connect failed:", repr(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user