Initial commit: Trading Portal - FastAPI + React + PostgreSQL

This commit is contained in:
2026-07-03 13:08:09 +00:00
commit 34a1e91541
198 changed files with 35110 additions and 0 deletions
View File
+188
View File
@@ -0,0 +1,188 @@
"""
WebSocket endpoint for real-time candle and ticker updates.
Authentication via JWT token passed as a query parameter (``?token=...``).
Once authenticated, clients can subscribe/unsubscribe to symbol+timeframe+exchange
channels and receive live candle updates pushed by the background scheduler.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.core.security import decode_token
from app.ws_manager import manager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.websocket("/ws/v1/candles")
async def candle_websocket(websocket: WebSocket) -> None:
"""
WebSocket endpoint for real-time candle updates.
**Authentication:** via query parameter ``?token=JWT``
**Client → Server messages:**
.. code-block:: json
{"action": "subscribe", "symbol": "BTC/USDT", "timeframe": "1h", "exchange": "mexc"}
{"action": "unsubscribe", "symbol": "BTC/USDT", "timeframe": "1h", "exchange": "mexc"}
**Server → Client messages:**
.. code-block:: json
{"type": "connection", "status": "connected"}
{"type": "connection", "status": "authenticated"}
{"type": "candle", "data": {"symbol": "...", "timeframe": "...", ...}}
{"type": "ticker", "data": {"symbol": "...", "price": 123.45, ...}}
{"type": "error", "message": "..."}
"""
# ------------------------------------------------------------------
# 1. Accept the connection
# ------------------------------------------------------------------
await websocket.accept()
logger.info("WebSocket connection accepted: %s", id(websocket))
# ------------------------------------------------------------------
# 2. Send connection status
# ------------------------------------------------------------------
await _send_json(websocket, {"type": "connection", "status": "connected"})
# ------------------------------------------------------------------
# 3. Extract JWT token: try subprotocol header first (secure),
# then fall back to query parameter (legacy compat)
# ------------------------------------------------------------------
token: str | None = None
# Try Sec-WebSocket-Protocol header (subprotocol-based auth)
subprotocols = websocket.headers.get("sec-websocket-protocol", "")
for sp in subprotocols.split(","):
sp = sp.strip()
if sp.startswith("token,"):
token = sp.split(",", 1)[1].strip()
break
elif sp.startswith("token-"):
token = sp[6:].strip()
break
# Fallback: query parameter (backward compat, less secure)
if not token:
token = websocket.query_params.get("token")
if not token:
await _send_json(websocket, {"type": "error", "message": "Missing token query parameter"})
await websocket.close(code=4001)
return
# ------------------------------------------------------------------
# 4. Verify JWT token
# ------------------------------------------------------------------
try:
decode_token(token)
except Exception as exc:
error_msg = str(exc) if str(exc) else "Invalid or expired token"
await _send_json(websocket, {"type": "error", "message": error_msg})
await websocket.close(code=4001)
return
# ------------------------------------------------------------------
# 5. Send authenticated status
# ------------------------------------------------------------------
await _send_json(websocket, {"type": "connection", "status": "authenticated"})
logger.info("WebSocket %s authenticated", id(websocket))
# ------------------------------------------------------------------
# 6. Register this websocket with the connection manager
# (it starts with no subscriptions — the client will subscribe below)
# ------------------------------------------------------------------
# manager.subscribe(...) is called per-subscription in the message loop
# ------------------------------------------------------------------
# 7. Message loop — handle subscribe / unsubscribe
# ------------------------------------------------------------------
try:
while True:
raw = await websocket.receive_text()
try:
data = json.loads(raw)
except json.JSONDecodeError:
await _send_json(websocket, {"type": "error", "message": "Invalid JSON"})
continue
action: str | None = data.get("action")
symbol: str | None = data.get("symbol")
timeframe: str | None = data.get("timeframe")
exchange: str | None = data.get("exchange")
if not action:
await _send_json(websocket, {"type": "error", "message": "Missing 'action' field"})
continue
if action not in ("subscribe", "unsubscribe"):
await _send_json(
websocket,
{"type": "error", "message": f"Unknown action: {action}"},
)
continue
if not all([symbol, timeframe, exchange]):
await _send_json(
websocket,
{
"type": "error",
"message": "Missing one or more required fields: symbol, timeframe, exchange",
},
)
continue
if action == "subscribe":
await manager.subscribe(websocket, symbol, timeframe, exchange)
logger.info(
"WebSocket %s subscribed to %s/%s/%s",
id(websocket),
exchange,
symbol,
timeframe,
)
elif action == "unsubscribe":
await manager.unsubscribe(websocket, symbol, timeframe, exchange)
logger.info(
"WebSocket %s unsubscribed from %s/%s/%s",
id(websocket),
exchange,
symbol,
timeframe,
)
except WebSocketDisconnect:
logger.info("WebSocket %s disconnected", id(websocket))
except Exception:
logger.exception("Unexpected error in WebSocket handler %s", id(websocket))
finally:
# ------------------------------------------------------------------
# 8. Clean up — remove from all subscriptions
# ------------------------------------------------------------------
await manager.unsubscribe_all(websocket)
logger.info("WebSocket %s cleaned up (unsubscribed from all channels)", id(websocket))
# ====================================================================
# Internal helpers
# ====================================================================
async def _send_json(websocket: WebSocket, data: dict) -> None:
"""Send a JSON-serialisable dict to the websocket, ignoring errors."""
try:
await websocket.send_json(data)
except Exception:
logger.debug("Failed to send JSON to WebSocket %s (may be disconnected)", id(websocket))