انتقل إلى المحتوى

الوحدة 01 · الدرس 4 من 5 (01.4)

العوائد والمتوسطات المتحركة وأول إشارة في pandas

مبتدئ18 دقيقةمسودة — قيد المراجعة

احسب العوائد البسيطة واللوغاريتمية، وقِس التقلّب، وابنِ متوسطات متحركة بنوافذ متدحرجة، وحوّلها إلى إشارة لا تعمل إلا بالمعلومات المتاحة في حينها.

قبل هذا الدرس: pandas: بيانات الأسعار في جدول

في هذا الدرس ستتعلّم أن

  • احسب العوائد البسيطة واللوغاريتمية من سلسلة أسعار، واشرح متى يفيد كل منهما.
  • قِس التقلّب اليومي وحوّله إلى رقم سنوي.
  • ابنِ متوسطات متحركة بنوافذ متدحرجة، وعالج القيم الناقصة التي تنشأ عنها.
  • حوّل شرطاً إلى إشارة وأزِحها بحيث لا تستخدم الصفقات إلا معلومات الماضي.
  • أظهر بالشيفرة كيف يُضخّم استخدام معلومات الشمعة نفسها النتائج.

يصعب مقارنة الأسعار وحدها. فحركة من 100 إلى 101 وحركة من 10 إلى 11 كلتاهما نقطة واحدة، لكنهما تغيّران مختلفان جداً. ولذلك يبدأ العمل الكمّي كله تقريباً بتحويل الأسعار إلى عوائد. يغطي هذا الدرس العوائد والتقلّب، ثم المتوسطات المتحركة، ثم الخطوة التي تحوّل حساباً إلى قرار تداول: إشارة بتوقيت صحيح.

العوائد البسيطة واللوغاريتمية

العائد البسيط من إغلاق إلى الذي يليه هو التغيّر النسبي:

rt=PtPt−1−1r_t = \frac{P_t}{P_{t-1}} - 1

حيث PtP_t هو إغلاق اليوم وPt−1P_{t-1} هو إغلاق الأمس. أما العائد اللوغاريتمي فيستخدم اللوغاريتم الطبيعي للنسبة نفسها:

ℓt=ln⁡(PtPt−1)\ell_t = \ln\left(\frac{P_t}{P_{t-1}}\right)

وفي الحركات الصغيرة يكاد الاثنان يتطابقان. والفرق في طريقة اجتماعهما عبر الزمن. العوائد البسيطة تتراكم: للحصول على عائد إجمالي تضرب (1+rt)(1 + r_t) عبر الأيام. أما العوائد اللوغاريتمية فتُجمع: العائد اللوغاريتمي الإجمالي هو مجموع العوائد اليومية. ولهذا جمع المسار العشوائي في الدرس السابق أرقاماً عشوائية ثم طبّق np.exp، التي تُبطل اللوغاريتم.

returns.pyPython
import numpy as np
import pandas as pd
 
 
def make_prices(n_days=260, seed=42):
    """Synthetic daily OHLCV bars from a random walk. Not real market data."""
    rng = np.random.default_rng(seed)
    dates = pd.bdate_range("2024-01-01", periods=n_days, name="date")
    close = 100 * np.exp(np.cumsum(rng.normal(0.0003, 0.012, n_days)))
    prev_close = np.concatenate([[100.0], close[:-1]])
    open_ = prev_close * (1 + rng.normal(0, 0.002, n_days))
    high = np.maximum(open_, close) * (1 + np.abs(rng.normal(0, 0.004, n_days)))
    low = np.minimum(open_, close) * (1 - np.abs(rng.normal(0, 0.004, n_days)))
    volume = rng.integers(800_000, 1_500_000, n_days)
    return pd.DataFrame(
        {"open": open_, "high": high, "low": low, "close": close, "volume": volume},
        index=dates,
    )
 
 
prices = make_prices()
close = prices["close"]
 
simple = close.pct_change()                 # (today / yesterday) - 1
log_ret = np.log(close / close.shift(1))    # ln(today / yesterday)
 
table = pd.DataFrame({"close": close, "simple": simple, "log": log_ret})
print(table.head(4).round(4))
 
total = close.iloc[-1] / close.iloc[0] - 1
print(f"Total return, first to last close: {total:.2%}")
print(f"Sum of log returns: {log_ret.sum():.4f} -> {np.exp(log_ret.sum()) - 1:.2%}")
print(f"Daily volatility: {simple.std():.2%}  annualised: {simple.std() * np.sqrt(252):.1%}")
Output
               close  simple     log
date                                
2024-01-01  100.3964     NaN     NaN
2024-01-02   99.1811 -0.0121 -0.0122
2024-01-03  100.1083  0.0093  0.0093
2024-01-04  101.2750  0.0117  0.0116
Total return, first to last close: -7.16%
Sum of log returns: -0.0743 -> -7.16%
Daily volatility: 1.13%  annualised: 17.9%

يحسب .pct_change() العوائد البسيطة في خطوة واحدة. ويحرّك close.shift(1) كل قيمة صفاً إلى الأسفل، فيرى كل صف إغلاق اليوم السابق بجوار إغلاقه؛ وقسمة الاثنين وتطبيق np.log يعطيان العوائد اللوغاريتمية. وتجري pandas العمليات الحسابية على أعمدة كاملة دفعة واحدة وتحاذي الصفوف بالتاريخ، فلا حلقة لتكتبها.

الصف الأول NaN، اختصار «ليس رقماً»، وهكذا تعلّم pandas القيمة الناقصة. لا عائد في اليوم الأول لأنه لا إغلاق سابق. وتتجاوز pandas القيم NaN في المجاميع والمتوسطات.

يختار .iloc[...] بالموضع لا بالتسمية، فـclose.iloc[0] هو الإغلاق الأول وclose.iloc[-1] هو الأخير. وتؤكد المخرجات الخاصية الأساسية: مجموع العوائد اللوغاريتمية، محوَّلاً عائداً بـnp.exp(...) - 1، يعطي العائد البسيط الإجمالي بالضبط.

التقلّب والتحويل إلى رقم سنوي

يُقاس التقلّب عادةً بـالانحراف المعياري للعوائد: أي مدى تشتّت العوائد اليومية حول متوسطها. ويحسبه .std(). وهو هنا 1.13% في اليوم، قريب من 1.2% التي استخدمها المولّد.

كثيراً ما تُحوَّل الأرقام اليومية إلى أرقام سنوية، وهذا يسمّى التحويل السنوي (Annualisation)، لتصح المقارنة عبر الأدوات والأطر الزمنية. فإذا كانت العوائد اليومية مستقلة، ينمو التباين بما يتناسب مع الزمن، وينمو التقلّب بجذره التربيعي. وبنحو 252 يوم تداول في السنة:

σannual=σdaily×252\sigma_{\text{annual}} = \sigma_{\text{daily}} \times \sqrt{252}

وهذا يعطي 17.9% هنا. وهذا التحويل عُرف يقوم على افتراض، هو استقلال العوائد، لا تحققه الأسواق الحقيقية إلا تقريباً. وتنظر الوحدة 03 في متى ينهار هذا الافتراض.

المتوسطات المتحركة وإشارة

المتوسط المتحرك هو متوسط آخر n إغلاق، يُعاد حسابه كل يوم. في pandas، ينشئ .rolling(n) نافذة تنزلق على طول العمود، ويحسب .mean() متوسط كل نافذة. ومقارنة متوسط سريع بآخر بطيء تعطي شرط اتجاه بسيطاً: حين يكون متوسط 20 يوماً أعلى من متوسط 50 يوماً، تكون الأسعار الأخيرة أعلى من الأقدم.

ولجعل هذا الشرط إشارة، خزّنه على هيئة 1 (شراء) أو 0 (بقاء خارج السوق). ثم تأتي الخطوة الأهم: تقرير متى يمكن التصرف بالإشارة.

signal.pyPython
import numpy as np
import pandas as pd
 
 
def make_prices(n_days=260, seed=42):
    """Synthetic daily OHLCV bars from a random walk. Not real market data."""
    rng = np.random.default_rng(seed)
    dates = pd.bdate_range("2024-01-01", periods=n_days, name="date")
    close = 100 * np.exp(np.cumsum(rng.normal(0.0003, 0.012, n_days)))
    prev_close = np.concatenate([[100.0], close[:-1]])
    open_ = prev_close * (1 + rng.normal(0, 0.002, n_days))
    high = np.maximum(open_, close) * (1 + np.abs(rng.normal(0, 0.004, n_days)))
    low = np.minimum(open_, close) * (1 - np.abs(rng.normal(0, 0.004, n_days)))
    volume = rng.integers(800_000, 1_500_000, n_days)
    return pd.DataFrame(
        {"open": open_, "high": high, "low": low, "close": close, "volume": volume},
        index=dates,
    )
 
 
prices = make_prices()
df = pd.DataFrame({"close": prices["close"]})
 
df["ma_fast"] = df["close"].rolling(20).mean()   # average of the last 20 closes
df["ma_slow"] = df["close"].rolling(50).mean()   # average of the last 50 closes
 
# Signal: 1 (long) when the fast average is above the slow one, else 0 (flat)
df["signal"] = (df["ma_fast"] > df["ma_slow"]).astype(int)
 
# Position: act on the NEXT bar, because today's signal needs today's close
df["position"] = df["signal"].shift(1).fillna(0).astype(int)
 
print(df.iloc[47:52].round(2))
print("Position changes:", int(df["position"].diff().abs().sum()))
print(f"Days in the market: {df['position'].mean():.0%}")
Output
             close  ma_fast  ma_slow  signal  position
date                                                  
2024-03-06  106.20   103.70      NaN       0         0
2024-03-07  107.10   104.01      NaN       0         0
2024-03-08  107.22   104.29   101.29       1         0
2024-03-11  107.63   104.47   101.44       1         1
2024-03-12  108.48   104.70   101.63       1         1
Position changes: 8
Days in the market: 28%

يضيف الإسناد إلى df["ma_fast"] عموداً جديداً. وتكون المتوسطات المتدحرجة NaN حتى تمتلئ نافذتها: يظهر المتوسط لـ50 يوماً أول مرة عند الشمعة الخمسين، 8 مارس. والمقارنات مع NaN تعطي False، فتبقى الإشارة 0 حتى ذلك الحين، وهذا الافتراضي الآمن. ويحوّل .astype(int) القيم True/False إلى 1/0.

انظر الآن إلى 8 مارس. تتحول الإشارة إلى 1 في ذلك اليوم، لكن المركز يبقى 0 ولا يصبح 1 إلا في 11 مارس، الشمعة التالية. وهذا أثر السطر المظلَّل. فإشارة 8 مارس تستخدم إغلاق 8 مارس، ولا يُعرف إلا بعد انتهاء اليوم. وأبكر وقت يمكنك التصرف فيه هو الجلسة التالية. ويحرّك .shift(1) كل إشارة صفاً واحداً لاحقاً، ويستبدل .fillna(0) القيمة NaN التي تتركها الإزاحة في الصف الأول بـ«خارج السوق».

يلخّص السطران الأخيران المركز. يأخذ .diff() التغيّر عن الصف السابق، فيظهر كل دخول أو خروج على هيئة +1 أو −1؛ وأخذ القيمة المطلقة وجمعها يحصي التغييرات. ومتوسط عمود 0/1 هو نسبة الأيام التي قُضيت في مركز شراء.

لماذا تهمّ الإزاحة: عرض عملي

نسيان تلك الإزاحة من أشيع أخطاء الـ Backtest، وقد يكون أثره هائلاً. يستخدم هذا المثال قاعدة ساذجة عن قصد، «ابقَ في مركز شراء حين يغلق اليوم أعلى من الأمس»، ويحسب نتيجتها مرتين: مرة بالتداول على الشمعة التالية، ومرة «بالتداول» على الشمعة نفسها التي أنتجت الإشارة.

lookahead.pyPython
import numpy as np
import pandas as pd
 
 
def make_prices(n_days=260, seed=42):
    """Synthetic daily OHLCV bars from a random walk. Not real market data."""
    rng = np.random.default_rng(seed)
    dates = pd.bdate_range("2024-01-01", periods=n_days, name="date")
    close = 100 * np.exp(np.cumsum(rng.normal(0.0003, 0.012, n_days)))
    prev_close = np.concatenate([[100.0], close[:-1]])
    open_ = prev_close * (1 + rng.normal(0, 0.002, n_days))
    high = np.maximum(open_, close) * (1 + np.abs(rng.normal(0, 0.004, n_days)))
    low = np.minimum(open_, close) * (1 - np.abs(rng.normal(0, 0.004, n_days)))
    volume = rng.integers(800_000, 1_500_000, n_days)
    return pd.DataFrame(
        {"open": open_, "high": high, "low": low, "close": close, "volume": volume},
        index=dates,
    )
 
 
prices = make_prices()
df = pd.DataFrame({"close": prices["close"]})
df["ret"] = df["close"].pct_change().fillna(0)
 
# A deliberately naive rule: be long whenever today closed higher than yesterday.
df["signal"] = (df["ret"] > 0).astype(int)
 
honest = df["signal"].shift(1).fillna(0) * df["ret"]   # trade on the next bar
leaky = df["signal"] * df["ret"]                       # "trade" on the bar that created the signal
 
print(f"Honest version:  {(1 + honest).prod() - 1:+.1%}")
print(f"Leaky version:   {(1 + leaky).prod() - 1:+.1%}")
Output
Honest version:  +24.3%
Leaky version:   +208.3%

ضرب المركز في عائد اليوم يعطي عائد الاستراتيجية في ذلك اليوم: العائد كاملاً حين تكون في مركز شراء، وصفراً حين تكون خارج السوق. ويضرب .prod() قيم (1+r)(1 + r) بعضها في بعض لتراكمها.

النسخة المتسرّبة في مركز شراء فقط في الأيام التي أُغلقت صاعدة، لأنها تقرّر بعد رؤية الإغلاق. فهي تلتقط كل يوم صاعد وتتجنب كل يوم هابط، وهذا ما لا يستطيعه أي متداول حقيقي. وهذا هو التحيّز الاستباقي (Look-ahead bias) في أوضح صوره.

والرقم النزيه يستحق الشك بالقدر نفسه. فهذا مسار عشوائي، ولا شيء فيه ليُكتشف، و+24.3% حظ: بذرة مختلفة ستعطي رقماً مختلفاً، وربما سالباً. اختبار واحد على مسار واحد لا يثبت شيئاً، ولهذا توجد الوحدتان 05 و06.

ما تعلّمته

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

أهم الخلاصات

  • العوائد البسيطة هي التغيّر النسبي من إغلاق إلى الذي يليه؛ أما العوائد اللوغاريتمية فتُجمع عبر الزمن، مما يجعلها مريحة في الحسابات.
  • التقلّب هو الانحراف المعياري للعوائد؛ وكثيراً ما يُضرب التقلّب اليومي في الجذر التربيعي للعدد 252 للحصول على رقم سنوي.
  • النافذة المتدحرجة لا قيمة لها حتى تمتلئ. الصفوف الأولى من متوسط 50 يوماً ناقصة، وهذا صحيح.
  • الإشارة المحسوبة من إغلاق اليوم لا يمكن التصرف بها إلا غداً. وإزاحة الإشارة بشمعة واحدة أبسط دفاع ضد التحيّز الاستباقي (Look-ahead bias).

تمرين الـ Lab

قارن الإشارات مع الإزاحة ودونها

باستخدام make_prices بثلاث بذور مختلفة، ابنِ إشارة المتوسطين المتحركين 20/50 واحسب العائد الإجمالي للاستراتيجية مرتين، مرة بإزاحة المركز بشمعة واحدة ومرة دون إزاحة. سجّل الأرقام الستة في جدول. ثم اشرح في جملتين لماذا لا تمثّل النسخة غير المُزاحة نتيجة كان يمكنك تحقيقها يوماً.

اختبار ذاتي

أجب بكلماتك أولاً، ثم اكشف الإجابة.

  1. 01ينتقل سعر من 100 إلى 110 ثم يعود إلى 100. ما العائدان البسيطان، وإلى كم يُجمع العائدان اللوغاريتميان؟

    اعرض الإجابة

    العائدان البسيطان +10% ونحو −9.09%، ولا يُجمعان إلى صفر مع أن السعر انتهى حيث بدأ. أما العائدان اللوغاريتميان فهما ln(1.1) ≈ +0.0953 وln(100/110) ≈ −0.0953، ويُجمعان إلى صفر تماماً، بما يطابق التغيّر الكلي.

  2. 02لماذا تكون أول 49 قيمة من المتوسط المتدحرج لـ50 يوماً ناقصة؟

    اعرض الإجابة

    المتوسط لـ50 يوماً يحتاج إلى 50 إغلاقاً. وحتى الشمعة الخمسين لا توجد قيم كافية، فتُرجع pandas القيمة NaN بدلاً من متوسط لأيام أقل. وملء هذه القيم بأي شيء آخر يعني اختلاق بيانات.

  3. 03ماذا يفعل df["signal"].shift(1)، ولماذا تستخدمه الاستراتيجية؟

    اعرض الإجابة

    يحرّك كل إشارة صفاً واحداً لاحقاً، فيكون المركز في كل يوم هو إشارة إغلاق اليوم السابق. تحتاج الإشارة إلى إغلاق اليوم كي تُحسب، فلا يمكن التداول بها قبل الشمعة التالية. ومن دون الإزاحة يتداول الاختبار بمعلومات ما كانت لتتوفر له.

هل أنهيت الدرس وتمرين الـ Lab والاختبار الذاتي؟

مصطلحات المسرد في هذا الدرس

مقالات ذات صلة من الأكاديمية

مادة تعليمية فقط. تستخدم الأمثلة بيانات اصطناعية، وليست نصيحة استثمارية ولا توقّعاً للنتائج.