From dbd2c4648b5661f588e5a0df8f2f2e5486c54e4c Mon Sep 17 00:00:00 2001 From: shuim Date: Thu, 26 Feb 2026 11:19:23 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=B9=EC=86=8C=EC=BC=93=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 +- backend/app/core/config.py | 7 +- backend/app/main.py | 12 +- backend/app/services/day_trading_strategy.py | 17 ++ backend/app/services/kis_realtime_stream.py | 211 ++++++++++++++----- backend/app/services/short_term_strategy.py | 4 +- docker-compose.yml | 3 +- mcp-server/gemini_client.py | 32 ++- scripts/ws_handshake_test.py | 94 +++++++++ 9 files changed, 312 insertions(+), 70 deletions(-) create mode 100644 scripts/ws_handshake_test.py diff --git a/Makefile b/Makefile index 04b1aa6..9307d90 100644 --- a/Makefile +++ b/Makefile @@ -71,7 +71,7 @@ shell-mcp: ## MCP Server 쉘 접속 @docker exec -it autostock-mcp-server /bin/bash 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 테스트 @cd backend && pytest diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 71309aa..fe53b5f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -65,8 +65,10 @@ class Settings(BaseSettings): KIS_WS_APPROVAL_KEY: str = "" # KIS WebSocket URL (.env에서 오버라이드 가능) - KIS_WS_REAL_URL: str = "wss://openapi.koreainvestment.com:21000/websocket/realtime" - KIS_WS_VIRTUAL_URL: str = "wss://openapiwss.koreainvestment.com:29443/websocket/realtime" + # KIS 공식 실시간 시세 WebSocket 엔드포인트 + # 경로: /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): super().__init__(**kwargs) @@ -242,6 +244,7 @@ class Settings(BaseSettings): # ═════════════════════════════════════════════════════════════════════════════ # KIS WebSocket & 실시간 스트림 (kis_realtime_stream.py) # ═════════════════════════════════════════════════════════════════════════════ + KIS_WS_ENABLED: bool = True # WebSocket 실시간 스트림 활성화 (True) / 비활성화 (False) KIS_SYNC_INTERVAL: int = 10 # 구독 목록 동기화 주기 (초) KIS_RECONNECT_WAIT: int = 5 # 재연결 대기 시간 (초) KIS_APPROVAL_KEY_TTL: int = 82800 # approval_key 캐시 유효시간 (초, 23시간) diff --git a/backend/app/main.py b/backend/app/main.py index 138830e..487d016 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -454,10 +454,14 @@ async def lifespan(app: FastAPI): app.state.batch_analysis_task = asyncio.create_task(batch_analysis_scheduler()) app.state.day_trade_task = asyncio.create_task(day_trade_scheduler()) - # KIS 실시간 스트림 시작 - from app.services.kis_realtime_stream import run_kis_realtime_stream - app.state.kis_stream_task = asyncio.create_task(run_kis_realtime_stream()) - logger.info("📡 KIS 실시간 스트림 태스크 시작") + # KIS 실시간 스트림 시작 (설정으로 제어 가능) + if settings.KIS_WS_ENABLED: + from app.services.kis_realtime_stream import run_kis_realtime_stream + app.state.kis_stream_task = asyncio.create_task(run_kis_realtime_stream()) + logger.info("📡 KIS 실시간 스트림 태스크 시작 (KIS_WS_ENABLED=True)") + else: + logger.info("⏭️ KIS 실시간 스트림 비활성화됨 (KIS_WS_ENABLED=False in .env)") + app.state.kis_stream_task = None yield diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py index 9ff8494..6380e96 100644 --- a/backend/app/services/day_trading_strategy.py +++ b/backend/app/services/day_trading_strategy.py @@ -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 class MinuteCandle: @@ -491,3 +498,13 @@ def is_daily_loss_exceeded( if total_assets <= 0: return False 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) \ No newline at end of file diff --git a/backend/app/services/kis_realtime_stream.py b/backend/app/services/kis_realtime_stream.py index 373e772..55e9418 100644 --- a/backend/app/services/kis_realtime_stream.py +++ b/backend/app/services/kis_realtime_stream.py @@ -17,6 +17,7 @@ import asyncio import json import logging import time +import ssl from typing import Optional try: @@ -26,10 +27,11 @@ except ImportError: try: import websockets - from websockets.exceptions import ConnectionClosed + from websockets.exceptions import ConnectionClosed, InvalidStatusCode except ImportError: websockets = None # type: ignore ConnectionClosed = Exception # type: ignore + InvalidStatusCode = Exception # type: ignore from app.core.config import settings from app.core.redis import setex_json, TTL_TICK, TTL_QUOTE @@ -66,8 +68,16 @@ def _get_lock() -> asyncio.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: - 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]: @@ -100,17 +110,24 @@ async def _fetch_approval_key() -> Optional[str]: logger.error("❌ httpx 패키지 미설치. pip install httpx") return None - base_url = settings.KOREA_INVESTMENT_API_BASE_URL - app_key = settings.KOREA_INVESTMENT_APP_KEY - app_secret = settings.KOREA_INVESTMENT_APP_SECRET + # KIS_MODE에 따라 REST API 엔드포인트 선택 (WebSocket 승인도 같은 서버) + is_real = getattr(settings, "KIS_MODE", "virtual").lower() == "real" + 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: logger.error("❌ KIS_APP_KEY / KIS_APP_SECRET 미설정. approval_key 발급 불가.") 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: - async with _httpx.AsyncClient(timeout=30.0) as client: + async with _httpx.AsyncClient(timeout=30.0, verify=False) as client: resp = await client.post( f"{base_url}/oauth2/Approval", json={ @@ -127,7 +144,7 @@ async def _fetch_approval_key() -> Optional[str]: if key: _cached_approval_key = key _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 logger.error(f"❌ approval_key 응답에 키 없음: {data}") else: @@ -316,6 +333,11 @@ class KisRealtimeStream: 1단계 → 2단계 → 3단계 → 4단계 반복. 연결 끊김/오류 시 approval_key 재발급 후 재연결. + + 주의: KIS WebSocket이 응답하지 않으면 일부 설정을 확인하십시오: + - .env: KIS_WS_VIRTUAL_URL / KIS_WS_REAL_URL (포트, 경로) + - KIS 모드: KIS_MODE (virtual/real) + - 재시도: 최대 5회 후 경고만 표시 (무한 루프 방지) """ if websockets is None: logger.error("❌ websockets 패키지 미설치. pip install websockets") @@ -324,6 +346,9 @@ class KisRealtimeStream: self._running = True ws_url = _get_ws_url() logger.info(f"🔌 KIS 실시간 스트림 시작 (URL: {ws_url})") + + connection_attempts = 0 + max_connection_attempts = 5 # 5회 실패 후 경고만 while self._running: # ── 1단계: approval_key 발급 ────────────────────────────────── @@ -334,55 +359,140 @@ class KisRealtimeStream: # ── 2단계: WebSocket 연결 ──────────────────────────────────── try: - async with websockets.connect( - ws_url, - ping_interval=30, - ping_timeout=10, - close_timeout=5, - ) as ws: - self._ws = ws - logger.info("✅ KIS WebSocket 연결 완료") + ssl_context = _create_ssl_context() + + 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 - # ── 3단계: 초기 구독 ────────────────────────────────── - await self._sync_subscriptions(ws) - last_sync = time.monotonic() + # 헤더에 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}") - # ── 4단계: 데이터 수신 루프 ────────────────────────── - async for raw_msg in ws: - # 주기적 구독 동기화 - if time.monotonic() - last_sync >= _SYNC_INTERVAL: - await self._sync_subscriptions(ws) - last_sync = time.monotonic() + ws_connect_kwargs = dict( + ping_interval=30, + ping_timeout=10, + close_timeout=5, + #ssl=ssl_context, + extra_headers=extra_headers, + ) + if subprotocols: + ws_connect_kwargs["subprotocols"] = subprotocols - parsed = _parse_realtime_message(raw_msg) - if not parsed: - continue + async with websockets.connect(ws_full_url, **ws_connect_kwargs) as ws: + self._ws = ws + connection_attempts = 0 # Reset on success + logger.info("✅ KIS WebSocket 연결 완료") - msg_type = parsed.get("_type") + # ── 3단계: 초기 구독 ────────────────────────────────── + await self._sync_subscriptions(ws) + last_sync = time.monotonic() - # PINGPONG → pong 응답 - if msg_type == "pingpong": - await ws.send(raw_msg) - continue + # ── 4단계: 데이터 수신 루프 ────────────────────────── + async for raw_msg in ws: + # 주기적 구독 동기화 + if time.monotonic() - last_sync >= _SYNC_INTERVAL: + await self._sync_subscriptions(ws) + last_sync = time.monotonic() - # ACK 로그 (구독 응답 등) - if msg_type == "json": - hdr = parsed.get("data", {}).get("header", {}) - if hdr.get("tr_id"): - logger.debug(f"[KIS ACK] tr_id={hdr['tr_id']} " - f"rt_cd={parsed['data'].get('body', {}).get('rt_cd')}") - continue + parsed = _parse_realtime_message(raw_msg) + if not parsed: + continue - # 체결/호가 → Redis 캐시 + 클라이언트 브로드캐스트 - if msg_type in ("tick", "quote"): - stock_code = parsed.get("stock_code", "") - if stock_code: - # 1) Redis SETEX 캐시 (실패해도 non-blocking) - await _cache_realtime_data(msg_type, stock_code, parsed) - # 2) WebSocket 클라이언트 브로드캐스트 - await websocket_manager.send_market_update(stock_code, parsed) + msg_type = parsed.get("_type") + + # PINGPONG → pong 응답 + if msg_type == "pingpong": + await ws.send(raw_msg) + continue + + # ACK 로그 (구독 응답 등) + if msg_type == "json": + hdr = parsed.get("data", {}).get("header", {}) + if hdr.get("tr_id"): + logger.debug(f"[KIS ACK] tr_id={hdr['tr_id']} " + f"rt_cd={parsed['data'].get('body', {}).get('rt_cd')}") + continue + + # 체결/호가 → Redis 캐시 + 클라이언트 브로드캐스트 + if msg_type in ("tick", "quote"): + stock_code = parsed.get("stock_code", "") + if stock_code: + # 1) Redis SETEX 캐시 (실패해도 non-blocking) + await _cache_realtime_data(msg_type, stock_code, parsed) + # 2) WebSocket 클라이언트 브로드캐스트 + 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: + connection_attempts += 1 logger.warning(f"⚡ KIS WS 연결 끊김: {e}. {_RECONNECT_WAIT}초 후 재연결...") self._ws = None self._subscribed_codes.clear() @@ -394,7 +504,10 @@ class KisRealtimeStream: break 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._subscribed_codes.clear() self._approval_key = None diff --git a/backend/app/services/short_term_strategy.py b/backend/app/services/short_term_strategy.py index 612e9bc..a48d594 100644 --- a/backend/app/services/short_term_strategy.py +++ b/backend/app/services/short_term_strategy.py @@ -137,8 +137,8 @@ class ShortTermStrategy: if rsi_15m is None or rsi_15m > self.M15_RSI_ENTRY: return None - # 조건 3: RSI 반등 방향 확인 — 바닥에서 회복 중인지 확인 (승률 햵심) - rsi_prev = calculate_rsi(m15_closes[:-RSI_DIRECTION_LOOKBACK], 14) + # 조건 3: RSI 반등 방향 확인 — 바닥에서 회복 중인지 확인 (승률 핵심) + rsi_prev = calculate_rsi(m15_closes[:-settings.SHORT_TERM_RSI_LOOKBACK], 14) if rsi_prev is not None and rsi_15m <= rsi_prev: # RSI가 여전히 하락 중 → 변곡점 전이므로 진입 금지 return None diff --git a/docker-compose.yml b/docker-compose.yml index 66638b8..61b4c02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: # RabbitMQ Message Queue rabbitmq: - image: rabbitmq:3-management-alpine + image: rabbitmq:3-management container_name: autostock-rabbitmq environment: RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} @@ -48,6 +48,7 @@ services: - "15672:15672" volumes: - rabbitmq_data:/var/lib/rabbitmq + # - rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf networks: - autostock-network healthcheck: diff --git a/mcp-server/gemini_client.py b/mcp-server/gemini_client.py index 349d5be..399ecc7 100644 --- a/mcp-server/gemini_client.py +++ b/mcp-server/gemini_client.py @@ -10,15 +10,18 @@ logger = logging.getLogger(__name__) class GeminiClient: - """Gemini AI 클라이언트 (ClaudeClient와 동일한 인터페이스)""" + """Gemini AI 클라이언트 (최신 google-genai SDK 버전)""" def __init__(self): if not settings.GEMINI_API_KEY: raise ValueError("GEMINI_API_KEY is not set") - genai.configure(api_key=settings.GEMINI_API_KEY) - - self.model = genai.GenerativeModel(settings.GEMINI_MODEL) - self.generate_config = types.GenerateContentConfig( + + # 최신 SDK에서는 Client 객체를 생성하여 사용합니다. + 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, temperature=settings.TEMPERATURE, ) @@ -31,16 +34,23 @@ class GeminiClient: ) -> Dict[str, Any]: """Gemini를 통한 분석 실행""" try: - model_with_system_prompt = genai.GenerativeModel( - model_name=self.model.model_name, - system_instruction=system_prompt + # 최신 SDK에서는 별도의 모델 객체 생성 없이 client.models.generate_content를 사용합니다. + # system_instruction을 config에 포함시켜 보냅니다. + 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 "" + return { "content": [{"type": "text", "text": text}], "stop_reason": "end_turn", diff --git a/scripts/ws_handshake_test.py b/scripts/ws_handshake_test.py new file mode 100644 index 0000000..b11a7e5 --- /dev/null +++ b/scripts/ws_handshake_test.py @@ -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())