مشروع مصغّر: ارسم منحنى رأس المال لقاعدة بسيطة
اجمع الوحدة في سكربت واحد يحوّل قاعدة المتوسط المتحرك إلى منحنى رأس مال، ويقارنه بالشراء والاحتفاظ، ويقيس الـ Drawdown، ويحفظ رسماً بيانياً، ثم يفحص مدى اعتماد النتيجة على الحظ.
في هذا الدرس ستتعلّم أن
- ابنِ منحنى رأس مال من سلسلة مراكز وعوائد يومية.
- قارن قاعدة بمعيار الشراء والاحتفاظ (Buy and hold) على البيانات نفسها.
- احسب أقصى Drawdown من منحنى رأس المال.
- احفظ رسماً بيانياً للنتائج باستخدام matplotlib.
- اشرح لماذا لا يُعدّ اختبار واحد على مسار سعري واحد دليلاً على وجود Edge.
يوظّف هذا الدرس الوحدة كلها في سكربت واحد. ستأخذ إشارة المتوسط المتحرك من الدرس السابق، وتحوّلها إلى منحنى رأس مال، وتقارنها بـالشراء والاحتفاظ ببساطة، وتقيس أسوأ Drawdown، وتحفظ رسماً بيانياً. ثم تنفّذ الخطوة التي يتجاوزها معظم المبتدئين: فحص مقدار ما في النتيجة من حظ.
هذا أول Backtest مبسّط. فهو يتجاهل التكاليف، ويفترض أن الصفقات تُنفَّذ تماماً عند عوائد الإغلاق إلى الإغلاق، ويعمل على بيانات اصطناعية. وتبني الوحدة 05 اختباراً أكثر واقعية. والمقصود هنا تعلّم الآليات، ومعرفة مبكرة بمدى ضآلة ما يمكن أن تقوله نتيجة واحدة.
الخطة
ينجز السكربت خمسة أشياء:
- يولّد سنتين من الأسعار اليومية الاصطناعية.
- يحسب العوائد اليومية ومركز المتوسطين المتحركين 20/50، مُزاحاً بشمعة واحدة كما سبق.
- يبني منحنيَي رأس مال من رأس مال ابتدائي قدره 10,000: واحد للقاعدة وواحد للشراء والاحتفاظ.
- يلخّص رأس المال النهائي والعائد الإجمالي وأقصى Drawdown.
- يرسم المنحنيين ويحفظ الرسم في ملف.
السكربت
احفظ هذا باسم equity_curve.py في مشروعك وشغّله. يطبع ملخصاً ويكتب equity_curve.png في المجلد نفسه.
import matplotlib.pyplot as plt
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,
)
def max_drawdown(equity):
"""Largest fall from a running peak, as a negative fraction."""
return (equity / equity.cummax() - 1).min()
START_CAPITAL = 10_000
FAST, SLOW = 20, 50
prices = make_prices(n_days=500, seed=42)
df = pd.DataFrame({"close": prices["close"]})
df["ret"] = df["close"].pct_change().fillna(0)
fast_ma = df["close"].rolling(FAST).mean()
slow_ma = df["close"].rolling(SLOW).mean()
df["signal"] = (fast_ma > slow_ma).astype(int)
df["position"] = df["signal"].shift(1).fillna(0)
df["rule_equity"] = START_CAPITAL * (1 + df["position"] * df["ret"]).cumprod()
df["hold_equity"] = START_CAPITAL * (1 + df["ret"]).cumprod()
rule_final = df["rule_equity"].iloc[-1]
hold_final = df["hold_equity"].iloc[-1]
summary = pd.DataFrame(
{
"final equity": [rule_final, hold_final],
"total return %": [100 * (rule_final / START_CAPITAL - 1), 100 * (hold_final / START_CAPITAL - 1)],
"max drawdown %": [100 * max_drawdown(df["rule_equity"]), 100 * max_drawdown(df["hold_equity"])],
},
index=[f"MA {FAST}/{SLOW} rule", "Buy and hold"],
)
print(summary.round(1))
print("Entries:", int((df["position"].diff() == 1).sum()))
print(f"Time in the market: {df['position'].mean():.0%}")
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(df.index, df["rule_equity"], color="white", linewidth=1.6, label=f"MA {FAST}/{SLOW} rule")
ax.plot(df.index, df["hold_equity"], color="gray", linewidth=1.2, label="Buy and hold")
ax.axhline(START_CAPITAL, color="dimgray", linewidth=0.8, linestyle="--")
ax.set_title("Equity curves on synthetic data (educational example)")
ax.set_ylabel("Equity ($)")
ax.legend(loc="upper left")
fig.tight_layout()
fig.savefig("equity_curve.png", dpi=150) final equity total return % max drawdown %
MA 20/50 rule 9196.2 -8.0 -14.4
Buy and hold 10696.0 7.0 -17.8
Entries: 7
Time in the market: 46%كيف يعمل
الإعدادات بأحرف كبيرة. كُتبت START_CAPITAL وFAST وSLOW بأحرف كبيرة، وهذا عُرف في Python للقيم التي تُحدَّد مرة واحدة ولا تتغيّر. وإبقاؤها في الأعلى يتيح لك تجربة قاعدة مختلفة بتعديل سطر واحد، وتتحدّث تسميات الرسم من تلقاء نفسها عبر الـ f-strings. ويسند السطر FAST, SLOW = 20, 50 قيمتين لمتغيّرين دفعة واحدة.
منحنيا رأس المال. الأسطر المظلَّلة هي قلب السكربت. df["position"] * df["ret"] هو العائد اليومي للاستراتيجية: عائد السوق في الأيام التي تكون فيها القاعدة في مركز شراء، وصفر في الأيام التي تكون فيها خارج السوق. وإضافة 1 تحوّل كل عائد إلى عامل نمو، فيصبح ربح 1% هو 1.01. ويضرب .cumprod() العوامل تراكمياً، يوماً بعد يوم، وهذا هو التراكم، والضرب في رأس المال الابتدائي يحوّل ذلك إلى مال. ومنحنى رأس المال للشراء والاحتفاظ هو الحساب نفسه مع بقاء المركز 1 دائماً.
الـ Drawdown. equity.cummax() هو أعلى رأس مال بُلغ حتى الآن في كل يوم. وقسمة رأس المال على هذه القمة المتحركة وطرح 1 يعطيان الـ Drawdown: مدى انخفاض الحساب عن أفضل مستوى له، بوصفه كسراً سالباً. والحد الأدنى لهذه السلسلة هو أقصى Drawdown، أي أسوأ هبوط من القمة إلى القاع في الاختبار.
جدول الملخّص. لا يشترط أن يحمل الـ DataFrame صفوفاً مؤرَّخة. هنا يحمل صفين، واحداً لكل أسلوب، بتسميات مقروءة فهرساً. وتعطي القائمة بعد اسم كل عمود قيمة القاعدة ثم قيمة الشراء والاحتفاظ.
عدّ الدخولات. يكون .diff() == 1 قيمته True في الأيام التي انتقل فيها المركز من 0 إلى 1. وجمع القيم المنطقية يعدّ القيم True، لأن True تُحتسب 1.
الرسم البياني. matplotlib.pyplot، المستورَدة باسم plt، مكتبة الرسم القياسية في Python. وينشئ plt.subplots() شكلاً (الصورة) ومحاور (منطقة الرسم داخله). ويرسم كل ax.plot(...) خطاً واحداً، ويرسم ax.axhline رأس المال الابتدائي مرجعاً متقطعاً، ويسمّي ax.legend الخطوط. ويكتب fig.savefig الملف. ونمط dark_background اختياري؛ احذف ذلك السطر للمظهر الافتراضي في matplotlib. وفي JupyterLab يظهر الرسم أيضاً تحت الخلية؛ وفي السكربت افتح الملف المحفوظ، أو أضف plt.show() في النهاية لفتح نافذة.
هذا هو الرسم الذي ينتجه السكربت:

قراءة النتيجة
على هذا المسار السعري، خسرت القاعدة 8% بينما ربح الشراء والاحتفاظ 7%. وكان أسوأ Drawdown للقاعدة أصغر، 14.4% مقابل 17.8%، ويعود ذلك في الغالب إلى أنها قضت أكثر من نصف الوقت خارج السوق؛ والمقاطع المسطحة في الرسم هي الفترات التي كانت فيها نقداً.
سيكون من السهل نسج قصة عن هذا: القاعدة تحمي رأس المال لكنها تتأخر في الأسواق الصاعدة. وقبل رواية أي قصة، اسأل كم من النتيجة حظ. ومع البيانات الاصطناعية، تستطيع الإجابة مباشرة بتغيير البذرة وتشغيل القاعدة نفسها على مسارات عشوائية مختلفة.
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,
)
def ma_rule_vs_hold(prices, fast=20, slow=50):
"""Total return of the moving-average rule and of buy and hold, as fractions."""
close = prices["close"]
ret = close.pct_change().fillna(0)
signal = (close.rolling(fast).mean() > close.rolling(slow).mean()).astype(int)
position = signal.shift(1).fillna(0)
rule = (1 + position * ret).prod() - 1
hold = (1 + ret).prod() - 1
return rule, hold
for seed in range(1, 6):
rule, hold = ma_rule_vs_hold(make_prices(n_days=500, seed=seed))
print(f"Seed {seed}: MA rule {rule:+6.1%} buy and hold {hold:+6.1%}")Seed 1: MA rule -4.9% buy and hold -6.7%
Seed 2: MA rule -24.4% buy and hold -15.9%
Seed 3: MA rule +24.0% buy and hold +57.6%
Seed 4: MA rule +23.8% buy and hold +19.7%
Seed 5: MA rule +18.4% buy and hold +18.2%تُرجع الدالة قيمتين دفعة واحدة، تفصل بينهما فاصلة، ويفكّ rule, hold = ... تجميعهما في متغيّرين. وفي رمز التنسيق، يعرض +6.1% الإشارة وخانة عشرية واحدة وعرضاً من ستة أحرف، فتصطف الأعمدة.
القاعدة نفسها، والإعدادات نفسها، وخمسة مسارات عشوائية: تتراوح النتائج من −24.4% إلى +24.0%. تتفوق القاعدة على الشراء والاحتفاظ في بعض المسارات وتتأخر عنه في غيرها. ولا شيء من هذا يقول شيئاً عن القاعدة، لأن المسار العشوائي لا يحوي اتجاهات لتجدها قاعدة اتجاه. لكنه يقول الكثير عن الاختبارات المفردة: منحنى رأس مال واحد سحبة واحدة من نطاق واسع من النتائج الممكنة.
ما قدّمته لك هذه الوحدة
يمكنك الآن إعداد مشروع Python، وكتابة الدوال، وحفظ بيانات الأسعار في pandas، وحساب العوائد والمؤشرات، وضبط توقيت الإشارة بالطريقة الصحيحة، وتحويلها إلى منحنى رأس مال مع معيار مقارنة وDrawdown. وهذا يكفي لبدء طرح أسئلة حقيقية. وتتناول الوحدة التالية البيانات التي تعتمد عليها تلك الأسئلة، والطرق التي قد تضللك بها قبل أن يُشغَّل سطر واحد من شيفرة الاستراتيجية.
أهم الخلاصات
- منحنى رأس المال يراكم العوائد اليومية للاستراتيجية انطلاقاً من رأس مال ابتدائي؛ وتتولى cumprod عملية التراكم.
- القاعدة لا يكون لها معنى إلا إلى جانب معيار مقارنة. والشراء والاحتفاظ على البيانات نفسها أبسط معيار.
- أقصى Drawdown يقيس أعمق هبوط من قمة متحركة، وكثيراً ما يكون هو الرقم الذي يقرّر هل يمكن التعايش مع النظام.
- على المسار العشوائي تتفاوت النتائج كثيراً من بذرة إلى أخرى. ومنحنى رأس المال الواحد سحبة واحدة، لا حكم نهائي.
اجعل المشروع المصغّر أكثر نزاهة
أضف تكلفة قدرها 0.1% في كل مرة يتغيّر فيها المركز، بطرحها من عائد الاستراتيجية في تلك الأيام. أعد تشغيل السكربت للبذور من 1 إلى 10، وسجّل العائد الإجمالي للقاعدة وأقصى Drawdown مع التكاليف وبدونها، وعُدّ في كم بذرة تتفوق القاعدة على الشراء والاحتفاظ في كل حالة. اكتب ثلاث جمل عمّا تقوله النتائج وما لا تقوله.
اختبار ذاتي
أجب بكلماتك أولاً، ثم اكشف الإجابة.
لماذا يستخدم حساب رأس المال
(1 + returns).cumprod()بدلاً من جمع العوائد؟اعرض الإجابةأخفِ الإجابة
العوائد تتراكم. يُطبَّق عائد كل يوم على رأس المال في نهاية اليوم السابق، فيجب ضرب عوامل النمو (1 + r) لا جمعها. وتعطي
cumprodالناتج المتراكم، وهو منحنى رأس المال نسبةً إلى رأس المال الابتدائي.كان لقاعدة المتوسط المتحرك أقصى Drawdown أصغر من الشراء والاحتفاظ لكن عائداً إجمالياً أدنى. هل يعني ذلك أنها استراتيجية أفضل بعد تعديل المخاطرة؟
اعرض الإجابةأخفِ الإجابة
ليس من هذا الاختبار. فالبيانات مسار عشوائي، ولا يعكس أي من الرقمين خاصية حقيقية للقاعدة. وحتى مع البيانات الحقيقية، لا يكفي مسار واحد واختيار واحد للمعاملات؛ ستحتاج إلى التكاليف وبيانات أكثر وطرق التحقّق في الوحدة 06 قبل استخلاص أي نتيجة.
ماذا يحسب
equity / equity.cummax() - 1؟اعرض الإجابةأخفِ الإجابة
الـ Drawdown في كل يوم. تعطي
cummaxأعلى رأس مال بلغه المنحنى حتى الآن، فالقسمة عليه وطرح 1 يعطيان مدى انخفاض المنحنى عن قمته، بوصفه كسراً سالباً. والحد الأدنى لهذه السلسلة هو أقصى Drawdown.
