API للمطورين

إضافة عمق سجل الأوامر المباشر عبر SAHMK WebSocket ‏(JavaScript + Python)

اعرض مستويات العرض والطلب مباشرة لرموز تداول. اطلب صلاحية عمق السوق، واشترك عبر WebSocket، وحافظ على تحديث واجهة سجل الأوامر باستخدام JavaScript أو Python.

WebSocketعمق السوقPro+متوسطقراءة 14 دقيقة

1. متى تستخدم WebSocket لعمق السوق بدلاً من REST

استخدم REST عندما تحتاج إلى لقطة واحدة من سجل الأوامر. استخدم WebSocket لعمق السوق عندما تريد دفع مستويات العرض والطلب مباشرة مع تغير السجل، وهو مثالي لواجهات التداول وقوائم المراقبة ولوحات الأبحاث خلال ساعات السوق.

  • REST: عمق بسيط عند الطلب لرمز واحد.
  • WebSocket: تحديثات مستمرة للعمق مع إجراءات الاشتراك وإلغائه واللقطة.

2. المتطلبات (مفتاح API والباقة والصلاحية)

  • مفتاح SAHMK API: `shmk_live_*` أو `shmk_test_*`.
  • باقة Pro أو Business أو Enterprise لعمق السوق الفوري.
  • صلاحية معتمدة لعمق السوق (بدء الطلب ← الاعتماد ← إكمال التفعيل إن طُلب).
  • بيئة تشغيل JavaScript (متصفح) أو Python 3.9 فأحدث.

اطلب صلاحية عمق السوق

يُفعّل بث العمق بعد اعتماد صلاحية عمق السوق لحسابك. ابدأ الطلب من لوحة البيانات؛ وبعد تفعيله ستتصل الأمثلة أدناه بصورة سليمة.

اطلب صلاحية عمق السوق

3. أساسيات القناة وتوفر البيانات

نقطة نهاية WebSocket لعمق السوق:

text
wss://api.sahmk.sa/ws/v1/market/depth/?api_key=YOUR_API_KEY

اختياري: أضف `&symbol=2222` لتلقي `connected` ثم `depth_snapshot` أولية لذلك الرمز.

إجراءات العميل المدعومة:

json
{"action":"snapshot","symbol":"2222","levels":5}
{"action":"subscribe","symbols":["2222","1120"],"levels":5}
{"action":"unsubscribe","symbols":["1120"]}
{"action":"ping"}
{"action":"subscribe","symbols":["*"],"levels":20} // Enterprise only

ترتيب الرسائل

  • الرموز الصريحة: رسالة `depth_snapshot` لكل رمز، ثم `subscribed`.
  • البدل العام في Enterprise: تصل `subscribed` أولاً ثم دفعة اللقطات الأولية.
  • تصل التحديثات المباشرة المستمرة أيضاً كرسائل `depth_snapshot`.

توفر البيانات حسب الباقة

  • Pro: مؤهلة لأفضل 5 مستويات عمق. اطلب الصلاحية.
  • Business: مؤهلة لعمق أكبر، حتى 20 مستوى، بعد اعتماد الصلاحية.
  • Enterprise: حتى 20 مستوى وحدود رموز أعلى والبدل `*` عند تفعيله.
  • اعتمد `connected.limits` مرجعاً لعدد الرموز لكل اتصال واستدعاء. قد يكون العمق المعاد أقل من المطلوب بحسب صلاحيتك وتوفر بيانات السوق.
  • إرشادات رموز الإغلاق: `4401` للمصادقة، و`4403` لعدم جاهزية الصلاحية، و`4429` للتقييد المؤقت؛ التفاصيل أدناه.

4. أنماط العميل الموصى بها لبث سلس

يعمل بث العمق بأفضل صورة عندما يكون العميل هادئاً ومتوقعاً. تحافظ عادات بسيطة على ندرة إعادة الاتصال واستجابة الواجهة:

  • مدير اتصال واحد لكل مفتاح API وقناة عمق.
  • افصل الإجراءات الصادرة بنحو `200–400ms`، وضع الإرسال في طابور بدلاً من دفعه دفعة واحدة.
  • قسّم استدعاءات الاشتراك لاحترام `connected.limits.max_symbols_per_call`.
  • تجنب اللقطات المتكررة بسرعة للرمز نفسه (فترة تهدئة قصيرة).
  • عند `4429` انتظر بتراجع مع عشوائية طفيفة واحترم `retry_after_seconds` عند توفره.
  • عند `4401` أو `4403` أوقف حلقة إعادة الاتصال ووجّه المستخدم لإصلاح مفتاح API أو حالة صلاحية البيانات الفورية.

لماذا يفيد ذلك

يبقى العملاء الهادئون متصلين مدة أطول. تطبق الأمثلة أدناه هذه الإعدادات لتطلق أسرع بمحاولات أقل وواجهة عمق أكثر سلاسة.

5. عميل عمق JavaScript (المتصفح)

يتضمن المثال طابور إجراءات وتراجع إعادة الاتصال ومعالجة رموز الإغلاق وفترة تهدئة للقطات ليبقى البث مستقراً تحت الحمل.

depth_stream_browser.js
const API_KEY = "YOUR_API_KEY";
const URL = `wss://api.sahmk.sa/ws/v1/market/depth/?api_key=${API_KEY}`;

// Keep these aligned with your account and connected.limits.
const REQUESTED_LEVELS = 5;
const SYMBOLS = ["2222", "1120", "2010"];

let ws = null;
let pingTimer = null;
let reconnectAttempt = 0;
let manualClose = false;
let lastErrorSignal = null;
let connectedLimits = null;
let desiredSymbols = [...SYMBOLS];
const lastSnapshotAtBySymbol = new Map();

const MIN_ACTION_INTERVAL_MS = 250;
let queueTimer = null;
const actionQueue = [];

function enqueueAction(payload) {
  actionQueue.push(payload);
  if (!queueTimer) flushQueue();
}

function flushQueue() {
  if (!ws || ws.readyState !== WebSocket.OPEN) {
    queueTimer = null;
    return;
  }
  const item = actionQueue.shift();
  if (!item) {
    queueTimer = null;
    return;
  }
  ws.send(JSON.stringify(item));
  queueTimer = setTimeout(flushQueue, MIN_ACTION_INTERVAL_MS);
}

function nextDelayMs(attempt) {
  const base = 1000;
  const cap = 30000;
  const exp = Math.min(cap, base * 2 ** attempt);
  const jitter = Math.floor(Math.random() * 500);
  return exp + jitter;
}

function parseRetryAfterSeconds(details) {
  if (!details || typeof details !== "object") return null;
  const value = Number(details.retry_after_seconds);
  return Number.isFinite(value) && value > 0 ? value : null;
}

function perCallLimit() {
  const runtime = Number(connectedLimits?.max_symbols_per_call);
  if (Number.isFinite(runtime) && runtime > 0) return runtime;
  return 20;
}

function chunk(arr, size) {
  const out = [];
  for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
  return out;
}

function subscribeSymbols(symbols) {
  for (const group of chunk(symbols, perCallLimit())) {
    enqueueAction({ action: "subscribe", symbols: group, levels: REQUESTED_LEVELS });
  }
}

function requestSnapshotSafe(symbol) {
  const now = Date.now();
  const last = lastSnapshotAtBySymbol.get(symbol) || 0;
  if (now - last < 1500) return; // snapshot cooldown per symbol
  lastSnapshotAtBySymbol.set(symbol, now);
  enqueueAction({ action: "snapshot", symbol, levels: REQUESTED_LEVELS });
}

function startPing() {
  clearInterval(pingTimer);
  pingTimer = setInterval(() => {
    if (ws && ws.readyState === WebSocket.OPEN) enqueueAction({ action: "ping" });
  }, 30000);
}

function stopPing() {
  clearInterval(pingTimer);
  pingTimer = null;
}

function connect() {
  ws = new WebSocket(URL);

  ws.onopen = () => {
    reconnectAttempt = 0;
    lastErrorSignal = null;
    subscribeSymbols(desiredSymbols);
    startPing();
  };

  ws.onmessage = (event) => {
    let msg;
    try {
      msg = JSON.parse(event.data);
    } catch {
      console.error("Invalid JSON from server:", event.data);
      return;
    }

    if (msg.type === "connected") {
      connectedLimits = msg.limits || null;
      console.log("Connected depth limits:", connectedLimits);
      return;
    }

    if (msg.type === "subscribed") {
      console.log("Subscribed symbols:", msg.symbols);
      return;
    }

    if (msg.type === "depth_snapshot") {
      // Render depth snapshot in UI/store.
      // msg: { symbol, bids, asks, entitled_levels, total_bid_quantity, total_ask_quantity, level_imbalance, ... }
      console.log("Depth snapshot:", msg.symbol);
      return;
    }

    if (msg.type === "error") {
      lastErrorSignal = { code: msg.code || null, details: msg.details || null };
      console.error("Depth server error:", msg);
      return;
    }
  };

  ws.onclose = (event) => {
    stopPing();
    console.warn(`Depth closed: code=${event.code} reason=${event.reason}`);

    if (event.code === 4401) {
      console.error("4401 auth error. Fix API key before reconnecting.");
      return;
    }
    if (event.code === 4403) {
      console.error(
        "4403 access error. Open Realtime Access, confirm Market Depth is approved/activated, then reconnect."
      );
      return;
    }
    if (event.code === 4429) {
      const retryAfter = parseRetryAfterSeconds(lastErrorSignal?.details);
      const requestedDelay = ((retryAfter || 1) * 1000) + Math.floor(Math.random() * 500);
      const delay = Math.max(nextDelayMs(reconnectAttempt++), requestedDelay);
      console.warn(`4429 temporary throttle. Reconnecting in ${delay}ms`);
      if (!manualClose) setTimeout(connect, delay);
      return;
    }

    if (!manualClose) {
      const delay = nextDelayMs(reconnectAttempt++);
      setTimeout(connect, delay);
    }
  };
}

window.addEventListener("beforeunload", () => {
  manualClose = true;
  if (ws && ws.readyState === WebSocket.OPEN && desiredSymbols.length) {
    enqueueAction({ action: "unsubscribe", symbols: desiredSymbols });
  }
  stopPing();
  ws?.close(1000, "Page unload");
});

// Public method your UI can call
window.depthRequestSnapshot = requestSnapshotSafe;

connect();

6. عميل عمق Python غير المتزامن

تُستخدم الأنماط نفسها في Python: إجراءات متباعدة واشتراكات مجزأة وتراجع عند التقييد المؤقت وتوقف سليم عندما تتطلب الصلاحية أو المصادقة تدخلاً.

depth_stream_async.py
import asyncio
import contextlib
import json
import random
import time
import websockets

API_KEY = "YOUR_API_KEY"
URL = f"wss://api.sahmk.sa/ws/v1/market/depth/?api_key={API_KEY}"
REQUESTED_LEVELS = 5
SYMBOLS = ["2222", "1120", "2010"]

MIN_ACTION_INTERVAL_SECONDS = 0.25
SNAPSHOT_MIN_INTERVAL_SECONDS = 1.5

def next_delay_seconds(attempt: int) -> float:
    base = 1.0
    cap = 30.0
    return min(cap, base * (2 ** attempt)) + random.uniform(0.0, 0.5)

def parse_retry_after_seconds(details):
    if not isinstance(details, dict):
        return None
    raw = details.get("retry_after_seconds")
    try:
        value = float(raw)
    except (TypeError, ValueError):
        return None
    return value if value > 0 else None

def chunk(items, size):
    out = []
    for i in range(0, len(items), size):
        out.append(items[i : i + size])
    return out

async def action_sender(ws, action_queue):
    while True:
        payload = await action_queue.get()
        await ws.send(json.dumps(payload))
        await asyncio.sleep(MIN_ACTION_INTERVAL_SECONDS)

async def stream_forever():
    attempt = 0
    connected_limits = {}
    last_error_signal = {}
    snapshot_last_at = {}
    desired_symbols = SYMBOLS[:]

    while True:
        try:
            async with websockets.connect(URL, ping_interval=None) as ws:
                attempt = 0
                connected_limits = {}
                last_error_signal = {}
                action_queue = asyncio.Queue()
                sender_task = asyncio.create_task(action_sender(ws, action_queue))

                async def queue_subscribe_all():
                    per_call = int(connected_limits.get("max_symbols_per_call", 20) or 20)
                    for group in chunk(desired_symbols, per_call):
                        await action_queue.put(
                            {"action": "subscribe", "symbols": group, "levels": REQUESTED_LEVELS}
                        )

                try:
                    # Proactive ping loop
                    async def ping_loop():
                        while True:
                            await asyncio.sleep(30)
                            await action_queue.put({"action": "ping"})

                    pinger = asyncio.create_task(ping_loop())

                    # Subscribe once; if connected limits arrive later, next reconnect applies them.
                    await queue_subscribe_all()

                    async for raw in ws:
                        try:
                            msg = json.loads(raw)
                        except json.JSONDecodeError:
                            print("Invalid JSON payload:", raw)
                            continue

                        msg_type = msg.get("type")
                        if msg_type == "connected":
                            connected_limits = msg.get("limits") or {}
                            print("Connected limits:", connected_limits)
                        elif msg_type == "subscribed":
                            print("Subscribed:", msg.get("symbols"))
                        elif msg_type == "depth_snapshot":
                            print("Depth snapshot:", msg.get("symbol"))
                        elif msg_type == "error":
                            last_error_signal = {
                                "code": msg.get("code"),
                                "details": msg.get("details"),
                            }
                            print("Server error:", msg)

                        # Example snapshot throttle call path:
                        # symbol = __TOKEN_30__
                        # now = time.time()
                        # if now - snapshot_last_at.get(symbol, 0.0) >= SNAPSHOT_MIN_INTERVAL_SECONDS:
                        #     snapshot_last_at[symbol] = now
                        #     await action_queue.put({__TOKEN_31__:__TOKEN_32__,__TOKEN_33__:symbol,__TOKEN_34__:REQUESTED_LEVELS})
                finally:
                    pinger.cancel()
                    sender_task.cancel()
                    with contextlib.suppress(asyncio.CancelledError):
                        await pinger
                    with contextlib.suppress(asyncio.CancelledError):
                        await sender_task
        except websockets.exceptions.ConnectionClosed as exc:
            print(f"Connection closed: code={exc.code} reason={exc.reason}")
            if exc.code == 4401:
                print("4401 auth error. Fix API key before reconnect.")
                return
            if exc.code == 4403:
                print(
                    "4403 access error. Confirm Market Depth is approved/activated in Realtime Access, then reconnect."
                )
                return
            if exc.code == 4429:
                retry_after = parse_retry_after_seconds(last_error_signal.get("details"))
                delay = max(next_delay_seconds(attempt), (retry_after or 1.0) + random.uniform(0.0, 0.5))
                print(f"4429 temporary throttle. Reconnecting in {delay:.2f}s")
                await asyncio.sleep(delay)
                attempt += 1
                continue
        except Exception as exc:
            print(f"Unexpected error: {exc}")

        delay = next_delay_seconds(attempt)
        print(f"Reconnecting in {delay:.2f}s")
        await asyncio.sleep(delay)
        attempt += 1

if __name__ == "__main__":
    asyncio.run(stream_forever())
bash
pip install websockets

7. الرجوع إلى REST أثناء إعادة الاتصال

حافظ على بيانات الواجهة أثناء إعادة اتصال WebSocket بطلب لقطات REST متباعدة، ثم عُد إلى البث فور استقراره:

bash
curl -X GET "https://api.sahmk.sa/api/v1/market/depth/2222/?levels=5" \
  -H "X-API-Key: YOUR_API_KEY"

نصائح الرجوع الاحتياطي

  • استخدم REST فقط عند انقطاع الاتصال أو تدهوره.
  • استخدم الاستقصاء باعتدال (مثلاً كل 3–5 ثوانٍ لكل رمز).
  • عُد إلى WebSocket بوصفه المصدر الأساسي عند استقرار البث.

8. قائمة التحقق للإنتاج

  • مدير اتصال واحد لكل مفتاح API وقناة عمق.
  • افصل إجراءات WebSocket بمقدار `200–400ms`، واستخدم طابوراً بدلاً من الإرسال الدفعي.
  • قسّم اشتراكات الرموز حسب `connected.limits.max_symbols_per_call`.
  • قيّد اللقطات المتكررة للرمز نفسه.
  • احترم `retry_after_seconds` عند `4429` وأضف عشوائية طفيفة.
  • لا تعد الاتصال تلقائياً عند `4401` أو `4403`؛ أصلح الصلاحية أو المصادقة أولاً.
  • سجّل أحدث `error.code` ورمز الإغلاق للدعم وتصحيح الأخطاء.
  • أعد الاشتراك بعد كل اتصال؛ فحالة الاشتراك خاصة بالاتصال.

9. المشكلات الشائعة واستكشافها

اتصلت لكنني لا أرى تحديثات للعمق. لماذا؟

أرسل `subscribe` أو `snapshot` بعد `connected`، واختبر خلال ساعات السوق عندما يتغير السجل. تأكد من ظهور عمق السوق متاحاً ونشطاً في صلاحيات البيانات الفورية.

ينقطع اتصالي بالرمز 4401 أو 4403.

عند `4401` تحقق من صيغة مفتاح API وصلاحيته. وعند `4403` افتح لوحة صلاحيات البيانات الفورية، وأكمل طلب عمق السوق أو تفعيله، ثم أعد الاتصال مرة واحدة. لا تكرر المحاولة في حلقة سريعة.

طلبت مستويات أكثر مما يصلني.

هذا متوقع. قد يكون العمق المعاد أقل من المطلوب بحسب صلاحيتك وتوفر البيانات. استخدم المستويات التي تتلقاها واقرأ `entitled_levels` في رسائل العمق عند توفره.

لست على باقة Enterprise ويفشل اشتراك `*`.

البدل `*` متاح لـEnterprise فقط. مع Pro وBusiness اشترك برموز صريحة حتى حدود `connected.limits`.

10. الخطوات التالية

  • اعرض مستويات الطلب والعرض في الواجهة وأبرز أفضل طلب وأفضل عرض.
  • ادمج العمق مع WebSocket لأسعار الأسهم لإنشاء شاشة تداول مباشرة متكاملة.
  • أضف الرجوع إلى REST فقط خلال فترات إعادة الاتصال القصيرة.

إلى أين تتجه بعد ذلك

هل أنت جاهز لعرض مستويات العرض والطلب مباشرة؟

اطلب صلاحية عمق السوق، ثم اشترك في بضعة رموز، ثم وسّع التغطية وفق ما تسمح به باقتك.

نشر بواسطة @sahmk_sa · مرخّص من تداول السعودية