> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# كيف تبني محرك تداول ينفذ أوامر Limit وStop لحظة وصول السعر
- URL: https://arabroot.io/articles/كيف-تبني-محرك-تداول-matching-engine/
- Published: 2026-10-13T05:45:00.000Z
- Updated: 2026-10-14T07:41:38.000Z
- Description: كيف يرتب محرك التداول أوامر البيع والشراء وينفذها بالسعر ثم الأسبقية، ولماذا يحتاج سجلاً يعيد بناءه بعد أي توقف.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة

عندما تضغط «شراء» في تطبيق الوسيط على سهم بسعر محدد، فأنت تتوقع أن يحدث شيء واحد واضح: إذا وجد بائع بسعرك أو أقل نفذ الأمر فوراً، وإذا لم يوجد انتظر أمرك في مكانه حتى يأتي البائع، ولا يسبقك أحد وضع أمره بعدك بالسعر نفسه. وخلف هذا التوقع البسيط برنامج واحد يسمى محرك المطابقة Matching Engine، يستقبل في ثانية واحدة آلاف الأوامر من آلاف المتداولين، ويجب أن يعطي كل واحد منهم النتيجة نفسها التي كان سوف يعطيها لو جاءت الأوامر واحداً بعد الآخر بالترتيب، وأن يبقى صحيحاً إذا توقف السيرفر فجأة في منتصف اليوم، وأن يمنع أمر إيقاف الخسارة Stop Order الواحد من أن يتحول إلى سلسلة تهوي بالسعر في ثوان. وفي [مقال المحفظة الرقمية](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%85%D8%AD%D9%81%D8%B8%D8%A9-%D8%B1%D9%82%D9%85%D9%8A%D8%A9-ledger/) بنينا الدفتر الذي يحفظ المال، وفي [مقال حجز التذاكر](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%86%D8%B8%D8%A7%D9%85-%D8%AD%D8%AC%D8%B2-%D8%AA%D8%B0%D8%A7%D9%83%D8%B1-%D9%8A%D8%AA%D8%AD%D9%85%D9%84-%D9%84%D8%AD%D8%B8%D8%A9-%D9%81%D8%AA%D8%AD-%D8%A7%D9%84%D8%A8%D9%8A%D8%B9/) رأينا كيف يتسابق الآلاف على مقعد واحد، وفي هذا المقال سوف نبني القطعة التي تقع بينهما في منصات التداول، وسوف نناقش ما يلي:

- ما الذي يفعله محرك التداول بالضبط، وما الذي لا يفعله.
- المعمارية الكاملة: المكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو والأعطال.
- أنواع الأوامر Market وLimit وStop وStop-Limit، ومدة صلاحيتها GTC وIOC وFOK.
- دفتر الأوامر Order Book من الداخل: مستويات السعر وطوابير FIFO، والأولوية بالسعر ثم الوقت، والتنفيذ الجزئي.
- لماذا يعمل محرك المطابقة Matching Engine على خيط واحد لكل مجموعة أسهم، كما في معمارية LMAX.
- متى يتفعل أمر الإيقاف Stop Order، ولماذا تختلف المنصات في ذلك، وكيف تنتج سلسلة أوامر الإيقاف Stop Cascade.
- فحوص ما قبل التداول Pre-Trade Risk، وال Sequencer والسجل Journal وإعادة التشغيل Replay وال Snapshot.
- بيانات السوق Market Data بمستوياتها L1 وL2 وL3.
- ميزانية الزمن Latency Budget، ولماذا يهم جامع القمامة Garbage Collector وحجز الذاكرة.
- القواطع Circuit Breakers ونطاقات الأسعار والعدالة والتدقيق، وما تقوله أنظمة السوق السعودية.
- مختبر بلغة Go فيه محرك مطابقة حقيقي، وقياس زمن المطابقة، وإيقاف المحرك بالقوة ثم إعادة بنائه من السجل بالبصمة نفسها، وتجربتان نكسر فيهما شيئاً عمداً.

⚠️

هذا مقال هندسي يشرح كيف تعمل الأنظمة من الداخل، وليس نصيحة مالية ولا توصية بشراء أو بيع أي ورقة مالية أو باستخدام أي نوع من الأوامر. وتشغيل منصة تداول أو تقديم خدمة الوساطة للناس في المملكة يحتاج ترخيصاً من هيئة السوق المالية كما سوف نرى في قسم [السوق السعودية](#saudi)، فتحقق من [قائمة المؤسسات المرخصة وأنشطتها على موقع الهيئة](https://cma.gov.sa/en/Market/AuthorisedPersons/Pages/default.aspx?ref=arabroot.io) ومن الأنظمة نفسها قبل أن تبني منتجاً للعملاء.

## ما الذي يفعله محرك التداول بالضبط؟

محرك المطابقة Matching Engine برنامج يحفظ لكل ورقة مالية قائمتين: أوامر الشراء التي تنتظر، وأوامر البيع التي تنتظر، وهاتان القائمتان معاً تسميان دفتر الأوامر Order Book. وعندما يصل أمر جديد يقارنه المحرك بأفضل أمر في الجهة الأخرى، فإذا تقابل السعران نفذ صفقة Trade، وإذا لم يتقابلا وضع الأمر في مكانه من الدفتر. وهذا كل شيء في الفكرة، ولكن المتطلبات التي تجعلها صعبة هي التالية:

- **العدالة Fairness:** السعر الأفضل ينفذ أولاً، وفي السعر نفسه الأسبق وصولاً ينفذ أولاً، وهذه القاعدة تسمى الأولوية بالسعر ثم الوقت Price-Time Priority. وصفحة [دورة التداول وأوقاته](https://www.saudiexchange.sa/wps/portal/saudiexchange/rules-guidance/capital-market-overview/trading-cycle-and-times?ref=arabroot.io) في موقع تداول السعودية تذكر صراحة أن السوق تطابق الأوامر بحسب السعر ثم الأولوية الزمنية.
- **الحتمية Determinism:** الأوامر نفسها بالترتيب نفسه يجب أن تعطي الصفقات نفسها والدفتر نفسه في كل مرة، والسبب أن هذا هو الطريق الوحيد لاسترجاع الحالة بعد العطل، ولتشغيل نسخة احتياطية تعطي النتيجة نفسها، ولإثبات ما حدث أمام المراجع.
- **عدم فقدان أي أمر مؤكد:** إذا قال النظام للوسيط إنه استلم الأمر، فيجب أن يبقى الأمر موجوداً بعد أي توقف.
- **زمن قصير وثابت:** المتوسط وحده لا يكفي هنا، فالذي يهم هو الذيل Tail Latency، أي أسوأ واحد في الألف أو في العشرة آلاف، لأن الأمر المتأخر قد يخسر مكانه في الطابور.
- **حماية السوق:** رفض الأسعار البعيدة والكميات الخاطئة، وإيقاف التداول عند الحركة الحادة، وتسجيل كل حدث للرقابة.

وما لا يفعله المحرك مهم أيضاً: لا يحرك المال بين حسابات البنوك، ولا ينقل ملكية الأسهم بنفسه، وإنما يخرج صفقات ترسل إلى المقاصة Clearing والتسوية Settlement، وصفحة تداول السعودية نفسها تذكر أن الصفقات ترسل إلكترونياً إلى مركز الإيداع، وأن التسوية النقدية تتم عبر نظام سريع، وهذا الجزء هو دفتر القيود الذي شرحناه في [مقال المحفظة](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%85%D8%AD%D9%81%D8%B8%D8%A9-%D8%B1%D9%82%D9%85%D9%8A%D8%A9-ledger/#double-entry).

## المعمارية الكاملة: كيف تتصل الأجزاء ببعضها؟

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

![ست طبقات. CLIENTS فيها تطبيق المستثمر لدى الوسيط ونظام أوامر الوسيط Broker OMS بفحوصه الخاصة ومستخدمو بيانات السوق. EDGE فيها بوابة الأوامر Order gateway بالتحقق من العضو وحد المعدل وبروتوكول FIX أو صيغة ثنائية. SEQUENCE فيها فحص ما قبل التداول بالنطاق والكمية والرصيد، ثم ال Sequencer الذي يعطي كل أمر رقماً، ثم السجل Journal الذي يقبل الإضافة فقط وله نسخة، وسهم أحمر يبين أن الكتابة في السجل أولاً. MATCHING فيها محركان A وB لكل منهما مجموعة أسهم، ونسخة احتياطية حية Hot standby تعيد تشغيل السجل نفسه. OUTPUT فيها تقارير التنفيذ للتأكيد والتنفيذ والإلغاء وتعود إلى الوسيط، وبيانات السوق L1 وL2 وL3 تنشر عبر WebSocket أو UDP multicast بأرقام تسلسلية. POST-TRADE فيها المقاصة ودفتر التسوية، والرقابة على التلاعب، وأرشيف التدقيق لكل حدث](https://arabroot.io/content/images/2026/10/trading-engine-02-architecture.webp)

كل أمر يفحص ثم يأخذ رقماً ويكتب في السجل قبل أن يصل إلى المحرك، وما يخرج من المحرك أحداث تنشر لمن يحتاجها

في الصورة أعلاه لاحظ التالي:

- السهم الأحمر من ال Sequencer إلى السجل Journal هو قلب التصميم: لا يصل أمر إلى المحرك قبل أن يكتب في السجل برقمه، فإذا توقف المحرك أعدنا بناءه من السجل، وهذا ما سوف نثبته في المختبر بالبصمة Hash.
- المحركان A وB لا يتشاركان شيئاً، فكل سهم يعيش في محرك واحد ويملكه خيط واحد، وهذا هو التقسيم الطبيعي، لأن أمراً على سهم لا يؤثر في دفتر سهم آخر.
- النسخة الاحتياطية الحية Hot standby لا تحتاج قاعدة بيانات مشتركة، وإنما تقرأ السجل نفسه وتنفذه، فتصل إلى الدفتر نفسه، وهذا ما تصفه معمارية LMAX كما سوف نرى.
- بيانات السوق ترسل في اتجاه واحد إلى عدد كبير من المستقبلين، لذلك تخرج من طريق الأوامر وتنشر بشكل غير متزامن.

### المكونات ومسؤولية كل منها

| المكون                            | مسؤوليته                                                                            | أداة مفتوحة المصدر مقترحة                                                                                          | لماذا هي                                                                                                                                                                                                                                                              |
| --------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| بوابة الأوامر Order Gateway       | جلسات الأعضاء والتحقق منهم، وحد المعدل، وتحويل الرسالة إلى أمر داخلي                | كودك، ومعه QuickFIX/J (رخصة QuickFIX Software License 1.0 بصيغة قريبة من BSD) إذا كان الوسطاء يتصلون ببروتوكول FIX | بروتوكول FIX هو اللغة المعتادة بين الوسطاء والأسواق، والمكتبة جاهزة للجلسات وإعادة الإرسال                                                                                                                                                                            |
| فحص ما قبل التداول Pre-Trade Risk | النطاق السعري والكمية القصوى والرصيد والمركز قبل أن يأخذ الأمر رقماً أو داخل المحرك | كودك، في الذاكرة                                                                                                   | فحص يحتاج قراءة من قاعدة بيانات لكل أمر سوف يكون أبطأ جزء في الطريق                                                                                                                                                                                                   |
| ال Sequencer والسجل Journal       | رقم لكل أمر، وكتابته على القرص ونسخه إلى سيرفر آخر قبل التنفيذ                      | ملف بسيط كالذي في المختبر، أو Aeron Cluster من مشروع Aeron (رخصة Apache-2.0)، أو Chronicle Queue (رخصة Apache-2.0) | مشروع Aeron فيه نقل UDP وIPC وعنقود بسجل منسوخ، وChronicle Queue سجل على ملفات مربوطة بالذاكرة                                                                                                                                                                        |
| محرك المطابقة Matching Engine     | الدفتر والمطابقة وأوامر الإيقاف والقاطع                                             | كودك، كالذي في المختبر                                                                                             | هذا هو منتجك، ولا توجد مكتبة تعرف قواعد سوقك                                                                                                                                                                                                                          |
| النقل بين الخيوط                  | تمرير الأوامر من خيط الشبكة إلى خيط المحرك دون أقفال                                | مكتبة LMAX Disruptor (رخصة Apache-2.0) في Java                                                                     | حلقة محجوزة مسبقاً وكاتب واحد، كما تشرح ورقة Disruptor                                                                                                                                                                                                                |
| نشر بيانات السوق                  | تحويل تغييرات الدفتر إلى تحديثات L1 وL2 لعدد كبير من المشتركين                      | أداة Centrifugo (رخصة Apache-2.0) لاتصالات WebSocket، وAeron للنشر بعنوان UDP Multicast داخل الشبكة                | توزيع الحمل على سيرفرات النشر بدل أن يرسل المحرك بنفسه                                                                                                                                                                                                                |
| أحداث ما بعد التداول              | نقل الصفقات إلى المقاصة والتقارير والرقابة                                          | منصة Apache Kafka (رخصة Apache-2.0)                                                                                | سجل أحداث يعاد قراءته، وشرحنا متى يناسبك في [مقارنة وسطاء الرسائل](https://arabroot.io/articles/rabbitmq-%D8%A7%D9%85-kafka-%D8%A7%D9%85-nats-%D9%83%D9%8A%D9%81-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%88%D8%B3%D9%8A%D8%B7-%D8%A7%D9%84%D8%B1%D8%B3%D8%A7%D8%A6%D9%84/) |
| الأرصدة والتسوية                  | قيود المال والأسهم بعد كل صفقة                                                      | قاعدة PostgreSQL (رخصة PostgreSQL License) بدفتر قيود مزدوج                                                        | الدفتر نفسه الذي بنيناه في [مقال المحفظة الرقمية](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%85%D8%AD%D9%81%D8%B8%D8%A9-%D8%B1%D9%82%D9%85%D9%8A%D8%A9-ledger/)                                                                     |
| قياس الزمن                        | توزيع الزمن لكل مرحلة بالمايكروثانية                                                | مكتبة HdrHistogram (ملكية عامة CC0 أو BSD-2-Clause) مع Prometheus (رخصة Apache-2.0)                                | المتوسط يخفي الذيل، وهذا المدرج مصمم لحفظ النسب المئوية بدقة                                                                                                                                                                                                          |

### نموذج البيانات

الدفتر نفسه يعيش في الذاكرة، والذي يحفظ على القرص هو السجل، ثم الصفقات بعد ذلك في قاعدة بيانات للتقارير والتسوية. والأسماء في الجدول هي نفسها أسماء الأنواع في كود المختبر:

| البنية             | الحقول الأساسية                                                                             | أين تعيش                                   |
| ------------------ | ------------------------------------------------------------------------------------------- | ------------------------------------------ |
| الأمر في السجل Cmd | الحقول Seq, TS, Typ, Side, Kind, TIF, Acct, ID, Price, Stop, Qty ثم CRC32، في 48 بايت ثابتة | ملف السجل، يقبل الإضافة فقط                |
| Order              | الحقول ID, Acct, Side, Kind, TIF, Price, Stop, Qty, Rem, Resv ومؤشرا الطابور                | الذاكرة، داخل مستوى السعر                  |
| Level              | السعر والمجموع Price, Total وأول الطابور وآخره                                              | مصفوفة بخانة لكل هللة داخل النطاق          |
| Account            | Cash, CashHeld, Shares, SharesHeld                                                          | الذاكرة، ونسخته الرسمية في دفتر التسوية    |
| Trade              | Price, Qty, BuyID, SellID                                                                   | تخرج من المحرك إلى تقارير التنفيذ والمقاصة |
| MD                 | Side, Price, Total                                                                          | تحديث L2 لكل مستوى تغير                    |

```sql
-- Downstream of the engine: the reporting and settlement side (one row per execution).
CREATE TABLE trades (
    trade_id    bigint      PRIMARY KEY,          -- from the engine, gap-free per instrument
    seq         bigint      NOT NULL,             -- journal sequence of the command that caused it
    symbol      text        NOT NULL,
    price       integer     NOT NULL,             -- halalas
    qty         integer     NOT NULL,
    buy_order   bigint      NOT NULL,
    sell_order  bigint      NOT NULL,
    maker_side  char(1)     NOT NULL,             -- B or S: whose order was resting
    matched_at  timestamptz NOT NULL              -- the sequencer time of the command, not the DB clock
);
CREATE UNIQUE INDEX ON trades (symbol, seq, trade_id);  -- replaying a journal twice cannot insert twice
```

### الواجهات الرئيسية

الوسيط يتحدث مع السوق عادة ببروتوكول FIX أو بصيغة ثنائية خاصة بالسوق، ونكتبها هنا كمسارات HTTP لأنها أوضح للقراءة، والمعنى واحد:

| الطلب  | المسار                  | ما يفعله                                                           | المفتاح والملاحظات                                                                                                                                                                                                                                                                                                                          |
| ------ | ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| POST   | /orders                 | أمر جديد بالنوع والجهة والسعر والكمية والصلاحية                    | معرف من الوسيط لكل أمر داخل جلسته، فإعادة الإرسال بعد انقطاع لا تنشئ أمراً ثانياً، كما شرحنا في [مقال Idempotency](https://arabroot.io/articles/idempotency-%D9%83%D9%8A%D9%81-%D8%AA%D8%AC%D8%B9%D9%84-%D8%A7%D9%84%D8%B9%D9%85%D9%84%D9%8A%D8%A9-%D8%A7%D9%85%D9%86%D8%A9-%D8%B9%D9%86%D8%AF-%D8%AA%D9%83%D8%B1%D8%A7%D8%B1%D9%87%D8%A7/) |
| DELETE | /orders/{id}            | إلغاء ما بقي من الأمر                                              | يأخذ رقماً في السجل مثل أي أمر، فقد يصل بعد أن نفذ الأمر كله                                                                                                                                                                                                                                                                                |
| POST   | /orders/{id}/replace    | تعديل السعر أو الكمية                                              | قرار تصميم يجب أن تكتبه: في تصميمنا تغيير السعر أو زيادة الكمية يعطي الأمر وقتاً جديداً، وتقليل الكمية يبقيه في مكانه                                                                                                                                                                                                                       |
| GET    | /book/{symbol}?depth=10 | صورة الدفتر الحالية L2 مع آخر رقم تسلسلي                           | يبدأ منها المشترك ثم يطبق التحديثات بعد ذلك الرقم                                                                                                                                                                                                                                                                                           |
| WS     | /md/{symbol}            | تحديثات L1 وL2 وكل صفقة                                            | كل رسالة برقم تسلسلي، والفجوة تعني اطلب صورة جديدة                                                                                                                                                                                                                                                                                          |
| حدث    | execution.report        | تأكيد أو تنفيذ جزئي أو كامل أو إلغاء أو رفض، إلى الوسيط صاحب الأمر | يرسل بعد أن يثبت الأمر في السجل وليس قبله                                                                                                                                                                                                                                                                                                   |
| حدث    | trade.executed          | إلى المقاصة والتسوية والرقابة                                      | مفتاحه رقم الصفقة، والمستهلك يتجاهل ما عالجه من قبل                                                                                                                                                                                                                                                                                         |

### تقدير السعة بالأرقام

الأرقام التالية كلها افتراضات مكتوبة صراحة وليست أرقام سوق حقيقية، والهدف أن ترى أين يقع الضغط:

- افتراض: عشرة ملايين أمر في يوم التداول، بين أمر جديد وإلغاء وتعديل.
- من صفحة تداول السعودية: التداول المستمر من 10:00 إلى 15:00، أي خمس ساعات تساوي 18,000 ثانية.
- افتراض: الذروة عشرون ضعف المتوسط، في الدقائق الأولى بعد الافتتاح وعند الأخبار.
- من المختبر: كل أمر في السجل 48 بايت، وكل أمر يغير مستوى أو مستويين في الدفتر.
- افتراض: ألف مشترك في بيانات السوق عبر WebSocket، وكل تحديث قرابة 40 بايت.
- افتراض: 250 يوم تداول في السنة.

| الكمية                    | الحساب                   | النتيجة                           |
| ------------------------- | ------------------------ | --------------------------------- |
| متوسط الأوامر             | 10,000,000 ÷ 18,000      | قرابة 556 أمراً في الثانية        |
| الأوامر في الذروة         | 556 × 20                 | قرابة 11,100 أمر في الثانية       |
| حجم السجل في اليوم        | 10,000,000 × 48 بايت     | 480 ميغابايت                      |
| حجم السجل في السنة        | 480 ميغابايت × 250       | 120 غيغابايت                      |
| تحديثات L2 في الذروة      | 11,100 × 1.5             | قرابة 16,700 تحديث في الثانية     |
| رسائل WebSocket في الذروة | 16,700 × 1,000 مشترك     | قرابة 16.7 مليون رسالة في الثانية |
| عرض النطاق لها            | 16.7 مليون × 40 بايت × 8 | قرابة 5.3 غيغابت في الثانية       |

والخلاصة من هذه الأرقام ثلاث نقاط: الأولى أن المطابقة نفسها ليست المشكلة، ففي المختبر عالج خيط واحد أكثر من مليون أمر في الثانية في الذاكرة، أي أن ذروة 11,100 أمر لا تأخذ منه إلا جزءاً صغيراً. والثانية أن تثبيت السجل على القرص هو الحد الحقيقي، ففي المختبر وصل المحرك مع تثبيت كل ألف أمر إلى قرابة 28 ألف أمر في الثانية فقط على قرص هذا الجهاز، وهذا قريب من الذروة المفترضة، لذلك تنسخ الأنظمة الكبيرة السجل إلى ذاكرة سيرفر آخر بدل انتظار القرص لكل أمر. والثالثة أن النشر هو الذي يكبر بسرعة، فكل تحديث واحد يتضاعف بعدد المشتركين، ولهذا تنشر الأسواق بيانات السوق عبر UDP Multicast داخل الشبكة، وتجمع التحديثات Conflation لمن يتصل عبر الإنترنت.

### مراحل النمو: ماذا تبني اليوم، وماذا تؤجل؟

| الجزء          | البداية MVP: سيرفر أو اثنان                        | النمو: آلاف المستخدمين المتزامنين           | الحجم الكبير: ملايين المستخدمين                                                     |
| -------------- | -------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------- |
| المحرك         | عملية واحدة وخيط واحد لكل الأسهم، كالذي في المختبر | عدة خيوط، لكل منها مجموعة أسهم ثابتة        | عدة سيرفرات، ولكل منها مجموعة أسهم، ونسخة حية لكل محرك                              |
| السجل          | ملف على قرص SSD محلي مع تثبيت كل مجموعة أوامر      | نسخ السجل إلى سيرفر ثان قبل الرد            | سجل منسوخ بإجماع بين ثلاث نسخ أو أكثر، وموقع بديل                                   |
| الاسترجاع      | إعادة تشغيل السجل من أوله                          | أخذ Snapshot كل ليلة ثم بقية السجل          | نسخة حية تعيد السجل لحظة بلحظة، والتحويل إليها في ثوان أو أقل                       |
| بيانات السوق   | اتصال WebSocket من السيرفر نفسه                    | طبقة نشر مستقلة مع تجميع التحديثات          | نشر عبر UDP Multicast داخل الشبكة، وخدمة لإعادة ما فات، وبوابات للمشتركين من الخارج |
| ما بعد التداول | كتابة الصفقات في PostgreSQL                        | نشر الصفقات عبر Kafka إلى المقاصة والتقارير | مقاصة مستقلة ورقابة على كل حدث                                                      |

والذي لا تبنيه في اليوم الأول: لا تضع الدفتر في قاعدة بيانات وتقفل السطور لكل أمر، ولا تقسم المحرك إلى خدمات صغيرة لكل نوع أمر، ولا تشغل عدة خيوط على دفتر سهم واحد بأقفال، ولا تبدأ بتجاوز النواة Kernel Bypass والشبكات الخاصة، والسبب أن المختبر يبين أن خيطاً واحداً بلغة عادية يكفي لأحمال كبيرة، وأن ما يصعب تغييره لاحقاً هو شيء آخر: الأرقام التسلسلية، والسجل الذي يكتب قبل التنفيذ، والحتمية، والمبالغ بأعداد صحيحة. فهذه يجب أن تكون موجودة من أول يوم.

### ماذا لو تعطل جزء منها؟

| ماذا لو تعطل              | الأثر                                       | كيف تخفف الأثر                                                                                                            |
| ------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| عملية المحرك              | توقف المطابقة للأسهم التي يملكها هذا المحرك | النسخة الحية تعيد السجل نفسه فتصل إلى الدفتر نفسه، أو إعادة التشغيل من Snapshot وبقية السجل كما في المختبر                |
| قرص السجل                 | لا يمكن قبول أوامر جديدة بأمان              | نسخ السجل إلى سيرفر آخر قبل الرد، والتحقق من CRC لكل سجل عند القراءة، ورفض الأوامر بدل قبولها دون تثبيت                   |
| بوابة الأوامر             | الوسطاء المتصلون بها لا يستطيعون الإرسال    | عدة بوابات، وإلغاء أوامر الجلسة عند الانقطاع إذا طلبها العضو، وهي الخاصية التي تسميها تداول السعودية Cancel on Disconnect |
| ناشر بيانات السوق         | الشاشات لا تتحدث، والمطابقة تستمر           | كل رسالة برقم، والمشترك يكتشف الفجوة ويطلب صورة جديدة من الدفتر                                                           |
| المقاصة أو قاعدة التقارير | تأخر التقارير والتسوية                      | الصفقات محفوظة في السجل وفي Kafka، وتعاد بالترتيب بعد العودة، والمفتاح الفريد يمنع التكرار                                |
| الموقع كله                | توقف السوق                                  | موقع بديل يستقبل السجل المنسوخ، وخطة مكتوبة ومجربة لمتى يعاد فتح السوق وبأي أوامر، كما سوف نرى في قصة بورصة طوكيو         |

## أنواع الأوامر ومدة صلاحيتها

كل أمر يجيب عن سؤالين: بأي سعر؟ وإلى متى؟ والإجابة عن السؤال الأول تعطيك نوع الأمر:

- **أمر السوق Market Order:** اشتر أو بع الآن بأفضل سعر موجود، ولا يحدد سعراً، لذلك قد ينفذ على عدة مستويات بأسعار أسوأ مما رأيت على الشاشة. وفي المختبر يحجز المحرك لأمر الشراء من السوق ما يكفي لأعلى سعر في النطاق، لأنه لا يعرف السعر النهائي مسبقاً.
- **الأمر المحدد Limit Order:** اشتر بهذا السعر أو أقل، أو بع بهذا السعر أو أعلى، فإذا لم يوجد ما يقابله انتظر في الدفتر.
- **أمر الإيقاف Stop Order:** لا يدخل الدفتر أصلاً، وإنما ينتظر جانباً حتى يصل السعر إلى سعر التفعيل Stop Price، ثم يتحول إلى أمر سوق. ويستخدمه المتداول عادة لإيقاف الخسارة Stop-Loss، فأمر بيع بإيقاف عند 19.90 يعني إذا نزل السعر إلى 19.90 فبع.
- **أمر الإيقاف المحدد Stop-Limit Order:** مثل السابق، ولكنه يتحول عند التفعيل إلى أمر محدد بسعر تختاره، فلا يبيع بأقل منه، والثمن أنه قد لا ينفذ أبداً إذا قفز السعر فوقه.

وأما السؤال الثاني فإجابته صلاحية الأمر Time in Force:

- **الصلاحية GTC أي Good Till Cancel:** يبقى في الدفتر حتى ينفذ أو يلغى.
- **الصلاحية IOC أي Immediate or Cancel:** ينفذ ما يمكن الآن، ويلغى الباقي فوراً دون أن يدخل الدفتر.
- **الصلاحية FOK أي Fill or Kill:** إما أن ينفذ كله الآن، أو لا ينفذ منه شيء.

ولاحظ أن الأسواق تسمي هذه الأنواع بأسماء مختلفة، ففي صفحة [نظرة عامة على السوق المالية](https://www.saudiexchange.sa/wps/portal/saudiexchange/rules-guidance/capital-market-overview?ref=arabroot.io) في موقع تداول السعودية نجد Fill and Kill بمعنى IOC، وسوف نعرض القائمة كاملة في قسم [السوق السعودية](#saudi).

## دفتر الأوامر Order Book من الداخل

دفتر الأوامر Order Book لكل سهم جهتان: الشراء Bids مرتبة من أعلى سعر إلى أقل، والبيع Asks مرتبة من أقل سعر إلى أعلى، والفرق بين أعلى شراء وأقل بيع يسمى الفارق Spread. وكل سعر فيه مستوى Price Level، وكل مستوى طابور FIFO من الأوامر بترتيب وصولها. والصورة التالية تبين الدفتر من المختبر لحظة وصول أمر شراء 250 سهماً بسعر 20.05:

![على اليسار سلم أسعار: البيع في الأعلى بأسعار 20.15 وفيه الأمر 6 بكمية 400، و20.10 وفيه الأمر 3 بكمية 150، و20.05 وفيه الأمر 1 بكمية 100 ثم الأمر 2 بكمية 200. ثم الفارق بين 19.95 و20.05. والشراء في الأسفل بأسعار 19.95 و19.90 و19.85 بثلاثمئة لكل منها. ويصل أمر شراء رقم 4 بكمية 250 بسعر 20.05، فينفذ أولاً 100 من الأمر 1، ثم 150 من الأمر 2 ويبقى منه 50. وعلى اليمين كيف يحفظه المحرك: مصفوفة فيها خانة لكل هللة من 18.00 إلى 22.00 أي 401 خانة، ولكل خانة قائمة مترابطة من الأوامر، ومؤشر لأفضل بيع عند 20.05 وأفضل شراء عند 19.95](https://arabroot.io/content/images/2026/10/trading-engine-01-order-book.webp)

السعر الأفضل أولاً، وفي السعر نفسه الأقدم أولاً، والسعر المنفذ هو سعر الأمر المنتظر

وفي الصورة أعلاه ثلاث قواعد سوف تراها في مخرجات المختبر: الأولى أن الأمر رقم 1 نفذ قبل الأمر رقم 2 مع أن السعر واحد، لأنه وصل أولاً، والثانية أن الأمر رقم 2 نفذ منه جزء وبقي الباقي في مكانه من الطابور، وهذا هو التنفيذ الجزئي Partial Fill، والثالثة أن الصفقة تمت بسعر الأمر المنتظر، والذي يسمى صانع السيولة Maker، وليس بسعر الأمر القادم Taker، فلو جاء أمر شراء بسعر 20.50 لنفذ أيضاً بسعر 20.05، لأن هذا هو أفضل سعر متاح له.

وقد يتساءل البعض: ما أفضل بنية بيانات لحفظ المستويات؟ والإجابة أن ذلك يعتمد على ما تعرفه عن الأسعار مسبقاً، وأمامك ثلاثة خيارات معروفة:

| البنية                                         | إضافة مستوى جديد        | إيجاد أفضل سعر                                | متى تناسب                                                                    |
| ---------------------------------------------- | ----------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
| جدول Hash من السعر إلى المستوى، مع Heap للأفضل | سريعة في المتوسط        | من رأس ال Heap، ولكن حذف مستوى من وسطه مكلف   | نموذج أولي، أو أسعار غير محدودة                                              |
| شجرة متوازنة Balanced Tree مثل Red-Black       | O(log n)                | أول عنصر في الشجرة، ثم الذي يليه بالترتيب     | أسعار بلا حدود معروفة، كالعملات الرقمية بخانات عشرية كثيرة                   |
| مصفوفة بخانة لكل وحدة سعر Tick                 | ثابتة O(1) بحساب الفهرس | مؤشر لأفضل خانة، ومسح قصير عندما يفرغ المستوى | عندما يكون للسعر حد أدنى وأعلى معروفان، وهذا هو حال الأسهم ذات النطاق اليومي |

والمختبر يستخدم المصفوفة، والسبب أن السوق السعودية تحدد للسعر نطاقاً يومياً بنسبة من سعر الإغلاق السابق، وتحدد وحدة تغير السعر Tick بحسب شريحة السعر، فسهم أغلق على 20.00 لا يمكن أن يتداول اليوم إلا بين 18.00 و22.00 بوحدة هللة واحدة، أي 401 سعراً فقط، ومصفوفة بهذا الحجم صغيرة جداً، والوصول إلى أي مستوى فيها عملية حسابية واحدة. وكل مستوى قائمة مترابطة Linked List، لأن الإلغاء من وسط الطابور يجب أن يكون سريعاً مثل الإضافة إلى آخره، وكل أمر يعرف المستوى الذي يعيش فيه. والدالة التالية من المختبر هي قلب المحرك كله:

```go
func (e *Engine) match(o *Order) {
	if o.TIF == FOK && e.available(o) < int64(o.Rem) {
		if e.Log != nil {
			e.logf("KILL    #%d FOK: only %d available of %d", o.ID, e.available(o), o.Rem)
		}
		e.done(o)
		return
	}
	opp := Sell
	if o.Side == Sell {
		opp = Buy
	}
	for o.Rem > 0 {
		lvl := e.best(opp)
		if lvl == nil || !crosses(o, lvl.Price) {
			break
		}
		if lvl.Price < e.blo || lvl.Price > e.bhi {
			e.halted = true
			if e.Log != nil {
				e.logf("HALT    next trade at %s is outside the breaker band %s..%s", sar(lvl.Price), sar(e.blo), sar(e.bhi))
			}
			break
		}
		for o.Rem > 0 && lvl.head != nil {
			r := lvl.head
			q := min(o.Rem, r.Rem)
			e.fill(o, r, lvl, q)
			if r.Rem == 0 {
				e.unlink(lvl, r)
				e.done(r)
			}
		}
		e.l2(opp, lvl)
		e.fixBest()
	}
	if o.Rem > 0 && o.Kind == Limit && o.TIF == GTC && !e.halted {
		e.rest(o)
	} else if o.Rem > 0 {
		if o.Rem < o.Qty || o.Kind != Limit || o.TIF != GTC {
			if e.Log != nil {
				e.logf("EXPIRE  #%d rem=%d (%s)", o.ID, o.Rem, expireWhy(o, e.halted))
			}
		}
		e.done(o)
	} else {
		e.forget(o)
	}
	e.checkStops()
}
```

في الكود أعلاه لاحظ التالي:

- أمر FOK يفحص السيولة المتاحة أولاً دون أن يغير شيئاً، فإذا لم تكف خرج دون أي صفقة.
- الحلقة الخارجية تمر على المستويات من الأفضل، والداخلية تمر على طابور المستوى من أوله، وهذا هو السعر ثم الوقت بالضبط.
- قبل التنفيذ على أي مستوى يتحقق المحرك من نطاق القاطع Circuit Breaker، فإذا كان السعر خارجه أوقف التداول ولم ينفذ، وسوف نرى أثر ذلك في [تجربة السلسلة](#lab-cascade).
- بعد الانتهاء يبقى الأمر المحدد GTC في الدفتر، أما أمر السوق وIOC فيلغى ما بقي منهما، ثم يفحص المحرك أوامر الإيقاف لأن آخر سعر تغير.

## لماذا خيط واحد لكل مجموعة أسهم؟

الحل البسيط عندما تريد سرعة أن تشغل عدة خيوط Threads على الدفتر نفسه، وتحمي الدفتر بقفل Lock، وهذا الحل يفشل في شيئين: الأول أن الخيوط سوف تقضي وقتها في انتظار القفل بدل العمل، والثاني أن ترتيب دخول الخيوط إلى القفل غير محدد، فلا تستطيع أن تضمن أن التشغيل الثاني سوف يعطي النتيجة نفسها. وقد شرحنا الأقفال والسباق بينها في [مقال الخيوط](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-threads-%D9%88%D8%A7%D9%84%D8%A3%D9%82%D9%81%D8%A7%D9%84-%D9%88-async-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/)، وفي التداول الجواب المعروف هو العكس تماماً: خيط واحد يملك الدفتر، ولا يكتب فيه غيره، وهذا يسمى مبدأ الكاتب الواحد Single Writer Principle.

وأشهر من كتب عن هذا هو فريق LMAX، وهي منصة تداول في لندن، ففي [مقال Martin Fowler عن معمارية LMAX](https://martinfowler.com/articles/lmax.html?ref=arabroot.io) المنشور في يوليو 2011 أن كل منطق الأعمال، أي كل الصفقات لكل العملاء في كل الأسواق، يعمل على خيط واحد، وأن هذا الخيط يعالج 6 ملايين أمر في الثانية على جهاز عادي، وأن حالة النظام كلها يمكن اشتقاقها من الأحداث الداخلة، لذلك تحفظ هذه الأحداث في سجل Journal قبل التنفيذ، وتنسخ إلى سيرفرات أخرى تعالج الأحداث نفسها، ويهمل خرج الجميع إلا واحداً، فإذا سقط انتقل العمل إلى غيره. ويذكر المقال أيضاً أنهم يأخذون Snapshot كل ليلة، وأن إعادة تشغيل النظام كله مع قراءة سجل يوم كامل تأخذ أقل من دقيقة.

ولماذا الخيط الواحد أسرع من عدة خيوط؟ في [الورقة التقنية لمكتبة Disruptor](https://lmax-exchange.github.io/disruptor/disruptor.html?ref=arabroot.io) من الفريق نفسه تجربة بسيطة تزيد عداداً 500 مليون مرة: خيط واحد دون قفل أخذ 300 ملي ثانية، وخيط واحد مع قفل أخذ 10,000 ملي ثانية، وخيطان مع قفل أخذا 224,000 ملي ثانية. والفكرة أن المعالج الحديث سريع جداً عندما تبقى البيانات في ذاكرته القريبة Cache، وأي تنافس بين خيطين على الكتابة في المكان نفسه يجبر المعالجات على تبادل هذه الذاكرة، ولهذا تقول الورقة إن الخوارزمية المثالية هي أن يملك خيط واحد كل الكتابة على المورد، ويقرأ الآخرون النتيجة.

ولكن الخيط الواحد له حد: ما يفعله يجب أن يكون في الذاكرة فقط، فلا يقرأ من قاعدة بيانات، ولا ينتظر الشبكة، ولا يكتب على القرص بنفسه. لذلك يقسم العمل في طابور: خيط يستقبل من الشبكة، وخيط يكتب السجل، وخيط واحد للمطابقة، ويمرر كل منهم إلى الذي يليه عبر حلقة محجوزة مسبقاً Ring Buffer، وهذه هي مكتبة Disruptor. والتوسع بعد ذلك يكون بتقسيم الأسهم بين عدة محركات، كما في المحركين A وB في المعمارية أعلاه، وليس بعدة خيوط على سهم واحد.

## أوامر الإيقاف Stop Orders: متى تتفعل بالضبط؟

أمر الإيقاف Stop Order ينتظر شرطاً على السعر، والسؤال الذي يبدو بسيطاً هو: أي سعر؟ والإجابة أن المنصات تختلف، ويجب أن تقرر ذلك في تصميمك وتكتبه للمستخدم:

- **سعر آخر صفقة Last Trade:** في [شرح أنواع أوامر العقود المستقبلية من CME Group](https://www.cmegroup.com/education/courses/things-to-know-before-trading-cme-futures/futures-order-types.html?ref=arabroot.io) أن أمر الإيقاف لا يدخل الدفتر عند قبوله، وإنما يجب أن تفعله صفقة في السوق عند السعر المحدد، ثم يدخل كأمر محدد Stop-Limit أو كأمر سوق بحماية Stop with Protection لا ينفذ خارج نطاق محدد.
- **آخر سعر أو سعر مرجعي:** في [توثيق واجهة العقود المستقبلية في Binance](https://developers.binance.com/docs/derivatives/usds-margined-futures/trade/rest-api/New-Algo-Order?ref=arabroot.io) حقل اسمه `workingType` يحدد ما إذا كان التفعيل بسعر العقد الأخير `CONTRACT_PRICE`، وهو الافتراضي، أو بسعر مرجعي يسمى `MARK_PRICE`.
- **أفضل عرض أو طلب Bid/Ask:** في [توثيق طرق التفعيل لدى الوسيط Interactive Brokers](https://www.interactivebrokers.com/docs/tws-api/doc/orders/trigger-methods?ref=arabroot.io) عدة طرق، منها آخر سعر، وأفضل عرض أو طلب، ومرتان متتاليتان من أيهما، والافتراضي للأسهم آخر سعر، ولعقود الخيارات الأمريكية مرتان متتاليتان من العرض أو الطلب، وللعملات العرض أو الطلب.

ولكل طريقة ثمن: آخر صفقة لا تتغير إلا بصفقة حقيقية، ولكن صفقة واحدة صغيرة بسعر شاذ تكفي لتفعيل كل الأوامر عند ذلك السعر، وأفضل عرض أو طلب يتغير مع كل أمر يدخل أو يلغى حتى دون صفقة، فهو أسهل في التلاعب بأوامر لا يقصد تنفيذها. والمختبر يستخدم آخر صفقة، كما في الدالة التالية:

```go
// checkStops moves every stop whose trigger the last trade price reached into the triggered queue.
// Sell stops fire from the highest stop down, buy stops from the lowest up: the order the price passed them.
func (e *Engine) checkStops() {
	for e.maxSellStop >= 0 && e.lo+int32(e.maxSellStop) >= e.last {
		e.drainStops(&e.sellStops[e.maxSellStop])
		e.fixStops()
	}
	for e.minBuyStop < len(e.buyStops) && e.lo+int32(e.minBuyStop) <= e.last {
		e.drainStops(&e.buyStops[e.minBuyStop])
		e.fixStops()
	}
}
```

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

وهنا تظهر أخطر مشكلة في أوامر الإيقاف، وهي السلسلة Stop Cascade: أمر بيع كبير ينزل بالسعر قليلاً، فيفعل أوامر إيقاف خسارة تحت السعر، وكل منها أمر بيع من السوق ينزل بالسعر أكثر، فيفعل أوامر أخرى تحتها، وهكذا. والذي يحدث هنا أن أمراً واحداً يتحول إلى موجة بيع لم يقررها أحد في تلك اللحظة، وسوف نصنع هذه السلسلة في المختبر ونرى كيف يوقفها القاطع.

📌

في 6 مايو 2010 هبطت الأسواق الأمريكية بحدة ثم عادت خلال دقائق، وفي [التقرير المشترك لهيئتي CFTC وSEC](https://www.sec.gov/news/studies/2010/marketevents-report.pdf?ref=arabroot.io) الصادر في 30 سبتمبر 2010 أن مؤسسة استثمار بدأت برنامج بيع آلي لـ 75,000 عقد من عقود E-Mini بقيمة قرابة 4.1 مليار دولار، ومع اختفاء أوامر الشراء بقي في الدفتر عند الساعة 2:45:28 أقل من 1,050 عقداً من أوامر الشراء، أي أقل من 1% مما كان صباحاً. وعندها أوقفت بورصة CME التداول على العقد خمس ثوان بآلية اسمها Stop Logic Functionality، ويذكر التقرير أنها صممت لمنع تنفيذ سلسلة أوامر إيقاف الخسارة التي كانت سوف تدفع السعر خارج نطاق محدد مسبقاً، وأن أوامر الشراء امتلأت خلال الثواني الخمس، وعاد السعر بعدها إلى الاستقرار. وفي الأسهم نفسها ذكر التقرير أن أكثر من 20,000 صفقة على أكثر من 300 ورقة نفذت بأسعار تبعد 60% أو أكثر عن قيمتها قبل لحظات، بعضها بسنت واحد أو أقل، وأن أوامر إيقاف خسارة من الأفراد كانت ضمن الأوامر التي لاحقت السعر إلى الأسفل. والدرس الهندسي أن خمس ثوان من الإيقاف في المحرك نفسه أعطت السوق ما يحتاجه لتعود أوامر الشراء.

## فحوص ما قبل التداول Pre-Trade Risk

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

- **النطاق السعري Price Band:** أمر بسعر خارج حدود اليوم يرفض قبل أن يدخل، وهذا ما سوف تراه برسالة `price 25.00 outside the band 18.00..22.00`.
- **خطأ الإصبع Fat-Finger:** كمية أو قيمة أكبر من حد معقول لأمر واحد، مثل صفر زائد في الكمية، ترفض حتى لو كان الرصيد يكفي، والسبب أن الخطأ في الإدخال أكثر احتمالاً من أمر حقيقي بهذا الحجم.
- **الرصيد Cash والمركز Position:** أمر الشراء يحجز ثمنه من الرصيد المتاح، وأمر البيع يحجز الأسهم من المركز، والحجز يعاد عند الإلغاء أو عند انتهاء ما بقي. وهذا هو الحجز Hold نفسه الذي شرحناه في [مقال المحفظة](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%85%D8%AD%D9%81%D8%B8%D8%A9-%D8%B1%D9%82%D9%85%D9%8A%D8%A9-ledger/#holds)، مع فرق مهم: أمر الشراء من السوق لا يعرف سعره، فيحجز على أعلى سعر ممكن، وهو حد النطاق.
- **حدود المعدل Rate Limits:** عدد الأوامر في الثانية لكل جلسة، لأن برنامجاً خاطئاً لدى عضو واحد قد يغرق المحرك بأوامر، وهذه البوابة نفسها تحتاج الحماية التي شرحناها في [مقال أدوات الحماية من DDoS](https://arabroot.io/articles/%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%A7%D9%84%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D9%85%D9%86-%D9%87%D8%AC%D9%85%D8%A7%D8%AA-ddos-%D9%85%D8%AF%D8%A7%D8%B1%D8%A9-%D9%88%D8%B9%D9%84%D9%89-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1%D9%83/).

والكود التالي من المختبر يبين هذه الفحوص بالترتيب، ولاحظ أن الحجز يحدث في الدالة نفسها قبل أن ينشأ الأمر، فلا يوجد وقت بين الفحص والحجز يدخل فيه أمر آخر، والسبب هو الخيط الواحد نفسه، فلا يحتاج الكود أي قفل:

```go
func (e *Engine) newOrder(c *Cmd) {
	switch {
	case e.halted:
		e.reject(c, "trading halted")
		return
	case c.Qty <= 0 || c.Qty > e.cfg.MaxQty:
		e.reject(c, fmt.Sprintf("quantity %d above the fat-finger limit %d", c.Qty, e.cfg.MaxQty))
		return
	case (c.Kind == Limit || c.Kind == StopLimit) && (c.Price < e.lo || c.Price > e.hi):
		e.reject(c, fmt.Sprintf("price %s outside the band %s..%s", sar(c.Price), sar(e.lo), sar(e.hi)))
		return
	case (c.Kind == StopMarket || c.Kind == StopLimit) && (c.Stop < e.lo || c.Stop > e.hi):
		e.reject(c, "stop price outside the band")
		return
	case int(c.Acct) >= len(e.accts):
		e.reject(c, "unknown account")
		return
	}
	resv := c.Price
	if c.Kind == Market || c.Kind == StopMarket {
		resv = e.hi // a market buy may pay up to the top of the band
	}
	a := &e.accts[c.Acct]
	if c.Side == Buy {
		need := int64(resv) * int64(c.Qty)
		if a.Cash-a.CashHeld < need {
			e.reject(c, fmt.Sprintf("insufficient cash: needs %s, available %s", sar64(need), sar64(a.Cash-a.CashHeld)))
			return
		}
		a.CashHeld += need
	} else {
		if a.Shares-a.SharesHeld < int64(c.Qty) {
			e.reject(c, "insufficient shares")
			return
		}
		a.SharesHeld += int64(c.Qty)
	}
	o := e.alloc()
	o.ID, o.Acct, o.Side, o.Kind, o.TIF = c.ID, c.Acct, c.Side, c.Kind, c.TIF
	o.Price, o.Stop, o.Qty, o.Rem, o.Resv = c.Price, c.Stop, c.Qty, c.Qty, resv
	e.index(o)
	if o.Kind == StopMarket || o.Kind == StopLimit {
		if (o.Side == Buy && e.last >= o.Stop) || (o.Side == Sell && e.last <= o.Stop) {
			e.triggered = append(e.triggered, o)
		} else {
			e.restStop(o)
		}
		return
	}
	e.match(o)
}
```

## ال Sequencer والسجل Journal وإعادة البناء Replay

إذا كان المحرك حتمياً، فالدفتر في أي لحظة هو نتيجة تطبيق كل الأوامر من بداية اليوم بالترتيب، وهذا يعني أنك لا تحتاج أن تحفظ الدفتر نفسه، وإنما تحتاج أن تحفظ الأوامر وترتيبها. وهذه هي فكرة Event Sourcing التي شرحناها في [مقال Event Sourcing](https://arabroot.io/articles/event-sourcing-%D9%88-cqrs-%D9%85%D8%AA%D9%89-%D8%AA%D8%B3%D8%AA%D8%AD%D9%82-%D8%A7%D9%84%D8%AA%D8%B9%D9%82%D9%8A%D8%AF/)، والمحرك أوضح مثال عليها. والتصميم يتكون من ثلاث قطع:

- **ال Sequencer:** يعطي كل أمر رقماً متسلسلاً بلا فجوات، ومعه وقت الاستلام، وهذا الرقم هو الترتيب الرسمي الذي لا يناقش.
- **السجل Journal:** ملف يقبل الإضافة فقط، يكتب فيه الأمر برقمه قبل أن يصل إلى المحرك، ولكل سجل فيه CRC32 يكشف السجل الناقص أو التالف عند القراءة. وتثبيت الكتابة على القرص fsync مكلف، لذلك يثبت النظام مجموعة أوامر معاً Group Commit، ولا يرسل التأكيد لأي أمر في المجموعة إلا بعد تثبيتها.
- **ال Snapshot:** صورة كاملة للدفتر والأرصدة عند رقم معين، فيبدأ الاسترجاع منها ثم يعيد ما بعدها فقط من السجل، كما شرحنا في [قسم ال Snapshots](https://arabroot.io/articles/event-sourcing-%D9%88-cqrs-%D9%85%D8%AA%D9%89-%D8%AA%D8%B3%D8%AA%D8%AD%D9%82-%D8%A7%D9%84%D8%AA%D8%B9%D9%82%D9%8A%D8%AF/#snapshots).

![في الأعلى المسار الحي: البوابة ثم ال Sequencer الذي يعطي الأرقام 1 و2 و3، ثم شريط سجلات السجل بأرقام 245,999 و246,000 و246,001 حتى 247,000 مع خط أحمر fsync بعد كل ألف، ثم المحرك الذي ينفذ بالترتيب، ثم علامة إيقاف بالقوة kill -9. وفي الأسفل بعد الإيقاف: ملف السجل وملف ال Snapshot عند 200,000، وثلاث طرق: إعادة كاملة من الرقم 1، وSnapshot مع بقية السجل، وتشغيل نظيف بالبذرة نفسها دون إيقاف، وكلها تصل إلى البصمة نفسها](https://arabroot.io/content/images/2026/10/trading-engine-04-journal.webp)

الأمر يكتب في السجل أولاً، والتأكيد يرسل بعد التثبيت، وإعادة السجل من أوله أو من Snapshot تعطي الدفتر نفسه

والأرقام في الصورة من [تجربة الإيقاف بالقوة](#lab-crash) في المختبر. وحتى ينجح هذا يجب أن يلتزم كود المحرك بقواعد الحتمية، وكل واحدة منها رأيناها تكسر أنظمة حقيقية:

- **لا تقرأ الساعة داخل المحرك:** الوقت يأتي من ال Sequencer ويحفظ في السجل، والمحرك لا يقرأ `time.Now()` أبداً، وإلا اختلفت النتيجة عند الإعادة، فانتهاء صلاحية أمر في نهاية اليوم مثلاً يجب أن يكون أمراً في السجل وليس قراراً من ساعة المحرك.
- **لا تمر على Map غير مرتب:** في Go ترتيب المرور على Map عشوائي في كل تشغيل، لذلك كل ما يؤثر في النتيجة يمر على مصفوفات وقوائم مرتبة.
- **لا أرقام عشرية Floating Point في الأسعار والمبالغ:** الأسعار بالهللة في أعداد صحيحة، كما شرحنا في [مقال الأرقام العشرية](https://arabroot.io/articles/%D8%A7%D9%84%D8%A3%D8%B1%D9%82%D8%A7%D9%85-%D8%A7%D9%84%D8%B9%D8%B4%D8%B1%D9%8A%D8%A9-floating-point-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84/#decimal-types).
- **الإعدادات جزء من السجل:** سعر الإغلاق المرجعي والنطاق وحدود الكمية يجب أن تكتب في السجل كأحداث، لأن إعادة السجل نفسه بإعدادات مختلفة تعطي دفتراً مختلفاً. وفي المختبر الإعدادات ثابتة في الكود لتبسيط المثال.

📌

في 1 أكتوبر 2020 توقف التداول على أسهم بورصة طوكيو يوماً كاملاً، وفي [بيان البورصة عن السبب](https://www.jpx.co.jp/english/corporate/news/news-releases/0060/b5b4pj000003qm41-att/arrowhead%5Fe.pdf?ref=arabroot.io) أن وحدة ذاكرة تعطلت في جهاز التخزين المشترك الأول في نظام التداول arrowhead، وكان يفترض أن ينتقل العمل تلقائياً إلى الجهاز الثاني، ولكن الانتقال التلقائي لم يكن يعمل لهذا النوع من الأعطال بالإعدادات التي كانت عليه. والجزء الأهم لمن يبني محركاً في [تقرير لجنة التحقيق](https://www.jpx.co.jp/english/corporate/news/news-releases/0020/b5b4pj000003xrsa-att/InvestigationReport.pdf?ref=arabroot.io) الصادر في 30 نوفمبر 2020: إيقاف التداول تم بقطع الشبكة، ولكن المعالجة نفسها لم تتوقف، فكانت هناك صفقات نفذت داخل النظام على أوامر استلمت حتى 08:54 ولم ترسل إشعاراتها للأعضاء، وأعلنت البورصة أن هذه الصفقات غير صالحة. ولم تجد البورصة طريقة في المعالجة العادية لحذف هذه الإشعارات المعلقة إذا استأنفت دون إعادة تشغيل، وإعادة التشغيل تمسح كل الأوامر المقبولة، بينما أنظمة الأعضاء تظن أن أوامرها موجودة، فقررت إيقاف التداول لبقية اليوم. والدرس أن الاسترجاع ليس إعادة تشغيل البرنامج فقط، وإنما معرفة دقيقة لما رآه كل طرف عند كل رقم في السجل، وإجراء مكتوب ومجرب لما بعده.

## بيانات السوق Market Data

كل تغيير في الدفتر يجب أن يصل إلى الشاشات والبرامج التي تتابع السهم، وتقسم بيانات السوق Market Data عادة إلى ثلاثة مستويات:

- **المستوى L1:** أفضل سعر شراء وأفضل سعر بيع وكمية كل منهما، وآخر صفقة، وهو ما يراه أغلب المستخدمين في التطبيق.
- **المستوى L2 أو عمق السوق Market Depth:** مجموع الكمية عند كل مستوى سعر لعدد من المستويات، دون تفاصيل الأوامر، وهذا ما يخرجه المختبر في البنية `MD`.
- **المستوى L3:** كل أمر بمفرده: أضيف، نفذ منه جزء، ألغي. ومثال عليه مواصفة [Nasdaq TotalView-ITCH](https://nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHspecification.pdf?ref=arabroot.io) التي فيها رسالة Add Order لكل أمر يدخل الدفتر، ثم رسائل Order Executed وOrder Cancel وOrder Delete وOrder Replace، ومنها يبني المشترك الدفتر كاملاً عنده.

والطريقة البسيطة أن ترسل الدفتر كاملاً مع كل تغيير، وهذا لا يصمد مع آلاف التحديثات في الثانية، لذلك ترسل التحديثات فقط Incremental Updates، ولكل رسالة رقم تسلسلي، والمشترك يبدأ بصورة كاملة Snapshot لها رقم، ثم يطبق كل تحديث بعده بالترتيب، فإذا وجد فجوة في الأرقام عرف أنه فقد شيئاً، فيطلب صورة جديدة بدل أن يعرض دفتراً خاطئاً. وأما النقل فطريقتان: WebSocket لكل مشترك على حدة عبر الإنترنت، وهو ما شرحناه في [قسم سيرفرات الاتصال في مقال تطبيق المحادثة](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D9%85%D8%AD%D8%A7%D8%AF%D8%AB%D8%A9-%D9%85%D8%AB%D9%84-whatsapp/#gateways) وفي [مقال تتبع المندوبين لحظياً](https://arabroot.io/articles/%D8%AA%D8%AA%D8%A8%D8%B9-%D9%85%D9%88%D8%A7%D9%82%D8%B9-%D8%A7%D9%84%D9%85%D9%86%D8%AF%D9%88%D8%A8%D9%8A%D9%86-%D9%84%D8%AD%D8%B8%D9%8A%D8%A7/)، وUDP Multicast داخل شبكة السوق، حيث يرسل الناشر الرسالة مرة واحدة وتنسخها الشبكة لكل المشتركين، ومواصفة ITCH نفسها تذكر نسخة MoldUDP64\. وال UDP لا يعيد الرسائل المفقودة، لذلك يأتي معه دائماً خادم يعيد ما فات بالرقم التسلسلي.

## ميزانية الزمن Latency Budget: أين تذهب المايكروثانية؟

ميزانية الزمن Latency Budget هي أن تقرر هدفاً للرحلة كاملة، ثم توزعه على المراحل، وتقيس كل مرحلة وحدها، لأن الرقم الكلي وحده لا يخبرك أين المشكلة. والجدول التالي مثال لهدف نفترضه من دخول الأمر إلى بوابة السوق حتى خروج التأكيد، وكل أرقامه افتراضات للتوضيح، ما عدا ما كتبنا بجانبه أنه من المختبر:

| المرحلة            | ما يحدث فيها                               | نصيبها من الميزانية                               | ما يجعلها أطول                                                                                              |
| ------------------ | ------------------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| الشبكة إلى البوابة | المسافة والسويتشات بين سيرفر الوسيط والسوق | خارج الميزانية، تقاس وحدها                        | المسافة: الضوء في الألياف الضوئية يقطع قرابة 200 ألف كيلومتر في الثانية، أي قرابة 5 مايكروثانية لكل كيلومتر |
| البوابة            | قراءة الرسالة والتحقق من الجلسة            | افتراض: 20 مايكروثانية                            | تحويل النصوص، وحجز الذاكرة لكل رسالة                                                                        |
| فحص المخاطر        | النطاق والكمية والرصيد من الذاكرة          | افتراض: 5 مايكروثانية                             | أي قراءة من قاعدة بيانات أو من الشبكة                                                                       |
| السجل              | الرقم والكتابة والتثبيت أو النسخ           | افتراض: 50 مايكروثانية بالنسخ إلى ذاكرة سيرفر آخر | التثبيت fsync على القرص: في المختبر قرابة 2.5 ملي ثانية للتثبيت الواحد، أي خمسون ضعف الميزانية كلها         |
| المطابقة           | الدفتر وأوامر الإيقاف                      | افتراض: 10 مايكروثانية                            | من المختبر: القيمة الوسطى p50 قرابة 0.2 والذيل p999 قرابة 12 مايكروثانية، و80 مع حجز الذاكرة لكل أمر        |
| التأكيد            | بناء تقرير التنفيذ وإرساله                 | افتراض: 15 مايكروثانية                            | تحويل JSON، ونسخ البيانات                                                                                   |

ولاحظ في الجدول أن السجل هو أكبر بند، ولهذا يأخذ أكثر الجهد في الأنظمة الحقيقية، ولاحظ أيضاً أن الذيل هو الذي يكسر الميزانية وليس المتوسط، وهنا يدخل حجز الذاكرة وجامع القمامة Garbage Collector. ففي اللغات التي فيها جامع قمامة مثل Java وGo وC#، كل كائن جديد يعني عملاً لاحقاً على الجامع، وهذا العمل إما توقف قصير لكل الخيوط، وإما مساعدة يفرضها الجامع على الخيط الذي يحجز كثيراً، وإما بطء في الكتابة على الذاكرة أثناء عمله، وقد شرحنا كيف يعمل في [قسم جامع القمامة في مقال الذاكرة](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86-stack-%D9%88-heap-%D9%88%D8%A7%D9%84%D9%85%D8%A4%D8%B4%D8%B1%D8%A7%D8%AA/#gc). وورقة Disruptor تذكر أن الحلقة تحجز كل عناصرها عند البدء، فتبقى حية طوال عمر البرنامج ولا تمثل عبئاً على الجامع، وهذا هو الأسلوب العام: احجز مرة واحدة، وأعد استخدام ما حجزته. وسوف نقيس الفرق بالأرقام في [تجربة حجز الذاكرة](#lab-gc).

وأما تجاوز النواة Kernel Bypass فهو أن تصل حزم الشبكة من بطاقة الشبكة إلى برنامجك مباشرة دون أن تمر بطبقة الشبكة في نظام التشغيل، فيختفي جزء من الزمن ومن تذبذبه، ولكنه يحتاج بطاقات وبرامج خاصة، ولا يفيدك قبل أن تقيس وتجد أن الشبكة داخل السيرفر هي البند الأكبر عندك. وقبل ذلك كله ابدأ بأدوات القياس التي شرحناها في [مقال تحليل أداء Linux](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D8%AF%D8%A7%D8%A1-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1-linux-%D9%81%D9%8A-60-%D8%AB%D8%A7%D9%86%D9%8A%D8%A9/)، وبقياس النسب المئوية كما في [قسم زمن الاستجابة في مقال اختبار الأحمال](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%AD%D9%85%D8%A7%D9%84-%D9%88%D8%A7%D9%84%D8%B6%D8%BA%D8%B7-load-%D9%88-stress-testing-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#latency).

## حماية السوق: النطاقات والقواطع والعدالة والتدقيق

المحرك الصحيح تقنياً قد ينتج سوقاً سيئاً، لذلك يضاف إليه ما يحمي السوق من الأخطاء ومن الحركة الحادة:

- **النطاق السعري الثابت Static Price Band:** حد يومي بنسبة من السعر المرجعي، وأي أمر خارجه يرفض، وهو في المختبر 10% كما في السوق الرئيسية في المملكة.
- **القاطع Circuit Breaker:** إذا كانت الصفقة التالية سوف تتم بسعر يبعد أكثر من حد معين عن السعر المرجعي، يتوقف التداول بدل أن ينفذ، ثم يستأنف بعد فترة أو بمزاد يجمع الأوامر. والمختبر يوقف فقط ولا ينفذ مزاداً، لتبقى الفكرة واضحة.
- **العدالة Fairness:** قاعدة السعر ثم الوقت، والرقم التسلسلي الذي يقرر الوقت، والنشر للجميع في الوقت نفسه. ومن التفاصيل اللطيفة هنا أن تداول السعودية تذكر أن وقت انتهاء مزاد الافتتاح متغير بين 10:00:00 و10:00:30، وأن وقت الإغلاق عشوائي بين 15:00:00 و15:10:30، ومن الطبيعي أن يكون الهدف أن لا يعرف أحد اللحظة الأخيرة بالضبط ليضع أمره فيها.
- **التدقيق Audit:** كل أمر وكل إلغاء وكل صفقة يحفظ بوقته ورقمه، والسجل نفسه هو أساس هذا التدقيق، ويضاف إليه ما شرحناه في [مقال سجل التدقيق](https://arabroot.io/articles/%D8%B3%D8%AC%D9%84-%D8%A7%D9%84%D8%AA%D8%AF%D9%82%D9%8A%D9%82-audit-trail-%D9%85%D9%86-%D9%81%D8%B9%D9%84-%D9%85%D8%A7%D8%B0%D8%A7-%D9%88%D9%85%D8%AA%D9%89/) لمنع العبث به. والرقابة Surveillance تقرأ هذه الأحداث وتبحث عن أنماط التلاعب، وسوف نرى ما تذكره الأنظمة السعودية منها.

## السوق السعودية: الأوقات والحدود وأنواع الأوامر

المعلومات في هذا القسم من صفحتين في موقع تداول السعودية قرأناهما في 13 أكتوبر 2026: [دورة التداول وأوقاته](https://www.saudiexchange.sa/wps/portal/saudiexchange/rules-guidance/capital-market-overview/trading-cycle-and-times?ref=arabroot.io) و[نظرة عامة على السوق المالية](https://www.saudiexchange.sa/wps/portal/saudiexchange/rules-guidance/capital-market-overview?ref=arabroot.io)، وهذه الأرقام تتغير بقرارات السوق، فارجع إلى الصفحتين قبل أن تعتمد عليها في أي كود:

| البند                                 | ما تذكره الصفحة للسوق الرئيسية                                                                                                                       |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| أيام التداول                          | من الأحد إلى الخميس عدا الإجازات الرسمية                                                                                                             |
| مزاد الافتتاح                         | من 9:30 إلى 10:00، وينتهي في وقت متغير حتى 10:00:30                                                                                                  |
| التداول المستمر                       | من 10:00 إلى 15:00                                                                                                                                   |
| مزاد الإغلاق                          | من 15:00 إلى 15:10، والإغلاق في وقت عشوائي حتى 15:10:30                                                                                              |
| التداول على سعر الإغلاق Trade at Last | من 15:10 إلى 15:20                                                                                                                                   |
| طريقة المطابقة                        | بالسعر ثم الأولوية الزمنية، على نظام X-Stream INET من Nasdaq                                                                                         |
| أنواع الأوامر                         | أمر محدد Limit وأمر سوق Market                                                                                                                       |
| شروط الأوامر                          | الأمر المخفي Hidden بحد أدنى 50,000 سهم ويظهر منه 5% على الأقل، وFill and Kill، وFill or Kill، والإلغاء عند الانقطاع Cancel on Disconnect            |
| صلاحية الأوامر                        | يومي Day، وللجلسة Session، وحتى تاريخ Good Till Date، وحتى الإلغاء Good Till Cancel                                                                  |
| حدود التذبذب اليومي للأسهم            | 10% صعوداً ونزولاً عن سعر إغلاق اليوم السابق، و30% في أول ثلاثة أيام للأسهم المدرجة حديثاً مع حد ثابت 10% عن السعر المرجعي في تلك الأيام             |
| وحدة تغير السعر Tick                  | هللة واحدة تحت 25 ريالاً، وهللتان من 25 إلى 49.98، وخمس هللات من 50 إلى 99.95، وعشر من 100 إلى 249.90، وعشرون من 250 إلى 499.80، وخمسون من 500 فأعلى |
| دورة التسوية                          | T+2                                                                                                                                                  |

ولاحظ أن الصفحة تذكر نوعين فقط من الأوامر، ولا تذكر أمر الإيقاف Stop Order، فإذا رأيت أمر إيقاف في تطبيق وسيط فالمنطقي أن نظام الوسيط يحتفظ به ويراقب السعر، ثم يرسل إلى السوق أمراً محدداً أو أمر سوق عند التفعيل، وتأكد من ذلك من شروط وسيطك. وهذا يعني أن قواعد التفعيل التي ناقشناها في [قسم أوامر الإيقاف](#stop-orders) قد تكون قواعد الوسيط وليس السوق.

وأما الأنظمة فمن هيئة السوق المالية، وقرأنا منها ثلاثة في نسخها الإنجليزية المنشورة على موقع الهيئة، والنص الرسمي هو العربي:

- [**لائحة أعمال الأوراق المالية**](https://cma.gov.sa/en/RulesRegulations/Regulations/Documents/Amended%20Securities%20Business%20Regulations.pdf?ref=arabroot.io) بتعديلها الصادر في 12 أغسطس 2020: تعرف التعامل Dealing بأنه بيع الورقة المالية أو شراؤها بصفة أصيل أو وكيل، وتنص المادة الخامسة على أنه لا يجوز لأي شخص ممارسة أعمال الأوراق المالية في المملكة إلا إذا كان مؤسسة سوق مالية مرخصاً لها من الهيئة أو شخصاً معفى، وتعد المادة السادسة مخالفة ذلك جريمة بحسب المادة 60 من نظام السوق المالية. فتطبيق يستقبل أوامر الناس ويرسلها إلى السوق هو تعامل يحتاج ترخيصاً.
- [**لائحة السوق المالية ومراكز الإيداع**](https://cma.gov.sa/en/RulesRegulations/Regulations/Documents/Securities%20ExchangesAndDepositoryCentersRegulationsEn.pdf?ref=arabroot.io) الصادرة في 22 يونيو 2022: تشترط لترخيص السوق أن يكون شركة مساهمة، وألا يقل رأس المال المدفوع للسوق الرئيسية عن 50 مليون ريال، وتترك رأس مال أنظمة التداول البديلة لتقدير الهيئة. وفيها مواد تمس تصميم المحرك مباشرة: المادة 37 تطلب ضوابط تخفف خطر تنفيذ الأوامر بأسعار غير طبيعية، وآلية لاكتشاف هذه الأوامر وإلغائها، والمادة 41 تطلب مزامنة الوقت في كل الأنظمة، وختم كل أمر مدخل أو معدل أو ملغى بوقت عند استلامه، وختم كل صفقة عند المطابقة، وإبلاغ الأعضاء بأوامرهم وصفقاتهم فوراً، والمادة 40 تطلب أن تقدم معلومات الصفقات للأعضاء بشكل منصف، وأن تتاح بيانات التداول الأساسية للجمهور مجاناً خلال 15 دقيقة كحد أقصى، والمادة 44 تطلب آليات لمراقبة السوق، والمادة 45 تعطي الهيئة صلاحية أن تطلب إيقاف التداول.
- [**لائحة سلوكيات السوق**](https://cma.gov.sa/en/RulesRegulations/Regulations/Documents/Market%5FConduct%5FRegulations%5FEn.pdf?ref=arabroot.io) بتعديلها الصادر في 25 يناير 2021: تعد من التلاعب صفقة لا تغير الملكية الفعلية، وأوامر متقابلة بالحجم والوقت والسعر نفسها مع العلم المسبق، والشراء بأسعار متصاعدة متتالية أو البيع بأسعار متنازلة متتالية بغرض إيهام السوق، وإدخال أوامر لا يقصد تنفيذها. وهذه بالضبط القواعد التي تبحث عنها الرقابة في أحداث السجل.

وتعرض الهيئة [قائمة مؤسسات السوق المالية المرخصة](https://cma.gov.sa/en/Market/AuthorisedPersons/Pages/default.aspx?ref=arabroot.io) مع نشاط كل منها مثل التعامل Dealing والحفظ Custody، وإذا كانت فكرتك منتجاً تقنياً مالياً جديداً تريد تجربته فاقرأ [تعليمات تصريح تجربة التقنية المالية](https://cma.gov.sa/en/RulesRegulations/Regulations/Documents/FinTech%5Fen.pdf?ref=arabroot.io)، واسأل مستشاراً قانونياً قبل أن تبدأ، ولا تعتمد على هذا الملخص.

## التجربة العملية: محرك مطابقة بلغة Go

في هذه التجربة سوف نبني محرك مطابقة كاملاً لسهم واحد بلغة Go، دون أي مكتبة خارجية، وفيه الأوامر المحددة وأوامر السوق وأوامر الإيقاف بنوعيها، وIOC وFOK، وفحوص ما قبل التداول، والقاطع، والسجل مع التثبيت بالمجموعات، وال Snapshot. واخترنا Go لأنها سريعة بما يكفي وكودها سهل القراءة، ولأن فيها جامع قمامة، فنستطيع أن نرى أثره بأعيننا. والسهم في كل التجارب أغلق أمس على 20.00، فنطاقه اليوم من 18.00 إلى 22.00 بوحدة هللة. وكل الأرقام الزمنية في هذا القسم من هذا الجهاز: معالج Intel Core i7-7700K وDocker Desktop على Windows، وسوف تختلف عندك.

### ملفات التجربة

المجلد فيه أربعة ملفات: `engine.go` فيه الدفتر والمطابقة والإيقاف والفحوص والبصمة، و`journal.go` فيه السجل وال Snapshot، و`main.go` فيه مولد الأوامر والقياس والتشغيل والإعادة، و`scenarios.go` فيه المثال الموجه وتجربة السلسلة. والملفات كاملة فيما يلي:

`engine.go` كاملاً 

```go
package main

// A single-instrument matching engine: one goroutine owns the whole book (single writer).
// Prices are int32 halalas (1 SAR = 100 halalas); the tick is 1 halala, as on Saudi Exchange below SAR 25.

import (
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"fmt"
)

const (
	Buy  uint8 = 0
	Sell uint8 = 1
)

const (
	Limit uint8 = iota
	Market
	StopMarket
	StopLimit
)

const (
	GTC uint8 = iota // rests on the book until filled or cancelled
	IOC              // fill what you can now, cancel the rest
	FOK              // fill everything now, or nothing
)

type Order struct {
	ID              uint64
	Acct            uint32
	Side, Kind, TIF uint8
	Price           int32 // limit price (0 for a market order)
	Stop            int32 // trigger price of a stop order
	Qty, Rem        int32
	Resv            int32 // price used to hold cash for a buy order
	inStops         bool
	next, prev      *Order // FIFO inside one price level
	lvl             *Level
}

type Level struct {
	Price      int32
	Total      int64
	head, tail *Order
}

type Account struct{ Cash, CashHeld, Shares, SharesHeld int64 }

type Trade struct {
	Price, Qty    int32
	BuyID, SellID uint64
}

type MD struct { // one L2 change: the new total quantity at a price level
	Side  uint8
	Price int32
	Total int64
}

type Config struct {
	Ref        int32 // reference price (previous close)
	BandPct    int32 // static price band around Ref: orders outside it are rejected
	BreakerPct int32 // circuit breaker: a trade further than this from Ref halts trading (0 = off)
	MaxQty     int32 // fat-finger limit per order
	Accounts   int
	Cash       int64 // starting cash of every account, halalas
	Shares     int64 // starting shares of every account
	Pooled     bool  // false: new object per order, fresh slices per command (the first version)
	Capacity   int   // expected number of orders, to size the id index once
}

type Engine struct {
	cfg              Config
	lo, hi           int32 // static band
	blo, bhi         int32 // circuit-breaker band
	bids, asks       []Level
	bestBid, bestAsk int     // index of the best level, -1 / len when empty
	buyStops         []Level // stop orders waiting for their trigger, by stop price
	sellStops        []Level
	minBuyStop       int
	maxSellStop      int
	last, low        int32
	halted           bool
	accts            []Account
	triggered        []*Order // stops that fired, run after the current order
	trigHead         int

	// order lookup and memory: slice + slab when pooled, map + new() otherwise
	byIDs []*Order
	byMap map[uint64]*Order
	slab  []Order
	free  []*Order

	Trades     []Trade
	MDs        []MD
	TradeCount int64
	Volume     int64
	StopsFired int
	Rejects    int
	Log        func(string)
}

func NewEngine(cfg Config) *Engine {
	e := &Engine{cfg: cfg}
	e.lo = cfg.Ref * (100 - cfg.BandPct) / 100
	e.hi = cfg.Ref * (100 + cfg.BandPct) / 100
	e.blo, e.bhi = e.lo, e.hi
	if cfg.BreakerPct > 0 {
		e.blo = cfg.Ref * (100 - cfg.BreakerPct) / 100
		e.bhi = cfg.Ref * (100 + cfg.BreakerPct) / 100
	}
	n := int(e.hi-e.lo) + 1
	e.bids, e.asks = make([]Level, n), make([]Level, n)
	e.buyStops, e.sellStops = make([]Level, n), make([]Level, n)
	for i := 0; i < n; i++ {
		p := e.lo + int32(i)
		e.bids[i].Price, e.asks[i].Price, e.buyStops[i].Price, e.sellStops[i].Price = p, p, p, p
	}
	e.bestBid, e.bestAsk = -1, n
	e.minBuyStop, e.maxSellStop = n, -1
	e.last = cfg.Ref
	e.accts = make([]Account, cfg.Accounts)
	for i := range e.accts {
		e.accts[i] = Account{Cash: cfg.Cash, Shares: cfg.Shares}
	}
	if cfg.Pooled {
		e.byIDs = make([]*Order, 1, max(cfg.Capacity, 1<<16)+1)
	} else {
		e.byMap = make(map[uint64]*Order)
	}
	return e
}

func (e *Engine) logf(format string, a ...any) {
	if e.Log != nil {
		e.Log(fmt.Sprintf(format, a...))
	}
}

// ---- memory: the only place where the two versions differ ----

func (e *Engine) alloc() *Order {
	if !e.cfg.Pooled {
		return new(Order)
	}
	if n := len(e.free); n > 0 {
		o := e.free[n-1]
		e.free = e.free[:n-1]
		*o = Order{}
		return o
	}
	if len(e.slab) == cap(e.slab) {
		e.slab = make([]Order, 0, 1<<16) // a new chunk; old chunks stay alive through their orders
	}
	e.slab = e.slab[:len(e.slab)+1]
	return &e.slab[len(e.slab)-1]
}

func (e *Engine) index(o *Order) {
	if !e.cfg.Pooled {
		e.byMap[o.ID] = o
		return
	}
	for uint64(len(e.byIDs)) <= o.ID {
		e.byIDs = append(e.byIDs, nil)
	}
	e.byIDs[o.ID] = o
}

func (e *Engine) lookup(id uint64) *Order {
	if !e.cfg.Pooled {
		return e.byMap[id]
	}
	if id < uint64(len(e.byIDs)) {
		return e.byIDs[id]
	}
	return nil
}

func (e *Engine) forget(o *Order) {
	if !e.cfg.Pooled {
		delete(e.byMap, o.ID)
		return
	}
	e.byIDs[o.ID] = nil
	e.free = append(e.free, o)
}

// ---- commands ----

func (e *Engine) Apply(c *Cmd) {
	if e.cfg.Pooled {
		e.Trades, e.MDs = e.Trades[:0], e.MDs[:0]
	} else {
		e.Trades, e.MDs = nil, nil // every command allocates its own result slices
	}
	switch c.Typ {
	case CmdNew:
		e.newOrder(c)
	case CmdCancel:
		e.cancel(c.ID)
	}
	for e.trigHead < len(e.triggered) && !e.halted {
		o := e.triggered[e.trigHead]
		e.triggered[e.trigHead] = nil
		e.trigHead++
		e.activate(o)
	}
	if e.trigHead == len(e.triggered) {
		e.triggered, e.trigHead = e.triggered[:0], 0
	}
}

func (e *Engine) reject(c *Cmd, why string) {
	e.Rejects++
	if e.Log != nil {
		e.logf("REJECT  #%d acct=%d: %s", c.ID, c.Acct, why)
	}
}

func (e *Engine) newOrder(c *Cmd) {
	switch {
	case e.halted:
		e.reject(c, "trading halted")
		return
	case c.Qty <= 0 || c.Qty > e.cfg.MaxQty:
		e.reject(c, fmt.Sprintf("quantity %d above the fat-finger limit %d", c.Qty, e.cfg.MaxQty))
		return
	case (c.Kind == Limit || c.Kind == StopLimit) && (c.Price < e.lo || c.Price > e.hi):
		e.reject(c, fmt.Sprintf("price %s outside the band %s..%s", sar(c.Price), sar(e.lo), sar(e.hi)))
		return
	case (c.Kind == StopMarket || c.Kind == StopLimit) && (c.Stop < e.lo || c.Stop > e.hi):
		e.reject(c, "stop price outside the band")
		return
	case int(c.Acct) >= len(e.accts):
		e.reject(c, "unknown account")
		return
	}
	resv := c.Price
	if c.Kind == Market || c.Kind == StopMarket {
		resv = e.hi // a market buy may pay up to the top of the band
	}
	a := &e.accts[c.Acct]
	if c.Side == Buy {
		need := int64(resv) * int64(c.Qty)
		if a.Cash-a.CashHeld < need {
			e.reject(c, fmt.Sprintf("insufficient cash: needs %s, available %s", sar64(need), sar64(a.Cash-a.CashHeld)))
			return
		}
		a.CashHeld += need
	} else {
		if a.Shares-a.SharesHeld < int64(c.Qty) {
			e.reject(c, "insufficient shares")
			return
		}
		a.SharesHeld += int64(c.Qty)
	}
	o := e.alloc()
	o.ID, o.Acct, o.Side, o.Kind, o.TIF = c.ID, c.Acct, c.Side, c.Kind, c.TIF
	o.Price, o.Stop, o.Qty, o.Rem, o.Resv = c.Price, c.Stop, c.Qty, c.Qty, resv
	e.index(o)
	if o.Kind == StopMarket || o.Kind == StopLimit {
		if (o.Side == Buy && e.last >= o.Stop) || (o.Side == Sell && e.last <= o.Stop) {
			e.triggered = append(e.triggered, o)
		} else {
			e.restStop(o)
		}
		return
	}
	e.match(o)
}

func (e *Engine) cancel(id uint64) {
	o := e.lookup(id)
	if o == nil {
		return // already filled or cancelled
	}
	if o.lvl != nil {
		lvl := o.lvl
		e.unlink(lvl, o)
		if o.inStops {
			e.fixStops()
		} else {
			e.l2(o.Side, lvl)
			e.fixBest()
		}
	}
	if e.Log != nil {
		e.logf("CANCEL  #%d rem=%d", o.ID, o.Rem)
	}
	e.done(o)
}

// done releases what the order still holds and forgets it.
func (e *Engine) done(o *Order) {
	a := &e.accts[o.Acct]
	if o.Side == Buy {
		a.CashHeld -= int64(o.Resv) * int64(o.Rem)
	} else {
		a.SharesHeld -= int64(o.Rem)
	}
	e.forget(o)
}

func (e *Engine) activate(o *Order) {
	e.StopsFired++
	if o.Kind == StopMarket {
		o.Kind = Market
	} else {
		o.Kind = Limit
	}
	if e.Log != nil {
		e.logf("TRIGGER #%d stop=%s last=%s -> %s", o.ID, sar(o.Stop), sar(e.last), kindName(o.Kind))
	}
	e.match(o)
}

// ---- matching: price first, then time (FIFO inside a level) ----

func (e *Engine) best(side uint8) *Level { // best level on the given side
	if side == Buy {
		if e.bestBid >= 0 {
			return &e.bids[e.bestBid]
		}
		return nil
	}
	if e.bestAsk < len(e.asks) {
		return &e.asks[e.bestAsk]
	}
	return nil
}

func crosses(o *Order, px int32) bool {
	if o.Kind == Market {
		return true
	}
	if o.Side == Buy {
		return o.Price >= px
	}
	return o.Price <= px
}

func (e *Engine) available(o *Order) int64 { // liquidity an FOK order could take right now
	var sum int64
	if o.Side == Buy {
		for i := e.bestAsk; i < len(e.asks) && sum < int64(o.Rem); i++ {
			if !crosses(o, e.asks[i].Price) || e.asks[i].Price > e.bhi {
				break
			}
			sum += e.asks[i].Total
		}
	} else {
		for i := e.bestBid; i >= 0 && sum < int64(o.Rem); i-- {
			if !crosses(o, e.bids[i].Price) || e.bids[i].Price < e.blo {
				break
			}
			sum += e.bids[i].Total
		}
	}
	return sum
}

func (e *Engine) match(o *Order) {
	if o.TIF == FOK && e.available(o) < int64(o.Rem) {
		if e.Log != nil {
			e.logf("KILL    #%d FOK: only %d available of %d", o.ID, e.available(o), o.Rem)
		}
		e.done(o)
		return
	}
	opp := Sell
	if o.Side == Sell {
		opp = Buy
	}
	for o.Rem > 0 {
		lvl := e.best(opp)
		if lvl == nil || !crosses(o, lvl.Price) {
			break
		}
		if lvl.Price < e.blo || lvl.Price > e.bhi {
			e.halted = true
			if e.Log != nil {
				e.logf("HALT    next trade at %s is outside the breaker band %s..%s", sar(lvl.Price), sar(e.blo), sar(e.bhi))
			}
			break
		}
		for o.Rem > 0 && lvl.head != nil {
			r := lvl.head
			q := min(o.Rem, r.Rem)
			e.fill(o, r, lvl, q)
			if r.Rem == 0 {
				e.unlink(lvl, r)
				e.done(r)
			}
		}
		e.l2(opp, lvl)
		e.fixBest()
	}
	if o.Rem > 0 && o.Kind == Limit && o.TIF == GTC && !e.halted {
		e.rest(o)
	} else if o.Rem > 0 {
		if o.Rem < o.Qty || o.Kind != Limit || o.TIF != GTC {
			if e.Log != nil {
				e.logf("EXPIRE  #%d rem=%d (%s)", o.ID, o.Rem, expireWhy(o, e.halted))
			}
		}
		e.done(o)
	} else {
		e.forget(o)
	}
	e.checkStops()
}

func expireWhy(o *Order, halted bool) string {
	if halted {
		return "halted"
	}
	if o.Kind == Market {
		return "market order, no more liquidity"
	}
	return "IOC"
}

func (e *Engine) fill(t, m *Order, lvl *Level, q int32) {
	px := lvl.Price // the resting (maker) order sets the price
	t.Rem -= q
	m.Rem -= q
	lvl.Total -= int64(q)
	buy, sell := t, m
	if t.Side == Sell {
		buy, sell = m, t
	}
	b, s := &e.accts[buy.Acct], &e.accts[sell.Acct]
	b.CashHeld -= int64(buy.Resv) * int64(q)
	b.Cash -= int64(px) * int64(q)
	b.Shares += int64(q)
	s.SharesHeld -= int64(q)
	s.Shares -= int64(q)
	s.Cash += int64(px) * int64(q)
	e.last = px
	if e.low == 0 || px < e.low {
		e.low = px
	}
	e.TradeCount++
	e.Volume += int64(q)
	e.Trades = append(e.Trades, Trade{Price: px, Qty: q, BuyID: buy.ID, SellID: sell.ID})
	if e.Log != nil {
		e.logf("TRADE   %d @ %s  buy #%d  sell #%d  (maker #%d)", q, sar(px), buy.ID, sell.ID, m.ID)
	}
}

func (e *Engine) rest(o *Order) {
	var lvl *Level
	i := int(o.Price - e.lo)
	if o.Side == Buy {
		lvl = &e.bids[i]
		if i > e.bestBid {
			e.bestBid = i
		}
	} else {
		lvl = &e.asks[i]
		if i < e.bestAsk {
			e.bestAsk = i
		}
	}
	e.push(lvl, o)
	e.l2(o.Side, lvl)
	if e.Log != nil {
		e.logf("REST    #%d %s %d @ %s", o.ID, sideName(o.Side), o.Rem, sar(o.Price))
	}
}

func (e *Engine) restStop(o *Order) {
	o.inStops = true
	i := int(o.Stop - e.lo)
	if o.Side == Buy {
		e.push(&e.buyStops[i], o)
		e.minBuyStop = min(e.minBuyStop, i)
	} else {
		e.push(&e.sellStops[i], o)
		e.maxSellStop = max(e.maxSellStop, i)
	}
	if e.Log != nil {
		e.logf("STOP    #%d %s %d when last %s %s", o.ID, sideName(o.Side), o.Rem, map[uint8]string{Buy: ">=", Sell: "<="}[o.Side], sar(o.Stop))
	}
}

// checkStops moves every stop whose trigger the last trade price reached into the triggered queue.
// Sell stops fire from the highest stop down, buy stops from the lowest up: the order the price passed them.
func (e *Engine) checkStops() {
	for e.maxSellStop >= 0 && e.lo+int32(e.maxSellStop) >= e.last {
		e.drainStops(&e.sellStops[e.maxSellStop])
		e.fixStops()
	}
	for e.minBuyStop < len(e.buyStops) && e.lo+int32(e.minBuyStop) <= e.last {
		e.drainStops(&e.buyStops[e.minBuyStop])
		e.fixStops()
	}
}

func (e *Engine) drainStops(lvl *Level) {
	for o := lvl.head; o != nil; {
		next := o.next
		o.next, o.prev, o.lvl, o.inStops = nil, nil, nil, false
		e.triggered = append(e.triggered, o)
		o = next
	}
	lvl.head, lvl.tail, lvl.Total = nil, nil, 0
}

func (e *Engine) fixStops() {
	for e.maxSellStop >= 0 && e.sellStops[e.maxSellStop].head == nil {
		e.maxSellStop--
	}
	for e.minBuyStop < len(e.buyStops) && e.buyStops[e.minBuyStop].head == nil {
		e.minBuyStop++
	}
}

func (e *Engine) fixBest() {
	for e.bestBid >= 0 && e.bids[e.bestBid].head == nil {
		e.bestBid--
	}
	for e.bestAsk < len(e.asks) && e.asks[e.bestAsk].head == nil {
		e.bestAsk++
	}
}

func (e *Engine) push(lvl *Level, o *Order) {
	o.lvl, o.prev, o.next = lvl, lvl.tail, nil
	if lvl.tail != nil {
		lvl.tail.next = o
	} else {
		lvl.head = o
	}
	lvl.tail = o
	lvl.Total += int64(o.Rem)
}

func (e *Engine) unlink(lvl *Level, o *Order) {
	if o.prev != nil {
		o.prev.next = o.next
	} else {
		lvl.head = o.next
	}
	if o.next != nil {
		o.next.prev = o.prev
	} else {
		lvl.tail = o.prev
	}
	lvl.Total -= int64(o.Rem)
	o.next, o.prev, o.lvl = nil, nil, nil
}

func (e *Engine) l2(side uint8, lvl *Level) {
	e.MDs = append(e.MDs, MD{Side: side, Price: lvl.Price, Total: lvl.Total})
}

// Hash is a SHA-256 over everything that defines the state: both books in price and FIFO order,
// the waiting stops, the last price, the halt flag and every account.
func (e *Engine) Hash() string {
	h := sha256.New()
	var b [8]byte
	w := func(v int64) { binary.LittleEndian.PutUint64(b[:], uint64(v)); h.Write(b[:]) }
	for _, side := range [][]Level{e.bids, e.asks, e.buyStops, e.sellStops} {
		for i := range side {
			for o := side[i].head; o != nil; o = o.next {
				w(int64(side[i].Price))
				w(int64(o.ID))
				w(int64(o.Acct))
				w(int64(o.Rem))
				w(int64(o.Kind)<<16 | int64(o.Side)<<8 | int64(o.TIF))
			}
		}
	}
	w(int64(e.last))
	if e.halted {
		w(1)
	}
	for _, a := range e.accts {
		w(a.Cash)
		w(a.CashHeld)
		w(a.Shares)
		w(a.SharesHeld)
	}
	for _, o := range e.triggered[e.trigHead:] { // stops that fired but wait (halt)
		w(int64(o.ID))
		w(int64(o.Rem))
	}
	return hex.EncodeToString(h.Sum(nil))[:16]
}

// Depth prints the top n levels of each side (L2).
func (e *Engine) Depth(n int) string {
	s := ""
	for i, k := e.bestAsk+n-1, 0; i >= e.bestAsk; i-- {
		if i < len(e.asks) && e.asks[i].Total > 0 {
			s += fmt.Sprintf("    ask %s  %6d\n", sar(e.asks[i].Price), e.asks[i].Total)
			k++
		}
	}
	for i, k := e.bestBid, 0; i >= 0 && k < n; i-- {
		if e.bids[i].Total > 0 {
			s += fmt.Sprintf("    bid %s  %6d\n", sar(e.bids[i].Price), e.bids[i].Total)
			k++
		}
	}
	return s
}

func sar(p int32) string   { return fmt.Sprintf("%d.%02d", p/100, p%100) }
func sar64(p int64) string { return fmt.Sprintf("%d.%02d", p/100, p%100) }
func sideName(s uint8) string {
	if s == Buy {
		return "buy"
	}
	return "sell"
}
func kindName(k uint8) string { return [...]string{"limit", "market", "stop", "stop-limit"}[k] }
```

`journal.go` كاملاً 

```go
package main

// The sequencer gives every command a number, and the journal stores it before the engine sees it.
// Replaying the journal from the start (or from a snapshot) rebuilds exactly the same book.

import (
	"bufio"
	"encoding/binary"
	"encoding/gob"
	"encoding/json"
	"errors"
	"hash/crc32"
	"io"
	"os"
)

const (
	CmdNew    uint8 = 1
	CmdCancel uint8 = 2
	RecSize         = 48
)

type Cmd struct {
	Seq             uint64
	TS              int64 // sequencer time (Unix ns): recorded for the audit trail, never read by the engine
	Typ             uint8
	Side, Kind, TIF uint8
	Acct            uint32
	ID              uint64 // the new order's id (= Seq), or the order to cancel
	Price, Stop     int32
	Qty             int32
}

// encode: seq u64 | typ side kind tif | acct u32 | id u64 | price i32 | stop i32 | qty i32 | ts i64 | crc32
func encode(b []byte, c *Cmd) {
	binary.LittleEndian.PutUint64(b[0:], c.Seq)
	b[8], b[9], b[10], b[11] = c.Typ, c.Side, c.Kind, c.TIF
	binary.LittleEndian.PutUint32(b[12:], c.Acct)
	binary.LittleEndian.PutUint64(b[16:], c.ID)
	binary.LittleEndian.PutUint32(b[24:], uint32(c.Price))
	binary.LittleEndian.PutUint32(b[28:], uint32(c.Stop))
	binary.LittleEndian.PutUint32(b[32:], uint32(c.Qty))
	binary.LittleEndian.PutUint64(b[36:], uint64(c.TS))
	binary.LittleEndian.PutUint32(b[44:], crc32.ChecksumIEEE(b[:44]))
}

func decode(b []byte, c *Cmd) bool {
	if crc32.ChecksumIEEE(b[:44]) != binary.LittleEndian.Uint32(b[44:]) {
		return false
	}
	c.Seq = binary.LittleEndian.Uint64(b[0:])
	c.Typ, c.Side, c.Kind, c.TIF = b[8], b[9], b[10], b[11]
	c.Acct = binary.LittleEndian.Uint32(b[12:])
	c.ID = binary.LittleEndian.Uint64(b[16:])
	c.Price = int32(binary.LittleEndian.Uint32(b[24:]))
	c.Stop = int32(binary.LittleEndian.Uint32(b[28:]))
	c.Qty = int32(binary.LittleEndian.Uint32(b[32:]))
	c.TS = int64(binary.LittleEndian.Uint64(b[36:]))
	return true
}

type Journal struct {
	f         *os.File
	w         *bufio.Writer
	buf       [RecSize]byte
	asJSON    bool // the first version: one JSON line per command
	syncEvery int
	pending   int
	Syncs     int
}

func OpenJournal(path string, syncEvery int, asJSON bool) (*Journal, error) {
	var f *os.File
	var err error
	if path == "" {
		f, err = os.OpenFile(os.DevNull, os.O_WRONLY, 0)
	} else {
		f, err = os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
	}
	if err != nil {
		return nil, err
	}
	return &Journal{f: f, w: bufio.NewWriterSize(f, 1<<20), syncEvery: syncEvery, asJSON: asJSON}, nil
}

// Append writes one command. With syncEvery > 0 it flushes and fsyncs every syncEvery commands
// (group commit) and returns true: only then may the commands of the group be acknowledged.
func (j *Journal) Append(c *Cmd) (synced bool, err error) {
	if j.asJSON {
		line, err := json.Marshal(c)
		if err != nil {
			return false, err
		}
		j.w.Write(line)
		j.w.WriteByte('\n')
	} else {
		encode(j.buf[:], c)
		j.w.Write(j.buf[:])
	}
	if j.syncEvery > 0 {
		j.pending++
		if j.pending >= j.syncEvery {
			return true, j.Sync()
		}
	}
	return false, nil
}

func (j *Journal) Sync() error {
	if err := j.w.Flush(); err != nil {
		return err
	}
	j.pending = 0
	j.Syncs++
	return j.f.Sync()
}

func (j *Journal) Close() error {
	j.w.Flush()
	return j.f.Close()
}

// ReadJournal calls fn for every complete, valid record after sequence number from, and stops at the
// first torn or corrupt one. Records have a fixed size and seq starts at 1, so it can seek straight to from.
func ReadJournal(path string, from uint64, fn func(*Cmd)) (n int, tail string, err error) {
	f, err := os.Open(path)
	if err != nil {
		return 0, "", err
	}
	defer f.Close()
	if _, err := f.Seek(int64(from)*RecSize, io.SeekStart); err != nil {
		return 0, "", err
	}
	r := bufio.NewReaderSize(f, 1<<20)
	var b [RecSize]byte
	var c Cmd
	for {
		k, err := io.ReadFull(r, b[:])
		if errors.Is(err, io.EOF) {
			return n, "clean end", nil
		}
		if errors.Is(err, io.ErrUnexpectedEOF) {
			return n, "torn record of " + itoa(k) + " bytes ignored", nil
		}
		if err != nil {
			return n, "", err
		}
		if !decode(b[:], &c) {
			return n, "bad checksum, stopped", nil
		}
		fn(&c)
		n++
	}
}

// ---- snapshots ----

type snapOrder struct {
	ID              uint64
	Acct            uint32
	Side, Kind, TIF uint8
	Price, Stop     int32
	Qty, Rem, Resv  int32
	InStops         bool
}

type Snapshot struct {
	Seq     uint64
	Cfg     Config
	Last    int32
	Low     int32
	Halted  bool
	Accts   []Account
	Orders  []snapOrder // book and stop levels, in price then FIFO order
	Pending []snapOrder // stops that fired but did not run (halted)
}

func (e *Engine) Snapshot(seq uint64, path string) error {
	s := Snapshot{Seq: seq, Cfg: e.cfg, Last: e.last, Low: e.low, Halted: e.halted, Accts: e.accts}
	so := func(o *Order) snapOrder {
		return snapOrder{o.ID, o.Acct, o.Side, o.Kind, o.TIF, o.Price, o.Stop, o.Qty, o.Rem, o.Resv, o.inStops}
	}
	for _, side := range [][]Level{e.bids, e.asks, e.buyStops, e.sellStops} {
		for i := range side {
			for o := side[i].head; o != nil; o = o.next {
				s.Orders = append(s.Orders, so(o))
			}
		}
	}
	for _, o := range e.triggered[e.trigHead:] {
		s.Pending = append(s.Pending, so(o))
	}
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close()
	w := bufio.NewWriter(f)
	if err := gob.NewEncoder(w).Encode(&s); err != nil {
		return err
	}
	if err := w.Flush(); err != nil {
		return err
	}
	return f.Sync()
}

func LoadSnapshot(path string, pooled bool) (*Engine, uint64, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, 0, err
	}
	defer f.Close()
	var s Snapshot
	if err := gob.NewDecoder(bufio.NewReader(f)).Decode(&s); err != nil {
		return nil, 0, err
	}
	s.Cfg.Pooled = pooled
	e := NewEngine(s.Cfg)
	e.last, e.low, e.halted = s.Last, s.Low, s.Halted
	copy(e.accts, s.Accts)
	mk := func(x snapOrder) *Order {
		o := e.alloc()
		o.ID, o.Acct, o.Side, o.Kind, o.TIF = x.ID, x.Acct, x.Side, x.Kind, x.TIF
		o.Price, o.Stop, o.Qty, o.Rem, o.Resv = x.Price, x.Stop, x.Qty, x.Rem, x.Resv
		e.index(o)
		return o
	}
	for _, x := range s.Orders {
		o := mk(x)
		if x.InStops {
			o.inStops = true
			i := int(o.Stop - e.lo)
			if o.Side == Buy {
				e.push(&e.buyStops[i], o)
				e.minBuyStop = min(e.minBuyStop, i)
			} else {
				e.push(&e.sellStops[i], o)
				e.maxSellStop = max(e.maxSellStop, i)
			}
			continue
		}
		i := int(o.Price - e.lo)
		if o.Side == Buy {
			e.push(&e.bids[i], o)
			e.bestBid = max(e.bestBid, i)
		} else {
			e.push(&e.asks[i], o)
			e.bestAsk = min(e.bestAsk, i)
		}
	}
	for _, x := range s.Pending {
		e.triggered = append(e.triggered, mk(x))
	}
	return e, s.Seq, nil
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	s := ""
	for ; n > 0; n /= 10 {
		s = string(rune('0'+n%10)) + s
	}
	return s
}
```

`main.go` كاملاً 

```go
package main

import (
	"flag"
	"fmt"
	"math/rand/v2"
	"os"
	"runtime"
	"slices"
	"time"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("usage: tradelab demo | bench | run | replay | cascade | fsync")
		os.Exit(2)
	}
	args := os.Args[2:]
	switch os.Args[1] {
	case "demo":
		demo()
	case "bench":
		bench(args)
	case "run":
		run(args)
	case "replay":
		replay(args)
	case "cascade":
		cascade(args)
	case "fsync":
		fsyncBench(args)
	default:
		fmt.Println("unknown command", os.Args[1])
		os.Exit(2)
	}
}

// Instrument used everywhere: previous close 20.00 SAR, static band +-10% (18.00..22.00).
func baseConfig() Config {
	return Config{Ref: 2000, BandPct: 10, MaxQty: 100_000, Accounts: 1000, Cash: 1_000_000_000_00, Shares: 50_000_000}
}

// ---- load generator: deterministic for a given seed ----

type Gen struct {
	r      *rand.Rand
	mid    int32
	recent [4096]uint64 // recent order ids, for cancels
	n      int
}

func NewGen(seed uint64) *Gen { return &Gen{r: rand.New(rand.NewPCG(seed, 7)), mid: 2000} }

func (g *Gen) Next(c *Cmd) {
	*c = Cmd{}
	// the mid price walks but is pulled back to 20.00, so the bench stays inside the bands
	g.mid += int32(g.r.IntN(3)) - 1
	if g.mid > 2020 || (g.mid > 2000 && g.r.IntN(4) == 0) {
		g.mid--
	} else if g.mid < 1980 || (g.mid < 2000 && g.r.IntN(4) == 0) {
		g.mid++
	}
	c.Acct = uint32(g.r.IntN(1000))
	c.Side = uint8(g.r.IntN(2))
	c.Qty = int32(1+g.r.IntN(50)) * 10
	x := g.r.IntN(100)
	switch {
	case x < 30 && g.n > 0: // cancel a recent order (it may already be gone)
		c.Typ = CmdCancel
		c.ID = g.recent[g.r.IntN(min(g.n, len(g.recent)))]
	case x < 85: // limit order, mostly passive, some crossing the spread
		c.Typ, c.Kind = CmdNew, Limit
		off := int32(g.r.IntN(12)) - 2
		if c.Side == Buy {
			c.Price = g.mid - off
		} else {
			c.Price = g.mid + off
		}
		if g.r.IntN(10) == 0 {
			c.TIF = IOC
		}
	case x < 95: // market order
		c.Typ, c.Kind, c.TIF = CmdNew, Market, IOC
	default: // stop or stop-limit, away from the mid
		c.Typ = CmdNew
		c.Kind = StopMarket
		d := int32(5 + g.r.IntN(25))
		if c.Side == Buy {
			c.Stop = g.mid + d
		} else {
			c.Stop = g.mid - d
		}
		if g.r.IntN(2) == 0 {
			c.Kind = StopLimit
			c.Price = c.Stop
		}
	}
}

func (g *Gen) Seen(id uint64) { g.recent[g.n%len(g.recent)] = id; g.n++ }

// ---- bench: matching latency per command, in memory ----

func bench(args []string) {
	fs := flag.NewFlagSet("bench", flag.ExitOnError)
	n := fs.Int("n", 2_000_000, "commands")
	pooled := fs.Bool("pooled", true, "slab + id slice + reused buffers (false = new object per order, map, fresh slices)")
	asJSON := fs.Bool("json", false, "journal as one JSON line per command instead of 40-byte binary records")
	seed := fs.Uint64("seed", 42, "generator seed")
	jpath := fs.String("journal", "", "journal file (empty = /dev/null)")
	fs.Parse(args)

	cfg := baseConfig()
	cfg.Pooled = *pooled
	cfg.Capacity = *n
	e := NewEngine(cfg)
	j, err := OpenJournal(*jpath, 0, *asJSON)
	if err != nil {
		panic(err)
	}
	g := NewGen(*seed)
	lat := make([]int32, *n)
	// the floor: two clock reads with nothing between them, on this machine
	for i := range lat {
		t0 := time.Now()
		lat[i] = int32(time.Since(t0))
	}
	slices.Sort(lat)
	fmt.Printf("clock only, us: p50=%.2f p99=%.2f p999=%.2f max=%.2f\n", us(lat, 0.5), us(lat, 0.99), us(lat, 0.999), us(lat, 1))
	var c Cmd
	var seq uint64
	runtime.GC()
	var m0, m1 runtime.MemStats
	runtime.ReadMemStats(&m0)
	start := time.Now()
	for i := 0; i < *n; i++ {
		g.Next(&c)
		t0 := time.Now()
		seq++ // sequencer
		c.Seq, c.TS = seq, time.Now().UnixNano()
		if c.Typ == CmdNew {
			c.ID = seq
		}
		j.Append(&c)
		e.Apply(&c)
		lat[i] = int32(time.Since(t0))
		if c.Typ == CmdNew {
			g.Seen(c.ID)
		}
	}
	el := time.Since(start)
	runtime.ReadMemStats(&m1)
	j.Close()
	slices.Sort(lat)
	q := func(p float64) string { return fmt.Sprintf("%.2f", us(lat, p)) }
	mode := "pooled"
	if !*pooled {
		mode = "alloc"
	}
	if *asJSON {
		mode += "+json"
	}
	fmt.Printf("mode=%s commands=%d trades=%d stops_fired=%d rejects=%d\n", mode, *n, e.TradeCount, e.StopsFired, e.Rejects)
	fmt.Printf("throughput: %.0f commands/s (whole loop incl. generator)\n", float64(*n)/el.Seconds())
	fmt.Printf("latency us: p50=%s p99=%s p999=%s p9999=%s max=%s\n", q(0.50), q(0.99), q(0.999), q(0.9999), q(1))
	fmt.Printf("gc: cycles=%d total_pause=%.2fms allocs/command=%.2f bytes/command=%.0f heap_end=%.0fMB\n",
		m1.NumGC-m0.NumGC, float64(m1.PauseTotalNs-m0.PauseTotalNs)/1e6,
		float64(m1.Mallocs-m0.Mallocs)/float64(*n), float64(m1.TotalAlloc-m0.TotalAlloc)/float64(*n), float64(m1.HeapAlloc)/1e6)
	fmt.Printf("state hash: %s\n", e.Hash())
}

// ---- run: journal with group commit, checkpoints, until killed ----

func run(args []string) {
	fs := flag.NewFlagSet("run", flag.ExitOnError)
	n := fs.Int("n", 50_000_000, "commands")
	seed := fs.Uint64("seed", 42, "generator seed")
	jpath := fs.String("journal", "/data/journal.bin", "journal file")
	sync := fs.Int("sync", 1000, "fsync every N commands (group commit)")
	every := fs.Int("checkpoint", 500_000, "print the state hash every N commands")
	snapAt := fs.Int("snapshot-at", 0, "write a snapshot after this many commands (0 = none)")
	snapPath := fs.String("snapshot", "/data/snap.gob", "snapshot file")
	fs.Parse(args)

	cfg := baseConfig()
	cfg.Pooled = true
	e := NewEngine(cfg)
	j, err := OpenJournal(*jpath, *sync, false)
	if err != nil {
		panic(err)
	}
	g := NewGen(*seed)
	var c Cmd
	start := time.Now()
	for seq := uint64(1); seq <= uint64(*n); seq++ {
		g.Next(&c)
		c.Seq, c.TS = seq, time.Now().UnixNano()
		if c.Typ == CmdNew {
			c.ID = seq
		}
		if _, err := j.Append(&c); err != nil { // written before the engine applies it
			panic(err)
		}
		e.Apply(&c)
		if c.Typ == CmdNew {
			g.Seen(c.ID)
		}
		if *snapAt > 0 && seq == uint64(*snapAt) {
			if err := e.Snapshot(seq, *snapPath); err != nil {
				panic(err)
			}
			fmt.Printf("snapshot seq=%d hash=%s\n", seq, e.Hash())
		}
		if seq%uint64(*every) == 0 {
			j.Sync()
			fmt.Printf("checkpoint seq=%d hash=%s t=%.1fs\n", seq, e.Hash(), time.Since(start).Seconds())
		}
	}
	j.Sync()
	fmt.Printf("finished seq=%d hash=%s\n", *n, e.Hash())
}

// ---- replay: rebuild the book from the journal (optionally starting from a snapshot) ----

func replay(args []string) {
	fs := flag.NewFlagSet("replay", flag.ExitOnError)
	jpath := fs.String("journal", "/data/journal.bin", "journal file")
	snap := fs.String("snapshot", "", "start from this snapshot")
	every := fs.Int("checkpoint", 500_000, "print the state hash every N commands")
	upto := fs.Uint64("upto", 0, "stop after this sequence number (0 = whole journal)")
	fs.Parse(args)

	start := time.Now()
	var e *Engine
	var from uint64
	if *snap != "" {
		var err error
		e, from, err = LoadSnapshot(*snap, true)
		if err != nil {
			panic(err)
		}
		fmt.Printf("loaded snapshot seq=%d hash=%s in %.0fms\n", from, e.Hash(), float64(time.Since(start).Microseconds())/1000)
	} else {
		cfg := baseConfig()
		cfg.Pooled = true
		e = NewEngine(cfg)
	}
	var last uint64
	applied := 0
	_, tail, err := ReadJournal(*jpath, from, func(c *Cmd) {
		if c.Seq <= from || (*upto > 0 && c.Seq > *upto) {
			return
		}
		if c.Seq != last+1 && last != 0 {
			panic(fmt.Sprintf("gap in journal: %d after %d", c.Seq, last))
		}
		e.Apply(c)
		last = c.Seq
		applied++
		if c.Seq%uint64(*every) == 0 {
			fmt.Printf("checkpoint seq=%d hash=%s\n", c.Seq, e.Hash())
		}
	})
	if err != nil {
		panic(err)
	}
	fmt.Printf("journal: %s\n", tail)
	fmt.Printf("replayed %d commands, last seq=%d, hash=%s, %.2fs\n", applied, last, e.Hash(), time.Since(start).Seconds())
}

// ---- fsync: what durability costs per command ----

func fsyncBench(args []string) {
	fs := flag.NewFlagSet("fsync", flag.ExitOnError)
	path := fs.String("journal", "/data/fsync.bin", "file")
	fs.Parse(args)
	for _, k := range []int{1, 16, 256, 4096} {
		n := 20_000
		if k >= 256 {
			n = 400_000
		}
		j, err := OpenJournal(*path, k, false)
		if err != nil {
			panic(err)
		}
		lat := make([]int32, n)
		var c Cmd
		start := time.Now()
		for i := 0; i < n; i++ {
			c = Cmd{Seq: uint64(i + 1), Typ: CmdNew, ID: uint64(i + 1), Price: 2000, Qty: 10}
			t0 := time.Now()
			j.Append(&c)
			lat[i] = int32(time.Since(t0))
		}
		j.Close()
		el := time.Since(start)
		slices.Sort(lat)
		fmt.Printf("fsync every %4d: %8.0f commands/s, one fsync p50=%.2fms, max=%.2fms\n",
			k, float64(n)/el.Seconds(), float64(lat[n-1-n/k/2])/1e6, float64(lat[n-1])/1e6)
	}
	os.Remove(*path)
}

func us(lat []int32, p float64) float64 { return float64(lat[int(float64(len(lat)-1)*p)]) / 1000 }
```

`scenarios.go` كاملاً 

```go
package main

import (
	"flag"
	"fmt"
	"strings"
	"time"
)

type driver struct {
	e   *Engine
	seq uint64
}

func (d *driver) send(c Cmd) *Cmd {
	d.seq++
	c.Seq = d.seq
	if c.Typ == 0 {
		c.Typ = CmdNew
	}
	if c.Typ == CmdNew {
		c.ID = d.seq
	}
	d.e.Apply(&c)
	return &c
}

func (d *driver) say(c Cmd, text string) {
	fmt.Printf("\n> #%d %s\n", d.seq+1, text)
	d.send(c)
}

// demo walks through price-time priority, partial fills, IOC, FOK, a stop and the risk checks.
func demo() {
	cfg := baseConfig()
	cfg.Accounts = 10
	cfg.MaxQty = 50_000
	cfg.Pooled = true
	e := NewEngine(cfg)
	e.accts[9].Cash = 1_000_00 // account 9 has 1,000.00 SAR only
	e.Log = func(s string) { fmt.Println("  " + s) }
	d := &driver{e: e}

	d.say(Cmd{Acct: 1, Side: Sell, Kind: Limit, Price: 2005, Qty: 100}, "acct 1: sell 100 @ 20.05 limit")
	d.say(Cmd{Acct: 2, Side: Sell, Kind: Limit, Price: 2005, Qty: 200}, "acct 2: sell 200 @ 20.05 limit (same price, later)")
	d.say(Cmd{Acct: 3, Side: Sell, Kind: Limit, Price: 2010, Qty: 150}, "acct 3: sell 150 @ 20.10 limit")
	d.say(Cmd{Acct: 4, Side: Buy, Kind: Limit, Price: 2005, Qty: 250}, "acct 4: buy 250 @ 20.05 limit")
	d.say(Cmd{Acct: 5, Side: Buy, Kind: Market, TIF: IOC, Qty: 300}, "acct 5: buy 300 market")
	d.say(Cmd{Acct: 6, Side: Sell, Kind: Limit, Price: 2015, Qty: 400}, "acct 6: sell 400 @ 20.15 limit")
	d.say(Cmd{Acct: 5, Side: Buy, Kind: Limit, TIF: FOK, Price: 2020, Qty: 1000}, "acct 5: buy 1000 @ 20.20 fill-or-kill")
	d.say(Cmd{Acct: 5, Side: Buy, Kind: Limit, TIF: IOC, Price: 2015, Qty: 150}, "acct 5: buy 150 @ 20.15 immediate-or-cancel")

	fmt.Println("\n--- bids under the market, and a stop-loss")
	d.say(Cmd{Acct: 1, Side: Buy, Kind: Limit, Price: 1995, Qty: 300}, "acct 1: buy 300 @ 19.95 limit")
	d.say(Cmd{Acct: 2, Side: Buy, Kind: Limit, Price: 1990, Qty: 300}, "acct 2: buy 300 @ 19.90 limit")
	d.say(Cmd{Acct: 3, Side: Buy, Kind: Limit, Price: 1985, Qty: 300}, "acct 3: buy 300 @ 19.85 limit")
	d.say(Cmd{Acct: 7, Side: Sell, Kind: StopMarket, Stop: 1990, Qty: 500}, "acct 7: sell 500 stop-market, stop 19.90")
	d.say(Cmd{Acct: 8, Side: Sell, Kind: Limit, TIF: IOC, Price: 1990, Qty: 600}, "acct 8: sell 600 @ 19.90 immediate-or-cancel")

	fmt.Println("\n--- pre-trade risk checks")
	d.say(Cmd{Acct: 4, Side: Buy, Kind: Limit, Price: 2500, Qty: 10}, "acct 4: buy 10 @ 25.00")
	d.say(Cmd{Acct: 4, Side: Buy, Kind: Limit, Price: 2000, Qty: 200_000}, "acct 4: buy 200000 @ 20.00 (an extra zero)")
	d.say(Cmd{Acct: 9, Side: Buy, Kind: Limit, Price: 2000, Qty: 100}, "acct 9: buy 100 @ 20.00 with 1,000.00 SAR")
	d.say(Cmd{Acct: 9, Side: Buy, Kind: Limit, Price: 1980, Qty: 50}, "acct 9: buy 50 @ 19.80")
	d.say(Cmd{Typ: CmdCancel, Acct: 9, ID: d.seq}, "acct 9: cancel its order")

	fmt.Printf("\n--- book (L2) after %d commands, last=%s\n%s", d.seq, sar(e.last), e.Depth(5))
	fmt.Printf("state hash: %s\n", e.Hash())
}

// cascade: a book with bids every halala down to 18.00, 300 stop-loss orders under the market,
// and one large market sell. With -breaker=N, a trade more than N% away from 20.00 halts the book.
func cascade(args []string) {
	fs := flag.NewFlagSet("cascade", flag.ExitOnError)
	breaker := fs.Int("breaker", 0, "circuit breaker, percent from the reference price (0 = off)")
	fs.Parse(args)

	cfg := baseConfig()
	cfg.BreakerPct = int32(*breaker)
	cfg.Pooled = true
	e := NewEngine(cfg)
	d := &driver{e: e}
	for p := int32(1999); p >= 1800; p-- { // market maker, account 0
		d.send(Cmd{Acct: 0, Side: Buy, Kind: Limit, Price: p, Qty: 1500})
	}
	for p := int32(2001); p <= 2050; p++ {
		d.send(Cmd{Acct: 0, Side: Sell, Kind: Limit, Price: p, Qty: 1500})
	}
	for i := 0; i < 300; i++ { // stop-losses from 19.90 down to 18.60
		d.send(Cmd{Acct: uint32(1 + i), Side: Sell, Kind: StopMarket, Stop: 1990 - int32(i*130/299), Qty: 1000})
	}
	fmt.Printf("book: bids 19.99..18.00 (1,500 each), asks 20.01..20.50, 300 sell stops 19.90..18.60 (1,000 each)\n")
	fmt.Printf("breaker: %s\n", map[bool]string{true: fmt.Sprintf("halt outside %s..%s", sar(e.blo), sar(e.bhi)), false: "off"}[*breaker > 0])

	t0 := time.Now()
	d.send(Cmd{Acct: 900, Side: Sell, Kind: Market, TIF: IOC, Qty: 20_000})
	el := time.Since(t0)

	waiting := len(e.triggered) - e.trigHead
	untouched := 0
	for i := range e.sellStops {
		for o := e.sellStops[i].head; o != nil; o = o.next {
			untouched++
		}
	}
	fmt.Printf("shock: one market sell of 20,000 at last=20.00\n")
	fmt.Printf("result: %d trades, %d shares, low=%s last=%s halted=%v, in %.0fus\n",
		len(e.Trades), e.Volume, sar(e.low), sar(e.last), e.halted, float64(el.Microseconds()))
	fmt.Printf("stops: %d fired and ran, %d fired but waiting (halt), %d not reached\n", e.StopsFired, waiting, untouched)
	fmt.Println("volume by price band:")
	bands := map[int32]int64{}
	for _, t := range e.Trades {
		bands[t.Price/20*20] += int64(t.Qty)
	}
	for b := int32(1980); b >= 1800; b -= 20 {
		if v := bands[b]; v > 0 {
			fmt.Printf("  %s-%s %7d %s\n", sar(b), sar(b+19), v, strings.Repeat("#", int(v/2000)))
		}
	}
}
```

والملف `go.mod` سطران فقط:

```text
module tradelab

go 1.27
```

نبني البرنامج داخل Container من Docker Image الرسمية للغة Go، فلا تحتاج أن تثبت Go على جهازك، ونحفظ ذاكرة البناء المؤقتة في Volume مستقل:

```bash
docker volume create lab-trade-gocache
docker run --rm --name lab-trade-build -v "$PWD:/lab" -v lab-trade-gocache:/root/.cache -w /lab \
  -e GOFLAGS=-buildvcs=false golang:1.27.2-bookworm \
  sh -c 'gofmt -l . ; go vet ./... && go build -o bin/tradelab . && go version'
```

```text
go version go1.27.2 linux/amd64
```

### أوامر يدوية: الأولوية والتنفيذ الجزئي والإيقاف والفحوص

الأمر `demo` يرسل ثمانية عشر أمراً مكتوبة في `scenarios.go`، ويطبع ما يفعله المحرك مع كل واحد:

```bash
docker run --rm --name lab-trade-demo -v "$PWD:/lab" -w /lab golang:1.27.2-bookworm ./bin/tradelab demo
```

والمخرج سوف يكون كما يلي:

```text

> #1 acct 1: sell 100 @ 20.05 limit
  REST    #1 sell 100 @ 20.05

> #2 acct 2: sell 200 @ 20.05 limit (same price, later)
  REST    #2 sell 200 @ 20.05

> #3 acct 3: sell 150 @ 20.10 limit
  REST    #3 sell 150 @ 20.10

> #4 acct 4: buy 250 @ 20.05 limit
  TRADE   100 @ 20.05  buy #4  sell #1  (maker #1)
  TRADE   150 @ 20.05  buy #4  sell #2  (maker #2)

> #5 acct 5: buy 300 market
  TRADE   50 @ 20.05  buy #5  sell #2  (maker #2)
  TRADE   150 @ 20.10  buy #5  sell #3  (maker #3)
  EXPIRE  #5 rem=100 (market order, no more liquidity)

> #6 acct 6: sell 400 @ 20.15 limit
  REST    #6 sell 400 @ 20.15

> #7 acct 5: buy 1000 @ 20.20 fill-or-kill
  KILL    #7 FOK: only 400 available of 1000

> #8 acct 5: buy 150 @ 20.15 immediate-or-cancel
  TRADE   150 @ 20.15  buy #8  sell #6  (maker #6)

--- bids under the market, and a stop-loss

> #9 acct 1: buy 300 @ 19.95 limit
  REST    #9 buy 300 @ 19.95

> #10 acct 2: buy 300 @ 19.90 limit
  REST    #10 buy 300 @ 19.90

> #11 acct 3: buy 300 @ 19.85 limit
  REST    #11 buy 300 @ 19.85

> #12 acct 7: sell 500 stop-market, stop 19.90
  STOP    #12 sell 500 when last <= 19.90

> #13 acct 8: sell 600 @ 19.90 immediate-or-cancel
  TRADE   300 @ 19.95  buy #9  sell #13  (maker #9)
  TRADE   300 @ 19.90  buy #10  sell #13  (maker #10)
  TRIGGER #12 stop=19.90 last=19.90 -> market
  TRADE   300 @ 19.85  buy #11  sell #12  (maker #11)
  EXPIRE  #12 rem=200 (market order, no more liquidity)

--- pre-trade risk checks

> #14 acct 4: buy 10 @ 25.00
  REJECT  #14 acct=4: price 25.00 outside the band 18.00..22.00

> #15 acct 4: buy 200000 @ 20.00 (an extra zero)
  REJECT  #15 acct=4: quantity 200000 above the fat-finger limit 50000

> #16 acct 9: buy 100 @ 20.00 with 1,000.00 SAR
  REJECT  #16 acct=9: insufficient cash: needs 2000.00, available 1000.00

> #17 acct 9: buy 50 @ 19.80
  REST    #17 buy 50 @ 19.80

> #18 acct 9: cancel its order
  CANCEL  #17 rem=50

--- book (L2) after 18 commands, last=19.85
    ask 20.15     250
state hash: dbb2e61423e3ef7e
```

في المخرج أعلاه لاحظ التالي:

- الأمر 4 نفذ 100 من الأمر 1 قبل الأمر 2، والاثنان بسعر 20.05، وهذه هي الأولوية الزمنية، ثم أخذ 150 من الأمر 2 وترك له 50 في مكانه، وهذا تنفيذ جزئي.
- أمر السوق 5 مر على مستويين بسعرين مختلفين، 20.05 ثم 20.10، ثم انتهى ما بقي منه لأنه لم يجد بائعاً، وهذا ما يحدث لأمر السوق في دفتر رقيق.
- أمر FOK رقم 7 طلب ألف سهم والمتاح 400، فلم ينفذ منه شيء، بينما أمر IOC رقم 8 أخذ المتاح وانتهى.
- الأمر 13 باع بسعرين، 19.95 ثم 19.90، فصار آخر سعر 19.90، ففعل أمر الإيقاف 12 مباشرة، فتحول إلى أمر سوق وباع 300 بسعر 19.85، أي أقل من سعر الإيقاف بخمس هللات، وانتهى ما بقي منه. وهذا هو الفرق بين أمر الإيقاف وأمر الإيقاف المحدد: الأول يضمن البيع ولا يضمن السعر.
- الفحوص الثلاثة رفضت قبل أن ينشأ أي أمر، ورسالة الرصيد تبين أن الحساب 9 يحتاج ألفي ريال ومعه ألف، ثم وضع أمراً أصغر وألغاه فعاد الحجز إلى رصيده.

### مليونا أمر: سرعة المطابقة وذيل الزمن

الأمر `bench` يولد مليوني أمر من مولد ثابت البذرة، فالأوامر نفسها في كل تشغيل: 55% أوامر محددة أغلبها قريب من السعر وبعضها يعبر الفارق، و30% إلغاءات لأوامر حديثة، و10% أوامر سوق، و5% أوامر إيقاف. ويقيس زمن كل أمر من لحظة أخذه الرقم إلى انتهاء المحرك منه، شاملاً كتابته في السجل إلى ملف `/dev/null`، أي دون قرص. ويطبع أولاً زمن قراءتين متتاليتين للساعة دون أي عمل بينهما، لتعرف أصغر رقم يستطيع هذا الجهاز قياسه:

```bash
docker run --rm --name lab-trade-bench -v "$PWD:/lab" -w /lab golang:1.27.2-bookworm ./bin/tradelab bench -pooled=true
```

```text
clock only, us: p50=0.00 p99=0.10 p999=0.20 max=9198.68
mode=pooled commands=2000000 trades=718026 stops_fired=2754 rejects=0
throughput: 1453108 commands/s (whole loop incl. generator)
latency us: p50=0.20 p99=1.30 p999=13.40 p9999=126.73 max=11199.86
gc: cycles=1 total_pause=0.23ms allocs/command=0.00 bytes/command=13 heap_end=51MB
state hash: 1a42c353f89ce2e5
```

في المخرج أعلاه لاحظ التالي:

- نصف الأوامر انتهت في 0.2 مايكروثانية أو أقل، و99% في قرابة مايكروثانية واحدة، وأكثر من 700 ألف صفقة و2,754 أمر إيقاف تفعل في الطريق.
- الذيل p999 أطول من القيمة الوسطى p50 بعشرات المرات، والسبب أن بعض الأوامر تعمل أكثر من غيرها، فأمر سوق قد يمر على عشرات الأوامر، وإلغاء آخر أمر في مستوى يجعل المحرك يبحث عن أفضل مستوى جديد، وجزء منه من الجهاز نفسه.
- القيمة max بالملي ثانية لا تعني شيئاً هنا، فحتى قراءتا الساعة دون عمل بينهما وصلتا إلى أكثر من تسع ملي ثوان في السطر الأول، وهذا من جدولة الأنظمة تحت Docker Desktop وليس من المحرك.
- عدد الحجوزات لكل أمر صفر، ودورة واحدة فقط لجامع القمامة في مليوني أمر.
- البصمة `1a42c353f89ce2e5` هي نفسها في كل تشغيل، وسوف تراها مرة أخرى في التجربة التالية.

### نكسر المحرك: حجز الذاكرة لكل أمر

الآن سوف نكتب المحرك بالطريقة التي يكتبه بها أغلبنا في المرة الأولى: كائن جديد لكل أمر بـ `new(Order)`، وMap من المعرف إلى الأمر، وشرائح جديدة للصفقات والتحديثات مع كل أمر، وهذا هو الوضع `-pooled=false`. ثم نضيف عليه ما يفعله كثير من الناس في السجل: سطر JSON لكل أمر بدالة `json.Marshal`، وهذا هو الخيار `-json`. أما الوضع الأول فيحجز الأوامر من قطع كبيرة مسبقة Slab ويعيد استخدام الأوامر المنتهية، ويحفظ المعرفات في مصفوفة، ويكتب السجل بسجلات ثنائية ثابتة الطول في مخزن واحد يعاد استخدامه. والكود الذي يفرق بين الطريقتين في `engine.go` هو هذا فقط:

```go
func (e *Engine) alloc() *Order {
	if !e.cfg.Pooled {
		return new(Order)
	}
	if n := len(e.free); n > 0 {
		o := e.free[n-1]
		e.free = e.free[:n-1]
		*o = Order{}
		return o
	}
	if len(e.slab) == cap(e.slab) {
		e.slab = make([]Order, 0, 1<<16) // a new chunk; old chunks stay alive through their orders
	}
	e.slab = e.slab[:len(e.slab)+1]
	return &e.slab[len(e.slab)-1]
}
```

وشغلنا كل وضع ثلاث مرات بالتناوب، وهذه نتائج التشغيل الثالث كاملة:

```bash
docker run --rm --name lab-trade-bench -v "$PWD:/lab" -w /lab golang:1.27.2-bookworm ./bin/tradelab bench -pooled=false
docker run --rm --name lab-trade-bench -v "$PWD:/lab" -w /lab golang:1.27.2-bookworm ./bin/tradelab bench -pooled=false -json
```

```text
clock only, us: p50=0.00 p99=0.10 p999=0.20 max=10098.45
mode=alloc commands=2000000 trades=718026 stops_fired=2754 rejects=0
throughput: 834181 commands/s (whole loop incl. generator)
latency us: p50=0.50 p99=2.70 p999=32.21 p9999=403.12 max=25881.23
gc: cycles=8 total_pause=5.22ms allocs/command=1.82 bytes/command=82 heap_end=61MB
state hash: 1a42c353f89ce2e5

clock only, us: p50=0.00 p99=0.10 p999=0.30 max=11015.66
mode=alloc+json commands=2000000 trades=718026 stops_fired=2754 rejects=0
throughput: 340347 commands/s (whole loop incl. generator)
latency us: p50=1.60 p99=6.60 p999=81.52 p9999=1485.99 max=21049.67
gc: cycles=21 total_pause=12.38ms allocs/command=2.82 bytes/command=215 heap_end=46MB
state hash: 1a42c353f89ce2e5
```

والجدول التالي يجمع التشغيلات التسعة، والقيمة في كل خانة هي القيمة الوسطى Median من ثلاثة تشغيلات، وبين القوسين أقل قيمة وأعلاها:

| الوضع        | p50 | p99               | p999                 | p9999                    | أوامر في الثانية | دورات الجامع | حجوزات لكل أمر |
| ------------ | --- | ----------------- | -------------------- | ------------------------ | ---------------- | ------------ | -------------- |
| pooled       | 0.2 | 1.0 (0.9 إلى 1.3) | 12.5 (11.8 إلى 13.4) | 119 (113 إلى 127)        | 1.45 مليون       | 1            | 0              |
| alloc        | 0.5 | 2.7 (2.7 إلى 2.9) | 32.2 (30.6 إلى 33.0) | 431 (403 إلى 746)        | 0.83 مليون       | 8            | 1.82           |
| alloc + JSON | 1.6 | 6.6 (6.3 إلى 7.0) | 81.5 (68.2 إلى 82.3) | 1,831 (1,486 إلى 10,491) | 0.33 مليون       | 21           | 2.82           |

![رسم أعمدة بمحور لوغاريتمي من 0.1 إلى 100 مايكروثانية، فيه ثلاث مجموعات p50 وp99 وp999، وفي كل مجموعة ثلاثة أعمدة: pooled بالأخضر، وalloc بالبرتقالي، وalloc مع JSON بالبنفسجي. في p50 القيم 0.2 و0.5 و1.6، وفي p99 القيم 1.0 و2.7 و6.6، وفي p999 القيم 12.5 و32.2 و81.5. وبجانبه أن pooled يحجز صفر مرة لكل أمر بدورة جامع واحدة و1.45 مليون أمر في الثانية، وalloc يحجز 1.82 مرة بثماني دورات و0.83 مليون، وalloc مع JSON يحجز 2.82 مرة بإحدى وعشرين دورة و0.33 مليون](https://arabroot.io/content/images/2026/10/trading-engine-05-latency.webp)

المنطق واحد والبصمة واحدة، والفرق كله في حجز الذاكرة: الذيل p999 يطول قرابة ثلاثة أضعاف ثم ستة أضعاف

في النتائج أعلاه لاحظ التالي:

- البصمة `1a42c353f89ce2e5` واحدة في الأوضاع الثلاثة، أي أن الدفتر والأرصدة في النهاية متطابقة تماماً، فالفرق كله في الطريقة وليس في النتيجة.
- مجموع توقفات جامع القمامة في التشغيل كله بضع ملي ثوان فقط، ومع ذلك طال الذيل p999 من 12.5 إلى 32 ثم إلى 81 مايكروثانية، والسبب أن الثمن لا يدفع في التوقف وحده، وإنما في كل حجز وفي مساعدة الجامع وفي ملء ذاكرة جديدة لم تكن في ال Cache.
- وشغلنا أيضاً وضع الحجز مع JSON مرتين بعد إيقاف الجامع كلياً بالمتغير `GOGC=off`، فنزل الذيل p999 إلى 38.2 و43.6 مايكروثانية، أي أن الجامع مسؤول هنا عن قرابة نصف الذيل، ولكن الذيل بقي أكثر من ثلاثة أضعاف الوضع الأول، ووصلت الذاكرة إلى 440 ميغابايت لمليوني أمر فقط، فإيقاف الجامع لا يصلح لنظام يعمل يوماً كاملاً، والحل ألا تحجز في الطريق الساخن أصلاً.
- تحويل الأمر إلى JSON في السجل وحده ضاعف القيمة الوسطى p50 ثلاث مرات، لذلك تستخدم الأسواق صيغاً ثنائية ثابتة الطول للسجل وللرسائل.

### نكسر السوق: سلسلة أوامر الإيقاف ثم القاطع

الأمر `cascade` يبني دفتراً فيه أوامر شراء عند كل هللة من 19.99 إلى 18.00 بـ 1,500 سهم لكل سعر، ثم يضع 300 أمر إيقاف خسارة لـ 300 مساهم، كل منها بيع ألف سهم من السوق، وأسعار تفعيلها موزعة من 19.90 إلى 18.60، ثم يرسل أمر بيع واحداً من السوق بعشرين ألف سهم. ونشغله مرتين، مرة دون قاطع، ومرة بقاطع يوقف التداول إذا كانت الصفقة التالية سوف تبعد أكثر من 5% عن 20.00:

```bash
docker run --rm --name lab-trade-cascade -v "$PWD:/lab" -w /lab golang:1.27.2-bookworm ./bin/tradelab cascade
docker run --rm --name lab-trade-cascade -v "$PWD:/lab" -w /lab golang:1.27.2-bookworm ./bin/tradelab cascade -breaker=5
```

والمخرج سوف يكون كما يلي:

```text
book: bids 19.99..18.00 (1,500 each), asks 20.01..20.50, 300 sell stops 19.90..18.60 (1,000 each)
breaker: off
shock: one market sell of 20,000 at last=20.00
result: 387 trades, 300000 shares, low=18.00 last=18.00 halted=false, in 139us
stops: 300 fired and ran, 0 fired but waiting (halt), 0 not reached
volume by price band:
  19.80-19.99   30000 ###############
  19.60-19.79   30000 ###############
  19.40-19.59   30000 ###############
  19.20-19.39   30000 ###############
  19.00-19.19   30000 ###############
  18.80-18.99   30000 ###############
  18.60-18.79   30000 ###############
  18.40-18.59   30000 ###############
  18.20-18.39   30000 ###############
  18.00-18.19   30000 ###############

book: bids 19.99..18.00 (1,500 each), asks 20.01..20.50, 300 sell stops 19.90..18.60 (1,000 each)
breaker: halt outside 19.00..21.00
shock: one market sell of 20,000 at last=20.00
result: 187 trades, 150000 shares, low=19.00 last=19.00 halted=true, in 46us
stops: 131 fired and ran, 79 fired but waiting (halt), 90 not reached
volume by price band:
  19.80-19.99   30000 ###############
  19.60-19.79   30000 ###############
  19.40-19.59   30000 ###############
  19.20-19.39   30000 ###############
  19.00-19.19   30000 ###############
```

![لوحتان بمحور سعر من 20.00 في الأعلى إلى 18.00 في الأسفل، وعلامات رمادية لأسعار تفعيل 300 أمر إيقاف من 19.90 إلى 18.60. في اليسرى دون قاطع عشرة أعمدة برتقالية بثلاثين ألف سهم لكل شريحة عشرين هللة من 19.99 حتى 18.00، والنتيجة 300 أمر إيقاف تفعلت و300,000 سهم وأدنى سعر 18.00، أي أن بيع عشرين ألف سهم صار بيع 300 ألف. وفي اليمنى بقاطع عند 5% خمسة أعمدة فقط حتى 19.00 وخط أحمر متقطع للإيقاف عند 19.00، والنتيجة 131 أمر إيقاف نفذت و79 تنتظر و90 لم يصل إليها السعر، و150,000 سهم](https://arabroot.io/content/images/2026/10/trading-engine-03-cascade.webp)

بيع عشرين ألف سهم صار بيع ثلاثمئة ألف دون قاطع، والقاطع أوقف السلسلة عند 19.00 بنصف الكمية

في المخرج أعلاه لاحظ التالي:

- دون قاطع تحول بيع عشرين ألف سهم إلى بيع 300 ألف سهم، والتهم الدفتر كله حتى حد النطاق اليومي 18.00، أي هبوط 10% في 139 مايكروثانية، ولم يتخذ أحد من أصحاب أوامر الإيقاف قراره في تلك اللحظة. وما بقي من آخر أوامر الإيقاف انتهى دون تنفيذ لأنه لم يبق مشتر.
- مع القاطع توقف التداول عند 19.00 بنصف الكمية، و79 أمر إيقاف تفعلت ولكنها تنتظر في الطابور، و90 أمراً لم يصل إليها السعر أصلاً. وفي سوق حقيقية تأتي بعد الإيقاف فترة تجمع فيها الأوامر، ومنها أوامر شراء جديدة، ثم مزاد يحدد سعر الاستئناف، وهذا ما أعطته الثواني الخمس لعقد E-Mini في قصة 2010.
- القاطع لا يمنع الهبوط، وإنما يمنع أن يحدث كله في لحظة واحدة لا يستطيع فيها أحد أن يقرر.

### ثمن التثبيت على القرص

قبل تجربة الإيقاف بالقوة نقيس ثمن fsync نفسه، لأنه يحدد كم أمراً نستطيع أن نؤكد في الثانية. والأمر `fsync` يكتب سجلات في Volume اسمه `lab-trade-data`، ويثبت بعد كل سجل، ثم بعد كل 16، ثم 256، ثم 4096:

```bash
docker volume create lab-trade-data
docker run --rm --name lab-trade-fsync -v "$PWD:/lab" -v lab-trade-data:/data -w /lab golang:1.27.2-bookworm ./bin/tradelab fsync
```

```text
fsync every    1:      132 commands/s, one fsync p50=2.70ms, max=305.43ms
fsync every   16:     3290 commands/s, one fsync p50=2.60ms, max=32.13ms
fsync every  256:    59439 commands/s, one fsync p50=2.35ms, max=59.58ms
fsync every 4096:   744129 commands/s, one fsync p50=2.55ms, max=32.32ms
```

التثبيت الواحد يأخذ قرابة 2.5 ملي ثانية على هذا القرص مهما كان عدد السجلات معه، لذلك فالتثبيت بعد كل أمر يعطي 132 أمراً في الثانية فقط، بينما التثبيت بعد كل 4096 يعطي أكثر من 700 ألف. والثمن أن الأمر الأول في المجموعة ينتظر حتى تكتمل المجموعة، لذلك تثبت الأنظمة الحقيقية المجموعة إما عند اكتمال عددها أو بعد مدة قصيرة أيهما أسبق، وتنسخ السجل إلى ذاكرة سيرفر آخر بدل الاعتماد على القرص وحده.

### نوقف المحرك بالقوة ثم نعيد بناءه من السجل

الأمر `run` يشغل المحرك مع السجل في `/data/journal.bin`، ويثبت كل ألف أمر، ويطبع بصمة الحالة كل مئة ألف أمر بعد التثبيت، ويكتب Snapshot عند الأمر 200,000\. ونشغله في الخلفية ثم نوقفه بعد تسع ثوان بإشارة KILL التي لا يستطيع البرنامج أن يعترضها، كما لو انقطع التيار عن العملية:

```bash
docker run -d --name lab-trade-run -v "$PWD:/lab" -v lab-trade-data:/data -w /lab golang:1.27.2-bookworm \
  ./bin/tradelab run -sync 1000 -checkpoint 100000 -snapshot-at 200000
sleep 9; docker kill -s KILL lab-trade-run
docker logs lab-trade-run; docker inspect -f 'exit={{.State.ExitCode}}' lab-trade-run; docker rm lab-trade-run
```

```text
checkpoint seq=100000 hash=142308140a77ff46 t=3.6s
snapshot seq=200000 hash=4ba53f64b17268a6
checkpoint seq=200000 hash=4ba53f64b17268a6 t=7.1s
exit=137
```

```bash
docker run --rm --name lab-trade-ls -v lab-trade-data:/data golang:1.27.2-bookworm sh -c \
  'ls -l /data; echo $(( $(stat -c %s /data/journal.bin) / 48 )) records, remainder $(( $(stat -c %s /data/journal.bin) % 48 )) bytes'
```

```text
total 12556
-rw-r--r-- 1 root root 11856000 Oct 12 22:07 journal.bin
-rw-r--r-- 1 root root   994620 Oct 12 22:07 snap.gob
247000 records, remainder 0 bytes
```

الرمز 137 يعني أن العملية قتلت بالإشارة 9، والسجل فيه 247,000 أمر كاملة. ولاحظ أن المحرك وصل إلى قرابة 28 ألف أمر في الثانية فقط مع التثبيت كل ألف أمر، أي مئتي ألف أمر في 7.1 ثانية، بينما وصل إلى أكثر من مليون في الذاكرة، وهذا هو الرقم الذي استخدمناه في [تقدير السعة](#arch-capacity). الآن نعيد بناء الدفتر بثلاث طرق، ثم نكسر آخر سجل عمداً كما لو انقطع التيار أثناء الكتابة:

```bash
docker run --rm --name lab-trade-replay -v "$PWD:/lab" -v lab-trade-data:/data -w /lab golang:1.27.2-bookworm sh -c '
  ./bin/tradelab replay -checkpoint 100000
  ./bin/tradelab replay -snapshot /data/snap.gob -checkpoint 100000
  ./bin/tradelab run -n 247000 -journal /data/clean.bin -checkpoint 100000
  cp /data/journal.bin /data/torn.bin; truncate -s -17 /data/torn.bin
  ./bin/tradelab replay -journal /data/torn.bin -checkpoint 100000'
```

```text
+ ./bin/tradelab replay -checkpoint 100000
checkpoint seq=100000 hash=142308140a77ff46
checkpoint seq=200000 hash=4ba53f64b17268a6
journal: clean end
replayed 247000 commands, last seq=247000, hash=be70a514e20d62db, 0.16s
+ ./bin/tradelab replay -snapshot /data/snap.gob -checkpoint 100000
loaded snapshot seq=200000 hash=4ba53f64b17268a6 in 41ms
journal: clean end
replayed 47000 commands, last seq=247000, hash=be70a514e20d62db, 0.07s
+ ./bin/tradelab run -n 247000 -journal /data/clean.bin -checkpoint 100000
checkpoint seq=100000 hash=142308140a77ff46 t=4.5s
checkpoint seq=200000 hash=4ba53f64b17268a6 t=12.9s
finished seq=247000 hash=be70a514e20d62db
+ cp /data/journal.bin /data/torn.bin
+ truncate -s -17 /data/torn.bin
+ ./bin/tradelab replay -journal /data/torn.bin -checkpoint 100000
checkpoint seq=100000 hash=142308140a77ff46
checkpoint seq=200000 hash=4ba53f64b17268a6
journal: torn record of 31 bytes ignored
replayed 246999 commands, last seq=246999, hash=be70a514e20d62db, 0.20s
```

في المخرج أعلاه لاحظ التالي:

- الإعادة الكاملة مرت على البصمتين اللتين طبعهما المحرك قبل إيقافه، `142308140a77ff46` عند 100,000 و`4ba53f64b17268a6` عند 200,000، ثم وصلت إلى `be70a514e20d62db` عند 247,000.
- البدء من ال Snapshot أعاد 47,000 أمر فقط ووصل إلى البصمة نفسها.
- التشغيل النظيف بالبذرة نفسها، دون أي إيقاف ودون أي سجل سابق، وصل إلى البصمة نفسها أيضاً، وهذا هو الدليل على الحتمية: الإيقاف والإعادة لم يغيرا شيئاً.
- عندما قطعنا 17 بايت من آخر الملف بقي من آخر سجل 31 بايت، فتجاهلته القراءة وتوقفت عند الأمر 246,999\. والبصمة لم تتغير لأن الأمر الأخير كان إلغاء لأمر نفذ من قبل، وقد تحققنا من ذلك بقراءة السجل نفسه. وفي نظام حقيقي هذا الأمر لم يؤكد للوسيط أصلاً لأنه لم يكتمل تثبيته، فيعيد الوسيط إرساله بالمعرف نفسه.

وأخيراً قارنا الإعادة الكاملة بال Snapshot على سجل أكبر فيه خمسة ملايين أمر، مرتين لكل طريقة:

```bash
docker run --rm --name lab-trade-replay -v "$PWD:/lab" -v lab-trade-data:/data -w /lab golang:1.27.2-bookworm sh -c '
  ./bin/tradelab run -n 5000000 -sync 0 -checkpoint 5000000 -snapshot-at 4500000 -journal /data/big.bin -snapshot /data/big.gob
  ./bin/tradelab replay -journal /data/big.bin -checkpoint 10000000
  ./bin/tradelab replay -journal /data/big.bin -snapshot /data/big.gob -checkpoint 10000000'
```

```text
snapshot seq=4500000 hash=0bbf4bcd87bd8e0a
checkpoint seq=5000000 hash=a22e2c4fcddd6f0f t=19.6s
finished seq=5000000 hash=a22e2c4fcddd6f0f
journal: clean end
replayed 5000000 commands, last seq=5000000, hash=a22e2c4fcddd6f0f, 2.30s
loaded snapshot seq=4500000 hash=0bbf4bcd87bd8e0a in 938ms
journal: clean end
replayed 500000 commands, last seq=5000000, hash=a22e2c4fcddd6f0f, 1.50s
```

وفي التشغيل الثاني كانت الأرقام 3.26 ثانية للإعادة الكاملة و2.35 ثانية مع ال Snapshot، أي أن ال Snapshot وفر قرابة ثلث الوقت فقط، والسبب أن الإعادة سريعة جداً، أكثر من مليوني أمر في الثانية، بينما قراءة Snapshot حجمها 21 ميغابايت بصيغة gob أخذت قرابة ثانية وحدها. وهذا لا يعني أن ال Snapshot غير مفيد، وإنما أن فائدته تظهر عندما يكبر السجل إلى أيام وأسابيع، وأن صيغة ال Snapshot نفسها يجب أن تكون سريعة القراءة، وهذا متسق مع ما يذكره مقال LMAX من Snapshot كل ليلة وإعادة سجل يوم كامل في أقل من دقيقة.

### حذف المختبر

```bash
docker volume rm lab-trade-data lab-trade-gocache
docker image rm golang:1.27.2-bookworm
rm -rf bin
```

## أخطاء شائعة

- **الدفتر في قاعدة بيانات بأقفال على السطور:** يجعل كل أمر ينتظر القرص والقفل، والمحرك يجب أن يعيش في الذاكرة ويحفظ أوامره في سجل.
- **عدة خيوط على دفتر واحد:** تنافس على الأقفال ونتائج تختلف بين تشغيل وآخر، فاجعل لكل سهم خيطاً واحداً يملكه.
- **قراءة الساعة أو المرور على Map داخل المحرك:** يكسر الحتمية، فلا تعيد الإعادة الدفتر نفسه.
- **إرسال التأكيد قبل تثبيت السجل:** أمر أكده النظام ثم اختفى بعد العطل، وهذا أسوأ من الرفض.
- **الأسعار في float:** 20.05 لا تمثل بدقة في النظام الثنائي، فاحفظها بأصغر وحدة في عدد صحيح.
- **أوامر الإيقاف دون قاطع ودون نطاق:** أمر واحد كبير يطلق سلسلة تأكل الدفتر كما رأينا.
- **قياس المتوسط فقط:** المتوسط في المختبر كان ممتازاً في الأوضاع الثلاثة، والفرق كله ظهر في p999.
- **حجز الذاكرة في الطريق الساخن:** كائن جديد لكل أمر وتحويل JSON لكل رسالة يضاعفان الذيل، وإيقاف جامع القمامة لا يصلح ذلك.
- **بناء منصة للعملاء قبل الترخيص:** استقبال أوامر الناس وتنفيذها تعامل في الأوراق المالية يحتاج ترخيصاً من الهيئة.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- محرك المطابقة Matching Engine يحفظ دفتر الأوامر Order Book لكل سهم، وينفذ بالسعر ثم الوقت، والصفقة بسعر الأمر المنتظر، والتنفيذ الجزئي يترك الباقي في مكانه.
- عندما يكون للسعر نطاق ووحدة معروفان، فمصفوفة بخانة لكل وحدة سعر مع قائمة مترابطة لكل مستوى أبسط وأسرع من الأشجار.
- خيط واحد يملك الدفتر، كما في معمارية LMAX، والتوسع بتقسيم الأسهم بين محركات وليس بعدة خيوط على سهم واحد.
- أمر الإيقاف ينتظر شرطاً على آخر صفقة أو على العرض والطلب بحسب المنصة، والسلسلة حولت في المختبر بيع عشرين ألف سهم إلى 300 ألف، والقاطع أوقفها عند 5%.
- كل أمر يأخذ رقماً ويكتب في السجل قبل التنفيذ، والتأكيد بعد التثبيت، وإعادة السجل بعد إيقاف المحرك بالقوة أعطت البصمة نفسها مثل التشغيل النظيف.
- الذيل هو ما يهم، وحجز الذاكرة لكل أمر رفع p999 في المختبر من 12.5 إلى 81.5 مايكروثانية مع النتيجة نفسها تماماً.
- السوق السعودية تطابق بالسعر ثم الوقت، بأوامر محددة وأوامر سوق، وحد تذبذب يومي 10% للأسهم، وتسوية T+2، وممارسة التعامل في الأوراق المالية أو تشغيل سوق يحتاج ترخيصاً من هيئة السوق المالية.

[![](https://arabroot.io/content/images/2026/10/wallet-ledger-cover-cover.webp)دليلالمحفظة الرقمية من الداخل: دفتر القيد المزدوج Ledgerكيف تبني محفظة رقمية لا يضيع فيها ريال ولا يخصم مرتين، بالقيد المزدوج والمعاملات والمطابقة مع مزود الدفع.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%85%D8%AD%D9%81%D8%B8%D8%A9-%D8%B1%D9%82%D9%85%D9%8A%D8%A9-ledger/)[![](https://arabroot.io/content/images/2026/10/ticket-booking-cover-cover.webp)دليلكيف تبني نظام حجز تذاكر يتحمل لحظة فتح البيعكيف تمنع بيع المقعد الواحد مرتين، وكيف يصمد نظام الحجز عندما يدخل الجميع في الدقيقة نفسها لشراء التذاكر.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%86%D8%B8%D8%A7%D9%85-%D8%AD%D8%AC%D8%B2-%D8%AA%D8%B0%D8%A7%D9%83%D8%B1-%D9%8A%D8%AA%D8%AD%D9%85%D9%84-%D9%84%D8%AD%D8%B8%D8%A9-%D9%81%D8%AA%D8%AD-%D8%A7%D9%84%D8%A8%D9%8A%D8%B9/)[![](https://arabroot.io/content/images/2026/10/event-sourcing-cqrs-cover-cover.webp)دليلEvent Sourcing وCQRS: متى تستحق التعقيد؟متى يستحق Event Sourcing وCQRS تعقيدهما، وكيف تبني مخزن أحداث صغيراً على PostgreSQL وتعيد بناء نماذج القراءة منه بأمان.](https://arabroot.io/articles/event-sourcing-%D9%88-cqrs-%D9%85%D8%AA%D9%89-%D8%AA%D8%B3%D8%AA%D8%AD%D9%82-%D8%A7%D9%84%D8%AA%D8%B9%D9%82%D9%8A%D8%AF/)[![](https://arabroot.io/content/images/2026/10/idempotency-cover-cover.webp)دليلIdempotency: كيف تجعل العملية آمنة عند تكرارهالماذا تصل الرسالة نفسها أكثر من مرة، وكيف تكتب مستهلكاً يطبقها مرة واحدة فقط مهما تكررت.](https://arabroot.io/articles/idempotency-%D9%83%D9%8A%D9%81-%D8%AA%D8%AC%D8%B9%D9%84-%D8%A7%D9%84%D8%B9%D9%85%D9%84%D9%8A%D8%A9-%D8%A7%D9%85%D9%86%D8%A9-%D8%B9%D9%86%D8%AF-%D8%AA%D9%83%D8%B1%D8%A7%D8%B1%D9%87%D8%A7/)[![](https://arabroot.io/content/images/2026/10/kafka-cover-cover.webp)دليلApache Kafka: بث الأحداث بأحجام ضخمةكيف يحفظ عنقود Kafka أحداثك بنسخ على عدة سيرفرات، وماذا يحدث للمنتجين والمستهلكين عندما يسقط أحدها.](https://arabroot.io/articles/apache-kafka-%D8%A8%D8%AB-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-%D8%A8%D8%A7%D8%AD%D8%AC%D8%A7%D9%85-%D8%B6%D8%AE%D9%85%D8%A9/)[![](https://arabroot.io/content/images/2026/10/queue-compare-cover-cover.webp)دليلRabbitMQ أم Kafka أم NATS؟ كيف تختار وسيط الرسائلكيف تختار بين RabbitMQ وKafka وNATS بحسب نموذج كل منها وضماناته وتكلفة تشغيله، مع قياس على جهاز واحد.](https://arabroot.io/articles/rabbitmq-%D8%A7%D9%85-kafka-%D8%A7%D9%85-nats-%D9%83%D9%8A%D9%81-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%88%D8%B3%D9%8A%D8%B7-%D8%A7%D9%84%D8%B1%D8%B3%D8%A7%D8%A6%D9%84/)[![](https://arabroot.io/content/images/2026/10/chat-app-cover-cover.webp)دليلكيف تبني تطبيق محادثة مثل WhatsApp لملايين المستخدمينكيف تصل الرسالة من هاتف إلى آخر عبر سيرفرات مختلفة، وكيف تحفظ لمن انقطع اتصاله وتظهر علامات التسليم والقراءة.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D9%85%D8%AD%D8%A7%D8%AF%D8%AB%D8%A9-%D9%85%D8%AB%D9%84-whatsapp/)[![](https://arabroot.io/content/images/2026/10/memory-basics-cover-cover.webp)دليلالذاكرة للمبرمجين: كيف تعمل ال Stack وال Heapلماذا تتغير قائمة لم تطلب تغييرها، وكيف تتسرب الذاكرة في لغة فيها جامع قمامة Garbage Collector، بأمثلة حقيقية من عدة لغات.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86-stack-%D9%88-heap-%D9%88%D8%A7%D9%84%D9%85%D8%A4%D8%B4%D8%B1%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/load-testing-cover-cover.webp)دليلاختبار الأحمال والضغط Load Testing للمبتدئينلماذا يعمل تطبيقك مع خمسة مستخدمين وينهار مع خمسمئة، وكيف تكتشف ذلك وتصلحه قبل يوم الإطلاق.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%AD%D9%85%D8%A7%D9%84-%D9%88%D8%A7%D9%84%D8%B6%D8%BA%D8%B7-load-%D9%88-stress-testing-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال مع قسم المعمارية الكاملة، والتحقق من صفحتي دورة التداول ونظرة عامة على السوق في موقع تداول السعودية، ولوائح أعمال الأوراق المالية والسوق المالية ومراكز الإيداع وسلوكيات السوق من هيئة السوق المالية، ومقال LMAX وورقة Disruptor، وتوثيق CME وBinance وInteractive Brokers ومواصفة ITCH، وتقرير CFTC وSEC عن 6 مايو 2010، وتقريري بورصة طوكيو عن 1 أكتوبر 2020، وتشغيل المختبر على Go 1.27.2.